🍉 Sync 2026-09-25 18:00:12

This commit is contained in:
github-actions[bot] committed 2026-09-25 18:00:12 +08:00
1 parent 7c17dda4d0
commit 2ee65a261e
135 files changed
+5726 -7799

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=airconnect PKG_NAME:=airconnect
PKG_VERSION:=1.12.2 PKG_VERSION:=1.12.3
PKG_RELEASE=1 PKG_RELEASE=1
PKG_SOURCE:=AirConnect-$(PKG_VERSION).zip PKG_SOURCE:=AirConnect-$(PKG_VERSION).zip
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=cloudflared-next PKG_NAME:=cloudflared-next
PKG_VERSION:=2026.9.1 PKG_VERSION:=2026.9.3
PKG_RELEASE:=1 PKG_RELEASE:=2
# Architecture mapping for pre-built binaries # Architecture mapping for pre-built binaries
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dae PKG_NAME:=dae
PKG_VERSION:=2026.09.23 PKG_VERSION:=2026.09.24
PKG_RELEASE:=47 PKG_RELEASE:=48
PKG_SOURCE:=dae-src-2026.09.23-c9f580bcaa8a.tar.gz PKG_SOURCE:=dae-src-2026.09.24-17135da7c881.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+1 -1
View File
@@ -20,7 +20,7 @@ start_service() {
local config_file local config_file
config_get config_file "config" "config_file" "/etc/dae/config.dae" config_get config_file "config" "config_file" "/etc/dae/config.dae"
"$PROG" validate -c "$config_file" || return 1 DAE_LOCATION_ASSET="/usr/share/v2ray" "$PROG" validate -c "$config_file" || return 1
local log_maxbackups log_maxsize local log_maxbackups log_maxsize
config_get log_maxbackups "config" "log_maxbackups" "1" config_get log_maxbackups "config" "log_maxbackups" "1"
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=daed PKG_NAME:=daed
PKG_VERSION:=2026.09.23 PKG_VERSION:=2026.09.24
PKG_RELEASE:=58 PKG_RELEASE:=59
PKG_SOURCE:=daed-src-2026.09.23-fcc80bac4542.tar.gz PKG_SOURCE:=daed-src-2026.09.24-527bb166b1ae.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
@@ -1,21 +0,0 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-09-23
Subject: [PATCH] daed: expose config marshaller bytes
The current wing generator calls Marshaller.Bytes when updating global config.
---
diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go
index f68006a..dfd6777 100644
--- a/dae-core/config/marshal.go
+++ b/dae-core/config/marshal.go
@@ -42,6 +42,10 @@ type Marshaller struct {
buf bytes.Buffer
}
+func (m *Marshaller) Bytes() []byte {
+ return m.buf.Bytes()
+}
+
func (m *Marshaller) writeLine(depth int, line string) {
if depth < 0 {
depth = 0
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dnsproxy PKG_NAME:=dnsproxy
PKG_VERSION:=0.84.2 PKG_VERSION:=0.85.0
PKG_RELEASE:=18 PKG_RELEASE:=19
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)?
-51
View File
@@ -1,51 +0,0 @@
#
# Copyright (C) 2026 VizzleTF
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=gitbackup
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:=VizzleTF <vizzlef@gmail.com>
include $(INCLUDE_DIR)/package.mk
define Package/gitbackup
SECTION:=net
CATEGORY:=Network
TITLE:=Automatic configuration backup to a Git repository
# Every entry earns its place with a measured 25.12.4 failure, not habit
# (spec, "Проверенные факты"): stat is entirely absent from the base image
# and the manifest depends on it (coreutils-stat); dbclient cannot read
# OpenSSH-format keys or honor UserKnownHostsFile, so ssh needs the real
# openssh client and keygen; git-http is required for `git fetch https://`
# (fails with "remote-https is not a git command" otherwise). flock is a
# busybox applet and needs nothing here.
DEPENDS:=+git +git-http +ca-bundle +jsonfilter +coreutils-stat +openssh-client +openssh-keygen
PKGARCH:=all
endef
define Package/gitbackup/description
gitbackup commits the router's configuration to a private git repository on
a schedule, without keeping a working copy of that repository on flash, and
restores it back onto a freshly flashed router.
endef
define Package/gitbackup/conffiles
/etc/config/gitbackup
endef
define Build/Compile
endef
define Package/gitbackup/install
$(INSTALL_DIR) $(1)
$(CP) ./files/* $(1)/
endef
$(eval $(call BuildPackage,gitbackup))
+7 -80
View File
@@ -2,18 +2,8 @@
# shellcheck shell=sh # shellcheck shell=sh
# shellcheck disable=SC2034 # START/USE_PROCD/EXTRA_COMMANDS are read by rc.common, not this file # shellcheck disable=SC2034 # START/USE_PROCD/EXTRA_COMMANDS are read by rc.common, not this file
# #
# gitbackup runs from cron, not as a daemon, so there is no process for # Not a daemon: applies the cron schedule and debounces config.change events
# start_service to supervise. This file's job is: (1) apply # of backed-up packages into one `gitbackup run`.
# gitbackup.main.schedule to /etc/crontabs/root and make crond pick it up
# (reload_service, ticket 06), and (2) watch the packages actually in the
# backup set for config.change and turn a burst of edits into one debounced
# `gitbackup run` (service_triggers/config_change, spec R106/R107).
#
# GB_SHARE/GB_STATE_DIR/GB_BIN are overridable only so tests/run.sh can
# source this file directly (rc.common's own dispatch is not exercised by
# the unit tests -- see this file's own functions being called straight
# from a test body) against a tree, state directory and gitbackup binary
# that are not the router's real ones; the router never sets any of them.
GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}"
@@ -29,11 +19,7 @@ GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}"
START=60 START=60
USE_PROCD=1 USE_PROCD=1
# config_change is not one of rc.common's built-in actions (start/stop/ # Lets rc.common dispatch the procd trigger target `config_change`.
# reload/...); EXTRA_COMMANDS is rc.common's own mechanism for adding one
# (it appends this to ALL_COMMANDS right after sourcing this file -- see
# /etc/rc.common on the stand), which is what lets procd's run_script
# target `/etc/init.d/gitbackup config_change` actually dispatch anywhere.
EXTRA_COMMANDS="config_change" EXTRA_COMMANDS="config_change"
EXTRA_HELP=" config_change Internal: debounced trigger used by service_triggers, not for interactive use" EXTRA_HELP=" config_change Internal: debounced trigger used by service_triggers, not for interactive use"
@@ -41,52 +27,17 @@ start_service() {
reload_service reload_service
} }
stop_service() {
:
}
# reload_service -- (re)applies gitbackup.main.schedule/cron_expr to the
# crontab and makes crond pick it up. Runs on `/etc/init.d/gitbackup
# reload|restart|start` and, through procd_add_reload_trigger below,
# whenever gitbackup's own UCI config changes (e.g. a Settings save in
# LuCI) -- so flipping schedule from daily to weekly takes effect without a
# manual reload.
reload_service() { reload_service() {
gb_cron_apply gb_cron_apply
} }
# _gb_backed_up_packages -- private. The set of UCI packages the config- # UCI packages in the backup set, minus gitbackup (has the reload trigger).
# change trigger below watches: every /etc/config/<pkg> that is actually
# part of the backup set, per `sysupgrade -l` (spec: "список берётся из
# sysupgrade -l, фильтруется до /etc/config/*"), minus gitbackup's own
# config -- that one already gets procd_add_reload_trigger's ordinary
# `reload` below, which re-applies the schedule, not a debounced backup
# run; watching it a second time here would just fire both for the same
# edit.
_gb_backed_up_packages() { _gb_backed_up_packages() {
sysupgrade -l 2>/dev/null | sed -n 's#^/etc/config/##p' | sort -u | grep -v '^gitbackup$' sysupgrade -l 2>/dev/null | sed -n 's#^/etc/config/##p' | sort -u | grep -v '^gitbackup$'
} }
# config_change -- procd's run_script target for a backed-up package's # (Re)arm one delayed run, cancelling the previous timer.
# config.change event, registered once per package in service_triggers # busybox `sleep` takes whole seconds only, hence the integer guard.
# below. Does not run a backup on the spot: a save in LuCI can touch
# several packages (e.g. network + firewall together) or the same one
# repeatedly, and each touch fires this again -- so this only (re)arms a
# single delayed `gitbackup run` after $debounce seconds, cancelling
# whatever the previous call armed first. A burst of edits therefore
# collapses into exactly one run (spec R106/R107), not one per edit.
#
# busybox ash's arithmetic (and `sleep`) only does whole seconds --
# `sleep 0.3` is "invalid number", measured on the stand -- hence the
# integer-only guard on $debounce below rather than trusting it unchecked.
#
# Killing the previous timer's PID only stops that subshell; if it had
# already progressed past `sleep` (a race that needs the previous debounce
# window to have fully elapsed a moment before this call, and is why a
# closer-together burst is exactly what this exists to collapse), the
# short-lived orphaned `sleep` left behind exits on its own and does
# nothing further -- rm and run only happen after it, in the same
# subshell, and that subshell is what got killed.
config_change() { config_change() {
_gb_debounce=$(gb_uci_get gitbackup.main.debounce 60) _gb_debounce=$(gb_uci_get gitbackup.main.debounce 60)
case "$_gb_debounce" in ''|*[!0-9]*) _gb_debounce=60 ;; esac case "$_gb_debounce" in ''|*[!0-9]*) _gb_debounce=60 ;; esac
@@ -94,18 +45,6 @@ config_change() {
mkdir -p "$GB_STATE_DIR" mkdir -p "$GB_STATE_DIR"
_gb_pidfile="$GB_STATE_DIR/debounce.pid" _gb_pidfile="$GB_STATE_DIR/debounce.pid"
# Review finding, ticket 06: killing the PID from the pidfile blind is
# fine right after this same router wrote it, but a long-uptime router
# can wrap PIDs, and by the time the NEXT config.change fires that same
# number may belong to an unrelated process this has no business
# signaling. `kill -0` (send no signal, just ask whether the PID
# exists) is the cheap half of that check -- it does not prove the PID
# is still OUR sleep, only that it is not free for the kernel to have
# reused by something else; reused PIDs are strictly the OS's own
# concern here. Deliberately `kill -0`, not a `/proc/<pid>` test: this
# file's own tests/run.sh unit test runs it on macOS, which has no
# /proc at all, while `kill -0` is the one portable primitive both that
# host and busybox ash on the router actually implement.
if [ -f "$_gb_pidfile" ]; then if [ -f "$_gb_pidfile" ]; then
_gb_prev_pid=$(cat "$_gb_pidfile") _gb_prev_pid=$(cat "$_gb_pidfile")
kill -0 "$_gb_prev_pid" 2>/dev/null && kill "$_gb_prev_pid" 2>/dev/null kill -0 "$_gb_prev_pid" 2>/dev/null && kill "$_gb_prev_pid" 2>/dev/null
@@ -118,21 +57,9 @@ config_change() {
service_triggers() { service_triggers() {
procd_add_reload_trigger "gitbackup" procd_add_reload_trigger "gitbackup"
# R106/R107 both live under one on/off switch: a router that wants
# gitbackup to run strictly on its own schedule can turn the
# config-change trigger off without touching the watched packages.
[ "$(gb_uci_get gitbackup.main.on_config_change 1)" = 1 ] || return 0 [ "$(gb_uci_get gitbackup.main.on_config_change 1)" = 1 ] || return 0
# Same path resolution procd.sh's own _procd_add_reload_trigger uses
# (readlink, falling back to $initscript itself when it is not a
# symlink) -- $initscript is rc.common's own variable (set to "$1"
# before this file is sourced, see /etc/rc.common on the stand), not
# invented here; shellcheck cannot see that external assignment.
# shellcheck disable=SC2154
_gb_script=$(readlink "$initscript")
_gb_name=$(basename "${_gb_script:-$initscript}")
for _gb_pkg in $(_gb_backed_up_packages); do for _gb_pkg in $(_gb_backed_up_packages); do
procd_add_config_trigger "config.change" "$_gb_pkg" /etc/init.d/"$_gb_name" config_change procd_add_config_trigger "config.change" "$_gb_pkg" /etc/init.d/gitbackup config_change
done done
} }
+2 -10
View File
@@ -1,15 +1,7 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup: create the secrets directory once, at first boot after install. # First boot, not postinst (postinst also runs at image build time).
# # Sourced by /etc/init.d/boot and deleted on exit 0: no shebang needed.
# Not done in postinst -- postinst runs while an image is being built, where
# there may be neither entropy nor a real, writable /etc yet, and the spec is
# explicit that installing the package must not run a backup or generate keys.
#
# /etc/init.d/boot runs every /etc/uci-defaults/* script as
# `( . "./$(basename $file)" )` and deletes it afterwards only if that exited
# 0, so this file has no shebang and is not executable -- same convention as
# base-files' own uci-defaults scripts (verified in the local checkout).
[ -d /etc/gitbackup ] || mkdir -m 0700 /etc/gitbackup [ -d /etc/gitbackup ] || mkdir -m 0700 /etc/gitbackup
chown root:root /etc/gitbackup chown root:root /etc/gitbackup
+75 -437
View File
@@ -1,53 +1,10 @@
#!/bin/sh #!/bin/sh
# shellcheck shell=sh # shellcheck shell=sh
# #
# luci.gitbackup -- rpcd plugin, ubus object "luci.gitbackup" (spec "rpcd и # rpcd plugin for ubus object "luci.gitbackup": translates calls into
# ACL"). Everything the CLI (usr/sbin/gitbackup) can do becomes reachable # `gitbackup` CLI runs. Always prints JSON and exits 0: a non-zero exit would
# from the web here, and NOTHING ELSE ever runs: this file's own job is # become a bodyless ubus error. Failures answer {"reason": ...}.
# translating between the rpcd "list"/"call" protocol (TechRef: # The method list is mirrored in acl.d/luci-app-gitbackup.json (tested).
# https://openwrt.org/docs/techref/rpcd) and a `gitbackup` CLI invocation --
# never `git`, `sysupgrade`, or any other router command directly.
#
# Ticket 21 (R110/R111/R109, D07 -- the operator's own words: "вызывать под
# капотом может любые команды роутера. желательно только gitbackup"). Before
# this ticket, gbrpc_history/gbrpc_diff/gbrpc_list_paths/gbrpc_audit_paths
# below ran `git ls-remote`/`git fetch`/`git log`/`git diff`/`git diff-tree`/
# `sysupgrade -l` themselves, straight against gitbackup.origin.url out of
# UCI -- WITHOUT ever running it through remoteurl.sh's own gb_parse_url
# first, which is the one validator this package already had for exactly
# this string (ticket 03). `gitbackup` itself always has validated its own
# GB_URL before spawning git (cmd_test/cmd_run already did; cmd_diff/
# cmd_restore now do too, ticket 21) -- so routing every one of these
# through the CLI closes that gap for free, rather than teaching this file
# a second copy of the same check. Where the CLI had no equivalent yet
# (the branch's commit list, a diff between two arbitrary commits), ticket
# 21 added `gitbackup history`/`gitbackup diff <from> <to>` instead of
# leaving a second git call here. What is left below with logic of its own
# is exactly the methods that touch no external command at all: set_paths/
# set_secret write plain files, validate_cron calls schedule.sh's own pure
# shell functions.
#
# Protocol, confirmed against this checkout's own stock examples
# (luci-app-https-dns-proxy, luci-app-squid) rather than invented:
# luci.gitbackup list -> one JSON object, method name -> example
# params object (types only, ubus does not
# enforce this against real calls).
# luci.gitbackup call <method> -> reads exactly one line of compact JSON
# (the call's params) from stdin, prints
# exactly one JSON object on stdout, and
# always exits 0 -- ubus turns a nonzero
# exit into an RPC-level error with no
# body, which would break this ticket's
# own "ошибка тоже JSON, а не пустой
# ответ" requirement.
#
# `get_secret` does not exist anywhere below. `status` prints only
# token_set (bool), never the token itself.
#
# GB_SHARE/GB_STATE_DIR/GB_BIN are the same override seam
# usr/sbin/gitbackup and etc/init.d/gitbackup already use, so tests/run.sh
# can point this at a tree, state directory and CLI binary that are not the
# router's real ones; the router never sets any of them.
set -u set -u
@@ -56,73 +13,49 @@ GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}"
GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}" GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}"
GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}" GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}"
# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically # shellcheck disable=SC1091 # GB_SHARE is a runtime path
. "$GB_SHARE/lib.sh" . "$GB_SHARE/lib.sh"
# device.sh (gb_device_id/gb_expand) and auth.sh (gb_git_env, the one `eval
# "$(...)"` this package's whole codebase has ever needed) are deliberately
# NOT sourced here any more (ticket 21): both were only ever used by the
# git-calling helpers this file no longer has. `gitbackup` itself still
# sources both to do that work -- this file just never touches either
# again, which is the strongest available answer to "can this eval be
# removed": it is gone from this process entirely, not merely justified.
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$GB_SHARE/schedule.sh" . "$GB_SHARE/schedule.sh"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$GB_SHARE/paths.sh" . "$GB_SHARE/paths.sh"
# card.sh (ticket 09) may not exist on a system built before it landed -- # shellcheck disable=SC1091
# same "command -v gb_card" tolerance usr/sbin/gitbackup's own cmd_run . "$GB_SHARE/card.sh"
# already uses, not a new convention invented here.
if [ -r "$GB_SHARE/card.sh" ]; then
# shellcheck disable=SC1091
. "$GB_SHARE/card.sh"
fi
# ---------------------------------------------------------------------------
# JSON param helpers -- jsonfilter, not jshn.sh: interfaces.md only promises
# "jsonfilter есть, ubus есть", jshn.sh is a libubox implementation detail
# this package's own DEPENDS never asked for, and the rest of gitbackup
# already hand-rolls JSON output (lib.sh's gb_json_str/gb_json_esc) instead
# of pulling in a second writer -- reusing that here keeps one JSON style
# in the whole package instead of two.
# ---------------------------------------------------------------------------
# _gb_rpc_param <json-line> <jsonfilter-expr> -- empty string (not an error)
# for a missing/wrong-type field: jsonfilter itself exits 1 and prints
# nothing when the path does not resolve (confirmed live against this
# project's own owlab image), which every caller below treats the same as
# "field omitted".
_gb_rpc_param() { _gb_rpc_param() {
printf '%s' "$1" | jsonfilter -e "$2" 2>/dev/null printf '%s' "$1" | jsonfilter -e "$2" 2>/dev/null
} }
# _gb_rpc_param_list <json-line> <jsonfilter-array-expr> -- one element per
# line (jsonfilter's own "@.x[*]" behavior, confirmed live), empty output
# for a missing array.
_gb_rpc_param_list() {
printf '%s' "$1" | jsonfilter -e "$2" 2>/dev/null
}
# _gb_rpc_error <reason> -- the one shape every failed method below prints:
# a JSON object with a "reason" field, never an empty body and never a bare
# exit code (ticket 10 acceptance criterion).
_gb_rpc_error() { _gb_rpc_error() {
printf '{ "reason": %s }\n' "$(gb_json_str "$1")" printf '{ "reason": %s }\n' "$(gb_json_str "$1")"
} }
# _gb_rpc_ok_msg <field> <text> -- "{ "<field>": "<text>" }", the shared
# shape for the several methods that just wrap one piece of CLI text.
_gb_rpc_text() { _gb_rpc_text() {
printf '{ "%s": %s }\n' "$1" "$(gb_json_str "$2")" printf '{ "%s": %s }\n' "$1" "$(gb_json_str "$2")"
} }
# --------------------------------------------------------------------------- # _gb_rpc_relay <key|-> <gitbackup args...> -- "-" relays JSON as is, else
# Method implementations, one gbrpc_<method> per ACL entry -- the case in # wraps as {"<key>": ...}. stderr stays out of a success answer: one gb_log
# main() at the bottom names every one of these and no others, and # notice in front of the JSON breaks the whole reply.
# t_rpcd_acl_matches_plugin (tests/run.sh) checks the reverse direction, so _gb_rpc_relay() {
# the two can never quietly drift apart. _gb_rpc_key="$1"
# --------------------------------------------------------------------------- shift
_gb_rpc_errf=$(mktemp "${TMPDIR:-/tmp}/gbrpc-err.XXXXXX") || {
# --- read tier --------------------------------------------------------- _gb_rpc_error 'could not create a scratch file under /tmp'
return 0
}
_gb_rpc_out=$("$GB_BIN" "$@" 2>"$_gb_rpc_errf")
_gb_rpc_rc=$?
_gb_rpc_err=$(cat "$_gb_rpc_errf")
rm -f "$_gb_rpc_errf"
if [ "$_gb_rpc_rc" -ne 0 ]; then
_gb_rpc_error "$(printf '%s\n%s' "$_gb_rpc_err" "$_gb_rpc_out" | grep .)"
elif [ "$_gb_rpc_key" = - ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_text "$_gb_rpc_key" "$_gb_rpc_out"
fi
}
gbrpc_status() { gbrpc_status() {
_gb_rpc_out=$("$GB_BIN" status 2>/dev/null) _gb_rpc_out=$("$GB_BIN" status 2>/dev/null)
@@ -140,132 +73,34 @@ gbrpc_log() {
} }
gbrpc_pubkey() { gbrpc_pubkey() {
_gb_rpc_out=$("$GB_BIN" pubkey 2>&1) _gb_rpc_relay pubkey pubkey
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
_gb_rpc_text pubkey "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
} }
# gbrpc_list_paths -- three distinct sets, on purpose (the read/write
# asymmetry this ticket exists to close): "paths" is the RAW, unexpanded
# content of GB_SYSUPGRADE_CONF (paths.sh's own gb_paths_list) -- exactly
# what the file contains, comments and blank lines included. "entries"
# (ticket 26) is gb_paths_entries -- the same content with every comment
# and blank line dropped, i.e. only genuine path entries, a directory line
# still included verbatim -- what the LuCI view lists and edits, and the
# only shape gbrpc_set_paths below may ever write back; a stock
# /etc/sysupgrade.conf's own header comments and commented-out example
# lines are not entries and must never render as a removable row. "effective"
# is plain `sysupgrade -l`, the full union spec §sysupgrade names
# (GB_SYSUPGRADE_CONF ∪ /lib/upgrade/keep.d/* ∪ changed conffiles) already
# recursively expanded to individual files/symlinks. Read-only: it exists
# for sizing (gb_paths_size_kb's own walk) and so a caller can show what is
# already covered without the operator having added it. Collapsing "paths"
# and "effective" into one field (the previous shape of this method) is
# exactly what let a directory a human added survive only until the next
# `set_paths` call, which would write the already-expanded file list
# straight back to disk and permanently lose the "anything added to this
# directory later is covered too" property a real sysupgrade gives a
# directory line.
#
# Ticket 21: used to build both halves itself, calling `sysupgrade -l`
# directly -- the one external command this method ever needed, and the
# one thing "только gitbackup" says this file should never do again. The
# CLI already builds exactly this shape (usr/sbin/gitbackup's own
# `paths list --json`, ticket 21) for the same reason `config_diff` below
# has always wrapped `gitbackup diff` instead of re-deriving it here.
gbrpc_list_paths() { gbrpc_list_paths() {
_gb_rpc_out=$("$GB_BIN" paths list --json 2>&1) _gb_rpc_relay - paths list --json
_gb_rpc_rc=$? }
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out" gbrpc_audit_paths() {
else _gb_rpc_relay - paths audit
_gb_rpc_error "$_gb_rpc_out"
fi
} }
# gbrpc_validate_cron -- live validation for the Settings raw-cron field
# (spec "живая валидацию в Settings"): reuses schedule.sh's own
# gb_cron_valid/gb_cron_next rather than reimplementing either, so the
# rpcd-facing answer and the crontab that gb_cron_apply actually writes can
# never disagree about what is valid.
gbrpc_validate_cron() { gbrpc_validate_cron() {
_gb_rpc_expr=$(_gb_rpc_param "$1" '@.expr') _gb_rpc_expr=$(_gb_rpc_param "$1" '@.expr')
# gb_cron_valid prints its reason on stderr only on failure -- captured if _gb_rpc_why=$(gb_cron_valid "$_gb_rpc_expr" 2>&1 >/dev/null); then
# unconditionally here (empty when the expression is fine) so both printf '{ "valid": true }\n'
# branches below read from the one variable instead of calling it a
# third time.
_gb_rpc_why=$(gb_cron_valid "$_gb_rpc_expr" 2>&1 >/dev/null)
if gb_cron_valid "$_gb_rpc_expr" >/dev/null 2>&1; then
_gb_rpc_next=$(gb_cron_next "$_gb_rpc_expr" 2>/dev/null)
printf '{ "valid": true, "next": %s }\n' "$(gb_json_str "$_gb_rpc_next")"
else else
printf '{ "valid": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_why")" printf '{ "valid": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_why")"
fi fi
} }
# gbrpc_history -- last N commits of this device's own branch: sha,
# subject (which already starts with the backup's own date, spec G03.1, so
# there is no separate "date" field to keep in sync with it) and the list
# of /etc/config/*-and-everything-else paths that commit actually touched.
#
# Ticket 21: this used to resolve GB_URL out of UCI and fetch/log/diff-tree
# it itself, WITHOUT ever validating its shape through gb_parse_url first
# -- exactly the bypass the ticket exists to close (the operator's own
# words: "вызывать под капотом может любые команды роутера. желательно
# только gitbackup"). `gitbackup history` (usr/sbin/gitbackup, ticket 21)
# now does the exact same fetch/log/diff-tree work, but only after running
# GB_URL through gb_parse_url the same way cmd_test/cmd_run always have --
# this wrapper only ever forwards --limit and relays whatever the CLI
# printed (success: its own valid JSON body; failure: its stderr, wrapped
# into the shared { "reason": ... } shape every other method here uses).
gbrpc_history() { gbrpc_history() {
_gb_rpc_limit=$(_gb_rpc_param "$1" '@.limit') _gb_rpc_limit=$(_gb_rpc_param "$1" '@.limit')
case "$_gb_rpc_limit" in '' | *[!0-9]*) _gb_rpc_limit=20 ;; esac case "$_gb_rpc_limit" in '' | *[!0-9]*) _gb_rpc_limit=20 ;; esac
_gb_rpc_relay - history --limit "$_gb_rpc_limit"
_gb_rpc_out=$("$GB_BIN" history --limit "$_gb_rpc_limit" 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
} }
# gbrpc_diff -- plain `git diff <from> <to>` text, restricted implicitly to # In the ACL's WRITE tier: it returns backed-up content (shadow hash, host
# this device's own branch (its tree only ever holds GB_PREFIX's own # keys, PSKs); tiers split on "may see secrets", not "changes something".
# content to begin with, per gb_build_tree) -- no separate path filter
# needed on top of that.
#
# Ticket 21: `from`/`to` used to be checked only for non-emptiness, with no
# separator in front of them in the `git diff` call that followed -- a
# value shaped like an option (leading "-") would have reached git's own
# argument parser as one. This still checks for the missing-field case
# itself (a fast, obvious refusal that need not spend a process on the
# CLI), but the actual FORM check -- "a full 40-character commit sha, not
# just any non-empty string" -- now lives once, in `gitbackup diff`
# (usr/sbin/gitbackup, ticket 21): the same "одна точка входа -- одна точка
# валидации" this whole ticket asks for. A malformed value is refused by
# the CLI (exit 2) and relayed here as the same { "reason": ... } shape,
# never reaching a `git` argv at all.
#
# In the ACL's WRITE tier, not read, unlike every other method here that
# only reads. Not because it writes anything -- it does not -- but because
# of what it returns: `git diff` between two commits of the backup branch
# is the CONTENT of the backed-up files, and at the shipped default
# (visibility=private, security.scrub='0') that content is unscrubbed --
# /etc/shadow's root hash, the dropbear host keys, the WPA PSKs. A read
# grant is meant to answer "is the backup healthy", which status/log/
# history/config_diff all do without handing over a single file's bytes
# (config_diff compares manifests: paths, modes, hashes, never content).
# Splitting the tiers on "may see the router's secrets" rather than on
# "changes something" is what makes the read tier actually safe to hand
# out. The LuCI History view's own "View diff" button therefore needs the
# write grant, which is the same grant its neighbouring "Restore" button
# has always needed.
gbrpc_diff() { gbrpc_diff() {
_gb_rpc_from=$(_gb_rpc_param "$1" '@.from') _gb_rpc_from=$(_gb_rpc_param "$1" '@.from')
_gb_rpc_to=$(_gb_rpc_param "$1" '@.to') _gb_rpc_to=$(_gb_rpc_param "$1" '@.to')
@@ -273,184 +108,65 @@ gbrpc_diff() {
_gb_rpc_error 'both "from" and "to" commit shas are required' _gb_rpc_error 'both "from" and "to" commit shas are required'
return 0 return 0
fi fi
_gb_rpc_relay diff diff "$_gb_rpc_from" "$_gb_rpc_to"
_gb_rpc_out=$("$GB_BIN" diff "$_gb_rpc_from" "$_gb_rpc_to" 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -ne 0 ]; then
_gb_rpc_error "$_gb_rpc_out"
return 0
fi
_gb_rpc_text diff "$_gb_rpc_out"
} }
# gbrpc_config_diff -- "has the LIVE config drifted from the last commit", # Live config vs last commit, by manifest -- sees a chmod `git diff` cannot.
# which the two-sha `diff` above cannot answer (that one compares two
# already-committed shas, ticket 14/R124 History). This wraps `gitbackup
# diff` (usr/sbin/gitbackup's cmd_diff, ticket 17) instead: it re-collects
# the current backup set and compares its manifest against the manifest of
# the last commit pushed to this device's own branch, field by field --
# catching a chmod/chown that `git diff`/`git status` never sees at all
# (same blob, same tree entry mode class). No params; `gitbackup diff`
# itself resolves the branch/prefix from UCI exactly like `run` does.
#
# `gitbackup diff` prints nothing at all when there is no difference (its
# own documented contract) and returns 0 either way once the comparison
# ran; a nonzero exit only ever means it could not even get as far as
# comparing (gb_die: bad config, exit 2; unreachable remote, exit 3) --
# turned into the same { "reason": ... } shape every other method here
# uses, never a bare exit code, so the overview indicator can tell "no
# drift" apart from "could not check" instead of defaulting either one to
# "all saved".
gbrpc_config_diff() { gbrpc_config_diff() {
_gb_rpc_out=$("$GB_BIN" diff 2>&1) if ! _gb_rpc_out=$("$GB_BIN" diff 2>&1); then
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -ne 0 ]; then
_gb_rpc_error "$_gb_rpc_out" _gb_rpc_error "$_gb_rpc_out"
return 0 elif [ -n "$_gb_rpc_out" ]; then
fi
if [ -n "$_gb_rpc_out" ]; then
printf '{ "differs": true, "text": %s }\n' "$(gb_json_str "$_gb_rpc_out")" printf '{ "differs": true, "text": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
else else
printf '{ "differs": false, "text": "" }\n' printf '{ "differs": false, "text": "" }\n'
fi fi
} }
# gbrpc_audit_paths -- G02: what changed on this router since the base
# image but is NOT in the backup set. /overlay/upper is the standard
# OpenWrt overlayfs upper layer (everything the running system has written
# relative to the read-only squashfs lower layer); anything under it that
# is neither hard-excluded (/etc/gitbackup/**, this package's own secrets)
# nor already named by `sysupgrade -l` is exactly "changed and not backed
# up".
#
# Ticket 21: this used to walk /overlay/upper and call `sysupgrade -l`
# itself -- no user-controlled input reaches it (no params at all), so it
# was never the injection risk history/diff/list_paths were, but it is
# still an external command run directly by this file instead of by
# `gitbackup`, which "только gitbackup" does not carve out an exception
# for. `gitbackup paths audit` (usr/sbin/gitbackup, ticket 21) is the exact
# same walk, moved rather than rewritten -- including the VERIFY below,
# which is about the walk itself, not about which process performs it.
#
# VERIFY: measured only that /overlay/upper does not exist inside the
# owlab package-test container used to develop this file (a plain
# extracted rootfs, never booted through OpenWrt's own fstab/mount_root) --
# not confirmed against a real overlay mount on the owrt2512 stand. Falls
# back to an empty list rather than erroring when the directory is absent,
# so a router (or dev container) without a live overlay mount still gets a
# valid, honestly-empty answer instead of a broken one.
gbrpc_audit_paths() {
_gb_rpc_out=$("$GB_BIN" paths audit 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# --- write tier ----------------------------------------------------------
# gbrpc_run/gbrpc_test/gbrpc_restore all share one shape: the operation can
# run past any sane ubus call timeout (network waits, `test`'s own
# ConnectTimeout=15 twice over, `run`'s full collect/push cycle), so none
# of them wait for the CLI to finish. Each backgrounds it, detached from
# this script's own stdin/stdout/stderr (`</dev/null >/dev/null 2>&1 &`,
# the same detach shape etc/init.d/gitbackup's own config_change debounce
# timer already uses and that this project's tests have already proven
# survives its parent exiting), and answers immediately with
# `{ "started": true }`. The operator watches progress through `log`
# (ticket 10 acceptance criterion: "результат забирается через log") --
# every one of these already logs via gb_log under the shared "gitbackup"
# syslog tag, so no new log format is needed here.
gbrpc_run() { gbrpc_run() {
"$GB_BIN" run --trigger luci </dev/null >/dev/null 2>&1 & "$GB_BIN" run --trigger luci </dev/null >/dev/null 2>&1 &
printf '{ "started": true }\n' printf '{ "started": true }\n'
} }
# stdin is /dev/null, so an unknown host key ends as "needs confirmation";
# the web flow then goes through `hostkey`.
gbrpc_test() { gbrpc_test() {
"$GB_BIN" test </dev/null >/dev/null 2>&1 & "$GB_BIN" test </dev/null >/dev/null 2>&1 &
printf '{ "started": true }\n' printf '{ "started": true }\n'
} }
# gbrpc_hostkey -- ticket 20. gbrpc_test above backgrounds `gitbackup test` # No fingerprint: show the remote's host key. With one: accept exactly it.
# and answers immediately, so it can never hand the operator an SSH host
# key's fingerprint to confirm -- and `gitbackup test`'s own stdin is
# `</dev/null` by construction here, so its interactive accept-or-decline
# prompt (auth.sh's gb_accept_hostkey) can never be answered from the web
# at all. This is the one new rpcd method that closes that gap, wrapping
# `gitbackup hostkey` (usr/sbin/gitbackup's cmd_hostkey) -- itself
# synchronous and fast (a single SSH host-key dial, ConnectTimeout=15;
# `accept` makes no network call at all), unlike run/test/restore above.
#
# One method serves both steps of the flow, picked by whether "fingerprint"
# was given: absent/empty -> `hostkey show` (dial, report, write nothing);
# present -> `hostkey accept <fingerprint>` (commit ONLY if it matches what
# is actually cached from the last `show` -- gb_hostkey_accept's own job,
# not this file's). Never accepts a host key with no fingerprint given: an
# empty "fingerprint" here can only mean "show", not "accept anything".
gbrpc_hostkey() { gbrpc_hostkey() {
_gb_rpc_fp=$(_gb_rpc_param "$1" '@.fingerprint') _gb_rpc_fp=$(_gb_rpc_param "$1" '@.fingerprint')
if [ -n "$_gb_rpc_fp" ]; then if [ -n "$_gb_rpc_fp" ]; then
_gb_rpc_out=$("$GB_BIN" hostkey accept "$_gb_rpc_fp" 2>&1) _gb_rpc_relay - hostkey accept "$_gb_rpc_fp"
else else
_gb_rpc_out=$("$GB_BIN" hostkey show 2>&1) _gb_rpc_relay - hostkey show
fi
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi fi
} }
# gbrpc_keygen -- ticket 25. "force" with no "confirm" (or the wrong one) # keygen over an existing key needs --force --confirm <fingerprint>; exit 4
# no longer regenerates anything over an existing key: `gitbackup keygen # means "confirm first" and carries the fingerprint on its first line.
# --force` now only prints the current key's fingerprint and refuses (exit
# 4, cmd_keygen's own gb_die message) -- exactly the same show-then-name-
# it-back shape gbrpc_hostkey above already uses for the host-key prompt.
# The fingerprint is pulled back out of cmd_keygen's own first stdout line
# ("confirm-required <fingerprint>", relayed verbatim ahead of its
# stderr explanation) rather than out of the human-readable message text,
# so a future wording change to that message cannot silently break this
# parse.
gbrpc_keygen() { gbrpc_keygen() {
_gb_rpc_force=$(_gb_rpc_param "$1" '@.force') _gb_rpc_force=$(_gb_rpc_param "$1" '@.force')
_gb_rpc_confirm=$(_gb_rpc_param "$1" '@.confirm') _gb_rpc_confirm=$(_gb_rpc_param "$1" '@.confirm')
_gb_rpc_args='' set -- keygen
case "$_gb_rpc_force" in true | 1) _gb_rpc_args='--force' ;; esac case "$_gb_rpc_force" in true | 1) set -- "$@" --force ;; esac
if [ -n "$_gb_rpc_confirm" ]; then [ -z "$_gb_rpc_confirm" ] || set -- "$@" --confirm "$_gb_rpc_confirm"
# shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token _gb_rpc_out=$("$GB_BIN" "$@" 2>&1)
_gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args --confirm "$_gb_rpc_confirm" 2>&1)
else
# shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token
_gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args 2>&1)
fi
_gb_rpc_rc=$? _gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '{ "ok": true, "message": %s }\n' "$(gb_json_str "$_gb_rpc_out")" printf '{ "ok": true, "message": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
return elif [ "$_gb_rpc_rc" -eq 4 ]; then
fi
if [ "$_gb_rpc_rc" -eq 4 ]; then
_gb_rpc_fp=$(printf '%s\n' "$_gb_rpc_out" | sed -n '1s/^confirm-required //p') _gb_rpc_fp=$(printf '%s\n' "$_gb_rpc_out" | sed -n '1s/^confirm-required //p')
printf '{ "ok": false, "confirm_required": true, "fingerprint": %s, "reason": %s }\n' \ printf '{ "ok": false, "confirm_required": true, "fingerprint": %s, "reason": %s }\n' \
"$(gb_json_str "$_gb_rpc_fp")" "$(gb_json_str "$_gb_rpc_out")" "$(gb_json_str "$_gb_rpc_fp")" "$(gb_json_str "$_gb_rpc_out")"
return else
printf '{ "ok": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
fi fi
printf '{ "ok": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
} }
# gbrpc_set_secret -- writes gitbackup.origin.token_file directly (there is # Writes the token to token_file (0600 in a 0700 dir). The value never goes
# no CLI subcommand for this: the CLI never accepts a secret as an # to argv, UCI or the log; there is no method to read it back.
# argument, spec "Токен... никогда в argv", and a web form field is not
# argv, but it is still not going anywhere near UCI -- R109, "/etc/config/*
# входит в sysupgrade -l"). Never logs the value: gb_log calls below name
# only the file path, exactly like gb_keygen's own logging does for the
# key it writes.
#
# An empty value clears the secret rather than writing a lone newline: a
# 1-byte file would still pass cmd_status's own `[ -s "$token_file" ]`
# check and misreport token_set=true for a field the operator just cleared.
gbrpc_set_secret() { gbrpc_set_secret() {
_gb_rpc_value=$(_gb_rpc_param "$1" '@.value') _gb_rpc_value=$(_gb_rpc_param "$1" '@.value')
_gb_rpc_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token) _gb_rpc_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token)
@@ -479,95 +195,37 @@ gbrpc_set_secret() {
printf '{ "ok": true, "token_set": %s }\n' "$_gb_rpc_token_set" printf '{ "ok": true, "token_set": %s }\n' "$_gb_rpc_token_set"
} }
# gbrpc_set_paths -- writes /etc/sysupgrade.conf, one RAW path per line, # Writes accepted entries verbatim, never the expanded effective set: files
# exactly as given (a directory line included, never expanded -- that # created later under a directory would silently stop being backed up.
# expansion is sysupgrade's own job at upgrade time, not this method's).
#
# Every entry is validated through paths.sh's own gb_paths_validate --
# the same function the CLI's `gitbackup paths add` already calls -- not a
# second, hand-rolled copy of its rules. Previously only two of its four
# checks (absolute, the fixed blacklist) were duplicated here, leaving a
# space in the path and a nonexistent path to the LuCI view's own JS to
# catch; that was never a safe backstop, because a write straight to flash
# is not something to trust a client's own validation for, and this
# method is not the LuCI view's exclusive caller. Routing through the one
# shared validator also fixes the JS view's own long-flagged gap: it could
# never reliably tell a dangling symlink (a legitimate entry, e.g.
# /etc/resolv.conf) from a genuinely missing path, because no rpcd ACL
# anywhere grants `fs.lstat`. This function runs ON the router, so `-e ||
# -L` (gb_paths_validate's own check) sees the real filesystem and gets it
# right without needing that ACL grant at all.
#
# Rejected entries are reported, not silently dropped (spec: "отклоняются
# с объяснением, а не молча"), but do not abort the whole write -- the
# remaining, valid paths are still saved.
#
# Ticket 26: this used to write the validated entries as the ENTIRE new
# file (`mv "$_gb_rpc_tmp" "$GB_SYSUPGRADE_CONF"`, nothing else). The LuCI
# Paths view only ever sends back gb_paths_entries' own list (comments
# excluded by design, see gbrpc_list_paths's own header above), so that
# used to mean the first `set_paths` call against a stock
# /etc/sysupgrade.conf -- one Add or Remove click anywhere -- silently
# erased its two header-comment lines and its two commented-out example
# lines, because this method never looked at them at all before
# overwriting the file. paths.sh's own gb_paths_replace_entries is the
# fix: it re-reads GB_SYSUPGRADE_CONF's comment/blank lines off disk right
# before the write and keeps them, unconditionally, regardless of what
# this call's "paths" param contained -- so a save from the view can never
# lose a line it was never shown. What still writes back verbatim,
# unexpanded, is exactly the caller's own entries -- ticket 13's decision.
gbrpc_set_paths() { gbrpc_set_paths() {
_gb_rpc_dir=$(dirname "$GB_SYSUPGRADE_CONF") mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 2>/dev/null
mkdir -p "$_gb_rpc_dir" 2>/dev/null
_gb_rpc_tmp="${GB_SYSUPGRADE_CONF}.newtmp.$$" _gb_rpc_tmp="${GB_SYSUPGRADE_CONF}.newtmp.$$"
_gb_rpc_kept_f="${_gb_rpc_tmp}.kept"
_gb_rpc_rej_f="${_gb_rpc_tmp}.rej"
: >"$_gb_rpc_tmp" : >"$_gb_rpc_tmp"
: >"$_gb_rpc_kept_f" : >"$_gb_rpc_tmp.kept"
: >"$_gb_rpc_rej_f" : >"$_gb_rpc_tmp.rej"
# One ready-made JSON fragment per line in each scratch file -- _gb_rpc_param "$1" '@.paths[*]' | while IFS= read -r _gb_rpc_p || [ -n "$_gb_rpc_p" ]; do
# gb_json_str already escapes any real newline in its input (lib.sh),
# so a line-at-a-time read below can never split a value in half. This
# validation loop runs in its own subshell (the pipe from
# _gb_rpc_param_list), which is fine: it only ever writes to these two
# files, never to a variable this function reads back afterward.
_gb_rpc_param_list "$1" '@.paths[*]' | while IFS= read -r _gb_rpc_p || [ -n "$_gb_rpc_p" ]; do
[ -n "$_gb_rpc_p" ] || continue [ -n "$_gb_rpc_p" ] || continue
_gb_rpc_reason=$(gb_paths_validate "$_gb_rpc_p" 2>&1 >/dev/null) _gb_rpc_reason=$(gb_paths_validate "$_gb_rpc_p" 2>&1 >/dev/null)
if [ -n "$_gb_rpc_reason" ]; then if [ -n "$_gb_rpc_reason" ]; then
printf '{ "path": %s, "reason": %s }\n' \ printf '{ "path": %s, "reason": %s }\n' \
"$(gb_json_str "$_gb_rpc_p")" "$(gb_json_str "$_gb_rpc_reason")" >>"$_gb_rpc_rej_f" "$(gb_json_str "$_gb_rpc_p")" "$(gb_json_str "$_gb_rpc_reason")" >>"$_gb_rpc_tmp.rej"
else else
printf '%s\n' "$_gb_rpc_p" >>"$_gb_rpc_tmp" printf '%s\n' "$_gb_rpc_p" >>"$_gb_rpc_tmp"
printf '%s\n' "$(gb_json_str "$_gb_rpc_p")" >>"$_gb_rpc_kept_f" gb_json_str "$_gb_rpc_p" >>"$_gb_rpc_tmp.kept"
echo >>"$_gb_rpc_tmp.kept"
fi fi
done done
gb_paths_replace_entries "$(cat "$_gb_rpc_tmp" 2>/dev/null)" gb_paths_replace_entries "$(cat "$_gb_rpc_tmp" 2>/dev/null)"
rm -f "$_gb_rpc_tmp"
printf '{ "ok": true, "written": [' printf '{ "ok": true, "written": [%s], "rejected": [%s] }\n' \
_gb_rpc_first=1 "$(gb_join_lines , <"$_gb_rpc_tmp.kept")" "$(gb_join_lines , <"$_gb_rpc_tmp.rej")"
while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do rm -f "$_gb_rpc_tmp" "$_gb_rpc_tmp.kept" "$_gb_rpc_tmp.rej"
[ -n "$_gb_rpc_j" ] || continue
[ "$_gb_rpc_first" -eq 1 ] || printf ','
_gb_rpc_first=0
printf '%s' "$_gb_rpc_j"
done <"$_gb_rpc_kept_f"
printf '], "rejected": ['
_gb_rpc_first=1
while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do
[ -n "$_gb_rpc_j" ] || continue
[ "$_gb_rpc_first" -eq 1 ] || printf ','
_gb_rpc_first=0
printf '%s' "$_gb_rpc_j"
done <"$_gb_rpc_rej_f"
printf '] }\n'
rm -f "$_gb_rpc_kept_f" "$_gb_rpc_rej_f"
} }
# Backgrounded like run/test; --yes because the view already asked twice.
gbrpc_restore() { gbrpc_restore() {
_gb_rpc_device=$(_gb_rpc_param "$1" '@.device') _gb_rpc_device=$(_gb_rpc_param "$1" '@.device')
_gb_rpc_commit=$(_gb_rpc_param "$1" '@.commit') _gb_rpc_commit=$(_gb_rpc_param "$1" '@.commit')
@@ -590,14 +248,7 @@ gbrpc_restore() {
printf '{ "started": true }\n' printf '{ "started": true }\n'
} }
# gbrpc_card -- card.sh (ticket 09) may not exist yet on a system built
# ahead of it landing; the same tolerance usr/sbin/gitbackup's own cmd_run
# already applies, not a stub invented for this file only.
gbrpc_card() { gbrpc_card() {
if ! command -v gb_card >/dev/null 2>&1; then
_gb_rpc_error 'the recovery card generator is not installed on this system yet'
return 0
fi
_gb_rpc_file=$(mktemp "${TMPDIR:-/tmp}/gbrpc-card.XXXXXX") || { _gb_rpc_file=$(mktemp "${TMPDIR:-/tmp}/gbrpc-card.XXXXXX") || {
_gb_rpc_error 'could not create a scratch file under /tmp' _gb_rpc_error 'could not create a scratch file under /tmp'
return 0 return 0
@@ -610,10 +261,6 @@ gbrpc_card() {
rm -f "$_gb_rpc_file" rm -f "$_gb_rpc_file"
} }
# ---------------------------------------------------------------------------
# rpcd entry point
# ---------------------------------------------------------------------------
case "${1:-}" in case "${1:-}" in
list) list)
cat <<'EOF' cat <<'EOF'
@@ -639,17 +286,8 @@ case "${1:-}" in
EOF EOF
;; ;;
call) call)
# `|| _gb_rpc_input='{}'` here (rather than `|| true`) would silently # rpcd sends params with NO trailing newline: `read` fills the
# DISCARD a real, fully-read line: confirmed live against the actual # variable but returns 1, so `|| X='{}'` would drop every parameter.
# rpcd daemon (not just a manual `echo ... | plugin call` test,
# which never reproduced this) -- rpcd's own call params arrive on
# stdin with NO trailing newline, so `read -r` reads the whole line
# correctly but still returns nonzero for hitting EOF instead of a
# newline. Every parameterized method (log/diff/history/
# validate_cron/set_secret/set_paths/restore) would otherwise always
# see "{}" and never its real arguments, while a hand-typed
# `printf '...\n' | ... call ...` test -- which DOES add a trailing
# newline -- would keep looking correct throughout development.
IFS= read -r _gb_rpc_input || true IFS= read -r _gb_rpc_input || true
[ -n "$_gb_rpc_input" ] || _gb_rpc_input='{}' [ -n "$_gb_rpc_input" ] || _gb_rpc_input='{}'
case "${2:-}" in case "${2:-}" in
File diff suppressed because it is too large. Load diff
+11 -45
View File
@@ -1,46 +1,17 @@
#!/bin/sh #!/bin/sh
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- GIT_ASKPASS helper (spec "Аутентификация"). # GIT_ASKPASS helper, executed by git with the prompt in $1. The only reader
# of the token file: the token reaches git through this pipe, never argv, a
# URL or a log.
# #
# Not sourced like the rest of usr/share/gitbackup/*.sh -- git execs this # Only the password prompt gets the token. git embeds the username in the
# directly (GIT_ASKPASS=/usr/share/gitbackup/askpass.sh, set by # URL of its next prompt and passes that as argv here, so a token answered
# auth.sh's gb_git_env) once per missing credential component, with the # as username would leak into argv (seen with GIT_TRACE=1).
# prompt text as $1 ("Username for '...': ", "Password for '...': ", the
# exact wording is git's own and varies by version), and reads exactly one
# line back from this script's stdout.
# #
# This is the only place gitbackup.origin.token_file is ever read for a # VERIFY: placeholder-user + token-as-password is documented for GitHub PATs
# push: the token reaches git through this pipe, never through argv, a URL, # only; not tested against GitLab/Bitbucket/Gitea.
# or a log line.
#
# The username prompt is answered with a fixed, non-secret placeholder --
# NEVER the token -- and only the password prompt gets the token. This is
# not the obvious design: an earlier version answered both prompts with the
# token (a fine-grained PAT works as either field on GitHub, per its own
# docs), on the reasoning that a value read from a file and only ever
# piped to git's stdin cannot show up in `ps` because it was never a
# command-line argument. That reasoning was wrong, caught only by actually
# tracing a live request on the owlab stand (GIT_TRACE=1): git treats
# whatever the username prompt returns as the credential's username, and
# builds the URL for its *next* prompt -- "Password for
# 'http://<username>@host:port': " -- with that value inline, then execs
# this script again with that whole string as $1. A token answered as the
# username therefore reappears as a literal substring of argv on the very
# next invocation, which is precisely what this package must never do
# (spec: "Токен... никогда в argv"). Answering the username with a fixed
# placeholder instead means the value that could ever leak into an argv or
# a URL is never the secret. Re-verified after the fix, same live trace:
# the second prompt now reads "Password for
# 'http://gitbackup@host:port': " -- the token appears nowhere in argv, at
# any point, confirmed by sampling `ps` throughout the whole exchange.
#
# VERIFY: the placeholder username plus token-as-password shape is
# confirmed against GitHub's own docs (a fine-grained PAT as the password
# with any non-empty username); not confirmed live against GitLab/
# Bitbucket/self-hosted Gitea, which needs a real hosted remote and real
# credentials neither of which exist on this dev stand (spec.md, "Открытые
# места": "push на живой GitHub/GitLab -- нет учётных данных на стенде").
set -u set -u
GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
@@ -49,10 +20,7 @@ GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
case "${1:-}" in case "${1:-}" in
*sername*) *sername*)
# Never the token: see this file's header. The exact string is # Any non-empty value works; never the token.
# arbitrary -- git only requires it to be non-empty -- "gitbackup"
# just makes a stray log line or trace recognizable as this
# package's own request rather than a real account name.
printf 'gitbackup\n' printf 'gitbackup\n'
;; ;;
*) *)
@@ -61,9 +29,7 @@ case "${1:-}" in
gb_log err "askpass: no readable token at $_gb_token_file (gitbackup.origin.token_file)" gb_log err "askpass: no readable token at $_gb_token_file (gitbackup.origin.token_file)"
exit 1 exit 1
fi fi
# head -n 1, not cat: a trailing blank line or a second line in the # First line only: a stray trailing newline must not reach git.
# token file (a stray newline from how it was pasted in) must not
# become part of what git sends as the credential.
head -n 1 "$_gb_token_file" head -n 1 "$_gb_token_file"
;; ;;
esac esac
+60 -343
View File
@@ -1,86 +1,18 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- access to the remote (spec "Аутентификация"). # gitbackup -- remote access: gb_git_env, gb_keygen, gb_keygen_forget_old,
# # gb_pubkey, gb_accept_hostkey, gb_hostkey_show, gb_hostkey_accept. Needs lib.sh.
# Exposes gb_git_env, gb_keygen, gb_pubkey and gb_accept_hostkey; every
# _gb_auth_-prefixed helper below is private and namespaced by module, same
# convention collect.sh uses (multiple *.sh here end up sourced into the
# same process by usr/sbin/gitbackup, so a bare _gb_ helper name could
# collide with a sibling module's). Sourced, never executed: nothing here
# runs at load time -- GB_ETC_DIR below is a variable assignment from an
# environment default, not a filesystem access.
#
# Depends on lib.sh (gb_log, gb_uci_get) already being sourced by the
# caller, same convention every other module here uses.
#
# GB_ETC_DIR overrides gitbackup's own secrets directory (default
# /etc/gitbackup). It is deliberately NOT a UCI option: gitbackup.origin.
# key_file and .token_file already give the operator a path for those two,
# but known_hosts has no such option in the shipped schema (spec:
# "known_hosts лежит в /etc/gitbackup/", a fixed location, not configurable)
# -- this exists only so tests/run.sh can point it at a tmp directory
# instead of the real /etc/gitbackup, the same seam GB_ROOT/GB_STATE_DIR/
# GB_SYSFS_NET give the other modules.
GB_ETC_DIR="${GB_ETC_DIR:-/etc/gitbackup}" GB_ETC_DIR="${GB_ETC_DIR:-/etc/gitbackup}"
# gb_git_env # gb_git_env -- "export KEY='VALUE'" lines for `eval "$(gb_git_env)"`.
# ConnectTimeout: BatchMode does not cover the TCP handshake, and a host that
# drops packets would hang ssh for minutes.
# #
# Prints "export KEY='VALUE'" lines, one per line, quoted so a caller can # Must be OpenSSH `ssh`, not dbclient: git's ssh-variant probe fails on it and
# safely `eval "$(gb_git_env)"` even though GIT_SSH_COMMAND's value itself # falls back to "simple", dropping every -o option, host-key checking included.
# contains spaces -- and so the assignments are exported into the calling
# shell's environment, where the `git` child process this whole thing
# exists for can actually see them.
# #
# GIT_SSH_COMMAND and GIT_ASKPASS are both always printed, regardless of # TLS verification can never be turned off; ca_file is the only way to trust
# gitbackup.origin.auth: it is the git remote URL's own scheme (ssh:// vs # a private CA. The token is never printed: askpass.sh reads it on demand.
# https://), not this option, that decides which one a given `git`
# invocation actually uses -- ssh transport never calls GIT_ASKPASS for
# anything, and GIT_SSH_COMMAND is simply inert for an https transport. Not
# branching on .auth here means there is exactly one thing to keep in sync
# with the remote URL parser (remoteurl.sh) instead of two.
#
# GIT_SSH_COMMAND carries the four options the spec names by name: -i (the
# deploy key), UserKnownHostsFile (this package's own known_hosts, never
# the invoking user's ~/.ssh/known_hosts -- a stale entry there must not be
# able to either accept or block this package's own host-key decision),
# StrictHostKeyChecking=yes (an unknown or changed host key is refused,
# full stop -- gitbackup test is the only place that ever adds one) and
# BatchMode=yes (never fall back to an interactive passphrase/password
# prompt that would hang a cron job forever) -- plus ConnectTimeout=15, not
# named in the spec but added after a live failure on the owlab stand: a
# host that never answers at all (as opposed to actively refusing the
# connection) leaves `ssh` hanging with neither of those two safeguards to
# stop it, well past two minutes with no output. BatchMode only rules out
# an interactive *prompt*; it does nothing about the TCP handshake itself
# never completing, which is exactly what a firewalled or simply
# nonexistent host looks like.
#
# GIT_SSL_CAINFO is printed only when gitbackup.origin.ca_file is set, and
# is the only sanctioned way to talk to a self-signed/private-CA remote:
# GIT_SSL_NO_VERIFY and http.sslVerify=false are refused even as an option
# by this package, spec "Аутентификация" and "Границы и швы" -- there is no
# code path anywhere in this package that can turn certificate verification
# off, only one that can hand it an extra trusted certificate.
#
# _gb_ssh_cmd below is built as a plain `ssh ...` command, not busybox's
# `dbclient` -- and this StrictHostKeyChecking=yes option is exactly why.
# Measured on the 25.12.4 stand (dropbear 2025.89, git 2.50.1): with
# GIT_SSH_COMMAND set to a dbclient invocation, `GIT_TRACE=1 git fetch`
# shows git's ssh-variant probe (`dbclient -G ...`, used to detect whether
# the ssh command understands OpenSSH's own option syntax) failing, after
# which git silently falls back to its "simple" transport form and stops
# passing any `-o NAME=VALUE` pair at all -- not just this one, every
# option this function sets, including UserKnownHostsFile and BatchMode.
# The failure is invisible: git does not warn that it dropped them, fetch
# still succeeds against a reachable host, and the option only turns out
# to have been a no-op the day host-key checking was supposed to catch
# something. That silent downgrade, not merely dbclient's separate inability
# to read OpenSSH-format keys (see package/gitbackup/Makefile), is why this
# package depends on the real openssh-client instead.
#
# Never prints the token itself: GIT_ASKPASS names askpass.sh's path, and
# the token is read off disk by askpass.sh's own process, later, only once
# git has actually decided it needs credentials.
gb_git_env() { gb_git_env() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
_gb_known="$GB_ETC_DIR/known_hosts" _gb_known="$GB_ETC_DIR/known_hosts"
@@ -88,8 +20,6 @@ gb_git_env() {
printf 'export GIT_SSH_COMMAND=%s\n' "$(_gb_auth_shquote "$_gb_ssh_cmd")" printf 'export GIT_SSH_COMMAND=%s\n' "$(_gb_auth_shquote "$_gb_ssh_cmd")"
printf 'export GIT_ASKPASS=%s\n' "$(_gb_auth_shquote "${GB_SHARE:-/usr/share/gitbackup}/askpass.sh")" printf 'export GIT_ASKPASS=%s\n' "$(_gb_auth_shquote "${GB_SHARE:-/usr/share/gitbackup}/askpass.sh")"
# Never let git fall back to its own interactive prompt on a real tty --
# askpass.sh is the only credential source this package allows.
printf 'export GIT_TERMINAL_PROMPT=%s\n' "$(_gb_auth_shquote 0)" printf 'export GIT_TERMINAL_PROMPT=%s\n' "$(_gb_auth_shquote 0)"
_gb_ca=$(gb_uci_get gitbackup.origin.ca_file) _gb_ca=$(gb_uci_get gitbackup.origin.ca_file)
@@ -97,23 +27,10 @@ gb_git_env() {
return 0 return 0
} }
# _gb_auth_shquote <string> -- <string> wrapped in single quotes, with any
# single quote inside it escaped, so the result can be eval'd back to the
# original value regardless of what it contains (a path with a space is the
# realistic case here, not an adversarial one, but the quoting is correct
# either way).
_gb_auth_shquote() { _gb_auth_shquote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
} }
# _gb_auth_key_fingerprint <key-path>
#
# ssh-keygen -lf's own fingerprint line for whichever half of the pair is
# actually readable, preferring the public half (same preference order
# gb_pubkey below already uses) -- unlike gb_pubkey, this never needs the
# `-y` derivation fallback: `ssh-keygen -lf` accepts either a private or a
# public key file directly and prints the identical fingerprint for
# either, since the fingerprint is a hash of the public half alone.
_gb_auth_key_fingerprint() { _gb_auth_key_fingerprint() {
_gb_akf_key="$1" _gb_akf_key="$1"
if [ -r "$_gb_akf_key.pub" ]; then if [ -r "$_gb_akf_key.pub" ]; then
@@ -123,84 +40,16 @@ _gb_auth_key_fingerprint() {
fi fi
} }
# gb_keygen [force] [confirm-fingerprint] # gb_keygen [force] [confirm-fingerprint] -- no passphrase: BatchMode=yes
# would make an encrypted key hang every push.
# #
# Generates the deploy key at gitbackup.origin.key_file (default # Replacing a key cuts off every remote it is registered with, so it takes
# /etc/gitbackup/id_ed25519): ed25519, no passphrase. No passphrase is # force AND the current fingerprint named back:
# deliberate, not an oversight -- GIT_SSH_COMMAND always carries # key, no force -- refuse, 1
# BatchMode=yes (gb_git_env), which refuses to prompt for one, so an # key, force, no/wrong fp -- print "confirm-required <fp>", destroy
# encrypted key would simply make every push hang forever instead of # nothing, 4
# failing loudly. # key, force, matching fp -- keep <key>.old(.pub) for manual rollback,
# # regenerate, 0
# Refuses to overwrite a key that already exists unless <force> is a
# non-empty argument: losing the only copy of a deploy key locks every
# remote it was already added to until the operator notices and re-adds
# the new public half everywhere (ticket 04 acceptance criterion). This
# part of the contract is unchanged by ticket 25 below: `gitbackup keygen`
# with no `force` at all on an existing key behaves exactly as before.
#
# Ticket 25: `force` alone used to be enough to destroy the working key on
# the spot, no warning, no way back -- reported live from a real router
# where `keygen --force`, run only to see where the operation writes,
# silently ate the key a working deploy relied on. The private half is
# gone the instant the `rm -f` below runs, and every remote it had been
# added to as a deploy key stops authenticating that same instant, with
# nothing on this router able to undo either side of that.
#
# gb_hostkey_show/gb_hostkey_accept above already solved the equivalent
# problem for a different irreversible decision -- "confirm by naming
# exactly what was shown", not a bare y/N -- and this reuses that same
# shape instead of adding a `read` prompt here: a `read`-based prompt
# would need a second, separate branch for "no controlling terminal at
# all" (ticket 20's own EOF-is-neither-yes-nor-no lesson, gb_accept_hostkey
# above), and that branch would need testing on its own. Requiring the
# caller to name the fingerprint back sidesteps the distinction entirely --
# an interactive operator and a non-interactive script take the identical
# path, and there is no `read` anywhere in this function to hang or
# misread an EOF as a decision nobody made:
#
# force set, no existing key -- generates immediately, exactly
# as before (nothing to confirm)
# force unset, existing key -- refused, exit 1, unchanged
# force set, existing key, confirm -- prints "confirm-required
# empty or not matching the current <fingerprint>" to stdout (nothing
# key's own fingerprint destroyed) and returns 4
# force set, existing key, confirm -- proceeds: the old key is kept
# matches the current fingerprint aside (see below) and a fresh one
# exactly is generated; returns 0
#
# A caller that never asks (only ever passes `force`) gets told what is
# missing on every single call -- it can never destroy anything by
# accident, and it is never met with a bare, unexplained refusal either.
#
# Decision (ticket 25 leaves this to the implementer, either answer
# acceptable if justified): the key about to be destroyed is copied aside
# as "<key>.old"/"<key>.old.pub" before anything is removed, left there
# until gb_keygen_forget_old below is called. Nothing on this router ever
# reads ".old" back automatically -- there is deliberately no "fall back
# to the old key if the new one fails" logic, which would make a failed
# push ambiguous about which key actually ran and silently reintroduce the
# very key this whole flow just told the operator to stop using. The
# reason to keep it anyway is narrower: the one step this flow cannot
# verify from the router is whether the operator actually went to the
# provider and added the NEW public key before the next push runs. If they
# forgot, or pasted the wrong key, the router itself becomes unreachable
# over the only channel this package uses, and the file this comment is
# justifying is then the difference between "cp it back and try again" and
# "nothing, the old private half is just gone" -- for the cost of one
# extra ~400-byte file that a successful `gitbackup test` (cmd_test,
# usr/sbin/gitbackup) removes on its own the first time the NEW key is
# actually proven to work. Deleting outright instead (the spec's other
# sanctioned answer) would not shorten the provider trip either way; it
# would only remove this one way an already-bad day gets worse.
#
# Creates the key's directory at 0700 if it does not exist yet (uci-
# defaults/99-gitbackup already does this for the default /etc/gitbackup,
# but gitbackup.origin.key_file can point anywhere), and leaves the
# private key at 0600 -- ssh-keygen's own default, asserted here rather
# than trusted, since a future ssh-keygen with a different umask-derived
# default would otherwise change this package's security posture with no
# code change on this side to catch it.
gb_keygen() { gb_keygen() {
_gb_force="${1:-}" _gb_force="${1:-}"
_gb_confirm="${2:-}" _gb_confirm="${2:-}"
@@ -225,11 +74,7 @@ gb_keygen() {
return 4 return 4
fi fi
# Confirmed: keep the outgoing key's own copy before touching # Best effort: must not block a confirmed regeneration.
# anything -- see this function's header comment for why. Best
# effort on purpose (2>/dev/null): a failure to write the backup
# copy must never block the regeneration the operator explicitly
# confirmed, only mean there is nothing to roll back to.
cp -f "$_gb_key" "$_gb_key.old" 2>/dev/null && chmod 0600 "$_gb_key.old" 2>/dev/null cp -f "$_gb_key" "$_gb_key.old" 2>/dev/null && chmod 0600 "$_gb_key.old" 2>/dev/null
[ -e "$_gb_key.pub" ] && cp -f "$_gb_key.pub" "$_gb_key.old.pub" 2>/dev/null [ -e "$_gb_key.pub" ] && cp -f "$_gb_key.pub" "$_gb_key.old.pub" 2>/dev/null
fi fi
@@ -249,31 +94,12 @@ gb_keygen() {
return 0 return 0
} }
# gb_keygen_forget_old # Called once `gitbackup test` has proven the current key works.
#
# Ticket 25's other half of the id_ed25519.old decision above (gb_keygen's
# own header comment): removes the pre-regeneration copy once something has
# actually proven the CURRENT key works end to end. Called from cmd_test's
# own success path (usr/sbin/gitbackup) -- a successful `git ls-remote`
# against the configured remote is the one signal this package already
# computes that means "the key at key_file is accepted by the provider
# right now", which is exactly the event this flow is waiting for. A no-op
# whenever there is nothing to forget (the overwhelming majority of
# successful `test` runs never followed a keygen at all), so this is safe
# to call unconditionally on every success rather than tracking anywhere
# whether a keygen happened first.
gb_keygen_forget_old() { gb_keygen_forget_old() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
rm -f "$_gb_key.old" "$_gb_key.old.pub" rm -f "$_gb_key.old" "$_gb_key.old.pub"
} }
# gb_pubkey
#
# Prints the deploy key's public half. Reads the already-generated .pub
# file when it is there (the common case, ssh-keygen always writes one),
# and falls back to deriving it from the private key with `ssh-keygen -y`
# only if that file is missing -- an operator could plausibly delete just
# the .pub half by hand and expect this to still work.
gb_pubkey() { gb_pubkey() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
if [ -r "$_gb_key.pub" ]; then if [ -r "$_gb_key.pub" ]; then
@@ -288,38 +114,15 @@ gb_pubkey() {
return 1 return 1
} }
# _gb_auth_dial_hostkey <host> <port> <out-file> # _gb_auth_dial_hostkey <host> <port> <out-file> -- no ssh-keyscan in
# # openssh-client: key exchange precedes auth, so accept-new against an empty
# The dial step gb_accept_hostkey and gb_hostkey_show below both need, # scratch known_hosts records the key even though auth fails. 2 unreachable.
# factored out so there is exactly one place that knows how to fetch a raw
# host key off the wire. There is no ssh-keyscan on this image without
# pulling in the separate openssh-client-utils package (measured on the
# 25.12.4 stand: `apk info -L openssh-client openssh-keygen` together
# provide neither ssh-keyscan nor any equivalent) -- rather than growing
# DEPENDS for one command, this reuses the `ssh` binary DEPENDS already
# carries. The SSH protocol completes its host-key exchange before user
# authentication even starts, so `ssh -o StrictHostKeyChecking=accept-new
# -o BatchMode=yes` against a scratch, empty known_hosts file writes the
# host's key into it regardless of whether the authentication that follows
# succeeds -- verified live on the owlab stand: `ssh -o
# UserKnownHostsFile=<scratch> -o StrictHostKeyChecking=accept-new -o
# BatchMode=yes git@github.com exit` still populates <scratch> with
# github.com's key even though the command itself exits 255 on "Permission
# denied (publickey)" right after, because no key for github.com had
# actually been added yet.
#
# Writes <out-file> and returns 0 when a key was obtained, or returns 2
# with <out-file> removed when nothing could be obtained at all (host
# unreachable, connection refused, DNS failure).
_gb_auth_dial_hostkey() { _gb_auth_dial_hostkey() {
_gb_adh_host="$1" _gb_adh_host="$1"
_gb_adh_port="$2" _gb_adh_port="$2"
_gb_adh_out="$3" _gb_adh_out="$3"
rm -f "$_gb_adh_out" rm -f "$_gb_adh_out"
# ConnectTimeout=15: same reasoning as gb_git_env's own copy of it -- a
# host that never answers at all, rather than actively refusing,
# otherwise leaves this hanging well past two minutes (measured live).
ssh -o UserKnownHostsFile="$_gb_adh_out" -o StrictHostKeyChecking=accept-new \ ssh -o UserKnownHostsFile="$_gb_adh_out" -o StrictHostKeyChecking=accept-new \
-o BatchMode=yes -o ConnectTimeout=15 -p "$_gb_adh_port" "git@$_gb_adh_host" exit >/dev/null 2>&1 -o BatchMode=yes -o ConnectTimeout=15 -p "$_gb_adh_port" "git@$_gb_adh_host" exit >/dev/null 2>&1
@@ -330,58 +133,33 @@ _gb_auth_dial_hostkey() {
return 0 return 0
} }
# _gb_hostkey_pending_file -- fixed path under GB_ETC_DIR, never part of # gb_hostkey_accept commits exactly the bytes gb_hostkey_show fetched, never
# known_hosts itself. Holds the raw known_hosts-format line(s) for the # a fresh dial, so what the operator confirmed is what gets trusted.
# LAST host key gb_hostkey_show fetched, and nothing else: gb_hostkey_accept
# below commits exactly this file's bytes, never a fresh dial of its own
# (see gb_hostkey_accept's own header comment for why that distinction is
# the whole point of the two-step design).
_gb_hostkey_pending_file() { _gb_hostkey_pending_file() {
printf '%s/hostkey_pending' "$GB_ETC_DIR" printf '%s/hostkey_pending' "$GB_ETC_DIR"
} }
# gb_accept_hostkey <host> [port] # Sets _gb_host, _gb_port, _gb_known; true when already in known_hosts.
# _gb_hostkey_init() {
# Interactive, and only ever meant to be called from `gitbackup test`
# (spec: "Host key принимается только внутри gitbackup test"). Fetches the
# host's SSH key, shows its fingerprint on stderr, asks for a yes/no on
# stdin, and only on "yes" appends it to this package's own known_hosts
# (GB_ETC_DIR/known_hosts) -- never the invoking user's ~/.ssh/known_hosts.
#
# Returns 0 once the key is confirmed and recorded (or was already known --
# see the ssh-keygen -F check below, which makes this safe to call
# unconditionally from `gitbackup test` every time), 1 when the operator
# explicitly declines, 2 when no host key could be obtained at all (host
# unreachable, connection refused, DNS failure), 3 when there was no way to
# even ASK the operator (ticket 20: `read` hit EOF -- no controlling
# terminal at all, e.g. rpcd's own `gitbackup test </dev/null`). 3 used to
# be indistinguishable from 1 here: the old code fed `read`'s result
# straight into the `case` below without ever looking at `read`'s own exit
# status, so an EOF -- which busybox ash's `read` reports as a nonzero
# return with `_gb_ans` left empty -- fell into the same "not y" branch a
# real, typed "no" does, logging "declined by the operator" for an operator
# who was never actually asked. Reported live against a real GitHub deploy
# key on a real router: `gitbackup test` run from `gbrpc_test` (stdin
# `</dev/null` by construction, ticket 20) always produced that exact
# false accusation, and offered no way to accept the key from the web at
# all -- gb_hostkey_show/gb_hostkey_accept below are that way.
gb_accept_hostkey() {
_gb_host="$1" _gb_host="$1"
_gb_port="${2:-22}" _gb_port="${2:-22}"
case "$_gb_port" in [ "$_gb_port" != 0 ] || _gb_port=22
''|0) _gb_port=22 ;;
esac
mkdir -p "$GB_ETC_DIR" 2>/dev/null mkdir -p "$GB_ETC_DIR" 2>/dev/null
_gb_known="$GB_ETC_DIR/known_hosts" _gb_known="$GB_ETC_DIR/known_hosts"
[ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1
}
# Already trusted: nothing to accept. Asked of ssh-keygen itself, not a _gb_hostkey_record() {
# raw grep, so a known_hosts entry hashed with -H (never written by this cat "$1" >>"$_gb_known"
# package, but nothing stops an operator from hand-editing the file) is chmod 0600 "$_gb_known"
# still recognized correctly. rm -f "$1"
if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then gb_log notice "$2: $_gb_host:$_gb_port accepted and recorded in $_gb_known"
return 0 }
fi
# gb_accept_hostkey <host> [port] -- terminal prompt; never trusts silently.
# 0 trusted, 1 declined, 2 unreachable, 3 no stdin (web uses show/accept).
gb_accept_hostkey() {
_gb_hostkey_init "$1" "${2:-}" && return 0
_gb_scratch=$(mktemp "${TMPDIR:-/tmp}/gitbackup-hostkey.XXXXXX") || return 2 _gb_scratch=$(mktemp "${TMPDIR:-/tmp}/gitbackup-hostkey.XXXXXX") || return 2
if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_scratch"; then if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_scratch"; then
@@ -392,60 +170,27 @@ gb_accept_hostkey() {
_gb_fp=$(ssh-keygen -lf "$_gb_scratch" 2>/dev/null) _gb_fp=$(ssh-keygen -lf "$_gb_scratch" 2>/dev/null)
printf 'Host key for %s:%s --\n %s\nAccept and remember it? [y/N] ' "$_gb_host" "$_gb_port" "$_gb_fp" >&2 printf 'Host key for %s:%s --\n %s\nAccept and remember it? [y/N] ' "$_gb_host" "$_gb_port" "$_gb_fp" >&2
# See this function's own header comment: `read`'s exit status, not if ! IFS= read -r _gb_ans; then
# just the string it captured, is what tells a real "no" (read
# succeeded, the operator typed something other than y/yes) apart from
# "could not ask at all" (read failed -- EOF, no stdin to read from).
if IFS= read -r _gb_ans; then
case "$_gb_ans" in
y|Y|yes|YES) ;;
*)
rm -f "$_gb_scratch"
gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port declined by the operator"
return 1
;;
esac
else
rm -f "$_gb_scratch" rm -f "$_gb_scratch"
gb_log err "gb_accept_hostkey: $_gb_host:$_gb_port could not ask for confirmation -- no interactive input is available here. Run 'gitbackup test' from a terminal with a real stdin, or accept the host key from the LuCI web UI (Settings -> Connection test), which uses 'gitbackup hostkey show'/'hostkey accept' for exactly this case." gb_log err "gb_accept_hostkey: $_gb_host:$_gb_port could not ask for confirmation -- no interactive input is available here. Run 'gitbackup test' from a terminal with a real stdin, or accept the host key from the LuCI web UI (Settings -> Connection test), which uses 'gitbackup hostkey show'/'hostkey accept' for exactly this case."
return 3 return 3
fi fi
case "$_gb_ans" in
cat "$_gb_scratch" >>"$_gb_known" y|Y|yes|YES) ;;
chmod 0600 "$_gb_known" *)
rm -f "$_gb_scratch" rm -f "$_gb_scratch"
gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port accepted and recorded in $_gb_known" gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port declined by the operator"
return 0 return 1
} ;;
# gb_hostkey_show <host> [port]
#
# The non-interactive half of ticket 20's web flow: dials <host>, same as
# gb_accept_hostkey above, but never asks anything and never writes
# known_hosts. Instead it caches the exact bytes it fetched in
# _gb_hostkey_pending_file, so a later gb_hostkey_accept call can commit
# precisely THAT material -- not whatever a second, independent dial might
# return -- which is what keeps the show/confirm round trip from opening a
# window for a key swap in between (see gb_hostkey_accept's own comment).
#
# Prints exactly one line to stdout:
# trusted <host> <port> -- nothing to show, already known
# pending <host> <port> <fingerprint...> -- ssh-keygen -lf's own text
# Deliberately plain, not JSON: usr/sbin/gitbackup's cmd_hostkey is the one
# JSON boundary here, same split gb_pubkey/`gitbackup pubkey` already uses.
#
# Returns 0 in both printed cases above, 2 when the host could not be
# reached at all (nothing printed, nothing cached).
gb_hostkey_show() {
_gb_host="$1"
_gb_port="${2:-22}"
case "$_gb_port" in
''|0) _gb_port=22 ;;
esac esac
mkdir -p "$GB_ETC_DIR" 2>/dev/null _gb_hostkey_record "$_gb_scratch" gb_accept_hostkey
_gb_known="$GB_ETC_DIR/known_hosts" }
if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then
# gb_hostkey_show <host> [port] -- "trusted <host> <port>" or
# "pending <host> <port> <fp>". Records nothing. 2 when unreachable.
gb_hostkey_show() {
if _gb_hostkey_init "$1" "${2:-}"; then
printf 'trusted %s %s\n' "$_gb_host" "$_gb_port" printf 'trusted %s %s\n' "$_gb_host" "$_gb_port"
return 0 return 0
fi fi
@@ -462,33 +207,11 @@ gb_hostkey_show() {
return 0 return 0
} }
# gb_hostkey_accept <host> [port] <fingerprint> # gb_hostkey_accept <host> <port> <fp> -- only on an exact fingerprint match.
# # 1 nothing pending, 4 mismatch.
# Commits the key gb_hostkey_show cached, but ONLY when <fingerprint>
# matches ssh-keygen's own fingerprint of exactly that cached material.
# <fingerprint> is the text the operator was shown and clicked "accept"
# on, so this check is what stops a second, possibly different dial from
# silently substituting a different key between the moment the fingerprint
# was displayed and the moment it was confirmed (an active MITM that
# changed which key answers the connection the second time around, or a
# second concurrent gb_hostkey_show call from another tab overwriting the
# pending file first). This is exactly why this function never dials the
# network itself: re-fetching "to double check" would defeat the point --
# it would be confirming a NEW dial, not the one the operator actually
# saw.
#
# Returns 0 once accepted and recorded, 1 when there is nothing pending
# (gb_hostkey_show was never called, or its result already got
# consumed/replaced -- call it again), 4 when <fingerprint> does not match
# what is actually cached (exit-code contract: "4 отказ по безопасности" --
# this is refused, never silently accepted with a mismatch logged instead).
gb_hostkey_accept() { gb_hostkey_accept() {
_gb_host="$1" _gb_hostkey_init "$1" "${2:-}"
_gb_port="${2:-22}"
_gb_want_fp="$3" _gb_want_fp="$3"
case "$_gb_port" in
''|0) _gb_port=22 ;;
esac
_gb_pending=$(_gb_hostkey_pending_file) _gb_pending=$(_gb_hostkey_pending_file)
if [ ! -s "$_gb_pending" ]; then if [ ! -s "$_gb_pending" ]; then
@@ -502,11 +225,5 @@ gb_hostkey_accept() {
return 4 return 4
fi fi
mkdir -p "$GB_ETC_DIR" 2>/dev/null _gb_hostkey_record "$_gb_pending" gb_hostkey_accept
_gb_known="$GB_ETC_DIR/known_hosts"
cat "$_gb_pending" >>"$_gb_known"
chmod 0600 "$_gb_known"
rm -f "$_gb_pending"
gb_log notice "gb_hostkey_accept: $_gb_host:$_gb_port accepted and recorded in $_gb_known"
return 0
} }
+9 -57
View File
@@ -1,42 +1,10 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- recovery card (spec "Recovery card и RECOVERY.md", ticket 09). # gitbackup -- recovery card: gb_card. Needs lib.sh and remoteurl.sh; reads
# GB_URL/GB_DEVICE/GB_SCRUB as resolved by the CLI.
# #
# Exposes gb_card; every _gb_card_-prefixed helper below is private, same # gb_card <outfile> -- writes RECOVERY.md. Never contains a secret. Runs
# per-module namespacing convention every sibling file here uses. Sourced, # mid-backup, so it never dies and always returns 0.
# never executed: nothing here runs at load time.
#
# Depends on lib.sh (gb_log, gb_uci_get) and remoteurl.sh (gb_parse_url,
# gb_provider) already being sourced by the caller -- same convention
# visibility.sh/restore.sh use. Reads GB_URL and GB_DEVICE from the
# environment rather than taking them as arguments: usr/sbin/gitbackup's
# _gb_run_backup already has both resolved (by gb_validate_config, before
# _gb_run_backup even starts) at the one call site this module is actually
# reached from today (step 11b, "the recovery card"), same convention
# gitio.sh/restore.sh already use for config context the CLI resolved once.
# A future LuCI "Download recovery card" button (ticket 11) goes through
# `gitbackup card`, which resolves the same two through gb_validate_config
# first, so this module never has to.
#
# gb_card is deliberately built to never die and never fail loudly: it is
# called from the middle of a real backup run (usr/sbin/gitbackup's
# `gb_card "$_gb_outdir/RECOVERY.md" 2>/dev/null`), and a bug in a recovery
# document must not be able to abort the backup it rides along with. Every
# helper below degrades to a shorter card instead of calling gb_die, and
# gb_card itself always returns 0.
# gb_card <outfile>
#
# Writes one recovery document to <outfile> -- the same content LuCI's
# "Download recovery card" button (ticket 11) offers for offline storage and
# `gitbackup run` commits to devices/<id>/RECOVERY.md (spec: "Оба генерирует
# gb_card; различаются только назначением и местом"). Contains a ready-made
# bootstrap one-liner with --repo/--device already filled in, a link to the
# repository's own web UI, a reminder that a token or deploy key still has
# to be supplied by hand, and a short Path 0 fallback for when there is no
# network to run bootstrap.sh over at all. Never the secret itself: neither
# the token nor the deploy key's private half ever passes through this
# module (interfaces.md: "Секрета в карточке нет").
gb_card() { gb_card() {
_gb_c_out="$1" _gb_c_out="$1"
_gb_c_dev="${GB_DEVICE:-}" _gb_c_dev="${GB_DEVICE:-}"
@@ -47,13 +15,8 @@ gb_card() {
_gb_c_authflag='--token <TOKEN>' _gb_c_authflag='--token <TOKEN>'
_gb_c_weblink='' _gb_c_weblink=''
# GB_SCRUB is this run's EFFECTIVE scrub decision, already resolved # GB_SCRUB, not gitbackup.security.scrub: visibility=public forces scrub
# once by gb_validate_config (usr/sbin/gitbackup) and exported for # on, and missing that would promise a Path 0 that does not exist.
# every subcommand -- the same seam collect.sh reads GB_DEVICE
# through, and the reason this file does not re-derive it from
# gitbackup.security.scrub itself: visibility=public forces scrub on
# regardless of what that option says, and a card that missed the
# override would promise a Path 0 that does not exist.
_gb_c_scrub=0 _gb_c_scrub=0
[ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] && _gb_c_scrub=1 [ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] && _gb_c_scrub=1
_gb_c_archive=0 _gb_c_archive=0
@@ -71,11 +34,7 @@ gb_card() {
_gb_c_owner="$4" _gb_c_owner="$4"
_gb_c_repo="$5" _gb_c_repo="$5"
[ "$_gb_c_scheme" = ssh ] && _gb_c_authflag='--ssh-key <PATH-TO-DEPLOY-KEY>' [ "$_gb_c_scheme" = ssh ] && _gb_c_authflag='--ssh-key <PATH-TO-DEPLOY-KEY>'
# No port here, same known limitation as gb_deeplink # No port: known provider web UIs are on the default https port.
# (remoteurl.sh): every provider this package recognizes serves
# its web UI on the default https port, so this has never
# needed one -- a self-hosted instance on a nonstandard port
# would need it added here too, at the same time.
_gb_c_weblink="https://$_gb_c_host/$_gb_c_owner/$_gb_c_repo" _gb_c_weblink="https://$_gb_c_host/$_gb_c_owner/$_gb_c_repo"
fi fi
fi fi
@@ -100,15 +59,8 @@ gb_card() {
if [ -n "$_gb_c_weblink" ]; then if [ -n "$_gb_c_weblink" ]; then
printf 'Repository: %s\n\n' "$_gb_c_weblink" printf 'Repository: %s\n\n' "$_gb_c_weblink"
fi fi
# Path 0 is printed only when there is actually an archive in the # Path 0 only when an archive is actually pushed (archive on, no
# repository to open it with. `gitbackup run` writes # scrub); otherwise say why it is unavailable.
# backup.tar.gz when main.archive is on AND this run does not
# scrub (usr/sbin/gitbackup's own step 11a -- sysupgrade -b reads
# the live filesystem, so pushing its output would undo the scrub
# the same run was just asked to perform). A recovery card is
# read exactly once, by someone whose router is already broken;
# sending them after a file that was never pushed is worse than
# telling them this path is unavailable, and why.
if [ "$_gb_c_archive" = 1 ]; then if [ "$_gb_c_archive" = 1 ]; then
printf '## Path 0 -- no network, or bootstrap.sh will not run\n\n' printf '## Path 0 -- no network, or bootstrap.sh will not run\n\n'
printf '1. Open the repository above from any other device and download\n' printf '1. Open the repository above from any other device and download\n'
+32 -167
View File
@@ -1,64 +1,24 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- backup-set collection and manifest.json (spec "Сбор и # gitbackup -- backup-set collection: gb_collect, gb_manifest_path,
# manifest.json"). # gb_manifest_equal. Needs lib.sh; reads GB_DEVICE and GB_SCRUB set by the CLI.
#
# Exposes gb_collect, gb_manifest_path and gb_manifest_equal; every
# _gb_collect_-prefixed helper below is private and namespaced by module
# (not just "_gb_") because every *.sh here ends up sourced into the same
# process by usr/sbin/gitbackup -- a same-named helper in a sibling module
# would silently shadow this one otherwise. Sourced, never executed:
# nothing here runs at load time.
#
# Depends on lib.sh (gb_log, gb_json_esc/str, ...) already being sourced by
# the caller, same convention device.sh uses. It does NOT depend on
# device.sh: the device name is read from $GB_DEVICE, which
# usr/sbin/gitbackup's gb_validate_config already resolves once via
# gb_device_id and exports for every subcommand (same contract as GB_URL
# and GB_SCRUB) -- collecting would otherwise resolve the device a second
# time and could disagree with the CLI's own answer.
#
# GB_ROOT overrides the filesystem root every real-path read goes through
# (default '', meaning the actual '/'). GB_EXCLUDE_LIST overrides where the
# hard-exclude patterns live (default GB_SHARE/exclude.list, GB_SHARE
# itself defaulting like the CLI's own). Neither is set by the router --
# both exist so tests/run.sh can point collect.sh at a fixture tree instead
# of the host filesystem, the same seam GB_SYSFS_NET gives device.sh.
GB_ROOT="${GB_ROOT:-}" GB_ROOT="${GB_ROOT:-}"
GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}" GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}"
# gb_manifest_path <outdir>
gb_manifest_path() { gb_manifest_path() {
printf '%s/manifest.json\n' "$1" printf '%s/manifest.json\n' "$1"
} }
# gb_manifest_equal <manifest-a> <manifest-b> # Only entries[] and scrubbed[]: "generated" and host metadata would
# # otherwise produce a commit every run.
# True when two manifest.json files describe the same backup set. Compares
# only entries[] and scrubbed[] (spec: "generated не участвует в сравнении
# манифестов, иначе изменения были бы каждый прогон") -- and, by the same
# reasoning, neither do version/hostname/device/openwrt/board: none of them
# describe what restore actually writes back, and comparing them would turn
# an unrelated hostname change into a spurious commit on the next run.
#
# gb_manifest_tail (lib.sh) is the shared reader now: this used to be its
# own private sed range here, one of three near-identical readers of the
# same flat manifest.json format that scrub.sh and restore.sh had each
# grown independently (ticket 18) -- one reader, not three.
gb_manifest_equal() { gb_manifest_equal() {
[ -r "$1" ] && [ -r "$2" ] || return 1 [ -r "$1" ] && [ -r "$2" ] || return 1
[ "$(gb_manifest_tail "$1")" = "$(gb_manifest_tail "$2")" ] [ "$(gb_manifest_tail "$1")" = "$(gb_manifest_tail "$2")" ]
} }
# gb_collect <outdir> # gb_collect <outdir> -- outdir/files, meta/, manifest.json. The path list is
# # `sysupgrade -l` only; sources are walked just for empty dirs.
# Builds outdir/files (backup-set tree, paths preserved), outdir/meta (five
# files) and outdir/manifest.json. The path list is `sysupgrade -l` and
# nothing else (R29) -- collect.sh never walks /etc on its own to decide
# what belongs in the set, only to find the empty directories `sysupgrade
# -l` can never report (see _gb_collect_empty_dirs) and to concatenate
# /etc/apk/repositories.d/* for meta/repositories.txt.
gb_collect() { gb_collect() {
_gb_outdir="$1" _gb_outdir="$1"
[ -n "$_gb_outdir" ] || { gb_log err 'gb_collect: outdir is required'; return 1; } [ -n "$_gb_outdir" ] || { gb_log err 'gb_collect: outdir is required'; return 1; }
@@ -77,31 +37,18 @@ gb_collect() {
rm -rf "$_gb_scratch" rm -rf "$_gb_scratch"
} }
# _gb_collect_is_excluded <path> -- true when <path> matches a pattern in
# GB_EXCLUDE_LIST. A pattern ending in "/**" matches the path itself and
# everything under it; anything else is matched with plain `case` glob
# rules. Re-reads the file on every call rather than caching it in a
# variable: the list is a handful of lines, and this keeps the match logic
# free of the quoting/IFS games a cached multi-line variable would need.
#
# Matched on the canonical spelling of <path>, never the raw one: `case`
# globbing is textual, so "//etc/gitbackup/token" and
# "/etc/./gitbackup/token" match no pattern that names /etc/gitbackup even
# though find(1), cp(1) and the kernel all agree they are that file. This
# is the second half of the same fix gb_paths_validate carries -- that one
# stops such an entry being accepted into sysupgrade.conf at all, this one
# holds even for a path that never went through it, since `sysupgrade -l`
# unions in /lib/upgrade/keep.d/* and changed conffiles that this package
# does not own. exclude.list's own R76 ("must never end up inside its own
# backup, no matter how the router is configured") is only true with both.
_gb_collect_is_excluded() { _gb_collect_is_excluded() {
# Own variable name, deliberately not the _gb_path every caller loop # Canonical: case globs are textual. Own variable name: callers loop over
# here already uses: POSIX sh has no locals, and canonicalizing into # _gb_path and sh has no locals.
# that name would silently rewrite the caller's loop variable too.
_gb_ie_path=$(gb_path_canon "$1") _gb_ie_path=$(gb_path_canon "$1")
[ -r "$GB_EXCLUDE_LIST" ] || return 1 [ -r "$GB_EXCLUDE_LIST" ] || return 1
while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do
case "$_gb_pat" in case "$_gb_pat" in
# Whole-file secrets: excluded only when this run scrubs.
'#public: '*)
[ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] || continue
_gb_pat="${_gb_pat#'#public: '}"
;;
''|'#'*) continue ;; ''|'#'*) continue ;;
esac esac
case "$_gb_pat" in case "$_gb_pat" in
@@ -122,25 +69,12 @@ _gb_collect_is_excluded() {
return 1 return 1
} }
# _gb_collect_files <outdir> <entries-file> # Pipe subshell: state goes to <entries-file>, not variables.
#
# Copies every file and symlink `sysupgrade -l` reports, minus hard-exclude,
# into outdir/files with its path preserved, and appends one manifest entry
# line per item to <entries-file>. Runs the loop body on the read side of a
# pipe (a subshell in POSIX sh), so it only ever accumulates state by
# appending to <entries-file> -- a real file survives the subshell boundary,
# a shell variable set in there would not.
_gb_collect_files() { _gb_collect_files() {
_gb_outdir="$1" _gb_outdir="$1"
_gb_entries="$2" _gb_entries="$2"
sysupgrade -l 2>/dev/null | while IFS= read -r _gb_path || [ -n "$_gb_path" ]; do sysupgrade -l 2>/dev/null | while IFS= read -r _gb_path || [ -n "$_gb_path" ]; do
[ -n "$_gb_path" ] || continue [ -n "$_gb_path" ] || continue
# One spelling per file from here down, so the staged tree, the
# manifest entry and the exclude decision cannot disagree about
# what "this path" is: "/etc/./config/network" and
# "/etc/config/network" are one file to the kernel but two
# distinct manifest entries (and two distinct restore targets)
# if the raw string is carried through.
_gb_path=$(gb_path_canon "$_gb_path") _gb_path=$(gb_path_canon "$_gb_path")
_gb_collect_is_excluded "$_gb_path" && continue _gb_collect_is_excluded "$_gb_path" && continue
@@ -149,40 +83,28 @@ _gb_collect_files() {
mkdir -p "$(dirname "$_gb_dest")" || continue mkdir -p "$(dirname "$_gb_dest")" || continue
if [ -L "$_gb_src" ]; then if [ -L "$_gb_src" ]; then
# Never dereferenced (R24.1): the target string is copied as-is, # Never dereferenced: stat without -L reports the link itself.
# and mode/uid/gid come from `stat` without -L, i.e. the link
# itself, not what it points to.
_gb_target=$(readlink "$_gb_src") || continue _gb_target=$(readlink "$_gb_src") || continue
ln -s "$_gb_target" "$_gb_dest" || continue ln -s "$_gb_target" "$_gb_dest" || continue
_gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0' _gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086 # word-splitting is the point: unpacking "mode uid gid" # shellcheck disable=SC2086 # word-splitting is the point: unpacking "mode uid gid"
set -- $_gb_mug set -- $_gb_mug
_gb_collect_entry_symlink "$_gb_path" "$1" "$2" "$3" "$_gb_target" >>"$_gb_entries" _gb_collect_entry symlink "$_gb_path" "$1" "$2" "$3" target "$_gb_target" >>"$_gb_entries"
elif [ -f "$_gb_src" ]; then elif [ -f "$_gb_src" ]; then
cp "$_gb_src" "$_gb_dest" || continue cp "$_gb_src" "$_gb_dest" || continue
_gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0' _gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086 # shellcheck disable=SC2086
set -- $_gb_mug set -- $_gb_mug
_gb_sha=$(sha256sum "$_gb_dest" 2>/dev/null | awk '{print $1}') _gb_sha=$(sha256sum "$_gb_dest" 2>/dev/null | awk '{print $1}')
_gb_collect_entry_file "$_gb_path" "$1" "$2" "$3" "$_gb_sha" >>"$_gb_entries" _gb_collect_entry file "$_gb_path" "$1" "$2" "$3" sha256 "$_gb_sha" >>"$_gb_entries"
else else
gb_log warning "gb_collect: sysupgrade -l listed '$_gb_path' but it is not there anymore" gb_log warning "gb_collect: sysupgrade -l listed '$_gb_path' but it is not there anymore"
fi fi
done done
} }
# _gb_collect_empty_dirs <outdir> <entries-file> # `sysupgrade -l` never lists directories and git stores none, so empty dirs
# # get explicit `dir` entries.
# `sysupgrade -l` never reports a directory (spec: it flattens every source
# through `find ... -type f -o -type l`), so an empty directory anywhere in
# the backup set would silently vanish from the tree and from git, which
# does not store empty directories either. This walks the same sources
# sysupgrade.conf feeds that `find` -- /etc/sysupgrade.conf and every file
# under /lib/upgrade/keep.d/ -- and records, as a `dir` entry, every
# directory under a directory-type line that turns out to have zero
# children. A directory that has children (even only other empty
# directories) is not itself empty and gets no entry of its own: restore
# recreates it implicitly by recreating what it contains.
_gb_collect_empty_dirs() { _gb_collect_empty_dirs() {
_gb_outdir="$1" _gb_outdir="$1"
_gb_entries="$2" _gb_entries="$2"
@@ -196,9 +118,7 @@ _gb_collect_empty_dirs() {
/*) ;; /*) ;;
*) continue ;; # blank, comment, or a relative line -- not a source we can resolve *) continue ;; # blank, comment, or a relative line -- not a source we can resolve
esac esac
# keep.d lines end directories in "/" (measured on the 25.12.4 stand, # keep.d lines end directories in "/"; strip it.
# e.g. "/etc/config/"); stripped here so `find`'s own top-level match
# does not echo it back into the manifest path.
_gb_line="${_gb_line%/}" _gb_line="${_gb_line%/}"
[ -n "$_gb_line" ] && [ -d "$GB_ROOT$_gb_line" ] || continue [ -n "$_gb_line" ] && [ -d "$GB_ROOT$_gb_line" ] || continue
@@ -209,43 +129,30 @@ _gb_collect_empty_dirs() {
_gb_mug=$(stat -c '%a %u %g' "$_gb_realdir" 2>/dev/null) || _gb_mug='0 0 0' _gb_mug=$(stat -c '%a %u %g' "$_gb_realdir" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086 # shellcheck disable=SC2086
set -- $_gb_mug set -- $_gb_mug
_gb_collect_entry_dir "$_gb_relpath" "$1" "$2" "$3" >>"$_gb_entries" _gb_collect_entry dir "$_gb_relpath" "$1" "$2" "$3" >>"$_gb_entries"
done done
done done
} }
# _gb_collect_meta <outdir> -- the five meta/ files R17-R21 ask for.
_gb_collect_meta() { _gb_collect_meta() {
_gb_outdir="$1" _gb_outdir="$1"
# board.json: `ubus call system board` (R17), read back by
# _gb_collect_write_manifest instead of calling ubus a second time.
ubus call system board 2>/dev/null >"$_gb_outdir/meta/board.json" ubus call system board 2>/dev/null >"$_gb_outdir/meta/board.json"
# installed_packages.txt: full "name-version" lines, not bare names --
# --with-packages (a later ticket) reads names from `apk info` instead
# (spec, "apk"). R18.
apk list --installed 2>/dev/null >"$_gb_outdir/meta/installed_packages.txt" apk list --installed 2>/dev/null >"$_gb_outdir/meta/installed_packages.txt"
# repositories.txt: /etc/apk/repositories.d/*, concatenated -- 25.12 has # 25.12 has no /etc/apk/repositories, only repositories.d/*.
# no /etc/apk/repositories at all (measured on stand), so that path is
# never read. R19.
: >"$_gb_outdir/meta/repositories.txt" : >"$_gb_outdir/meta/repositories.txt"
for _gb_f in "$GB_ROOT/etc/apk/repositories.d/"*; do for _gb_f in "$GB_ROOT/etc/apk/repositories.d/"*; do
[ -r "$_gb_f" ] && cat "$_gb_f" >>"$_gb_outdir/meta/repositories.txt" [ -r "$_gb_f" ] && cat "$_gb_f" >>"$_gb_outdir/meta/repositories.txt"
done done
# sysupgrade.conf: a copy of the extra-paths file as it stood at
# collection time (R20).
if [ -r "$GB_ROOT/etc/sysupgrade.conf" ]; then if [ -r "$GB_ROOT/etc/sysupgrade.conf" ]; then
cp "$GB_ROOT/etc/sysupgrade.conf" "$_gb_outdir/meta/sysupgrade.conf" cp "$GB_ROOT/etc/sysupgrade.conf" "$_gb_outdir/meta/sysupgrade.conf"
else else
: >"$_gb_outdir/meta/sysupgrade.conf" : >"$_gb_outdir/meta/sysupgrade.conf"
fi fi
# os-release.txt: a copy of /etc/os-release (R21). Confirmed on the
# owlab 25.12.4 stand: the file exists and VERSION_ID="25.12.4" is
# exactly the field manifest.openwrt below reads out of it.
if [ -r "$GB_ROOT/etc/os-release" ]; then if [ -r "$GB_ROOT/etc/os-release" ]; then
cp "$GB_ROOT/etc/os-release" "$_gb_outdir/meta/os-release.txt" cp "$GB_ROOT/etc/os-release" "$_gb_outdir/meta/os-release.txt"
else else
@@ -253,7 +160,6 @@ _gb_collect_meta() {
fi fi
} }
# _gb_collect_write_manifest <outdir> <entries-file>
_gb_collect_write_manifest() { _gb_collect_write_manifest() {
_gb_outdir="$1" _gb_outdir="$1"
_gb_entries="$2" _gb_entries="$2"
@@ -266,11 +172,7 @@ _gb_collect_write_manifest() {
"$_gb_outdir/meta/os-release.txt" 2>/dev/null | head -n 1) "$_gb_outdir/meta/os-release.txt" 2>/dev/null | head -n 1)
_gb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) _gb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# sort -u both dedupes (an empty directory reachable from two sources in # Stable order across runs.
# sysupgrade.conf would otherwise get two identical entries) and makes
# two collections of an unchanged router byte-identical apart from
# "generated" -- gb_manifest_equal does not depend on this, but it means
# `diff` on two manifests is actually readable.
_gb_sorted=$(sort -u "$_gb_entries") _gb_sorted=$(sort -u "$_gb_entries")
{ {
@@ -282,40 +184,16 @@ _gb_collect_write_manifest() {
printf ' "openwrt": %s,\n' "$(gb_json_str "$_gb_openwrt")" printf ' "openwrt": %s,\n' "$(gb_json_str "$_gb_openwrt")"
printf ' "board": %s,\n' "$(gb_json_str "$_gb_model")" printf ' "board": %s,\n' "$(gb_json_str "$_gb_model")"
printf ' "entries": [\n' printf ' "entries": [\n'
_gb_collect_join "$_gb_sorted" printf '%s\n' "$_gb_sorted" | gb_manifest_join
printf ' ],\n' printf ' ],\n'
printf ' "scrubbed": [\n' printf ' "scrubbed": [\n'
# Always empty here: scrub only runs at visibility=public and edits # Filled later by gb_scrub.
# outdir/files in place after gb_collect has already run (ticket 05
# owns that step and, with it, the entries/scrubbed this manifest
# needs once scrubbing has happened -- not this function's concern,
# see collect.sh's own header comment on ownership).
printf ' ]\n' printf ' ]\n'
printf '}\n' printf '}\n'
} >"$_gb_manifest" } >"$_gb_manifest"
} }
# _gb_collect_join <text> -- <text>'s non-empty lines, each indented 4 # A failed stat must not produce invalid JSON.
# spaces and comma-joined, with no trailing comma on the last one -- the
# shape a JSON array's body needs. <text> is a whole variable, not a file:
# the values it holds already went through sort -u in the one caller that
# exists today.
_gb_collect_join() {
_gb_first=1
printf '%s\n' "$1" | while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do
[ -n "$_gb_line" ] || continue
if [ "$_gb_first" -eq 1 ]; then _gb_first=0; else printf ',\n'; fi
printf ' %s' "$_gb_line"
done
[ -z "$1" ] || printf '\n'
}
# _gb_collect_json_num <value> -- <value> if it is all digits, else "0".
#
# Guards mode/uid/gid against a failed `stat` leaving them empty: an
# unquoted empty field would make the manifest invalid JSON, and "0" is a
# safe, obviously-wrong value a human reviewing the manifest will notice
# rather than a parser choking on it.
_gb_collect_json_num() { _gb_collect_json_num() {
case "${1:-}" in case "${1:-}" in
''|*[!0-9]*) printf '0' ;; ''|*[!0-9]*) printf '0' ;;
@@ -323,23 +201,10 @@ _gb_collect_json_num() {
esac esac
} }
# _gb_collect_entry_file <path> <mode> <uid> <gid> <sha256> # _gb_collect_entry <type> <path> <mode> <uid> <gid> [<key> <value>]
_gb_collect_entry_file() { _gb_collect_entry() {
printf '{"path":%s,"type":"file","mode":%s,"uid":%s,"gid":%s,"sha256":%s}\n' \ printf '{"path":%s,"type":"%s","mode":%s,"uid":%s,"gid":%s%s}\n' \
"$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \ "$(gb_json_str "$2")" "$1" "$(_gb_collect_json_num "$3")" \
"$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")" "$(gb_json_str "$5")" "$(_gb_collect_json_num "$4")" "$(_gb_collect_json_num "$5")" \
} "${6:+,\"$6\":$(gb_json_str "$7")}"
# _gb_collect_entry_symlink <path> <mode> <uid> <gid> <target>
_gb_collect_entry_symlink() {
printf '{"path":%s,"type":"symlink","mode":%s,"uid":%s,"gid":%s,"target":%s}\n' \
"$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \
"$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")" "$(gb_json_str "$5")"
}
# _gb_collect_entry_dir <path> <mode> <uid> <gid>
_gb_collect_entry_dir() {
printf '{"path":%s,"type":"dir","mode":%s,"uid":%s,"gid":%s}\n' \
"$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \
"$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")"
} }
+10 -49
View File
@@ -1,16 +1,7 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- device identity (spec G01). # gitbackup -- device identity: gb_device_id, gb_expand.
#
# Exposes gb_device_id and gb_expand; every _gb_-prefixed helper below is
# private and may change without notice to callers, same convention as
# lib.sh. Sourced, never executed: nothing here runs at load time.
# gb_device_id -- resolves gitbackup.main.device_id to a device name.
#
# One of three strategies. Called on every subcommand (through the CLI's
# config validation), so the strategies and their failure modes live here
# once instead of being reimplemented at every call site.
gb_device_id() { gb_device_id() {
_gb_strategy=$(gb_uci_get gitbackup.main.device_id hostname) _gb_strategy=$(gb_uci_get gitbackup.main.device_id hostname)
case "$_gb_strategy" in case "$_gb_strategy" in
@@ -21,42 +12,18 @@ gb_device_id() {
esac esac
} }
# gb_expand <template> -- substitutes every {device} in <template> with # gb_expand <template> -- substitutes {device}; resolves the device only when
# gb_device_id's answer. # the placeholder is present, so a plain template never fails on it.
#
# Takes only the template on purpose (see interfaces.md): the device name is
# always gb_device_id's own resolution, never a caller-supplied override, so
# there is exactly one place that can disagree with it. gb_device_id is only
# called when the template actually has a placeholder, so a plain template
# (e.g. a literal path_prefix) never pays for device resolution it does not
# need and cannot fail because of it.
gb_expand() { gb_expand() {
_gb_tpl="$1" _gb_dev=''
case "$_gb_tpl" in case "$1" in
*'{device}'*) _gb_dev=$(gb_device_id) || exit $? ;; *'{device}'*) _gb_dev=$(gb_device_id) || exit $? ;;
esac esac
_gb_out='' gb_subst_device "$1" "$_gb_dev"
_gb_rest="$_gb_tpl"
while true; do
case "$_gb_rest" in
*'{device}'*)
_gb_out="$_gb_out${_gb_rest%%\{device\}*}$_gb_dev"
_gb_rest="${_gb_rest#*\{device\}}"
;;
*)
_gb_out="$_gb_out$_gb_rest"
break
;;
esac
done
printf '%s\n' "$_gb_out"
} }
# _gb_device_by_hostname -- gitbackup.main.device_id='hostname'. # Refuses the stock 'OpenWrt' hostname: two routers left at the default
# # would push to the same branch and overwrite each other.
# Refuses the stock 'OpenWrt' hostname: two routers left at the default would
# resolve to the same device name, push to the same branch, and overwrite
# each other's backups.
_gb_device_by_hostname() { _gb_device_by_hostname() {
_gb_board=$(ubus call system board 2>/dev/null) _gb_board=$(ubus call system board 2>/dev/null)
_gb_h=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname') _gb_h=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname')
@@ -67,7 +34,6 @@ _gb_device_by_hostname() {
printf '%s\n' "$_gb_h" printf '%s\n' "$_gb_h"
} }
# _gb_device_by_custom -- gitbackup.main.device_id='custom'.
_gb_device_by_custom() { _gb_device_by_custom() {
_gb_d=$(gb_uci_get gitbackup.main.device) _gb_d=$(gb_uci_get gitbackup.main.device)
[ -n "$_gb_d" ] || [ -n "$_gb_d" ] ||
@@ -81,12 +47,8 @@ _gb_device_by_custom() {
printf '%s\n' "$_gb_d" printf '%s\n' "$_gb_d"
} }
# _gb_device_by_board -- gitbackup.main.device_id='board'. # <model-slug>-<last 6 hex of first NIC MAC>: hardware-only, deterministic.
# # GB_SYSFS_NET is a test seam.
# <model-slug>-<last 6 hex digits of the first NIC's MAC>, deterministic
# because it depends only on hardware, never on config. GB_SYSFS_NET
# overrides the sysfs root so tests/run.sh can feed a fake set of interfaces;
# the router never sets it and gets the real /sys/class/net.
_gb_device_by_board() { _gb_device_by_board() {
_gb_board=$(ubus call system board 2>/dev/null) _gb_board=$(ubus call system board 2>/dev/null)
_gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model') _gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model')
@@ -113,7 +75,6 @@ _gb_device_by_board() {
printf '%s-%s\n' "$(_gb_slugify "$_gb_model")" "$_gb_tail" printf '%s-%s\n' "$(_gb_slugify "$_gb_model")" "$_gb_tail"
} }
# _gb_slugify <string> -- lowercase, every byte outside [a-z0-9] becomes '-'.
_gb_slugify() { _gb_slugify() {
# shellcheck disable=SC2018,SC2019 # ASCII on purpose: device names must match [A-Za-z0-9._-] # shellcheck disable=SC2018,SC2019 # ASCII on purpose: device names must match [A-Za-z0-9._-]
printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g' printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g'
@@ -1,50 +1,18 @@
# gitbackup hard-exclude list. # gitbackup hard-exclude list: one glob per line, matched against absolute
# # paths (collect.sh). "/**" matches the path and everything under it;
# One glob pattern per line, matched against an absolute path -- either one # otherwise plain shell `case` glob rules. Enforced at every visibility.
# `sysupgrade -l` reported or a directory found while looking for empty
# directories (collect.sh, _gb_collect_is_excluded). "#" starts a comment,
# blank lines are ignored. A pattern ending in "/**" matches the path
# itself and everything under it; anything else is matched with plain
# shell `case` glob rules.
#
# This block is unconditional: it is enforced at every visibility setting.
# The extra secret-file excludes visibility=public adds (/etc/shadow,
# dropbear host keys, uhttpd.key, /etc/ssl/private/**, WireGuard private
# keys -- spec "Scrub") are not here: they depend on a UCI setting this
# file has no way to read, and belong to scrub.sh instead (ticket 05).
# The package's own credentials must never end up inside its own backup, # The package's own credentials never go into its own backup.
# no matter how the router is configured (R76).
/etc/gitbackup/** /etc/gitbackup/**
# --- public-only hard-exclude (ticket 05, spec "Scrub") --- # --- excluded only while scrubbing ---
# # "#public: <pattern>" lines are DATA: collect.sh excludes them when the run
# Every line below starting "#public:" is, to collect.sh, just another # scrubs (forced on by visibility=public). Whole-file secrets; no redaction
# comment -- '#'* is always skipped by _gb_collect_is_excluded, so # makes them safe to publish.
# collect.sh's own unconditional read of this file (used at every
# visibility, private included) never sees these as patterns and its
# behavior needs no visibility awareness added to it. scrub.sh is the only
# reader that treats "#public:" specially: it strips the prefix and applies
# what remains as a hard-exclude pattern against the already-collected tree,
# and scrub.sh itself only ever runs when visibility=public -- so the
# distinction lives in who reads the line, not in a flag either reader has
# to check. These are files no redaction can make safe to publish, because
# the secret IS the file, not a value inside it: removed from the tree
# entirely and dropped from manifest.json's entries[] (never scrubbed[] --
# that array is for redacted option values, not removed files). Same glob
# rules as above, applied to the pattern text after "#public:".
#
# Named verbatim in the brief and spec "Scrub":
#public: /etc/shadow #public: /etc/shadow
#public: /etc/dropbear/*_host_key* #public: /etc/dropbear/*_host_key*
#public: /etc/uhttpd.key #public: /etc/uhttpd.key
#public: /etc/ssl/private/** #public: /etc/ssl/private/**
# VERIFY: no OpenWrt-specific source confirms this path is ever populated -- # VERIFY: no OpenWrt package is known to write here (UCI wireguard keeps keys
# the wireguard package itself keeps keys inline in UCI (network.*.private_key, # inline, see scrub.list); wg-quick convention, kept for hand-made keys.
# already in scrub.list) and has no file-based key storage of its own. This
# is the upstream wg-quick convention (a *.conf/key pair under
# /etc/wireguard/), kept as defense-in-depth for a router where someone
# generated keys by hand (`wg genkey`) and left the file there instead of
# only pasting it into UCI -- not verified against an OpenWrt package that
# actually writes there.
#public: /etc/wireguard/** #public: /etc/wireguard/**
+23 -196
View File
@@ -1,46 +1,14 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- push without a clone (spec "Как коммит попадает в # gitbackup -- push without a clone: gb_remote_head, gb_fetch_meta,
# репозиторий без клона (G05, G06)"). This is the trick the whole package # gb_build_tree, gb_commit_push.
# exists to prove: `git commit-tree` only ever needs the PARENT COMMIT
# object to exist in the local object database -- never its tree, never
# its blobs.
# #
# Ticket 07's own V10 check (done before any of this was written, both # `git commit-tree -p <parent>` needs the parent commit object locally or
# against this repo's git 2.51 on the dev host and consistent with git # fails "not a valid object" -- hence gb_fetch_meta's blobless shallow fetch.
# 2.50.1 on the owlab 25.12.4 stand): a parent SHA that is not in the local # Reads GB_URL, GB_PREFIX, GB_PARENT as resolved by the CLI. Needs lib.sh.
# object database AT ALL makes `commit-tree -p <sha>` hard-fail with
# "fatal: <sha> is not a valid object" -- so fetching that one commit
# object (gb_fetch_meta below) is mandatory, not an optional optimization.
# Once just the commit object exists locally, `commit-tree` happily builds
# a new tree/commit on top of it, and `git push` negotiates and sends only
# the objects the far end is actually missing -- proven with a single
# hand-copied commit object and zero blobs, `git fsck --full` clean on the
# receiving bare repository afterward.
#
# Every function here reads GB_URL from the environment rather than taking
# it as an argument -- the same convention visibility.sh/auth.sh use for
# config context the CLI already resolved once (gb_validate_config) -- so a
# caller only ever names what actually varies per call: a branch, a tree, a
# directory. gb_build_tree additionally reads GB_PREFIX and GB_PARENT this
# same way (see its own comment) for the same reason.
#
# Sourced, never executed: nothing here runs at load time. Depends on
# lib.sh (gb_log) already being sourced by the caller, same convention as
# every other module in this directory.
# gb_remote_head <branch> # gb_remote_head <branch> -- tip SHA, or nothing (rc 0) for a new branch.
# # Returns 1 when ls-remote fails; mapping to exit 3 is the caller's.
# Prints <branch>'s current tip SHA on GB_URL and returns 0; prints nothing
# and returns 0 when the branch does not exist yet (a device's first ever
# backup) -- verified live: `git ls-remote <a-reachable-repo>
# refs/heads/<no-such-branch>` exits 0 with empty output, not an error, as
# long as the repository itself was reachable. Prints nothing and returns 1
# when `git ls-remote` itself failed (network unreachable, host key
# refused, authentication rejected -- git folds all of these into one
# nonzero exit and a "fatal: ..." message); turning that into the shared
# exit-3 (network/auth) contract is the caller's job, not this module's,
# same division of responsibility gb_visibility_ok's 0/3/4 already uses.
gb_remote_head() { gb_remote_head() {
_gb_branch="$1" _gb_branch="$1"
_gb_out=$(git ls-remote "${GB_URL:?gb_remote_head: GB_URL is not set}" \ _gb_out=$(git ls-remote "${GB_URL:?gb_remote_head: GB_URL is not set}" \
@@ -54,63 +22,10 @@ gb_remote_head() {
return 0 return 0
} }
# gb_fetch_meta <branch> <repodir> [depth] # gb_fetch_meta <branch> <repodir> [depth] -- depth "" = full history.
# # blob:none, not tree:0: trees stay so one blob can be lazily fetched by path.
# Prepares <repodir> as a scratch, non-bare git repository (created empty # Named remote: an anonymous-URL partial fetch breaks later lazy fetches
# the first time; reused as-is on a retry, spec step 14) and fetches only # ("promisor remote name cannot begin with '/'").
# <branch>'s tip -- commit and trees, never blobs, when the remote
# understands partial clone (`--filter=blob:none`).
#
# <depth> defaults to 1 -- this function's original, and still by far its
# most common, shape: `run`'s own "just the tip" need, every call site in
# usr/sbin/gitbackup. An explicit empty string ("") skips `--depth`
# entirely and fetches the branch's whole history instead, which is what
# restore.sh needs to reach an arbitrary past commit (ticket 18: this
# parameter is what let restore.sh's own near-identical
# init/remote-add/fetch sequence for exactly that one case go away, instead
# of staying a second, hand-maintained copy of this same function that
# differed from it only in this one flag).
#
# Not `--filter=tree:0`, even though that measures smaller in the spec's
# own table (12 KiB flat vs. 644 KiB for openwrt/luci -- "Проверенные
# факты 25.12.4"): the very next thing usr/sbin/gitbackup's run does with
# this fetch is read one specific blob out of it (the old manifest.json,
# for the unchanged-since-last-time comparison), and `blob:none` leaves
# every tree in place so that blob can be fetched lazily by path -- a
# `git cat-file -p <parent>:<prefix>/manifest.json` -- instead of a second,
# bespoke transfer. Confirmed live (this repo's own git, promisor remote
# behavior): after a `--filter=blob:none` fetch against a repository with
# `uploadpack.allowFilter` on, the fetched pack carries zero blobs, and the
# very next `cat-file -p <path>` transparently pulls in exactly the one
# blob asked for and no more (object count went from 8 with blobs already
# present to a lazy on-demand fetch of 1 when they were not).
#
# A remote that does not support the filter extension at all -- confirmed
# live against a plain local-path remote, which never speaks the partial-
# clone protocol extension regardless of git version -- degrades on its
# own to an ordinary `--depth=1` (git's own message: "warning: filtering
# not recognized by server, ignoring"). Either way this is still only
# <branch>'s single tip commit, on this device's own branch -- never
# history, never another device's branch -- so "no clone, ever" holds
# regardless of what the remote can do.
#
# Fetched through a NAMED remote ("origin"), configured once and reused on
# the one retry spec step 14 allows, rather than the anonymous-URL form
# (`git fetch <url> <branch>`) gb_remote_head/gb_commit_push use -- an
# anonymous partial-clone fetch turned out to leave the resulting promisor
# pack in a state where a LATER operation needing one of its still-missing
# objects could not resolve where to lazily fetch it from again (confirmed
# live: an anonymous fetch's own "remote" bookkeeping is the URL string
# itself, which git's shorthand-remote-name rule rejects outright for a
# plain filesystem path -- "warning: promisor remote name cannot begin
# with '/'" -- and the lazy re-fetch a later `write-tree` needs then fails
# outright, not just warns). A named remote sidesteps this by construction
# and is not a behavior change for a real https/ssh URL, which was never
# affected by the rejected-shorthand rule to begin with.
#
# Returns 0 on success; 1 if the repository could not be initialized or the
# fetch itself failed (network/auth -- again the caller's exit-3 to assign,
# same convention as gb_remote_head).
gb_fetch_meta() { gb_fetch_meta() {
_gb_branch="$1" _gb_branch="$1"
_gb_repodir="$2" _gb_repodir="$2"
@@ -138,55 +53,11 @@ gb_fetch_meta() {
return 0 return 0
} }
# gb_build_tree <repodir> <treedir> [extra_file] [extra_gitpath] # gb_build_tree <repodir> <treedir> [extra_file] [extra_gitpath] -- prints the
# # tree SHA. With GB_PARENT, the parent tree is seeded and GB_PREFIX cleared:
# Hashes and stages every regular file and symlink under <treedir> into a # other devices on a shared branch survive, dropped paths disappear.
# scratch index, rooted at GB_PREFIX (environment, not an argument -- see # Symlinks are hashed from the target string; hashing the path would follow
# this file's header), writes a tree object from that index into <repodir> # the link and fail on dangling ones.
# and prints its SHA on stdout. <treedir> holds this run's own collected
# backup set (collect.sh's outdir) with no prefix baked into it on disk --
# the prefix only ever exists inside the git tree this function builds, not
# on the filesystem gb_collect wrote to.
#
# [extra_file]/[extra_gitpath] (ticket 22) stage exactly one additional
# regular file at a git path given VERBATIM, bypassing GB_PREFIX entirely --
# the one thing usr/sbin/gitbackup needs this for is a device branch's own
# README.md, which has to sit at the BRANCH ROOT (a sibling of
# "devices/<id>") for GitHub to render it when a human opens that branch
# from a phone; a copy staged under GB_PREFIX would never be seen there.
# Both empty (the two-argument form every other call site, including every
# existing test, still uses) skips this step entirely -- behavior is
# unchanged. Mode is always 100644: nothing that calls this today needs an
# executable or a symlink at the extra path.
#
# When GB_PARENT (also environment: the SHA gb_remote_head returned, empty
# for a brand-new branch) is set, the index is first seeded from that
# commit's FULL tree (`git read-tree`), and GB_PREFIX's own old subtree is
# then cleared (`git rm --cached`) before <treedir>'s fresh content is
# staged over it. This is what spec step 11 calls out for a branch shared
# by several devices (gitbackup.origin.branch with no `{device}`):
# everything outside GB_PREFIX survives untouched, and its blobs are never
# fetched -- gb_fetch_meta only ever asked for trees, and read-tree alone
# needs no blobs either, confirmed live. Clearing GB_PREFIX before restaging
# it (rather than only adding paths <treedir> happens to still have) is
# what makes a path this device stopped backing up actually disappear from
# the pushed tree instead of lingering forever; on the shipped default (a
# branch of "device/{device}" per device) GB_PARENT's tree only ever
# contained this same GB_PREFIX to begin with, so the clear+restage is a
# no-op in effect there, not a special case this function has to detect.
#
# Only three modes ever reach the index -- git stores no others: 100644,
# 100755 (an executable regular file, `[ -x ]`), 120000 (symlink). A
# symlink's blob is hashed from the raw target STRING via --stdin, never
# from the path: `git hash-object <symlink-path>` opens the path and so
# follows the link, and fails outright ("could not open ... No such file or
# directory") for the dangling symlinks this backup set is full of
# (/etc/resolv.conf and friends) -- confirmed live, and confirmed live that
# `printf '%s' <target> | git hash-object --stdin` reproduces exactly the
# blob SHA `git add` itself would have stored for that same symlink.
#
# Returns 0 and prints the tree SHA on success; 1 (nothing printed) if any
# git plumbing step failed.
gb_build_tree() { gb_build_tree() {
_gb_repodir="$1" _gb_repodir="$1"
_gb_treedir="$2" _gb_treedir="$2"
@@ -207,11 +78,6 @@ gb_build_tree() {
git rm -r --cached --ignore-unmatch -q -- "$_gb_prefix" >/dev/null 2>&1 git rm -r --cached --ignore-unmatch -q -- "$_gb_prefix" >/dev/null 2>&1
fi fi
# The loop body runs on the read side of a pipe (a subshell, POSIX sh) --
# same technique collect.sh's own _gb_collect_files relies on -- but
# every git call inside it addresses $_gb_idx as a real file via
# GIT_INDEX_FILE, which survives the subshell boundary; only a shell
# variable set in there would not.
( cd "$_gb_treedir" && find . \( -type f -o -type l \) ) | while IFS= read -r _gb_rel; do ( cd "$_gb_treedir" && find . \( -type f -o -type l \) ) | while IFS= read -r _gb_rel; do
_gb_rel="${_gb_rel#./}" _gb_rel="${_gb_rel#./}"
_gb_full="$_gb_treedir/$_gb_rel" _gb_full="$_gb_treedir/$_gb_rel"
@@ -249,33 +115,9 @@ gb_build_tree() {
return 0 return 0
} }
# gb_commit_push <repodir> <tree> <parent> <msgfile> <branch> # gb_commit_push <repodir> <tree> <parent> <msgfile> <branch> -- prints the
# # new SHA. Returns 1 commit-tree failed, 2 branch moved (caller retries once),
# Creates one commit from <tree> and <msgfile> -- parented on <parent>, or # 3 other push failure.
# a root commit when <parent> is empty (a brand-new branch) -- and pushes
# it straight to <branch> on GB_URL: `git push <sha>:refs/heads/<branch>`,
# which is what lets git figure out and send only the objects the remote
# does not already have, without this side ever holding a checkout of
# anything (spec step 12/13).
#
# Prints the new commit's SHA on stdout and returns 0 on success. On
# failure prints nothing and returns:
# 1 `commit-tree` itself failed (a bad tree/parent SHA -- a bug on this
# side, not a remote problem)
# 2 the push was rejected because the branch moved (spec step 14:
# "Отказ non-fast-forward -> повтор с шага 8 один раз, дальше exit 1").
# Matched against git's own literal wording for BOTH of the two ways
# it phrases this same situation -- confirmed live, forcing the race
# two different ways: "(non-fast-forward)" when the branch diverged
# from a history this side never fetched, "(fetch first)" when this
# side's own remote-tracking view is simply stale (the far more
# common shape in practice: this side fetched the parent once, then
# someone else pushed before this side's own push landed). Both are
# "the branch moved under us, re-fetch and retry" to this package.
# The caller owns the one retry; this function only reports which
# kind of failure happened.
# 3 any other push failure (auth rejected, network gone mid-run) -- the
# caller's exit 3, same convention as gb_remote_head/gb_fetch_meta.
gb_commit_push() { gb_commit_push() {
_gb_repodir="$1" _gb_repodir="$1"
_gb_tree="$2" _gb_tree="$2"
@@ -284,25 +126,10 @@ gb_commit_push() {
_gb_branch="$5" _gb_branch="$5"
_gb_gitdir="$_gb_repodir/.git" _gb_gitdir="$_gb_repodir/.git"
# GIT_AUTHOR_*/GIT_COMMITTER_* set explicitly, not left to git's own # Routers have no user.name/email and commit-tree refuses to guess.
# fallback chain (user.name/user.email, then a guessed _gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
# username@hostname): found live on the owlab stand -- a freshly GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
# booted router has no ~/.gitconfig and no [user] section anywhere, git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" ${_gb_parent:+-p "$_gb_parent"} -F "$_gb_msgfile" 2>&1)
# and commit-tree refuses outright ("Author identity unknown ...
# Please tell me who you are") rather than guess. A dev machine's own
# global gitconfig had been masking this in every host-side test up to
# that point. One fixed bot identity for every device's every commit --
# the commit body's own Device: field is what actually identifies
# which router pushed it, this is not trying to be a person.
if [ -n "$_gb_parent" ]; then
_gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" -p "$_gb_parent" -F "$_gb_msgfile" 2>&1)
else
_gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" -F "$_gb_msgfile" 2>&1)
fi
_gb_rc=$? _gb_rc=$?
if [ "$_gb_rc" -ne 0 ]; then if [ "$_gb_rc" -ne 0 ]; then
gb_log err "gb_commit_push: commit-tree failed: $_gb_commit" gb_log err "gb_commit_push: commit-tree failed: $_gb_commit"
+54 -168
View File
@@ -1,37 +1,24 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- primitives shared by every module. # gitbackup -- shared primitives: gb_log, gb_die, gb_uci_get, gb_path_canon,
# gb_json_esc/str/bool, gb_manifest_field/tail/each/join, gb_free_kb,
# gb_have_net, gb_subst_device, gb_join_lines.
# #
# Sourced, never executed: defining functions is all this file does, so a test # No side effects at load time. _gb_ prefix instead of `local` (not POSIX).
# can pull it in and call one function without a router underneath it.
#
# Variables are prefixed _gb_ instead of being declared `local`, because
# `local` is not in POSIX sh; busybox ash has it, the shells this suite also
# runs under do not all agree, and the prefix costs nothing.
#
# Nothing here runs at load time: sourcing this file defines functions and
# touches nothing else, which is what makes it testable off the router.
# gb_log <level> <message>... # gb_log <syslog-level> <message>... -- syslog is the log (`gitbackup log`
# # reads it back with logread).
# level is a syslog level -- debug, info, notice, warning, err -- so it can be
# handed to logger unchanged. Syslog is the log, because `gitbackup log` reads
# it back with logread and nothing else survives a reboot on a read-only root.
gb_log() { gb_log() {
_gb_level="$1" _gb_level="$1"
shift shift
logger -t gitbackup -p "daemon.$_gb_level" -- "$*" 2>/dev/null logger -t gitbackup -p "daemon.$_gb_level" -- "$*" 2>/dev/null
# Anything the operator has to act on also goes to stderr: a CLI run by
# hand that answers only into syslog looks like it did nothing.
case "$_gb_level" in case "$_gb_level" in
notice|warning|err) printf '%s: %s\n' "$_gb_level" "$*" >&2 ;; notice|warning|err) printf '%s: %s\n' "$_gb_level" "$*" >&2 ;;
esac esac
} }
# gb_die <exit-code> <message>... # gb_die <exit-code> <message>...
# # Exit codes: 0 ok, 1 general error, 2 invalid config, 3 network/auth, 4 refused for safety.
# Exit codes are the contract of the whole package: 0 ok, 1 general error,
# 2 invalid configuration, 3 network or authentication, 4 refused for safety.
gb_die() { gb_die() {
_gb_code="$1" _gb_code="$1"
shift shift
@@ -39,40 +26,18 @@ gb_die() {
exit "$_gb_code" exit "$_gb_code"
} }
# gb_uci_get <config.section.option> [default] # gb_uci_get <config.section.option> [default] -- empty yields the default
# # too: LuCI writes `option x ''` for a cleared field.
# An unset option and an empty one are the same answer here on purpose: UCI
# writes `option x ''` for a field the user cleared in LuCI, and every caller
# wants the default in both cases.
gb_uci_get() { gb_uci_get() {
_gb_value=$(uci -q get "$1") || _gb_value='' _gb_value=$(uci -q get "$1") || _gb_value=''
[ -n "$_gb_value" ] || _gb_value="${2-}" [ -n "$_gb_value" ] || _gb_value="${2-}"
printf '%s\n' "$_gb_value" printf '%s\n' "$_gb_value"
} }
# gb_path_canon <path> # gb_path_canon <path> -- lexical canonical spelling (no realpath on the
# # image; readlink -f would follow symlinks, which are backed up as symlinks).
# Prints <path> in its one canonical spelling: repeated slashes collapsed, # Exclusion checks are textual `case` matches: without this "//etc/gitbackup"
# "." components dropped, ".." components resolved lexically, no trailing # slips past them and the deploy key and token get pushed.
# slash. A relative path is printed back unchanged -- there is nothing to
# resolve it against here, and every caller checks for absoluteness itself.
#
# Lexical, not filesystem-resolving: no realpath(1) on the base image, and
# `readlink -f` would additionally follow symlinks, which is exactly what
# this package must NOT do to a backup path (collect.sh's R24.1 -- a
# symlink is backed up as a symlink, never dereferenced).
#
# This exists because two separate decisions in this package are made by
# matching a path against a fixed pattern -- gb_paths_validate's blacklist
# and collect.sh's _gb_collect_is_excluded -- and `case` glob matching is
# purely textual. "/etc/gitbackup", "//etc/gitbackup", "/etc/./gitbackup"
# and "/etc/../etc/gitbackup" name the same directory to the kernel and to
# find(1), but only the first one matches a "/etc/gitbackup*" pattern. The
# package's own exclude.list calls its exclusion of that directory
# unconditional ("must never end up inside its own backup, no matter how
# the router is configured", R76); without canonicalization it was not,
# and an entry spelled "//etc/gitbackup" pushed the deploy private key and
# the git token into the very repository they authenticate to.
gb_path_canon() { gb_path_canon() {
_gb_pc_rest="${1-}" _gb_pc_rest="${1-}"
case "$_gb_pc_rest" in case "$_gb_pc_rest" in
@@ -97,16 +62,7 @@ gb_path_canon() {
printf '%s\n' "${_gb_pc_out:-/}" printf '%s\n' "${_gb_pc_out:-/}"
} }
# gb_json_esc <string> # The base image has no JSON writer; only ", \, newline, tab and CR can reach us.
#
# Escapes a string for use inside JSON double quotes. Written out rather than
# delegated because the base image has no JSON writer, and "just interpolate
# it" breaks on the first quote in a path -- which is exactly the input this
# package handles.
#
# Only the five characters that can actually reach us are escaped: the values
# are UCI options, filesystem paths and error messages. A NUL cannot survive a
# shell variable in the first place.
gb_json_esc() { gb_json_esc() {
_gb_rest="${1-}" _gb_rest="${1-}"
_gb_out='' _gb_out=''
@@ -115,7 +71,6 @@ gb_json_esc() {
_gb_tab=$(printf '\t') _gb_tab=$(printf '\t')
_gb_cr=$(printf '\r') _gb_cr=$(printf '\r')
while [ -n "$_gb_rest" ]; do while [ -n "$_gb_rest" ]; do
# The first character: strip everything after it, then strip it.
_gb_ch="${_gb_rest%"${_gb_rest#?}"}" _gb_ch="${_gb_rest%"${_gb_rest#?}"}"
_gb_rest="${_gb_rest#?}" _gb_rest="${_gb_rest#?}"
# shellcheck disable=SC1003 # the '\' branch matches a literal backslash, not an escaped quote # shellcheck disable=SC1003 # the '\' branch matches a literal backslash, not an escaped quote
@@ -131,7 +86,7 @@ gb_json_esc() {
printf '%s' "$_gb_out" printf '%s' "$_gb_out"
} }
# gb_json_str <string> -- an escaped, quoted JSON string, or null when empty. # Quoted JSON string, or null when empty.
gb_json_str() { gb_json_str() {
if [ -z "${1-}" ]; then if [ -z "${1-}" ]; then
printf 'null' printf 'null'
@@ -140,7 +95,6 @@ gb_json_str() {
fi fi
} }
# gb_json_bool <value> -- true when the value is a UCI-style truth, else false.
gb_json_bool() { gb_json_bool() {
case "${1-}" in case "${1-}" in
1|on|true|yes|enabled) printf 'true' ;; 1|on|true|yes|enabled) printf 'true' ;;
@@ -148,40 +102,9 @@ gb_json_bool() {
esac esac
} }
# gb_manifest_field <object> <field> # gb_manifest_field <object> <field> -- one single-line manifest item; empty
# # when absent or null. Walks char by char so an escaped \" inside a path does
# <object> is one manifest.json array item -- exactly one line, no nested # not end the value early.
# brackets, the shape gb_collect (collect.sh) writes and gb_manifest_each
# (below) hands to its own callback. Prints <field>'s value: unescaped and
# unquoted for a JSON string (gb_json_esc above is the only writer, and it
# only ever emits "\"" and "\\" for a filesystem path or symlink target --
# newlines/tabs/CR do not occur in either), or the raw text up to the next
# "," or "}" for a bare (numeric) field. Empty when the field is absent, or
# JSON null -- gb_json_str's own bare-word encoding of an empty string
# (above), which no caller here ever wants to see as the literal text
# "null".
#
# One reader for what used to be three (ticket 18): collect.sh compared
# whole manifests with a hand-rolled sed tail (gb_manifest_tail, below, is
# what that became), restore.sh had two near-identical field extractors of
# its own (a quoted-string one and a bare-numeric one), and scrub.sh
# reached for "path" with one more copy of the same parameter-expansion
# pair, unescaped. Same review finding, same fix, in one place instead of
# three that could each drift the moment the manifest format ever does.
#
# The quoted-string branch walks the value one character at a time instead
# of cutting at the first literal '"' with a parameter-expansion pattern:
# `${v%%\"*}` (the consolidation's first shape, inherited from ticket 08's
# _gb_restore_json_str) stops at the FIRST quote character in the string
# with no regard for whether a backslash precedes it, so a path containing
# a literal quote -- which gb_json_esc above escapes as `\"`, and which
# sysupgrade places no restriction against (only whitespace in a path is
# unsupported) -- truncates there instead of at its own closing quote. The
# writer can produce `\"`; the reader must be able to consume it. Walking
# character by character and tracking "the previous character was an
# unescaped backslash" finds the real closing quote and un-escapes `\"`/`\\`
# in the same pass, so the trailing `sed` cleanup this branch used to need
# is gone -- there is nothing left for it to fix up.
gb_manifest_field() { gb_manifest_field() {
_gb_mf_obj="$1" _gb_mf_obj="$1"
_gb_mf_field="$2" _gb_mf_field="$2"
@@ -194,10 +117,6 @@ gb_manifest_field() {
_gb_mf_ch="${_gb_mf_rest%"${_gb_mf_rest#?}"}" _gb_mf_ch="${_gb_mf_rest%"${_gb_mf_rest#?}"}"
_gb_mf_rest="${_gb_mf_rest#?}" _gb_mf_rest="${_gb_mf_rest#?}"
if [ "$_gb_mf_bs" -eq 1 ]; then if [ "$_gb_mf_bs" -eq 1 ]; then
# Whatever follows a backslash is emitted as itself:
# gb_json_esc only ever escapes '"' and '\' into a
# manifest string field, so this covers both cases
# without needing to special-case which one it was.
_gb_mf_v="$_gb_mf_v$_gb_mf_ch" _gb_mf_v="$_gb_mf_v$_gb_mf_ch"
_gb_mf_bs=0 _gb_mf_bs=0
continue continue
@@ -221,35 +140,15 @@ gb_manifest_field() {
esac esac
} }
# gb_manifest_tail <manifest.json> [section] # gb_manifest_tail <manifest.json> [section] -- section key to EOF. Relies on
# # gb_collect keeping "entries" and "scrubbed" last, so comparing tails
# Everything from ' "<section>": [' (default "entries") onward, i.e. that # ignores "generated".
# key's own array plus every key written after it, to end of file. Relies
# on gb_collect's fixed layout (collect.sh): "entries" and "scrubbed" are
# always the last two top-level keys, one object per line, so this one sed
# range captures both without a JSON parser this image does not have.
# gb_manifest_equal (collect.sh) is the one caller: comparing this tail
# between two manifests is exactly comparing entries[] and scrubbed[]
# together (spec: "generated не участвует в сравнении манифестов").
gb_manifest_tail() { gb_manifest_tail() {
sed -n '/^ "'"${2:-entries}"'": \[/,$p' "$1" sed -n '/^ "'"${2:-entries}"'": \[/,$p' "$1"
} }
# gb_manifest_each <manifest.json> <section> <callback> # gb_manifest_each <manifest.json> <section> <callback> -- one call per item.
# # Redirect, not a pipe, so the callback's globals survive the loop.
# Calls "<callback> <object>" once per <section>'s array item (e.g.
# "entries" or "scrubbed"), <object> already unindented and with its
# trailing comma (present on every item but the last) stripped -- the
# shape gb_manifest_field expects. Reads the file via a plain redirect, not
# a pipe: POSIX sh only forks a subshell for the pipe form, so a callback's
# own global-variable accumulation (no `local` anywhere in this codebase)
# survives the loop here.
#
# A section's own closing line is either ' ],' (something else follows it
# in the object, e.g. "entries" with "scrubbed" after) or ' ]' (it is the
# last key, e.g. "scrubbed" itself); both are accepted regardless of which
# section was asked for, because the two sections never overlap and each
# one's own opening line is what starts the count in the first place.
gb_manifest_each() { gb_manifest_each() {
_gb_me_manifest="$1" _gb_me_manifest="$1"
_gb_me_section="$2" _gb_me_section="$2"
@@ -271,54 +170,14 @@ gb_manifest_each() {
done <"$_gb_me_manifest" done <"$_gb_me_manifest"
} }
# gb_free_kb <directory> -- free kilobytes on the filesystem holding it. # -P keeps long device names from wrapping onto their own line.
#
# -P because the default df output wraps long device names onto their own line
# and the awk below would then read the wrong row.
gb_free_kb() { gb_free_kb() {
df -Pk "$1" 2>/dev/null | awk 'NR==2 {print $4}' df -Pk "$1" 2>/dev/null | awk 'NR==2 {print $4}'
} }
# gb_have_net <host> [port] # gb_have_net <host> [port] -- TCP connect within ~5s. busybox nc takes no
# # -w/-z and there is no `timeout`, so nc's pid is polled. No `sleep; kill`
# True when a TCP connection opens. There is no curl in the base image, and # watchdog: its orphaned sleep kept run's flock fd open after exit.
# -- D01, measured live on the owlab 25.12.4 stand -- this image's busybox
# nc (v1.37.0) is not the usual BSD/GNU nc at all: `nc --help` prints
# "Usage: nc [IPADDR PORT]" and nothing else. No `-w`, no `-z`, no flags of
# any kind; `nc -w 5 host port` (this function's previous body) prints that
# same usage banner and exits 1 for every host, reachable or not, which is
# exactly the bug ticket 01 flagged as D01 and interfaces.md warned every
# later module off relying on. The fix is simply never passing nc a flag it
# does not have: `nc host port` alone is accepted, connects, and exits 0/1
# correctly -- confirmed live for a real open port, a closed one (ECONNREFUSED)
# and a bad hostname.
#
# The remaining problem a flag-free nc leaves is a *hang*: an address that
# is filtered rather than refused (no RST, no reply at all) leaves a bare
# `nc host port` blocked on the kernel's own TCP connect timeout, which is
# well over a minute -- unacceptable for a check `run` is supposed to fail
# out of quickly. There is no `timeout` applet either (confirmed: absent
# from `busybox --list` and no separate binary on the stand), so the bound
# is hand-rolled by POLLING, not by a second background "watchdog" job that
# kills nc after 5s: an earlier version of this function did exactly that
# (`( sleep 5; kill $ncpid ) & watchpid=$!; ...; kill $watchpid`), and it
# leaked a process every time nc finished BEFORE the 5s elapsed (the common
# case) -- found live on the owlab stand, not in any unit test: killing the
# watchdog SUBSHELL does not kill the plain `sleep 5` still running inside
# it, which is a separate child process with its own pid; that orphaned
# sleep keeps running for whatever time was left, still holding every file
# descriptor it inherited at the moment it was forked -- including, when
# `run`'s own step 1 flock (fd 9 on /var/run/gitbackup.lock) was already
# held at the time gb_have_net was called, the lock file itself. Confirmed
# live: calling `run` twice back to back after a fast, successful
# gb_have_net check left the SECOND call seeing the lock as still busy for
# up to ~5s, purely from the first call's orphaned watchdog sleep -- despite
# the first `run` process having already exited outright. Polling for nc's
# own pid to disappear (`kill -0`, no signal sent, just an existence check)
# needs no second process at all, so there is nothing left over to leak.
# Measured live: an address that never answers (192.0.2.1, a TEST-NET-1
# address routed nowhere) returns in ~5s instead of hanging; a real open
# port and a real closed port both still return in well under a second.
gb_have_net() { gb_have_net() {
_gb_hn_host="$1" _gb_hn_host="$1"
_gb_hn_port="${2:-443}" _gb_hn_port="${2:-443}"
@@ -337,3 +196,30 @@ gb_have_net() {
wait "$_gb_hn_pid" 2>/dev/null wait "$_gb_hn_pid" 2>/dev/null
return $? return $?
} }
# Device comes from the caller: restore has no device_id config yet.
gb_subst_device() {
_gb_sd_out=''
_gb_sd_rest="$1"
while true; do
case "$_gb_sd_rest" in
*'{device}'*)
_gb_sd_out="$_gb_sd_out${_gb_sd_rest%%\{device\}*}$2"
_gb_sd_rest="${_gb_sd_rest#*\{device\}}"
;;
*)
printf '%s\n' "$_gb_sd_out$_gb_sd_rest"
return 0
;;
esac
done
}
gb_join_lines() {
awk -v sep="$1" '$0 != "" { printf "%s%s", s, $0; s = sep }'
}
# stdin lines as a manifest JSON array body.
gb_manifest_join() {
grep . | sed 's/^/ /; $!s/$/,/'
}
+21 -163
View File
@@ -1,90 +1,27 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- Paths: the /etc/sysupgrade.conf editor (ticket 17, spec # gitbackup -- editor for /etc/sysupgrade.conf, so paths added for backup also
# "Paths", "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade"). # survive a real sysupgrade. Exposes gb_paths_list, gb_paths_entries,
# # gb_paths_size_kb, gb_paths_validate, gb_paths_add, gb_paths_del,
# The whole point (spec, verbatim): "Расширение списка -- редактирование # gb_paths_replace_entries. Path validation lives only here, not in JS.
# /etc/sysupgrade.conf, а не отдельный UCI-список. То, что пользователь # Needs lib.sh.
# добавил ради бэкапа, автоматически переживёт реальный sysupgrade." This
# module owns that file, its blacklist, and the effective-set size
# estimate; usr/sbin/gitbackup's `paths` subcommand is its only caller
# today, but nothing here assumes that -- a future rpcd method could call
# these same functions instead of re-deriving the blacklist a second time.
#
# GB_SYSUPGRADE_CONF overrides the file this module reads/writes (default
# /etc/sysupgrade.conf) -- the same override name
# usr/libexec/rpcd/luci.gitbackup's own gbrpc_set_paths already uses for
# the identical file. GB_ROOT overrides the filesystem root existence
# checks resolve against, same convention collect.sh/restore.sh already
# use, so tests can point this at a fixture tree instead of the host
# filesystem.
#
# Depends on lib.sh (gb_log) already being sourced by the caller, same
# convention every sibling module here uses. Sourced, never executed:
# nothing here runs at load time -- both variable defaults below are cheap
# parameter expansions, not commands.
GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}" GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}"
GB_ROOT="${GB_ROOT:-}" GB_ROOT="${GB_ROOT:-}"
# gb_paths_list -- the raw content of GB_SYSUPGRADE_CONF, one line per # The file verbatim, comments included -- writes rely on it. Not the effective
# call. This is deliberately NOT sysupgrade -l's own wider effective set # set (keep.d, conffiles): this editor cannot change those.
# (spec: that is GB_SYSUPGRADE_CONF union /lib/upgrade/keep.d/* union
# changed conffiles) -- a package's own paths editor has nothing to add or
# remove from the other two sources, so listing them here would be lines
# `paths del` could never actually delete. gb_paths_size_kb below is the
# one place the wider effective set matters.
gb_paths_list() { gb_paths_list() {
[ -r "$GB_SYSUPGRADE_CONF" ] && cat "$GB_SYSUPGRADE_CONF" [ -r "$GB_SYSUPGRADE_CONF" ] && cat "$GB_SYSUPGRADE_CONF"
return 0 return 0
} }
# _gb_paths_is_comment_or_blank <line> -- true (0) when <line> is not a real
# path entry: empty, or a sysupgrade.conf comment. sysupgrade.conf's own
# comment syntax is "first character is '#'" -- nothing past that character
# is ever inspected, so "# /etc/openvpn/" is a commented-out EXAMPLE line,
# not a disabled entry for /etc/openvpn/, and backs up nothing at all
# (ticket 26). This is the one and only place that decides what counts as
# "not an entry" -- gb_paths_entries (what gets listed) and
# gb_paths_replace_entries (what gets preserved on a full-list write) both
# call it, so the two can never quietly disagree about the same line.
_gb_paths_is_comment_or_blank() {
case "$1" in
'' | '#'*) return 0 ;;
*) return 1 ;;
esac
}
# gb_paths_entries -- gb_paths_list, filtered down to genuine path entries:
# blank lines and comments (see _gb_paths_is_comment_or_blank) are dropped.
# gb_paths_list itself is left alone and stays raw -- ticket 26 chose two
# functions with two distinct meanings ("what the file literally contains"
# vs. "what a human's own path list looks like") over one function whose
# answer would depend on which caller was asking. This is what the LuCI
# view and `paths list --json`'s new "entries" field are built on: a stock
# /etc/sysupgrade.conf's four header/example lines are comments, contain
# zero real entries, and must render as an empty, unremovable list.
gb_paths_entries() { gb_paths_entries() {
gb_paths_list | while IFS= read -r _gb_pe_l || [ -n "$_gb_pe_l" ]; do gb_paths_list | grep -v -e '^$' -e '^#'
_gb_paths_is_comment_or_blank "$_gb_pe_l" && continue
printf '%s\n' "$_gb_pe_l"
done
return 0 return 0
} }
# gb_paths_size_kb -- kilobytes of everything `sysupgrade -l` would # KB of the full effective set (`sysupgrade -l`); symlinks count as 0.
# actually collect (the full effective set: GB_SYSUPGRADE_CONF union
# keep.d union changed conffiles -- the union sysupgrade itself computes,
# not just this file's own lines), read straight off the filesystem with
# no copy made. Moved here from usr/sbin/gitbackup's own former
# _gb_run_estimate_kb (ticket 07) so `run`'s pre-flight space check and
# `paths`'s own 2 MB warning threshold (spec "LuCI -> Paths": "Счётчик
# суммарного размера набора с предупреждением на 2 MB") can never quietly
# disagree about what "the size of the backup set" means. Symlinks are
# skipped (their own size is a few bytes and does not matter to either
# caller's headroom math); a path sysupgrade -l names that is no longer
# there contributes 0, same tolerance gb_collect already has for that
# case.
gb_paths_size_kb() { gb_paths_size_kb() {
sysupgrade -l 2>/dev/null | while IFS= read -r _gb_pkb_p || [ -n "$_gb_pkb_p" ]; do sysupgrade -l 2>/dev/null | while IFS= read -r _gb_pkb_p || [ -n "$_gb_pkb_p" ]; do
[ -n "$_gb_pkb_p" ] || continue [ -n "$_gb_pkb_p" ] || continue
@@ -93,27 +30,9 @@ gb_paths_size_kb() {
done | awk '{s += $1} END {printf "%d", (s + 1023) / 1024}' done | awk '{s += $1} END {printf "%d", (s + 1023) / 1024}'
} }
# gb_paths_validate <path> # gb_paths_validate <path> -- 0, or a reason on stderr and 2 (bad argument) /
# # 4 (blacklisted). Spaces are refused: sysupgrade passes lines to find(1)
# 0 when <path> may be added to GB_SYSUPGRADE_CONF; on refusal, prints one # unquoted.
# human-readable reason to stderr and returns a code from this package's
# own shared contract (interfaces.md "Коды выхода") -- 2 for a plain bad
# argument (not absolute, contains a space, not canonically spelled, does
# not exist), 4 for a refusal on safety grounds (the fixed blacklist) --
# so a caller can pass the return value straight to gb_die without having
# to reclassify it.
#
# Checked in the order a human would want to hear about it: absolute
# first (everything below assumes that), then the one syntactic limit
# `sysupgrade -l`'s own `find` invocation imposes (spec "Проверенные
# факты 25.12.4 -> sysupgrade": "пути с пробелами не поддерживаются
# конструктивно"), then the one spelling every textual check below (and
# collect.sh's own hard-exclude) is able to reason about, then the fixed
# blacklist (never configurable -- ticket 17's own brief: "запрет на
# /etc/gitbackup/**, /proc, /sys, /tmp"), and
# only last whether the path actually exists -- there is no point telling
# an operator their entry is missing from disk before telling them it was
# never going to be accepted at all.
gb_paths_validate() { gb_paths_validate() {
_gb_pv_path="$1" _gb_pv_path="$1"
@@ -132,25 +51,8 @@ gb_paths_validate() {
;; ;;
esac esac
# The blacklist below is `case` glob matching, i.e. purely textual, so # The blacklist is textual, so only one spelling may pass; refused rather
# it only holds for one spelling of each path. "//etc/gitbackup", # than rewritten, since entries are stored exactly as given.
# "/etc/./gitbackup" and "/etc/../etc/gitbackup" all name the reserved
# directory to find(1) -- which is what a sysupgrade.conf line becomes
# -- while matching none of the patterns that reserve it, and
# collect.sh's own hard-exclude of that directory is textual in the
# same way. An entry spelled like that therefore used to sail past
# both and put /etc/gitbackup/token and the deploy private key into
# the backup repository itself.
#
# Refused rather than silently rewritten to the canonical spelling:
# what lands in sysupgrade.conf is what the operator (or the LuCI
# Paths view) asked for, verbatim, everywhere else in this file --
# gb_paths_add's idempotence and gb_paths_del's exact-line removal
# both depend on that -- and quietly editing an entry on the way in
# would break the "add what I typed, remove what I see" contract for
# the sake of one accepted keystroke. The canonical spelling is named
# in the message instead, and reaches the Paths view as the rejection
# reason gbrpc_set_paths already reports per entry.
_gb_pv_canon=$(gb_path_canon "$_gb_pv_path") _gb_pv_canon=$(gb_path_canon "$_gb_pv_path")
if [ "$_gb_pv_canon" != "$_gb_pv_path" ]; then if [ "$_gb_pv_canon" != "$_gb_pv_path" ]; then
printf '%s: not a canonical path -- write it as %s\n' "$_gb_pv_path" "$_gb_pv_canon" >&2 printf '%s: not a canonical path -- write it as %s\n' "$_gb_pv_path" "$_gb_pv_canon" >&2
@@ -176,10 +78,7 @@ gb_paths_validate() {
;; ;;
esac esac
# -e alone misses a dangling symlink (lstat vs stat) -- and this # -L too: -e misses dangling symlinks such as /etc/resolv.conf.
# backup set is full of legitimate ones, /etc/resolv.conf being the
# standard example (collect.sh's own R24.1 comment) -- so -L is
# checked too before refusing.
if [ ! -e "$GB_ROOT$_gb_pv_path" ] && [ ! -L "$GB_ROOT$_gb_pv_path" ]; then if [ ! -e "$GB_ROOT$_gb_pv_path" ] && [ ! -L "$GB_ROOT$_gb_pv_path" ]; then
printf '%s: no such file or directory\n' "$_gb_pv_path" >&2 printf '%s: no such file or directory\n' "$_gb_pv_path" >&2
return 2 return 2
@@ -188,11 +87,6 @@ gb_paths_validate() {
return 0 return 0
} }
# gb_paths_add <path> -- appends <path> to GB_SYSUPGRADE_CONF unless
# gb_paths_validate refuses it (its own reason already on stderr by then,
# its return code propagated unchanged) or it is already there verbatim
# (idempotent: spec/ticket 17 "add/del правят именно его, идемпотентно" --
# running add twice must not duplicate the line).
gb_paths_add() { gb_paths_add() {
_gb_pa_path="$1" _gb_pa_path="$1"
gb_paths_validate "$_gb_pa_path" || return $? gb_paths_validate "$_gb_pa_path" || return $?
@@ -204,23 +98,7 @@ gb_paths_add() {
printf '%s\n' "$_gb_pa_path" >>"$GB_SYSUPGRADE_CONF" printf '%s\n' "$_gb_pa_path" >>"$GB_SYSUPGRADE_CONF"
} }
# gb_paths_del <path> -- removes every line equal to <path>, verbatim. # Deliberately unvalidated: must be able to remove any stale entry.
# Silently succeeds when it was not there to begin with -- same
# idempotence gb_paths_add already provides, in the other direction.
#
# Deliberately NOT routed through gb_paths_validate (ticket 26 checked
# this): an exact-line removal of something that is not a valid entry is
# harmless -- there is nothing to protect against by refusing to delete a
# line that was never going to be backed up in the first place -- and a
# human running this subcommand by hand is trusted the way `rm` is. This
# does mean `gitbackup paths del '## some comment'` will happily strip
# that exact comment line the same way it would any other line; that is
# an accepted property of a deliberately unvalidated CLI subcommand, not a
# defect. The actual bug ticket 26 fixed lived one layer up: the LuCI view
# never called this function at all for its own "Remove" button (it always
# went through `set_paths`'s full-list replace, see gbrpc_set_paths in
# usr/libexec/rpcd/luci.gitbackup), and gb_paths_entries below is what
# stops that button from ever being offered for a comment line again.
gb_paths_del() { gb_paths_del() {
_gb_pd_path="$1" _gb_pd_path="$1"
[ -r "$GB_SYSUPGRADE_CONF" ] || return 0 [ -r "$GB_SYSUPGRADE_CONF" ] || return 0
@@ -230,36 +108,16 @@ gb_paths_del() {
mv "$_gb_pd_tmp" "$GB_SYSUPGRADE_CONF" mv "$_gb_pd_tmp" "$GB_SYSUPGRADE_CONF"
} }
# gb_paths_replace_entries <entries> -- rewrites GB_SYSUPGRADE_CONF as: # Keeps the file's comment lines (a save must not erase the stock header),
# every comment/blank line gb_paths_list already contains, in its original # then <entries> verbatim. Never expand directories into the effective set:
# order and wording, completely unchanged -- followed by <entries> # files created later would silently stop being backed up.
# (newline-separated, blank lines ignored), one per line, exactly as
# given. Ticket 13's decision that a full-list write (`set_paths`, the
# rpcd plugin's only way to persist the LuCI Paths view's edits) writes
# back VERBATIM what it was handed stays -- a directory entry is still
# never expanded here. What changes is what "the file" means during that
# rewrite: it used to mean "only what the caller sent", so the very first
# save from the Paths view against a stock /etc/sysupgrade.conf silently
# erased its own header comments and the two commented-out example lines,
# because the caller (the LuCI view, then `gbrpc_set_paths`) never saw
# them in the first place -- gb_paths_entries (this same module) is what
# the view lists and edits, and comments are excluded from it by design.
# Losing someone else's lines on a save they never touched is not
# acceptable (ticket 26's own brief), so this function -- the one place a
# full-list write happens -- re-reads the comments straight from disk
# and keeps them, unconditionally, regardless of what the caller sent.
gb_paths_replace_entries() { gb_paths_replace_entries() {
_gb_pre_tmp="${GB_SYSUPGRADE_CONF}.tmp.$$" _gb_pre_tmp="${GB_SYSUPGRADE_CONF}.tmp.$$"
mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 2>/dev/null mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 2>/dev/null
{ {
gb_paths_list | while IFS= read -r _gb_pre_l || [ -n "$_gb_pre_l" ]; do gb_paths_list | grep -e '^$' -e '^#'
_gb_paths_is_comment_or_blank "$_gb_pre_l" && printf '%s\n' "$_gb_pre_l" printf '%s\n' "$1" | grep .
done
printf '%s\n' "$1" | while IFS= read -r _gb_pre_e || [ -n "$_gb_pre_e" ]; do
[ -n "$_gb_pre_e" ] || continue
printf '%s\n' "$_gb_pre_e"
done
} >"$_gb_pre_tmp" } >"$_gb_pre_tmp"
mv "$_gb_pre_tmp" "$GB_SYSUPGRADE_CONF" mv "$_gb_pre_tmp" "$GB_SYSUPGRADE_CONF"
+10 -102
View File
@@ -1,30 +1,11 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- remote URL parsing, provider detection, deploy-key deep links # gitbackup -- remote URL parsing: gb_parse_url, gb_provider. Needs lib.sh.
# (spec "Парсер remote URL", brief §5.3).
#
# Sourced, never executed: nothing here runs at load time. gb_provider reads
# gitbackup.origin.provider through gb_uci_get (lib.sh), so a caller must
# source lib.sh first, same convention as device.sh.
# gb_parse_url <url> # gb_parse_url <url> -- accepts [user@]host:owner/repo, ssh://[user@]host[:port]/
# # owner/repo, https://host[:port]/owner/repo (".git" optional). Prints
# Parses one of the four remote URL forms the spec requires, with or without # "scheme host port owner repo", port 0 when absent. Anything else: return 1,
# a trailing ".git": # no output -- never half-parses.
# git@host:owner/repo.git scp-like (git's own alias syntax)
# ssh://[user@]host[:port]/owner/repo.git
# https://host[:port]/owner/repo.git
#
# Prints "scheme host port owner repo" on one line, so a caller does
# `set -- $(gb_parse_url "$url")`. port is "0" when the URL did not name
# one -- the parser does not guess a scheme's default port, because what
# that default even means differs by caller (auth.sh's ssh -p vs.
# visibility.sh's HTTPS API host, see visibility.sh's _gb_hostport_for_api).
#
# Returns 1 and prints nothing on anything that is not one of the three
# forms above, or where owner/repo fail the charset check -- a mangled URL
# must fail outright, never be parsed halfway into a wrong-but-plausible
# result (ticket 03 acceptance criterion).
gb_parse_url() { gb_parse_url() {
_gb_u="$1" _gb_u="$1"
_gb_scheme='' _gb_scheme=''
@@ -48,15 +29,10 @@ gb_parse_url() {
_gb_parse_hostport_path "$_gb_rest" || return 1 _gb_parse_hostport_path "$_gb_rest" || return 1
;; ;;
*://*) *://*)
# Some other scheme (git://, ftp://, ...) -- not one of the three
# forms the spec names, refused rather than guessed at.
return 1 return 1
;; ;;
*:*) *:*)
# scp-like alias syntax: "[user@]host:owner/repo(.git)?". Git itself # git treats it as scp-like only with no '/' before the first ':'.
# only accepts this when the part before the first ':' has no '/' --
# that is what tells it apart from a local path or an already-handled
# scheme above.
_gb_scheme=ssh _gb_scheme=ssh
_gb_port='' _gb_port=''
_gb_userhost="${_gb_u%%:*}" _gb_userhost="${_gb_u%%:*}"
@@ -79,11 +55,7 @@ gb_parse_url() {
printf '%s %s %s %s %s\n' "$_gb_scheme" "$_gb_host" "${_gb_port:-0}" "$_gb_owner" "$_gb_repo" printf '%s %s %s %s %s\n' "$_gb_scheme" "$_gb_host" "${_gb_port:-0}" "$_gb_owner" "$_gb_repo"
} }
# _gb_parse_hostport_path <host[:port]/owner/repo[.git]> # Sets _gb_host, _gb_port, _gb_owner, _gb_repo.
#
# Shared by the https:// and ssh:// branches once their scheme prefix (and,
# for ssh://, an optional user@) is already stripped. Sets _gb_host, _gb_port
# and, through _gb_parse_ownerrepo, _gb_owner/_gb_repo.
_gb_parse_hostport_path() { _gb_parse_hostport_path() {
_gb_r="$1" _gb_r="$1"
case "$_gb_r" in case "$_gb_r" in
@@ -109,10 +81,7 @@ _gb_parse_hostport_path() {
_gb_parse_ownerrepo "$_gb_path" _gb_parse_ownerrepo "$_gb_path"
} }
# _gb_parse_ownerrepo <owner/repo[.git]> -- sets _gb_owner and _gb_repo. # Exactly one '/': nested groups are refused, not guessed at.
#
# Exactly one '/' is allowed: nested groups ("group/subgroup/repo") are not
# one of the four supported forms and are refused, not guessed at.
_gb_parse_ownerrepo() { _gb_parse_ownerrepo() {
_gb_p="$1" _gb_p="$1"
case "$_gb_p" in case "$_gb_p" in
@@ -128,7 +97,6 @@ _gb_parse_ownerrepo() {
return 0 return 0
} }
# _gb_charset_ok <string> -- non-empty and only [A-Za-z0-9._-].
_gb_charset_ok() { _gb_charset_ok() {
[ -n "$1" ] || return 1 [ -n "$1" ] || return 1
case "$1" in case "$1" in
@@ -137,18 +105,8 @@ _gb_charset_ok() {
return 0 return 0
} }
# gb_provider <host> # gb_provider <host> -- a non-auto gitbackup.origin.provider wins (self-hosted
# # escape hatch). codeberg.org is Forgejo, whose API is Gitea's.
# github.com/gitlab.com/bitbucket.org/codeberg.org are recognized by host;
# anything else is "generic" unless gitbackup.origin.provider names one
# explicitly, which always wins over host detection (ticket 03 acceptance
# criterion) -- the one escape hatch for a self-hosted GitLab/Gitea/Forgejo
# instance, which cannot be told apart from an unknown host by name alone.
#
# codeberg.org maps to "gitea": Codeberg runs Forgejo, whose anonymous repo
# API is the same shape as Gitea's (spec, "Проверенные факты 25.12.4 →
# Провайдеры"), and gitbackup.origin.provider's enum has no separate
# "codeberg"/"forgejo" value to return instead.
gb_provider() { gb_provider() {
_gb_host="$1" _gb_host="$1"
_gb_opt=$(gb_uci_get gitbackup.origin.provider auto) _gb_opt=$(gb_uci_get gitbackup.origin.provider auto)
@@ -164,53 +122,3 @@ gb_provider() {
*) printf 'generic\n' ;; *) printf 'generic\n' ;;
esac esac
} }
# gb_deeplink <provider> <scheme> <host> <owner> <repo>
#
# A URL to the provider's "add deploy key" page, or -- for generic, which has
# no such page anywhere -- an instruction to add the key to authorized_keys
# by hand (brief §5.3).
#
# GitHub and Bitbucket are hardcoded to their one cloud host: gb_provider
# only ever returns "github"/"bitbucket" for github.com/bitbucket.org, so
# there is no self-hosted variant to build a different host for. GitLab and
# Gitea/Forgejo are commonly self-hosted, so their link is built from the
# remote's own host instead -- the brief's table shows a gitlab.com example,
# but GitLab's repository-settings page lives at the same path on every
# instance, not just gitlab.com. <scheme> is coerced to https: a deploy-key
# page is only ever reachable over the web, but the parsed scheme is often
# "ssh" (the git remote's own transport, from an scp-like or ssh:// URL),
# which is never a valid scheme for a browser link.
gb_deeplink() {
_gb_p="$1"
_gb_sch="$2"
_gb_h="$3"
_gb_o="$4"
_gb_r="$5"
case "$_gb_sch" in
http|https) ;;
*) _gb_sch=https ;;
esac
case "$_gb_p" in
github)
# VERIFY: an anonymous request to /settings/keys/new returns 404 for
# both a real and a garbage owner/repo (spec, "Проверенные факты"),
# so unlike the other three this exact path could not be confirmed
# by the same probe -- it is GitHub's documented deploy-key URL, not
# a measured one.
printf 'https://github.com/%s/%s/settings/keys/new\n' "$_gb_o" "$_gb_r"
;;
gitlab)
printf '%s://%s/%s/%s/-/settings/repository\n' "$_gb_sch" "$_gb_h" "$_gb_o" "$_gb_r"
;;
gitea)
printf '%s://%s/%s/%s/settings/keys\n' "$_gb_sch" "$_gb_h" "$_gb_o" "$_gb_r"
;;
bitbucket)
printf 'https://bitbucket.org/%s/%s/admin/access-keys/\n' "$_gb_o" "$_gb_r"
;;
*)
printf 'No provider API to link to; add the deploy key to ~/.ssh/authorized_keys for the git user on %s, ideally with restrict,command="git-shell".\n' "$_gb_h"
;;
esac
}
+41 -365
View File
@@ -1,79 +1,19 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- Path 2 restore, applying a backup set straight back onto the # gitbackup -- restore: gb_restore. Needs lib.sh and gitio.sh; GB_URL from
# filesystem (spec "Восстановление"). This is the module that makes # the environment. Applies manifest mode/uid/gid/symlinks, which git does not
# manifest.json's mode/uid/gid/target fields matter: git stores none of # store: a plain checkout would leave /etc/shadow world-readable. Not
# them, so a plain checkout of files/ would hand back /etc/shadow at # `sysupgrade -r`, which is not atomic. The device comes from the argument (a
# whatever mode git happens to give a new file (world-readable) and every # bare router has no device_id yet): gb_subst_device, never gb_expand.
# dropbear host key owned by whoever ran the restore -- a router that
# LOOKS restored but that dropbear and login refuse to trust.
# #
# Exposes gb_restore; every _gb_restore_-prefixed helper below is private # VERIFY: Path 0 (LuCI upload -> stock `sysupgrade -r`) is not this code and
# and namespaced by module, same convention every other module here uses # was never clicked through.
# (all of *.sh end up sourced into one process by usr/sbin/gitbackup, so a
# bare _gb_ helper name could collide with a sibling module's). Sourced,
# never executed: nothing here runs at load time.
#
# Depends on lib.sh (gb_log, gb_uci_get, gb_manifest_field, gb_manifest_each
# -- gb_json_str is NOT used here, this module only ever reads manifest.json,
# never writes one) and gitio.sh (gb_remote_head, gb_fetch_meta) already
# being sourced by the caller -- restore reuses gitio.sh's own proven fetch
# machinery (a NAMED "origin" remote, a --filter=blob:none fetch, an
# optional depth) for both the common case and the specific-past-commit one
# instead of re-deriving either.
# Does NOT depend on collect.sh or device.sh: the device being restored is
# whatever the caller passes as an argument, never gb_device_id's own
# resolution -- a fresh/bare router calling `gitbackup restore --device D`
# has, by construction, no working device_id config yet (bootstrap's own
# Path 1 step 4 runs this before the restored gitbackup.main.device_id even
# exists on disk), so gb_expand's automatic gb_device_id() call cannot be
# reused here; _gb_restore_expand below is a deliberate, small duplicate of
# gb_expand's substitution loop that takes the device explicitly instead.
#
# GB_ROOT overrides the filesystem root every write and every real-path
# read goes through (default '', meaning the actual '/') -- the same seam
# collect.sh's GB_ROOT already is, so a test can point this at a fixture
# tree instead of the host filesystem and collect_fixture-shaped setups
# apply unchanged. GB_URL is read from the environment, not taken as an
# argument -- same convention gitio.sh/visibility.sh use for config context
# the CLI already resolved once.
#
# VERIFY: Path 0 ("uploaded devices/<d>/backup.tar.gz through LuCI's own
# stock Backup/Flash Firmware -> Restore configuration") is NOT this
# module's own code at all -- Path 0 is the router's stock `sysupgrade -r`
# reading a plain sysupgrade archive, gitbackup never runs in that path.
# What was actually confirmed live on the owlab owrt2512 25.12.4 stand:
# `sysupgrade -b` followed straight by `sysupgrade -r` on that same
# archive genuinely restores config (a changed system.hostname reverted
# correctly) with no error, AS LONG AS the archive excludes /etc/hosts --
# on THIS container-based stand /etc/hosts is a Docker/OrbStack bind
# mount, not a real file, so any tar restore that includes it hits `tar:
# can't remove old file etc/hosts: Resource busy`. That failure is
# confirmed to be the stand's own container artifact, not a router
# behavior: a real router has no such bind mount over /etc/hosts. What
# remains genuinely unverified: an actual click through LuCI's own
# Restore configuration upload widget (no browser available here), and
# the container limitation above itself was never worked around inside
# gitbackup's own code -- only demonstrated by hand-filtering the tar
# before feeding it to sysupgrade -r.
GB_ROOT="${GB_ROOT:-}" GB_ROOT="${GB_ROOT:-}"
# gb_restore <device> <commit-or-empty> [--dry-run] [--force] [--yes] [--with-packages] # gb_restore <device> <commit-or-empty> [--dry-run] [--force] [--yes] [--with-packages]
# # Returns 0 ok, 1 failure, 2 bad argument, 3 network/auth, 4 refused (board
# <commit-or-empty> is "" or "HEAD" for the branch's current tip (the # mismatch without --force, or an unusable manifest).
# common case, and the only one bootstrap's Path 1 ever uses); anything
# else is fetched as a specific historical commit, which needs the whole
# branch's history (not gb_fetch_meta's own --depth=1) to be reachable at
# all -- still only THIS device's one branch, never every branch in the
# repository, so "полного клона не появляется" holds either way.
#
# Returns 0 on success, 1 on a general failure (sha256 mismatch, missing
# manifest, declined confirmation), 2 on a bad argument, 3 on network/auth,
# 4 refused for safety (board mismatch without --force) -- the same
# four-code contract every other module here already answers to
# (interfaces.md, "Коды выхода"); usr/sbin/gitbackup's cmd_restore is what
# turns this into the process's actual exit code.
gb_restore() { gb_restore() {
_gb_device="$1" _gb_device="$1"
_gb_commit="${2:-}" _gb_commit="${2:-}"
@@ -95,15 +35,10 @@ gb_restore() {
[ -n "$_gb_device" ] || { gb_log err 'gb_restore: device is required'; return 2; } [ -n "$_gb_device" ] || { gb_log err 'gb_restore: device is required'; return 2; }
: "${GB_URL:?gb_restore: GB_URL is not set}" : "${GB_URL:?gb_restore: GB_URL is not set}"
_gb_branch=$(_gb_restore_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')" "$_gb_device") _gb_branch=$(gb_subst_device "$(gb_uci_get gitbackup.origin.branch 'device/{device}')" "$_gb_device")
_gb_prefix=$(_gb_restore_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')" "$_gb_device") _gb_prefix=$(gb_subst_device "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')" "$_gb_device")
# Everything from here lives under /tmp, same rule cmd_run's own WORK # Under /tmp, never flash.
# follows (never flash, never USB) -- trap fires when THIS PROCESS (or
# the caller's subshell, in a test) exits, not when this function
# returns, so every early `return` below needs no manual cleanup of
# its own, same convention cmd_run's scattered gb_die calls already
# rely on.
_gb_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-restore.XXXXXX") || _gb_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-restore.XXXXXX") ||
{ gb_log err 'gb_restore: cannot create a work directory under /tmp'; return 1; } { gb_log err 'gb_restore: cannot create a work directory under /tmp'; return 1; }
trap 'rm -rf "$_gb_work"' EXIT INT TERM trap 'rm -rf "$_gb_work"' EXIT INT TERM
@@ -120,17 +55,7 @@ gb_restore() {
gb_fetch_meta "$_gb_branch" "$_gb_repodir" || return 3 gb_fetch_meta "$_gb_branch" "$_gb_repodir" || return 3
_gb_target="$_gb_tip" _gb_target="$_gb_tip"
else else
# A specific past commit needs the branch's full history to be # A past commit needs full branch history (still one branch).
# reachable at all -- gb_fetch_meta's own default depth of 1
# (gitio.sh, tuned for `run`'s own "just the tip" need) cannot
# reach it. Passing it an explicit empty depth skips --depth
# entirely instead of this module repeating gb_fetch_meta's
# init/remote-add/fetch sequence a second time (ticket 18: this
# WAS that second copy, byte-for-byte, before gb_fetch_meta grew
# a depth argument). Still one branch, not the repository:
# "минимально: своя ветка, нужный коммит" (spec). Same mapping
# to exit 3 on any failure as the tip-fetch branch above, whether
# gb_fetch_meta failed at init or at the fetch itself.
gb_fetch_meta "$_gb_branch" "$_gb_repodir" '' || return 3 gb_fetch_meta "$_gb_branch" "$_gb_repodir" '' || return 3
_gb_target="$_gb_commit" _gb_target="$_gb_commit"
fi fi
@@ -140,13 +65,7 @@ gb_restore() {
return 1 return 1
fi fi
# The one checkout call this whole module needs: a pathspec-scoped # Pathspec checkout lazily fetches just these blobs (promisor remote).
# `checkout <commit> -- <path>` reads only $_gb_prefix's own tree from
# $_gb_target and lazily fetches only the blobs under it (the
# --filter=blob:none fetch above already marked this remote a
# promisor, same mechanism gitio.sh's own gb_fetch_meta comment
# documents for `cat-file -p`) -- this is "sparse на devices/<D>/"
# without needing git's separate sparse-checkout feature at all.
_gb_co_err=$(git -C "$_gb_repodir" checkout -q "$_gb_target" -- "$_gb_prefix" 2>&1) _gb_co_err=$(git -C "$_gb_repodir" checkout -q "$_gb_target" -- "$_gb_prefix" 2>&1)
_gb_co_rc=$? _gb_co_rc=$?
if [ "$_gb_co_rc" -ne 0 ] || [ ! -d "$_gb_repodir/$_gb_prefix" ]; then if [ "$_gb_co_rc" -ne 0 ] || [ ! -d "$_gb_repodir/$_gb_prefix" ]; then
@@ -161,14 +80,8 @@ gb_restore() {
return 1 return 1
fi fi
# Before anything reads a field out of it: the manifest is remote # The manifest comes from the remote and is untrusted: validate every
# input. Everything else in this function already treats the CONTENT # field before it picks a destination or becomes chmod/chown argv.
# of files/ as untrusted-until-hashed; this pass extends the same
# suspicion to the entry metadata that decides WHERE that content
# goes and what it ends up owned by. Refusing the whole manifest
# rather than skipping the offending entry is deliberate -- a
# manifest this package did not write is not a backup that was
# partially applied, it is a backup that should not be applied.
_gb_restore_check_entries "$_gb_manifest" || { _gb_restore_check_entries "$_gb_manifest" || {
gb_log err "gb_restore: $_gb_prefix/manifest.json at $_gb_target has unusable entries, nothing was written:$_gb_entries_bad" gb_log err "gb_restore: $_gb_prefix/manifest.json at $_gb_target has unusable entries, nothing was written:$_gb_entries_bad"
return 4 return 4
@@ -180,20 +93,11 @@ gb_restore() {
_gb_restore_check_release "$_gb_srcroot/meta/os-release.txt" _gb_restore_check_release "$_gb_srcroot/meta/os-release.txt"
# The whole point of this module: not one byte reaches GB_ROOT until # Not one byte is written until every file's sha256 is proven.
# every file's content is proven correct against the manifest.
_gb_restore_verify_sha "$_gb_srcroot/files" "$_gb_manifest" || return 1 _gb_restore_verify_sha "$_gb_srcroot/files" "$_gb_manifest" || return 1
# Ticket 19: a second read-only pass, same "сверили всё -> потом # Unreplaceable paths are skipped and reported instead of failing in cp
# пишем" principle as the sha256 one just above, run before the # with busybox's misleading "File exists".
# dry-run branch for the same reason -- it finds out WHICH paths
# cannot possibly be replaced before a single byte is written,
# instead of finding out only when cp fails mid-restore.
# _gb_precheck_bad is consumed below by _gb_restore_write_one, which
# skips a doomed cp entirely for anything in it rather than let
# busybox's own misleading "File exists" (produced by an unlink that
# silently failed, then a create that hits EEXIST) stand in as the
# reported reason.
_gb_restore_check_writable "$_gb_manifest" _gb_restore_check_writable "$_gb_manifest"
if [ "$_gb_dry" -eq 1 ]; then if [ "$_gb_dry" -eq 1 ]; then
@@ -211,37 +115,11 @@ gb_restore() {
esac esac
fi fi
# Files first (content only), THEN mode/uid/gid/empty-dirs/symlinks in
# a second pass (spec: "разложить файлы, затем применить mode/uid/gid,
# создать пустые каталоги, пересоздать симлинки") -- content and
# ownership are deliberately two passes, not interleaved per entry.
_gb_restore_write_files "$_gb_srcroot/files" "$_gb_manifest" _gb_restore_write_files "$_gb_srcroot/files" "$_gb_manifest"
_gb_write_rc=$? _gb_write_rc=$?
# Ticket 19: this call used to be gated behind # Always apply perms, even after a partial write: otherwise /etc/shadow
# `_gb_restore_write_files ... || { ...; return 1; }`, so ONE failed # could stay world-readable.
# write (one busy bind mount, one read-only path) skipped applying
# rights to EVERY OTHER file. Found live on the owlab owrt2512 stand:
# a restore that failed only on /etc/hosts (a Docker/OrbStack bind
# mount there, but on a real router this is any path that is
# mounted, busy, read-only, or immutable) left /etc/shadow 644
# root:root -- world-readable -- because this line was never
# reached, and dropbear separately refused its own restored host
# keys for the same reason.
#
# Decision (spec explicitly leaves this to the implementer): a
# partially-restored router with correct permissions on every path
# that DID land is strictly safer than either extreme -- refusing
# the WHOLE restore over one stubborn path throws away everything
# else that would have worked (network, wireless, dropbear keys,
# cron, ...) over a file that is present in literally every backup
# (/etc/hosts is always in sysupgrade -l); silently reporting success
# is worse still. So this module always writes what it can, always
# applies rights to whatever got written, and always tells the
# operator the truth (below) with a non-zero exit -- unconditionally,
# not only under --force. --force here keeps meaning exactly what it
# already means one check up (override a board mismatch); it is not
# involved in this decision at all.
_gb_restore_apply_perms "$_gb_manifest" _gb_restore_apply_perms "$_gb_manifest"
_gb_restore_print_scrubbed "$_gb_manifest" _gb_restore_print_scrubbed "$_gb_manifest"
@@ -260,48 +138,8 @@ gb_restore() {
return 0 return 0
} }
# _gb_restore_expand <template> <device> -- gb_expand's own substitution # "model" is absent on generic/armsr; "release.target" catches
# loop (device.sh), duplicated rather than reused: gb_expand always # generic-to-generic hardware changes.
# resolves {device} through gb_device_id() itself, which is exactly what
# this module must NOT do (see this file's header). Kept in lockstep with
# gb_expand by inspection -- the loop body is identical on purpose.
_gb_restore_expand() {
_gb_tpl="$1"
_gb_dev="$2"
_gb_out=''
_gb_rest="$_gb_tpl"
while true; do
case "$_gb_rest" in
*'{device}'*)
_gb_out="$_gb_out${_gb_rest%%\{device\}*}$_gb_dev"
_gb_rest="${_gb_rest#*\{device\}}"
;;
*)
_gb_out="$_gb_out$_gb_rest"
break
;;
esac
done
printf '%s\n' "$_gb_out"
}
# _gb_restore_check_board <old-board.json> <force> -- 0 (match, or --force
# overrode a mismatch) or 4 (refused). Compares "model" (the field
# device.sh's own board strategy already reads, and the one confirmed
# `null` on generic/armsr targets -- spec: "Поле board бывает null...
# Это не дефект") and "release.target" (the actual build target, e.g.
# "mediatek/filogic" -- present even when model is not, so it is what
# actually catches a generic-target-to-generic-target restore across
# genuinely different hardware that model alone would wave through as
# "both null, must match").
#
# Confirmed live on the owlab 25.12.4 armsr/armv8 stand: `ubus call system
# board` there has no "model" key at all (this target's own real-world
# instance of "Поле board бывает null" -- not a fixture assumption) and
# does have "release": {"target": "armsr/armv8", ...}; the real jsonfilter
# parses that pretty-printed, multi-line, tab-indented object correctly
# with `-e '@.release.target'` -- confirmed against the real binary, not
# just this suite's flat-JSON test stub.
_gb_restore_check_board() { _gb_restore_check_board() {
_gb_old_board="$1" _gb_old_board="$1"
_gb_force="$2" _gb_force="$2"
@@ -327,12 +165,8 @@ _gb_restore_check_board() {
return 4 return 4
} }
# _gb_restore_check_release <old-os-release.txt> -- a major-version # Major-version mismatch warns only. The backup is untrusted: VERSION_ID is
# mismatch is a warning, never a refusal (spec: "Расхождение os-release.txt # read with sed, the file is never sourced.
# в мажорной версии -> предупреждение, не отказ"). Missing/unparsable data
# on either side is silently skipped, not a warning of its own -- an old
# backup predating this field, or a target with no /etc/os-release at all,
# is not itself evidence of anything wrong.
_gb_restore_check_release() { _gb_restore_check_release() {
_gb_old_rel="$1" _gb_old_rel="$1"
[ -r "$_gb_old_rel" ] || return 0 [ -r "$_gb_old_rel" ] || return 0
@@ -349,18 +183,7 @@ _gb_restore_check_release() {
return 0 return 0
} }
# Field reads and entries[] iteration used to be this module's own private # Hard gate: any mismatch refuses the whole restore.
# copies (a quoted-string extractor, a bare-numeric one, and a hand-rolled
# entries[] walker) -- ticket 18 moved all three to lib.sh as
# gb_manifest_field and gb_manifest_each, shared with collect.sh's
# gb_manifest_equal and scrub.sh's manifest rewriter instead of each
# module maintaining its own copy of the same one-object-per-line,
# no-nested-brackets parsing.
# gb_restore's own hard gate: computes _gb_sha_bad (space-separated bad
# paths) via _gb_restore_verify_sha_one, then refuses as a whole if
# anything mismatched. _gb_srcfiles is set right before the each_entry
# call and consumed only inside it -- a temporary, not a public seam.
_gb_restore_verify_sha() { _gb_restore_verify_sha() {
_gb_srcfiles="$1" _gb_srcfiles="$1"
_gb_manifest="$2" _gb_manifest="$2"
@@ -385,73 +208,8 @@ _gb_restore_verify_sha_one() {
[ -n "$_gb_got" ] && [ "$_gb_got" = "$_gb_want" ] || _gb_sha_bad="$_gb_sha_bad $_gb_path" [ -n "$_gb_got" ] && [ "$_gb_got" = "$_gb_want" ] || _gb_sha_bad="$_gb_sha_bad $_gb_path"
} }
# _gb_restore_check_writable <manifest.json> -- ticket 19's preflight: a # Offenders go to _gb_entries_bad. No path allowlist: restoring the set
# second read-only pass, run alongside _gb_restore_verify_sha above (same # wherever it lived is the point.
# "сверили всё -> потом пишем" principle applied to writability, not just
# content). Sets _gb_precheck_bad, a space-separated path list in the same
# shape as _gb_sha_bad above, to every "file" entry whose destination
# already exists and is, as far as metadata alone can predict, NOT going
# to survive cp's own unlink-then-recreate:
#
# - its containing directory has no write permission. Also correctly
# answers "no" when the directory's own FILESYSTEM is mounted
# read-only, even for root -- access(2), which `[ -w ... ]` is built
# on, checks the mount's own read-only flag regardless of permission
# bits. The one half of this check reproducible from an
# unprivileged host test (chmod 555 a directory).
# - the destination's own device differs from its containing
# directory's (`stat -c %d` on each). True only when the exact
# destination PATH is itself a distinct mount point -- e.g. a
# Docker/OrbStack bind mount of a single file over /etc/hosts.
# Confirmed live on the owlab owrt2512 stand: `stat -c%d /etc/hosts`
# there is 41, `stat -c%d /etc` is 1048628 -- while /etc/shadow and
# /etc/config both report 1048628, same as /etc itself, so this does
# NOT misfire on an ordinary file. This is exactly what made cp -f
# fail there with a misleading "File exists" (busybox/coreutils cp's
# own unlink-before-recreate silently failed the unlink, then hit
# EEXIST on the create) -- also confirmed live: `cp -f /tmp/x
# /etc/hosts` -> "cp: can't create '/etc/hosts': File exists". This
# half cannot be fabricated from an unprivileged host test (that
# would need a real mount of its own), so it is stand-verified only,
# same as several other OS-level facts already in this file.
#
# Deliberately narrow: a permission-bit problem on the FILE itself, a
# read-only attribute, or an immutable flag are not caught here -- there
# is no metadata-only signal for those that would not also misfire on an
# ordinary read-only file elsewhere in the tree (mode 444 is common and
# perfectly overwritable by root). Those still only surface at actual
# write time, exactly as before this ticket; this preflight only removes
# the ONE failure mode the spec explicitly asks to catch ahead of time.
# _gb_restore_check_entries <manifest.json> -- read-only validation of
# every entry's METADATA, before any of it is used. Sets _gb_entries_bad
# (a multi-line " <path>: <reason>" report, empty when everything is
# usable) and returns 0 iff it stayed empty.
#
# What this does NOT do is restrict WHICH paths may be restored. Writing
# back whatever the backup set contained, wherever it lived, is the whole
# point of this module -- there is no allowlist here and there should not
# be one. What it checks is that each entry says what it means, exactly
# once, in the one spelling every later pass will agree on:
#
# - path is absolute and canonical (gb_path_canon, lib.sh). Two passes
# read this field independently -- _gb_restore_check_writable_one
# builds _gb_precheck_bad from it and _gb_restore_write_one looks
# itself up in that list by string match -- so "/etc/./hosts" and
# "/etc/hosts" naming the same file in two entries would make the
# preflight and the write disagree about which one it decided on.
# - type is one this module actually handles, so an unknown one is a
# loud refusal instead of an entry silently skipped by all three
# `case` statements below.
# - mode is 3 or 4 octal digits, uid/gid are digits. These go straight
# into `chmod`/`chown` argv (_gb_restore_perm_one), where a value
# like "--reference=/etc/shadow" or a leading "-" would be read as an
# option rather than as the mode it claims to be.
#
# A manifest that fails any of this was not written by this package's own
# collect.sh -- every field above is machine-generated there from stat(1)
# output -- so the honest response is to refuse the restore outright
# (gb_restore does, with exit 4), not to sanitize a hostile manifest into
# a plausible-looking one and apply it.
_gb_restore_check_entries() { _gb_restore_check_entries() {
_gb_manifest="$1" _gb_manifest="$1"
_gb_entries_bad='' _gb_entries_bad=''
@@ -459,7 +217,6 @@ _gb_restore_check_entries() {
[ -z "$_gb_entries_bad" ] [ -z "$_gb_entries_bad" ]
} }
# _gb_restore_entry_reject <shown-path> <reason> -- one line onto the report.
_gb_restore_entry_reject() { _gb_restore_entry_reject() {
_gb_entries_bad="$_gb_entries_bad _gb_entries_bad="$_gb_entries_bad
$1: $2" $1: $2"
@@ -518,6 +275,8 @@ _gb_restore_check_entries_one() {
done done
} }
# Sets _gb_precheck_bad: unwritable directory (read-only mount too), or the
# path is its own mount point (e.g. a bind mount over /etc/hosts).
_gb_restore_check_writable() { _gb_restore_check_writable() {
_gb_manifest="$1" _gb_manifest="$1"
_gb_precheck_bad='' _gb_precheck_bad=''
@@ -532,8 +291,6 @@ _gb_restore_check_writable_one() {
esac esac
_gb_path=$(gb_manifest_field "$_gb_obj" path) _gb_path=$(gb_manifest_field "$_gb_obj" path)
_gb_dest="${GB_ROOT:-}$_gb_path" _gb_dest="${GB_ROOT:-}$_gb_path"
# Nothing at $_gb_dest yet -- cp will just create it, no replace
# involved, so there is nothing here that could go wrong this way.
[ -e "$_gb_dest" ] || return 0 [ -e "$_gb_dest" ] || return 0
_gb_wdir=$(dirname "$_gb_dest") _gb_wdir=$(dirname "$_gb_dest")
@@ -549,11 +306,6 @@ _gb_restore_check_writable_one() {
fi fi
} }
# _gb_restore_print_plan <manifest.json> -- --dry-run's own output, and
# also what the interactive confirmation prompt shows before asking. Marks
# each path "overwrite" (something is already there at GB_ROOT) or
# "create" (nothing is), which is what "печатает, что будет перезаписано"
# actually needs to be useful, not just a bare path list.
_gb_restore_print_plan() { _gb_restore_print_plan() {
_gb_manifest="$1" _gb_manifest="$1"
printf 'The following paths will be written:\n' printf 'The following paths will be written:\n'
@@ -572,23 +324,7 @@ _gb_restore_plan_one() {
fi fi
} }
# _gb_restore_write_files <srcfiles> <manifest.json> -- pass 1: file # Pass 1, content only; failures collect in _gb_write_bad.
# CONTENT only, no mode/uid/gid yet (_gb_restore_apply_perms is pass 2).
# Only reached after _gb_restore_verify_sha has already confirmed every
# file's hash, so a failure here is an I/O problem (disk full, a path
# collision with an existing directory, a mount that refuses to give up
# its own path), not a data-integrity one.
#
# Sets _gb_write_bad, a multi-line " <path>: <reason>" report (empty
# when nothing failed), and returns 0 iff it stayed empty. The caller
# (gb_restore) deliberately does NOT gate _gb_restore_apply_perms on this
# return value (ticket 19) -- it only reads _gb_write_rc/_gb_write_bad
# afterward, to tell the operator the truth and end the call non-zero,
# once rights have already been applied to whatever DID get written.
# gb_manifest_each itself never stops iterating on a callback's own
# return code (lib.sh), so one failing entry here already does not
# prevent the REST of this same pass from running either -- only the
# call site one level up used to throw that away.
_gb_restore_write_files() { _gb_restore_write_files() {
_gb_srcfiles="$1" _gb_srcfiles="$1"
_gb_manifest="$2" _gb_manifest="$2"
@@ -605,9 +341,6 @@ _gb_restore_write_one() {
esac esac
_gb_path=$(gb_manifest_field "$_gb_obj" path) _gb_path=$(gb_manifest_field "$_gb_obj" path)
# Already known doomed by _gb_restore_check_writable above -- skip
# the attempt outright rather than let cp run into it and report its
# own less useful message (see that function's own comment for why).
case " $_gb_precheck_bad " in case " $_gb_precheck_bad " in
*" $_gb_path "*) *" $_gb_path "*)
_gb_write_bad="$_gb_write_bad _gb_write_bad="$_gb_write_bad
@@ -622,23 +355,10 @@ _gb_restore_write_one() {
$_gb_path: $_gb_mkdir_err" $_gb_path: $_gb_mkdir_err"
return 0 return 0
} }
# A symlink sitting where a "file" entry wants to land is removed # Never write through a symlink: cp -f follows it to a path the manifest
# first, never written through. Two reasons, and the second is the # never named.
# one that matters: a "file" entry must end up an actual file, which
# is what the symlink branch of _gb_restore_perm_one already assumes
# in the other direction (it rm -f's whatever is there before its own
# ln -s); and `cp -f` disagrees with itself about this across the two
# implementations this package runs on -- GNU cp opens the
# destination O_TRUNC, which FOLLOWS the link and rewrites whatever
# it points at, somewhere the manifest never named, while busybox cp
# unlinks first (the -f comment below). Deciding it here makes both
# behave the same, and makes the set of files a restore can touch
# exactly the set of paths its manifest lists.
[ -L "$_gb_dest" ] && rm -f "$_gb_dest" 2>/dev/null [ -L "$_gb_dest" ] && rm -f "$_gb_dest" 2>/dev/null
# -f: busybox cp (unlike GNU/BSD cp) refuses an existing destination # -f: busybox cp refuses an existing destination without it.
# outright ("File exists") without it -- found live on the owlab
# stand restoring over /etc/hosts, not caught by any host-side test
# since macOS/GNU cp both overwrite by default with no flag at all.
_gb_cp_err=$(cp -f "$_gb_srcfiles$_gb_path" "$_gb_dest" 2>&1) || { _gb_cp_err=$(cp -f "$_gb_srcfiles$_gb_path" "$_gb_dest" 2>&1) || {
_gb_write_bad="$_gb_write_bad _gb_write_bad="$_gb_write_bad
$_gb_path: $_gb_cp_err" $_gb_path: $_gb_cp_err"
@@ -646,11 +366,7 @@ _gb_restore_write_one() {
} }
} }
# _gb_restore_apply_perms <manifest.json> -- pass 2: mode/uid/gid for # Pass 2: mode/uid/gid, empty dirs, symlinks.
# files and directories, empty directories created outright (they have no
# content of their own to have been written in pass 1), symlinks created
# from scratch (their "content" IS the target string, never copied as a
# file -- collect.sh's own R24.1 comment on the same asymmetry).
_gb_restore_apply_perms() { _gb_restore_apply_perms() {
_gb_manifest="$1" _gb_manifest="$1"
gb_manifest_each "$_gb_manifest" entries _gb_restore_perm_one gb_manifest_each "$_gb_manifest" entries _gb_restore_perm_one
@@ -672,36 +388,13 @@ _gb_restore_perm_one() {
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null [ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;; ;;
symlink) symlink)
# _gb_symtarget, NOT _gb_target: found live on the owlab stand -- # Not _gb_target: gb_restore keeps the commit sha there (no locals).
# gb_restore's own outer scope holds the commit sha being
# restored in a variable of that exact name (no `local` anywhere
# in this codebase, interfaces.md's own convention), and this
# helper runs from inside gb_restore's own call chain
# (_gb_restore_apply_perms -> gb_manifest_each -> here) --
# reusing _gb_target here clobbered it, so the FINAL "restored
# <device> from <commit>" message printed the last symlink's
# target string instead of the commit sha whenever the backup
# set contained even one symlink. The restore itself was
# unaffected (this ran after every actual write), only the
# closing report was wrong.
_gb_symtarget=$(gb_manifest_field "$_gb_obj" target) _gb_symtarget=$(gb_manifest_field "$_gb_obj" target)
mkdir -p "$(dirname "$_gb_dest")" 2>/dev/null mkdir -p "$(dirname "$_gb_dest")" 2>/dev/null
rm -f "$_gb_dest" 2>/dev/null rm -f "$_gb_dest" 2>/dev/null
# `--`: the target string is the one manifest field with no
# shape of its own to validate (any string is a legal symlink
# target), so it is the one that can still arrive looking like
# an option -- "-s", "--force". End-of-options settles it
# without constraining what a symlink is allowed to point at.
ln -s -- "$_gb_symtarget" "$_gb_dest" 2>/dev/null ln -s -- "$_gb_symtarget" "$_gb_dest" 2>/dev/null
# -h: change the symlink's own ownership, not the (possibly # -h: chown the link itself, not a possibly dangling target. No
# dangling, e.g. /etc/resolv.conf) target's -- busybox chown # chmod: Linux symlinks are always 0777.
# documents -h for exactly this. Mode is never set on a
# symlink: Linux always reports lstat mode 0777 for one
# regardless of chmod, so a freshly `ln -s`'d link already
# matches whatever collect.sh recorded (it read the same
# lstat mode the same way). Confirmed live on the owlab
# 25.12.4 stand: chown -h on this busybox actually changes
# the symlink's own uid/gid, not a dangling target's.
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown -h "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null [ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown -h "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;; ;;
file) file)
@@ -711,17 +404,7 @@ _gb_restore_perm_one() {
esac esac
} }
# _gb_restore_print_scrubbed <manifest.json> -- prints scrubbed[]'s option # Redacted options on stderr, so the operator re-enters them.
# paths to stderr (informational for the operator, same stream
# gb_accept_hostkey's own prompts use), one per line, with a header only
# once and only if there is at least one. Silent when scrubbed[] is empty
# -- a private backup restoring cleanly should say nothing extra.
#
# gb_manifest_each (lib.sh) walks "scrubbed" the same way
# _gb_restore_apply_perms's own each-entry call above walks "entries" --
# this used to be its own hand-rolled entries/scrubbed-array walker,
# parallel to (and slightly different from) the one gb_restore's other
# helpers shared, before ticket 18 moved both to lib.sh.
_gb_restore_print_scrubbed() { _gb_restore_print_scrubbed() {
_gb_manifest="$1" _gb_manifest="$1"
_gb_scrub_any=0 _gb_scrub_any=0
@@ -740,15 +423,8 @@ _gb_restore_scrubbed_one() {
printf ' %s\n' "$_gb_opt" >&2 printf ' %s\n' "$_gb_opt" >&2
} }
# _gb_restore_packages <meta-dir> -- --with-packages, best-effort (spec: # Best effort. Names come from the {origin} group of `apk list --installed`:
# "apk add по installed_packages.txt best-effort, в конце напечатать # splitting "name-version" is ambiguous.
# список неустановившегося"). Package names come from the `{origin}`
# group `apk list --installed` itself prints (meta/installed_packages.txt
# is that command's own full-line output, per collect.sh's own comment) --
# not from splitting the leading "name-version" token, which is exactly
# the fragile parse the spec warns against (a version string can itself
# contain a dash where the name also could). Restores repositories.txt
# first so a custom feed's own packages have somewhere to come from.
_gb_restore_packages() { _gb_restore_packages() {
_gb_meta="$1" _gb_meta="$1"
_gb_repos="$_gb_meta/repositories.txt" _gb_repos="$_gb_meta/repositories.txt"
+26 -310
View File
@@ -1,36 +1,11 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- schedule (spec "Расписание", R100/R101/R103-R107). # gitbackup -- schedule: gb_cron_valid, gb_preset_expr, gb_cron_apply. Needs
# # lib.sh and device.sh.
# Exposes gb_cron_valid, gb_preset_expr, gb_cron_apply and gb_cron_next.
# Every _gb_-prefixed helper is private, same convention as lib.sh. Sourced,
# never executed: nothing here runs at load time, so tests/run.sh can source
# it and call one function without a router underneath it. Assumes lib.sh
# (gb_uci_get, gb_log) and device.sh (gb_device_id) are already sourced,
# same as every other module in this directory.
# gb_preset_expr <preset> <device> -- hourly/daily/weekly, spec's table. # gb_preset_expr <preset> <device> -- time derived from a hash of <device>:
# # a fleet does not hit the remote at once, re-saving does not reshuffle.
# The minute (and, for daily/weekly, the hour) is not fixed: it is folded # sha256sum: busybox has no cksum; "0x": ash has no 16#N arithmetic.
# out of a hash of <device> so that a fleet of routers all set to the same
# preset do not wake up in the same second and hit GitHub's API together
# (spec: "двадцать роутеров с daily... получат rate limit все разом").
# Deterministic on purpose, not `$RANDOM` -- re-saving the settings form
# must not reshuffle a router's own schedule.
#
# H is folded into 0-5 (spec's "ночное окно 0:00-5:59"), not the full
# 0-23, so a daily/weekly backup never lands in the middle of the day.
#
# The hash is sha256sum, not a literal "crc" tool: the base image ships no
# cksum(1) at all (`busybox cksum` -> "applet not found", measured on the
# 25.12.4 stand), while sha256sum is already there and already a DEPENDS
# collect.sh relies on. Any digest with a roughly uniform byte distribution
# is equally deterministic for this purpose. 8 hex characters -> 32 bits,
# read as decimal through the explicit "0x" arithmetic prefix -- ash's
# arithmetic does not support the ksh/bash "16#..." base-literal syntax
# (verified on the stand: `$((16#$h))` is an "arithmetic syntax error"),
# but the POSIX "0x..." form works on both ash and the bash/zsh /bin/sh this
# suite also runs under during development.
gb_preset_expr() { gb_preset_expr() {
_gb_preset="$1" _gb_preset="$1"
_gb_dev="$2" _gb_dev="$2"
@@ -50,92 +25,20 @@ gb_preset_expr() {
esac esac
} }
# _gb_strip_lead0 <digits> -- private. Prints <digits> with every leading # _gb_field_valid <field-expr> <min> <max> -- bounds use `test`, never $(( )):
# zero removed, down to a single "0" for an all-zero input ("007" -> "7", # a leading zero would read as octal there.
# "00" -> "0", "46" -> "46"). _gb_field_valid() {
#
# Exists only so the rest of this file can safely hand a cron field's
# leading-zero-tolerant value (e.g. the hour "08") to `$(( ))`: busybox
# ash's arithmetic reads a leading zero as an octal prefix ("08" is then an
# invalid octal digit and the whole expression errors out), and -- verified
# on the stand, where it produced the exact same "arithmetic syntax
# error" -- does not implement POSIX's "10#N" decimal-radix prefix as a
# workaround either, unlike the bash/zsh /bin/sh this suite also runs
# under during development. A plain glob-based strip has no radix opinion
# at all.
_gb_strip_lead0() {
_gb_s0="$1"
while true; do
case "$_gb_s0" in
0?*) _gb_s0="${_gb_s0#0}" ;;
*) break ;;
esac
done
printf '%s' "$_gb_s0"
}
# _gb_field_values <field-expr> <min> <max> -- private.
#
# Expands one cron field into its sorted, deduplicated, space-separated list
# of matching integers, or prints nothing and returns 1 if the field is not
# a comma-separated list of "*", "*/N", "A", "A-B" or "A-B/N" terms, or any
# number in it falls outside [<min>,<max>]. This is the one engine behind
# both gb_cron_valid (a field is valid exactly when this expands it without
# error) and gb_cron_next (which needs the actual expansion to search for
# the next match).
#
# Bounds checking always goes through `[ -ge ]`/`[ -le ]` (POSIX `test`),
# never `$(( ))`: a leading-zero field value like the hour "08" is a normal
# decimal 8 to `test`, but `$(( 08 ))` is an invalid octal literal and
# busybox ash's arithmetic errors out on it outright (verified on the
# stand: `v=08; echo $((v))` exits nonzero). Arithmetic is used only once a
# value is already known to be a plain decimal string, and only after
# _gb_strip_lead0 below has removed any leading zero -- ash's arithmetic
# does not support the POSIX "10#N" decimal-radix prefix either (also
# verified on the stand: `$((10#08))` is the same "arithmetic syntax
# error", not the workaround it looks like on a bash/zsh /bin/sh).
#
# "*/N" starts from <min>, matching busybox for every field this package's
# presets or a hand-written cron_expr actually vary (minute, hour, month,
# weekday all have min 0 or 1 in a way that lines up with busybox's own
# internal 0-based bitmap). The one field where this is a deliberate,
# disclosed simplification is day-of-month: busybox's own bitmap for that
# field reserves index 0 for a "day zero" that can never occur and starts
# stepping from THAT, so its real "*/10" fires on 10/20/30 (index 0 is
# inert), not the 1/11/21/31 this function returns starting from min=1 --
# measured on the stand by running `busybox crond -f -d0` against fixture
# crontabs and reading its own field-parse debug dump. Nothing in this
# package's presets or acceptance criteria exercises a stepped day-of-month
# field; gb_cron_next's answer for a hand-written one could come out one
# step earlier than the real crond fires. Documented rather than silently
# assumed away.
_gb_field_values() {
_gb_fv_expr="$1" _gb_fv_expr="$1"
_gb_fv_min="$2" _gb_fv_min="$2"
_gb_fv_max="$3" _gb_fv_max="$3"
_gb_fv_out=''
# A leading/trailing/doubled comma has to be rejected before the split # IFS splitting silently drops a trailing empty field: "5," would pass.
# below, not after it: shell field-splitting on IFS="," drops a
# *trailing* delimiter instead of yielding a trailing empty field (the
# same expansion that turns "a," into the one field "a", not "a" and
# ""), so "5," would otherwise sail through as the single term "5" --
# measured by a fixture in tests/fixtures/cron.tsv that stayed green
# for the wrong reason until this guard was added. A *leading* comma
# does still produce a real empty first field and would be caught
# below regardless, but checking both shapes here up front is one rule
# instead of two.
case "$_gb_fv_expr" in case "$_gb_fv_expr" in
,*|*,|*,,*) return 1 ;; ,*|*,|*,,*) return 1 ;;
esac esac
_gb_fv_saved_ifs="$IFS" _gb_fv_saved_ifs="$IFS"
IFS=',' IFS=','
# -f (noglob) around the split is not optional: an unquoted "*" or
# "*/5" term is exactly the input this field parses, and without it the
# shell pathname-expands "*" against the current directory instead of
# leaving it as a literal word -- caught by a test run from the repo
# root, where "*" silently became a couple dozen filenames.
set -f set -f
# shellcheck disable=SC2086 # splitting on IFS=',' is the point # shellcheck disable=SC2086 # splitting on IFS=',' is the point
set -- $_gb_fv_expr set -- $_gb_fv_expr
@@ -144,35 +47,22 @@ _gb_field_values() {
[ "$#" -gt 0 ] || return 1 [ "$#" -gt 0 ] || return 1
for _gb_fv_term in "$@"; do for _gb_fv_term in "$@"; do
# An empty term is a bare/leading/trailing/doubled comma ("," ",,"),
# never a valid field on its own.
[ -n "$_gb_fv_term" ] || return 1 [ -n "$_gb_fv_term" ] || return 1
_gb_fv_step=1
_gb_fv_range="$_gb_fv_term" _gb_fv_range="$_gb_fv_term"
case "$_gb_fv_term" in case "$_gb_fv_term" in
*/*) */*)
_gb_fv_range="${_gb_fv_term%%/*}" _gb_fv_range="${_gb_fv_term%%/*}"
_gb_fv_step="${_gb_fv_term#*/}" _gb_fv_step="${_gb_fv_term#*/}"
case "$_gb_fv_step" in ''|*[!0-9]*) return 1 ;; esac case "$_gb_fv_step" in ''|*[!0-9]*) return 1 ;; esac
# A step of 0, or one bigger than the field's own span, # Step 0 or beyond the span parses in busybox crond but fires
# parses without complaint in real busybox crond but # only on the lowest value.
# silently degenerates to firing on the field's single
# lowest value forever -- measured on the stand
# (`*/0`/`*/61` in a minute field both produce a bitmap
# with only minute 0 set, no parse error at all). That is
# exactly the kind of "looks configured, never really
# runs" trap R101's live validation exists to catch
# before it reaches a crontab.
[ "$_gb_fv_step" -ge 1 ] && [ "$_gb_fv_step" -le "$_gb_fv_max" ] || return 1 [ "$_gb_fv_step" -ge 1 ] && [ "$_gb_fv_step" -le "$_gb_fv_max" ] || return 1
;; ;;
esac esac
case "$_gb_fv_range" in case "$_gb_fv_range" in
'*') '*') ;;
_gb_fv_lo="$_gb_fv_min"
_gb_fv_hi="$_gb_fv_max"
;;
*-*) *-*)
_gb_fv_lo="${_gb_fv_range%%-*}" _gb_fv_lo="${_gb_fv_range%%-*}"
_gb_fv_hi="${_gb_fv_range#*-}" _gb_fv_hi="${_gb_fv_range#*-}"
@@ -180,52 +70,19 @@ _gb_field_values() {
case "$_gb_fv_hi" in ''|*[!0-9]*) return 1 ;; esac case "$_gb_fv_hi" in ''|*[!0-9]*) return 1 ;; esac
[ "$_gb_fv_lo" -ge "$_gb_fv_min" ] && [ "$_gb_fv_lo" -le "$_gb_fv_max" ] || return 1 [ "$_gb_fv_lo" -ge "$_gb_fv_min" ] && [ "$_gb_fv_lo" -le "$_gb_fv_max" ] || return 1
[ "$_gb_fv_hi" -ge "$_gb_fv_min" ] && [ "$_gb_fv_hi" -le "$_gb_fv_max" ] || return 1 [ "$_gb_fv_hi" -ge "$_gb_fv_min" ] && [ "$_gb_fv_hi" -le "$_gb_fv_max" ] || return 1
# A reversed range ("5-2") parses in real busybox crond # Reversed range parses in busybox but yields a wrong bitmap.
# too, but produces a bitmap that is wrong in a way no
# user intended (measured on the stand) rather than an
# error -- rejected here instead of reproduced.
[ "$_gb_fv_lo" -le "$_gb_fv_hi" ] || return 1 [ "$_gb_fv_lo" -le "$_gb_fv_hi" ] || return 1
;; ;;
*) *)
case "$_gb_fv_range" in ''|*[!0-9]*) return 1 ;; esac case "$_gb_fv_range" in ''|*[!0-9]*) return 1 ;; esac
[ "$_gb_fv_range" -ge "$_gb_fv_min" ] && [ "$_gb_fv_range" -le "$_gb_fv_max" ] || return 1 [ "$_gb_fv_range" -ge "$_gb_fv_min" ] && [ "$_gb_fv_range" -le "$_gb_fv_max" ] || return 1
_gb_fv_lo="$_gb_fv_range"
_gb_fv_hi="$_gb_fv_range"
;; ;;
esac esac
# A plain "A/N" (no "-" range) is real, accepted busybox syntax --
# measured on the stand -- but the step is a no-op: it fires on A
# alone, never repeating. The loop below already reproduces that
# with no special case, because it starts and ends at the same
# value: one iteration, done.
_gb_fv_v=$(_gb_strip_lead0 "$_gb_fv_lo")
_gb_fv_v=$(( _gb_fv_v ))
_gb_fv_hi_n=$(_gb_strip_lead0 "$_gb_fv_hi")
_gb_fv_hi_n=$(( _gb_fv_hi_n ))
_gb_fv_step_n=$(_gb_strip_lead0 "$_gb_fv_step")
_gb_fv_step_n=$(( _gb_fv_step_n ))
while [ "$_gb_fv_v" -le "$_gb_fv_hi_n" ]; do
_gb_fv_out="$_gb_fv_out $_gb_fv_v"
_gb_fv_v=$((_gb_fv_v + _gb_fv_step_n))
done
done done
printf '%s' "$_gb_fv_out" | tr -s ' ' '\n' | sed '/^$/d' | sort -n -u | tr '\n' ' '
printf '\n'
} }
# gb_cron_valid <expr> -- 0 if busybox crond on 25.12 can actually run # gb_cron_valid <expr> -- 1 with a reason on stderr. busybox crond has no
# <expr>, 1 otherwise, with a reason on stderr. # @-macros: "@daily ..." errors or, under six tokens, is dropped silently.
#
# Rejects the "@daily"/"@hourly"/"@reboot" family outright and by name:
# `CONFIG_FEATURE_CROND_SPECIAL_TIMES=n` on the 25.12.4 build means the
# binary has no macro table at all, so "@daily aa bb cc dd ee ff" gives a
# loud "parse error at @daily" -- but a *short* macro line like the classic
# "@daily /bin/echo A" (fewer than six whitespace-separated tokens) is
# dropped by crond with no error and no run, ever (both measured on the
# stand). A validator that let @-macros through on the strength of the
# first, louder failure mode would still ship the second, silent one.
gb_cron_valid() { gb_cron_valid() {
_gb_cv_expr="${1-}" _gb_cv_expr="${1-}"
case "$_gb_cv_expr" in case "$_gb_cv_expr" in
@@ -236,8 +93,6 @@ gb_cron_valid() {
;; ;;
esac esac
# -f (noglob): see _gb_field_values -- a field is often a literal "*"
# and must not be pathname-expanded by this split.
set -f set -f
# shellcheck disable=SC2086 # splitting on whitespace is the point # shellcheck disable=SC2086 # splitting on whitespace is the point
set -- $_gb_cv_expr set -- $_gb_cv_expr
@@ -248,19 +103,13 @@ gb_cron_valid() {
return 1 return 1
fi fi
# Field bounds: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, # busybox has no Sunday-as-7 alias.
# day-of-week 0-6 -- the last confirmed on the stand by the fact that
# "0 3 * * 7" is itself a parse error (no Sunday-as-7 alias on this
# busybox), while 0 and 6 both parse. Five explicit checks rather than a
# table-driven loop: POSIX sh has no arrays, and indexing positional
# parameters by an arithmetic offset would need `eval` for no real gain
# over just naming each field once.
_gb_cv_bad='' _gb_cv_bad=''
_gb_field_values "$1" 0 59 >/dev/null || _gb_cv_bad='minute' _gb_field_valid "$1" 0 59 || _gb_cv_bad='minute'
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$2" 0 23 >/dev/null || _gb_cv_bad='hour'; } [ -n "$_gb_cv_bad" ] || { _gb_field_valid "$2" 0 23 || _gb_cv_bad='hour'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$3" 1 31 >/dev/null || _gb_cv_bad='day-of-month'; } [ -n "$_gb_cv_bad" ] || { _gb_field_valid "$3" 1 31 || _gb_cv_bad='day-of-month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$4" 1 12 >/dev/null || _gb_cv_bad='month'; } [ -n "$_gb_cv_bad" ] || { _gb_field_valid "$4" 1 12 || _gb_cv_bad='month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$5" 0 6 >/dev/null || _gb_cv_bad='day-of-week'; } [ -n "$_gb_cv_bad" ] || { _gb_field_valid "$5" 0 6 || _gb_cv_bad='day-of-week'; }
if [ -n "$_gb_cv_bad" ]; then if [ -n "$_gb_cv_bad" ]; then
printf '%s field is not a valid value, range, step or list of them in "%s"\n' \ printf '%s field is not a valid value, range, step or list of them in "%s"\n' \
"$_gb_cv_bad" "$_gb_cv_expr" >&2 "$_gb_cv_bad" "$_gb_cv_expr" >&2
@@ -269,149 +118,16 @@ gb_cron_valid() {
return 0 return 0
} }
# gb_cron_next <expr> -- the next UTC "YYYY-MM-DD HH:MM" <expr> fires at or # gb_cron_apply -- rewrites only the "# gitbackup"-marked line. schedule=off
# after now, or nothing (exit 1) if <expr> is invalid. # keeps the crontab file even if empty: cron's init script refuses to start
# # on an empty crontabs directory, which would kill every other job.
# Searches whole days forward from today (up to ~4 years, comfortably past
# any Feb-29-only expression), because every field this function has to
# reason about -- month, day-of-month, day-of-week -- only ever changes at a
# day boundary; only the final hour/minute search happens inside the
# matching day. All arithmetic is on Unix time in UTC, which never has a day
# shorter or longer than 86400 seconds (no DST to account for), so a whole
# day is added by adding exactly 86400 -- no calendar library, no date
# string round-trip, just `date -u -d @<epoch>` to read back the
# month/day-of-month/weekday for a given instant (the "@seconds_since_1970"
# form busybox `date --help` documents as recognized on the stand).
gb_cron_next() {
_gb_cn_expr="$1"
gb_cron_valid "$_gb_cn_expr" >/dev/null 2>&1 || return 1
# -f (noglob): see _gb_field_values -- a field is often a literal "*"
# and must not be pathname-expanded by this split.
set -f
# shellcheck disable=SC2086
set -- $_gb_cn_expr
set +f
_gb_cn_minutes=$(_gb_field_values "$1" 0 59) || return 1
_gb_cn_hours=$(_gb_field_values "$2" 0 23) || return 1
_gb_cn_doms=$(_gb_field_values "$3" 1 31) || return 1
_gb_cn_months=$(_gb_field_values "$4" 1 12) || return 1
_gb_cn_dows=$(_gb_field_values "$5" 0 6) || return 1
# Standard cron day-selection rule: when BOTH day-of-month and
# day-of-week are restricted (neither is "*"), a day qualifies if it
# matches EITHER one (OR); when only one is restricted, that one alone
# decides.
_gb_cn_dom_star=0
case "$3" in '*') _gb_cn_dom_star=1 ;; esac
_gb_cn_dow_star=0
case "$5" in '*') _gb_cn_dow_star=1 ;; esac
_gb_cn_now=$(date -u +%s) || return 1
_gb_cn_midnight=$(( _gb_cn_now - (_gb_cn_now % 86400) ))
_gb_cn_day=0
while [ "$_gb_cn_day" -le 1466 ]; do
_gb_cn_day_epoch=$(( _gb_cn_midnight + _gb_cn_day * 86400 ))
_gb_cn_ymw=$(date -u -d "@$_gb_cn_day_epoch" '+%m %d %w') || return 1
_gb_cn_mo=${_gb_cn_ymw%% *}
_gb_cn_rest=${_gb_cn_ymw#* }
_gb_cn_dm=${_gb_cn_rest%% *}
_gb_cn_wd=${_gb_cn_rest#* }
# Same _gb_strip_lead0 as _gb_field_values above, needed here
# because `date`'s own %m/%d output is zero-padded.
_gb_cn_mo=$(_gb_strip_lead0 "$_gb_cn_mo")
_gb_cn_mo=$(( _gb_cn_mo ))
_gb_cn_dm=$(_gb_strip_lead0 "$_gb_cn_dm")
_gb_cn_dm=$(( _gb_cn_dm ))
_gb_cn_wd=$(_gb_strip_lead0 "$_gb_cn_wd")
_gb_cn_wd=$(( _gb_cn_wd ))
case " $_gb_cn_months " in
*" $_gb_cn_mo "*) _gb_cn_month_ok=1 ;;
*) _gb_cn_month_ok=0 ;;
esac
if [ "$_gb_cn_month_ok" -eq 1 ]; then
_gb_cn_dom_ok=0
case " $_gb_cn_doms " in *" $_gb_cn_dm "*) _gb_cn_dom_ok=1 ;; esac
_gb_cn_dow_ok=0
case " $_gb_cn_dows " in *" $_gb_cn_wd "*) _gb_cn_dow_ok=1 ;; esac
if [ "$_gb_cn_dom_star" -eq 1 ] && [ "$_gb_cn_dow_star" -eq 1 ]; then
_gb_cn_day_ok=1
elif [ "$_gb_cn_dom_star" -eq 1 ]; then
_gb_cn_day_ok="$_gb_cn_dow_ok"
elif [ "$_gb_cn_dow_star" -eq 1 ]; then
_gb_cn_day_ok="$_gb_cn_dom_ok"
elif [ "$_gb_cn_dom_ok" -eq 1 ] || [ "$_gb_cn_dow_ok" -eq 1 ]; then
_gb_cn_day_ok=1
else
_gb_cn_day_ok=0
fi
if [ "$_gb_cn_day_ok" -eq 1 ]; then
for _gb_cn_h in $_gb_cn_hours; do
for _gb_cn_m in $_gb_cn_minutes; do
_gb_cn_cand=$(( _gb_cn_day_epoch + _gb_cn_h * 3600 + _gb_cn_m * 60 ))
if [ "$_gb_cn_cand" -ge "$_gb_cn_now" ]; then
date -u -d "@$_gb_cn_cand" '+%Y-%m-%d %H:%M'
return 0
fi
done
done
fi
fi
_gb_cn_day=$((_gb_cn_day + 1))
done
return 1
}
# gb_cron_apply -- reads gitbackup.main.schedule (and cron_expr/device_id as
# needed), (re)writes the single "# gitbackup"-marked line in the crontab,
# and makes it live.
#
# Idempotent by construction: every existing line ending in "# gitbackup" is
# dropped before the (at most one) new one is appended, so applying twice in
# a row -- two saves of the settings form, a reboot re-running this from
# start_service, whatever -- leaves exactly one line, never a growing pile,
# and never touches a line that is not ours (spec: OpenWrt's own crontab
# ships with unrelated vnstat/adblock entries out of the box; confirmed
# present, untouched by this, on the owlab stand).
#
# schedule=off removes that line but leaves the file itself in place, even
# at zero length: `/etc/init.d/cron`'s start_service begins with
# `[ -z "$(ls /etc/crontabs/)" ] && return 1` -- crond refuses to start only
# when the *directory* is empty, and a zero-byte file already satisfies
# that (spec "Проверенные факты", confirmed unchanged on this stand).
# Deleting the file on schedule=off would silently take crond down with it
# for every *other* cron job on the router, which is not this package's
# call to make.
#
# GB_CRONTAB overrides the crontab path so tests/run.sh can point this at a
# plain temp file; the router never sets it. The final
# `/etc/init.d/cron enable && /etc/init.d/cron restart` is guarded by
# `[ -x /etc/init.d/cron ]` for the same reason -- there is no such script
# on a development host -- and is the one piece of this function verified
# only on the owlab stand (via `ps`), never in tests/run.sh, per this
# ticket's own acceptance criteria.
gb_cron_apply() { gb_cron_apply() {
_gb_ca_crontab="${GB_CRONTAB:-/etc/crontabs/root}" _gb_ca_crontab="${GB_CRONTAB:-/etc/crontabs/root}"
_gb_ca_sched=$(gb_uci_get gitbackup.main.schedule daily) _gb_ca_sched=$(gb_uci_get gitbackup.main.schedule daily)
_gb_ca_expr='' _gb_ca_expr=''
# Deliberately not named with a "gitbackup" + "." substring next to # No "gitbackup." substring in the temp name: a test grep would read it as
# each other: t_config_sections_match_code's own grep reads any # a UCI path. Sweeps leftovers from crashed runs.
# "gitbackup.<word>." it finds anywhere in this directory as a UCI
# section reference, and a suffix like that would fail it outright
# for naming a temp file, not a config path (hit during development).
#
# A stray one of these from some earlier crashed run (this file's own
# development left several on the owlab stand before a bug in
# _gb_field_values was fixed) is swept up here rather than left to
# accumulate one file per bad run forever -- the crontab directory has
# no other owner watching it for that.
rm -f "${_gb_ca_crontab}".newtmp.* 2>/dev/null rm -f "${_gb_ca_crontab}".newtmp.* 2>/dev/null
_gb_ca_tmp="${_gb_ca_crontab}.newtmp.$$" _gb_ca_tmp="${_gb_ca_crontab}.newtmp.$$"
if [ -f "$_gb_ca_crontab" ]; then if [ -f "$_gb_ca_crontab" ]; then
+27 -70
View File
@@ -1,100 +1,57 @@
# gitbackup scrub list -- UCI options whose value is a secret, redacted by # gitbackup scrub list: UCI options whose value is a secret, redacted by
# scrub.sh when visibility=public. One pattern per line, "#" starts a # scrub.sh when scrubbing. Every entry must cite where the option was
# trailing comment that MUST name where the option was confirmed to exist -- # confirmed to exist -- no entries from memory.
# a line without one is not something this ticket allows to exist (spec
# "Scrub": "собирается обходом реальных источников, не из головы").
# #
# Pattern shape: "<config>.@<type>[*].<option>" # Pattern: "<config>.@<type>[*].<option>"
# <config> the /etc/config/<config> file the option lives in. # <type> section TYPE, not name. A trailing "*" is a case-glob prefix
# <type> the UCI section TYPE the option is nested under (NOT a section # (dynamic WireGuard peer types); anything else is literal.
# name -- names are user-chosen and unpredictable, e.g. "wan" or # [*] every section of that type, named or anonymous; an option not
# "myvpn", but every section still declares a type). May end in # set on a section is left alone, never created.
# a literal "*" to prefix-match a family of dynamically-named
# types (WireGuard peers, see below) -- scrub.sh treats a
# trailing "*" here as a shell case-glob, everything else as a
# literal string.
# [*] every section of that type, addressed the same way `uci show`
# itself would print an anonymous one (@type[N]); scrub.sh
# resolves this itself by listing every section declaration of
# the matching type in the file and checking whether <option> is
# actually set on it, so a named section (radio0, wan, myvpn --
# uci addresses those by name, not [N]) is still found. Nothing
# is fabricated: an option that is not present on a matching
# section is left alone, never created.
# <option> the option name to redact.
# #
# Checked and found to hold NO inline UCI secret (file-based secrets, if # No inline secret: dropbear (dropbear.config), mwan3 (luci-app-mwan3 views).
# any, are exclude.list's job, not this file's): # Not listed: openvpn tls_auth/tls_crypt/secret/askpass/auth_user_pass are
# - dropbear: openwrt/openwrt package/network/services/dropbear/files/ # file paths, not secret values; scrub.sh only rewrites values.
# dropbear.config -- enable/PasswordAuth/RootPasswordAuth/Port only.
# - mwan3: openwrt/luci applications/luci-app-mwan3, every
# view/mwan3/network/*.js -- no password/secret/key option, "keyup" in
# the source is an event name, not an option.
#
# Deliberately NOT listed even though the subsystem has secrets, because the
# secret lives in a FILE the option merely points at, not in the option
# value itself (a file path is not "the value replaced by a placeholder" --
# scrub.sh only ever rewrites option values, and a leaked path is not the
# same class of leak as a leaked passphrase):
# - openvpn: tls_auth/tls_crypt/secret/askpass/auth_user_pass point at
# /etc/openvpn/*, per openwrt/packages net/openvpn/files/openvpn.config.
# - wireguard: the private/preshared keys ARE inline UCI values (listed
# below); an interface's ca/usercert-equivalent is not applicable here.
# wireless -- WPA/WPA2/WPA3 PSK. Given verbatim in the brief and already the # wireless -- WPA PSK on wifi-iface (not wifi-device), openwrt/luci
# stock gitbackup.security.scrub_option default; confirmed as a wifi-iface # modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js:2531
# option (not wifi-device) in
# openwrt/luci modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js:2531
wireless.@wifi-iface[*].key wireless.@wifi-iface[*].key
# wireless -- WEP keys 1-4 (legacy, still settable). Same file, the # wireless -- WEP keys 1-4, wireless.js:1857-1882
# uci.unset/taboption block generating 'key1'..'key4', wireless.js:1857-1882
wireless.@wifi-iface[*].key1 wireless.@wifi-iface[*].key1
wireless.@wifi-iface[*].key2 wireless.@wifi-iface[*].key2
wireless.@wifi-iface[*].key3 wireless.@wifi-iface[*].key3
wireless.@wifi-iface[*].key4 wireless.@wifi-iface[*].key4
# wireless -- SAE/WPA3 "password" field (distinct option from 'key' in the # wireless -- SAE/WPA3 password, wireless.js:2120,2531
# same form), wireless.js:2120,2531
wireless.@wifi-iface[*].password wireless.@wifi-iface[*].password
# wireless -- 802.1X/WPA-Enterprise RADIUS secrets, wireless.js:1729,1746,1830 # wireless -- 802.1X RADIUS secrets, wireless.js:1729,1746,1830
wireless.@wifi-iface[*].auth_secret wireless.@wifi-iface[*].auth_secret
wireless.@wifi-iface[*].acct_secret wireless.@wifi-iface[*].acct_secret
wireless.@wifi-iface[*].dae_secret wireless.@wifi-iface[*].dae_secret
# network -- PAP/CHAP login used by every ppp-family proto (pppoe, pptp, # network -- ppp-family login (pppoe, pptp, l2tp, pppoa), openwrt/luci
# l2tp, pppoa) on a generic 'interface' section; confirmed identical across # protocols/luci-proto-ppp/htdocs/luci-static/resources/protocol/{pppoe,pptp,l2tp,pppoa,ppp}.js
# openwrt/luci protocols/luci-proto-ppp/htdocs/luci-static/resources/protocol/{pppoe,pptp,l2tp,pppoa,ppp}.js
network.@interface[*].username network.@interface[*].username
network.@interface[*].password network.@interface[*].password
# network -- OpenVPN client cert passphrase and login password once migrated # network -- OpenVPN cert passphrase after migration to network/interface,
# onto 'network' (config file says "package network", type stays # openwrt/packages net/openvpn/files/openvpn.config:16,19 and
# 'interface', proto=openvpn): openwrt/packages
# net/openvpn/files/openvpn.config:16,19 (password, cert_password) plus the
# migration confirming the target file/type,
# net/openvpn/files/etc/uci-defaults/60_openvpn_migrate.sh # net/openvpn/files/etc/uci-defaults/60_openvpn_migrate.sh
network.@interface[*].cert_password network.@interface[*].cert_password
# network -- OpenConnect: second factor/OTP password and an inline PEM # network -- OpenConnect OTP password and inline PEM key, openwrt/luci
# private key (form.TextValue, not a file path), openwrt/luci
# protocols/luci-proto-openconnect/htdocs/luci-static/resources/protocol/openconnect.js:148,166 # protocols/luci-proto-openconnect/htdocs/luci-static/resources/protocol/openconnect.js:148,166
network.@interface[*].password2 network.@interface[*].password2
network.@interface[*].userkey network.@interface[*].userkey
# network -- WireGuard interface private key, openwrt/luci # network -- WireGuard private key, openwrt/luci
# protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js:128,380 # protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js:128,380
network.@interface[*].private_key network.@interface[*].private_key
# network -- WireGuard peer preshared key. Peer sections use a DYNAMIC # network -- WireGuard peer PSK; peer type is dynamic "wireguard_<ifname>",
# section type "wireguard_<ifname>" (one per WireGuard interface), not a # wireguard.js:195,344-384,572
# fixed type name -- confirmed by wireguard.js:195,344-384,572
# ("uci.add('network', 'wireguard_' + s.section)"), hence the type prefix
# wildcard instead of a literal type.
network.@wireguard_*[*].preshared_key network.@wireguard_*[*].preshared_key
# ddns -- login credentials substituted into the update URL, on a 'service' # ddns -- update-URL credentials, openwrt/luci
# section (uci.add('ddns', 'service', section_id)): openwrt/luci
# applications/luci-app-ddns/htdocs/luci-static/resources/view/ddns/overview.js:751,757-763 # applications/luci-app-ddns/htdocs/luci-static/resources/view/ddns/overview.js:751,757-763
ddns.@service[*].username ddns.@service[*].username
ddns.@service[*].password ddns.@service[*].password
# uhttpd -- HTTP Basic-Auth credentials for a URL prefix, on a 'httpauth' # uhttpd -- Basic-Auth credentials, openwrt/openwrt
# section (config_list_foreach "$cfg" httpauth create_httpauth): # package/network/services/uhttpd/files/uhttpd.init:68-79
# openwrt/openwrt package/network/services/uhttpd/files/uhttpd.init:68-79
uhttpd.@httpauth[*].username uhttpd.@httpauth[*].username
uhttpd.@httpauth[*].password uhttpd.@httpauth[*].password
+39 -262
View File
@@ -1,55 +1,17 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- secret redaction for visibility=public (spec "Scrub"). # gitbackup -- secret redaction: gb_scrub. Needs lib.sh. Works only on the
# # collected copy, never the live config. Whether to run is the CLI's call
# Exposes gb_scrub; every _gb_scrub_-prefixed helper below is private, # (GB_SCRUB); whole-file secrets are excluded by collect.sh instead.
# namespaced the same way collect.sh explains its own helpers are (every
# *.sh here ends up sourced into one process by usr/sbin/gitbackup, so a
# same-named helper in a sibling module would silently shadow this one
# otherwise). Sourced, never executed: nothing here runs at load time --
# t_scrub_no_side_effect_on_source in tests/run.sh asserts this directly by
# sourcing the module over a tree that still has its secrets and checking
# nothing changed.
#
# Depends on lib.sh (gb_log, gb_json_str, gb_uci_get, gb_manifest_field) already being sourced
# by the caller, same convention every other module here uses. Does NOT
# depend on collect.sh: gb_scrub computes manifest.json's path itself
# (outdir/manifest.json, the one fixed shape gb_collect ever writes) rather
# than calling collect.sh's gb_manifest_path, so this module has no load-order
# requirement on it and the two stay testable apart.
#
# Who calls this and when is not this module's concern (spec: "Только при
# visibility=public, где он принудителен" is a decision usr/sbin/gitbackup's
# gb_validate_config/_gb_effective_scrub already makes and exposes as
# GB_SCRUB -- see tests/run.sh's t_cli_public_forces_scrub). gb_scrub itself
# has no opinion on visibility and no config lookup for whether to run: it
# scrubs whatever tree it is given, unconditionally, the same way gb_collect
# has no opinion on what to do with the tree it builds.
#
# GB_SCRUB_LIST/GB_EXCLUDE_LIST override where the static pattern files live
# (defaulting under GB_SHARE, same convention collect.sh's GB_EXCLUDE_LIST
# uses) so tests/run.sh can point this at a fixture instead of the real
# package data. Neither is set by the router.
GB_SCRUB_LIST="${GB_SCRUB_LIST:-${GB_SHARE:-/usr/share/gitbackup}/scrub.list}" GB_SCRUB_LIST="${GB_SCRUB_LIST:-${GB_SHARE:-/usr/share/gitbackup}/scrub.list}"
GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}"
# The placeholder every redacted value becomes. No hash: spec "Scrub" -- # Fixed placeholder, no hash: a short PSK is brute-forceable from its hash.
# "Хеша не остаётся: короткий PSK по хешу подбирается, и хеш сам по себе
# был бы утечкой." A fixed constant, not overridable -- unlike the two
# above, there is no legitimate reason a caller would want a different one.
GB_SCRUB_PLACEHOLDER='<gitbackup:redacted>' GB_SCRUB_PLACEHOLDER='<gitbackup:redacted>'
# gb_scrub <outdir> # gb_scrub <outdir> -- redacts via `uci -c ... -t <savedir>`, never sed
# # (multi-line lists break line-based edits silently), re-hashes, fills
# Mutates outdir/files/etc/config/* in place via `uci -c`, never sed (spec: # scrubbed[], prints "<path>\t<option>" per redacted option.
# "многострочные list ломают любой построчный разбор, и молча"), removes the
# files exclude.list's public-only block names entirely, rewrites
# outdir/manifest.json's scrubbed[] array and re-hashes/drops the affected
# entries[] lines, and prints one "<path>\t<uci-option-path>" line per
# redacted option to stdout for the caller's own logging. A tree with no
# etc/config directory at all (a stripped-down fixture, or a router with
# nothing there) is a no-op, not an error.
gb_scrub() { gb_scrub() {
_gb_outdir="$1" _gb_outdir="$1"
[ -n "$_gb_outdir" ] || { gb_log err 'gb_scrub: outdir is required'; return 1; } [ -n "$_gb_outdir" ] || { gb_log err 'gb_scrub: outdir is required'; return 1; }
@@ -61,34 +23,24 @@ gb_scrub() {
mkdir -p "$_gb_savedir" mkdir -p "$_gb_savedir"
: >"$_gb_scratch/records" : >"$_gb_scratch/records"
: >"$_gb_scratch/rehash" : >"$_gb_scratch/rehash"
: >"$_gb_scratch/removed"
[ -d "$_gb_confdir" ] && _gb_scrub_options "$_gb_confdir" "$_gb_scratch" [ -d "$_gb_confdir" ] && _gb_scrub_options "$_gb_confdir" "$_gb_scratch"
_gb_scrub_hard_exclude "$_gb_outdir" "$_gb_scratch"
cat "$_gb_scratch/records" cat "$_gb_scratch/records"
[ -r "$_gb_manifest" ] && _gb_scrub_write_manifest "$_gb_manifest" \ [ -r "$_gb_manifest" ] && _gb_scrub_write_manifest "$_gb_manifest" \
"$_gb_scratch/records" "$_gb_scratch/rehash" "$_gb_scratch/removed" "$_gb_scratch/records" "$_gb_scratch/rehash"
rm -rf "$_gb_scratch" rm -rf "$_gb_scratch"
} }
# _gb_scrub_options <confdir> <scratch> -- the UCI-editing half of gb_scrub. # Grouped by config, so each file is shown and committed once.
# Walks scrub.list (+ the UCI-configurable list scrub_option) grouped by
# config file, so each touched file is `uci show`n and committed exactly
# once regardless of how many patterns target it.
_gb_scrub_options() { _gb_scrub_options() {
_gb_confdir="$1" _gb_confdir="$1"
_gb_scratch="$2" _gb_scratch="$2"
_gb_savedir="$_gb_scratch/save" _gb_savedir="$_gb_scratch/save"
# sort -u: gitbackup.security's own shipped default # sort -u: the shipped scrub_option default duplicates a scrub.list line.
# (list scrub_option 'wireless.@wifi-iface[*].key', ticket 01) is the
# same line scrub.list already carries -- found live on the owlab
# stand, where an unfiltered run redacted the one real option correctly
# but printed and recorded it twice. One pattern, one record, however
# many sources agreed on it.
_gb_scrub_load_patterns | sort -u >"$_gb_scratch/patterns" _gb_scrub_load_patterns | sort -u >"$_gb_scratch/patterns"
[ -s "$_gb_scratch/patterns" ] || return 0 [ -s "$_gb_scratch/patterns" ] || return 0
@@ -98,10 +50,8 @@ _gb_scrub_options() {
_gb_sections="$_gb_scratch/sections.$_gb_cfg" _gb_sections="$_gb_scratch/sections.$_gb_cfg"
: >"$_gb_sections" : >"$_gb_sections"
# One line per section DECLARATION ("cfg.ref=type"), ref TAB type. # Section declarations only ("cfg.ref=type" -> ref TAB type); option
# A declaration line has exactly one dot after the config name; an # lines have a second dot.
# option line ("cfg.ref.option=value") has two, so the ".*" guard
# tells them apart without needing to know option names in advance.
uci -c "$_gb_confdir" show "$_gb_cfg" 2>/dev/null | \ uci -c "$_gb_confdir" show "$_gb_cfg" 2>/dev/null | \
while IFS= read -r _gb_l; do while IFS= read -r _gb_l; do
case "$_gb_l" in case "$_gb_l" in
@@ -122,24 +72,14 @@ _gb_scrub_options() {
[ "$_gb_pc" = "$_gb_cfg" ] || continue [ "$_gb_pc" = "$_gb_cfg" ] || continue
while IFS=' ' read -r _gb_ref _gb_type; do while IFS=' ' read -r _gb_ref _gb_type; do
[ -n "$_gb_ref" ] || continue [ -n "$_gb_ref" ] || continue
# Unquoted on the right on purpose: a scrub.list type may end # Unquoted: a trailing "*" prefix-matches (wireguard_<ifname>).
# in "*" to prefix-match a dynamically-named type family
# (WireGuard peers, "wireguard_<ifname>") -- see scrub.list's
# own header. A plain type name has no glob metacharacters,
# so this is an exact match for every pattern that is not
# using the prefix form.
# shellcheck disable=SC2254 # shellcheck disable=SC2254
case "$_gb_type" in case "$_gb_type" in
$_gb_ptype) ;; $_gb_ptype) ;;
*) continue ;; *) continue ;;
esac esac
_gb_path="$_gb_cfg.$_gb_ref.$_gb_popt" _gb_path="$_gb_cfg.$_gb_ref.$_gb_popt"
# Existence check first: `uci set` on a nonexistent option # `uci set` on a missing option would create it.
# CREATES it (verified against openwrt/uci's list.c uci_set,
# the "!ptr->o && ptr->option" branch), and fabricating an
# option the user never set is not this module's job to do,
# ever -- scrub redacts what is there, it does not invent
# config the restored router would then apply.
uci -c "$_gb_confdir" get "$_gb_path" >/dev/null 2>&1 || continue uci -c "$_gb_confdir" get "$_gb_path" >/dev/null 2>&1 || continue
uci -c "$_gb_confdir" -t "$_gb_savedir" set \ uci -c "$_gb_confdir" -t "$_gb_savedir" set \
"$_gb_path=$GB_SCRUB_PLACEHOLDER" >/dev/null 2>&1 || continue "$_gb_path=$GB_SCRUB_PLACEHOLDER" >/dev/null 2>&1 || continue
@@ -149,15 +89,8 @@ _gb_scrub_options() {
done <"$_gb_scratch/patterns" done <"$_gb_scratch/patterns"
if [ -f "$_gb_touched" ]; then if [ -f "$_gb_touched" ]; then
# -t, not -P: -P also sets CLI_FLAG_NOCOMMIT (verified live on # -t, not -P: -P also sets no-commit, so commit would silently
# the owlab stand -- a `commit` issued with -P silently no-ops, # no-op. -t keeps set/commit on a private savedir, off /tmp/.uci.
# ret=0, file untouched), which would make this commit do
# nothing. -t sets the same custom save directory without that
# flag, so `set` and `commit` agree on where the pending delta
# lives without ever touching the router's own default savedir
# (/tmp/.uci) -- needed since two gb_scrub calls (or two test
# cases) touching a config of the same name must not collide
# through a savedir keyed only by that name.
uci -c "$_gb_confdir" -t "$_gb_savedir" commit "$_gb_cfg" >/dev/null 2>&1 uci -c "$_gb_confdir" -t "$_gb_savedir" commit "$_gb_cfg" >/dev/null 2>&1
_gb_sha=$(sha256sum "$_gb_confdir/$_gb_cfg" 2>/dev/null | awk '{print $1}') _gb_sha=$(sha256sum "$_gb_confdir/$_gb_cfg" 2>/dev/null | awk '{print $1}')
[ -n "$_gb_sha" ] && printf '/etc/config/%s\t%s\n' "$_gb_cfg" "$_gb_sha" >>"$_gb_scratch/rehash" [ -n "$_gb_sha" ] && printf '/etc/config/%s\t%s\n' "$_gb_cfg" "$_gb_sha" >>"$_gb_scratch/rehash"
@@ -165,21 +98,14 @@ _gb_scrub_options() {
done done
} }
# _gb_scrub_load_patterns -- prints "config type option" (space separated) # scrub.list plus gitbackup.security.scrub_option (space-joined UCI list).
# for every recognized scrub.list line plus every gitbackup.security's list
# scrub_option entry, same format, spec "Расширяется через list scrub_option
# в UCI". gb_uci_get (lib.sh) already returns a UCI list option's values
# space-joined on one line -- the same shape `uci get` itself prints -- so
# splitting it with a plain for loop needs no extra parsing.
_gb_scrub_load_patterns() { _gb_scrub_load_patterns() {
if [ -r "$GB_SCRUB_LIST" ]; then if [ -r "$GB_SCRUB_LIST" ]; then
while IFS= read -r _gb_l || [ -n "$_gb_l" ]; do while IFS= read -r _gb_l || [ -n "$_gb_l" ]; do
_gb_scrub_parse_pattern "$_gb_l" _gb_scrub_parse_pattern "$_gb_l"
done <"$GB_SCRUB_LIST" done <"$GB_SCRUB_LIST"
fi fi
# set -f: same reasoning as _gb_scrub_parse_pattern's own -- a pattern's # noglob: "[*]" is a valid glob bracket expression.
# "[*]" is a valid glob bracket expression, so splitting this on words
# must not also pathname-expand it.
# shellcheck disable=SC2046 # word-splitting is the point: one pattern per word # shellcheck disable=SC2046 # word-splitting is the point: one pattern per word
set -f set -f
for _gb_p in $(gb_uci_get gitbackup.security.scrub_option); do for _gb_p in $(gb_uci_get gitbackup.security.scrub_option); do
@@ -188,18 +114,11 @@ _gb_scrub_load_patterns() {
set +f set +f
} }
# _gb_scrub_parse_pattern <line> -- parses one "<config>.@<type>[*].<option>" # "<config>.@<type>[*].<option>" -> "<config> <type> <option>"; anything
# line (comment and surrounding blanks already allowed in <line>) and prints # unrecognized warns, never a guessed result.
# "<config> <type> <option>", or nothing for a blank/comment-only line, or a
# warning (never a partial/guessed result) for anything else.
_gb_scrub_parse_pattern() { _gb_scrub_parse_pattern() {
_gb_raw="${1%%#*}" _gb_raw="${1%%#*}"
# set -f: a pattern's own "[*]" wildcard marker is a syntactically valid # noglob: "[*]" would otherwise pathname-expand.
# glob bracket expression (matches one literal "*"), so plain word
# splitting here could also pathname-expand against the working
# directory -- the exact live bug _gb_scrub_hard_exclude's own comment
# describes, guarded against here for the same reason even though no
# fixture has hit it yet.
set -f set -f
# shellcheck disable=SC2086 # word-splitting trims surrounding blanks; a pattern itself has none # shellcheck disable=SC2086 # word-splitting trims surrounding blanks; a pattern itself has none
set -- $_gb_raw set -- $_gb_raw
@@ -222,91 +141,7 @@ _gb_scrub_parse_pattern() {
esac esac
} }
# _gb_scrub_hard_exclude <outdir> <scratch> -- removes exclude.list's # Never a value or hash in the manifest.
# public-only lines (every "#public: <pattern>" line -- a plain comment to
# collect.sh, see exclude.list's own header on why) from outdir/files,
# recording each removed absolute path (one per line, matching
# manifest.json's own path format) to scratch/removed so
# _gb_scrub_write_manifest drops it from entries[] too. Same glob rules as
# collect.sh's own hard-exclude ("/**" suffix = itself and everything under
# it, else a plain case/shell glob) -- reimplemented here rather than
# calling collect.sh's private _gb_collect_is_excluded, which is not this
# module's to reach into (see this file's header on cross-module coupling).
_gb_scrub_hard_exclude() {
_gb_outdir="$1"
_gb_scratch="$2"
_gb_files="$_gb_outdir/files"
[ -r "$GB_EXCLUDE_LIST" ] || return 0
while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do
case "$_gb_pat" in
'#public:'*) _gb_pat="${_gb_pat#'#public:'}" ;;
*) continue ;;
esac
# set -f around this: found live on the owlab stand -- with globbing
# on, this bare `set --` doesn't just trim the mandatory space after
# "#public:", it pathname-expands the pattern against the CURRENT
# working directory right then, and an absolute pattern like
# "/etc/dropbear/*_host_key*" glob-matches the ROUTER'S OWN real
# /etc/dropbear -- not outdir/files -- silently replacing the
# pattern with whatever real host keys exist on the box running the
# test. The intended glob (against "$_gb_files$_gb_pat" below) never
# ran on the right string, and the real host key was never removed.
set -f
# shellcheck disable=SC2086
set -- $_gb_pat
set +f
_gb_pat="${1:-}"
[ -n "$_gb_pat" ] || continue
case "$_gb_pat" in
*/'**')
_gb_prefix=${_gb_pat%/\*\*}
find "$_gb_files$_gb_prefix" -mindepth 0 2>/dev/null | \
while IFS= read -r _gb_found; do
_gb_relpath="${_gb_found#"$_gb_files"}"
printf '%s\n' "$_gb_relpath" >>"$_gb_scratch/removed"
done
rm -rf "${_gb_files:?}$_gb_prefix"
;;
*)
# Unquoted glob on purpose: real filesystem expansion against
# outdir/files is exactly what a hard-exclude pattern like
# "/etc/dropbear/*_host_key*" needs. No match leaves the
# literal pattern string, which -e then rejects -- ash and
# every POSIX sh here run with nullglob off, same as a bare
# `for f in *.nosuchext` on an empty match.
# shellcheck disable=SC2086,SC2231
for _gb_found in "$_gb_files"$_gb_pat; do
[ -e "$_gb_found" ] || continue
_gb_relpath="${_gb_found#"$_gb_files"}"
printf '%s\n' "$_gb_relpath" >>"$_gb_scratch/removed"
rm -f "$_gb_found"
done
;;
esac
done <"$GB_EXCLUDE_LIST"
}
# _gb_scrub_join -- reads raw (unindented, comma-free) object-body lines on
# stdin and writes them 4-space-indented and comma-joined (no trailing comma
# on the last one), the exact array-body shape collect.sh's own
# _gb_collect_join produces -- reimplemented locally rather than called
# (collect.sh's version is private to it, see this file's header). Empty
# input produces empty output, matching what collect.sh itself writes for an
# empty array.
_gb_scrub_join() {
_gb_first=1
while IFS= read -r _gb_b || [ -n "$_gb_b" ]; do
[ -n "$_gb_b" ] || continue
if [ "$_gb_first" -eq 1 ]; then _gb_first=0; else printf ',\n'; fi
printf ' %s' "$_gb_b"
done
[ "$_gb_first" -eq 1 ] || printf '\n'
}
# _gb_scrub_records_json <records> -- <records> is "<path>\t<option>" lines;
# prints one {"path":...,"option":...} object per line. No value and no hash
# ever appears here (spec: "значений в нём нет") -- only the two fields the
# records file itself carries.
_gb_scrub_records_json() { _gb_scrub_records_json() {
[ -s "$1" ] || return 0 [ -s "$1" ] || return 0
while IFS=' ' read -r _gb_rp _gb_ro || [ -n "$_gb_rp" ]; do while IFS=' ' read -r _gb_rp _gb_ro || [ -n "$_gb_rp" ]; do
@@ -315,80 +150,22 @@ _gb_scrub_records_json() {
done <"$1" done <"$1"
} }
# _gb_scrub_write_manifest <manifest.json> <records> <rehash> <removed> # _gb_scrub_write_manifest <manifest.json> <records> <rehash> -- relies on our
# # own manifest having one entry per line.
# Line-oriented, same technique collect.sh's own gb_manifest_equal already
# relies on (gb_collect emits exactly one JSON object per array item, no
# nested newlines) -- this is editing gitbackup's OWN manifest format, not
# UCI config text, so the "no sed on multi-line list" rule this module
# exists for does not apply to it. Rewrites entries[] (dropping the paths in
# <removed>, re-hashing the ones in <rehash>) and scrubbed[] (built fresh
# from <records>) in one pass; everything else in the file is copied
# unchanged.
_gb_scrub_write_manifest() { _gb_scrub_write_manifest() {
_gb_manifest="$1" _gb_tmp="$1.tmp.$$"
_gb_records="$2" _gb_scrub_records_json "$2" | gb_manifest_join >"$_gb_tmp.scrubbed"
_gb_rehash="$3" cp "$1" "$_gb_tmp"
_gb_removed="$4" while IFS=' ' read -r _gb_rp _gb_rs || [ -n "$_gb_rp" ]; do
_gb_tmp="$_gb_manifest.tmp.$$" [ -n "$_gb_rp" ] || continue
_gb_ent="$_gb_manifest.entries.$$" _gb_needle="\"path\":$(gb_json_str "$_gb_rp"),"
: >"$_gb_tmp" awk -v n="$_gb_needle" -v sha="$_gb_rs" '
: >"$_gb_ent" index($0, n) { sub(/"sha256":"[^"]*"/, "\"sha256\":\"" sha "\"") } { print }
_gb_in_entries=0 ' "$_gb_tmp" >"$_gb_tmp.new" && mv "$_gb_tmp.new" "$_gb_tmp"
_gb_in_scrubbed=0 done <"$3"
awk -v f="$_gb_tmp.scrubbed" '
while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do { print }
if [ "$_gb_in_entries" -eq 1 ]; then /^ "scrubbed": \[/ { while ((getline l < f) > 0) print l }
case "$_gb_line" in ' "$_gb_tmp" >"$1"
' ],') rm -f "$_gb_tmp" "$_gb_tmp.scrubbed"
_gb_scrub_join <"$_gb_ent" >>"$_gb_tmp"
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_in_entries=0
;;
*)
# Strip the trailing "," (absent only on the last item)
# and the fixed 4-space indent collect.sh always writes,
# so _gb_scrub_join can re-add both consistently once the
# surviving set is known.
_gb_obj="${_gb_line%,}"
_gb_obj="${_gb_obj# }"
_gb_p=$(gb_manifest_field "$_gb_obj" path)
if [ -s "$_gb_removed" ] && grep -qxF "$_gb_p" "$_gb_removed"; then
: # dropped -- hard-excluded, no longer in the tree
else
_gb_newsha=''
if [ -s "$_gb_rehash" ]; then
_gb_newsha=$(awk -F'\t' -v p="$_gb_p" '$1==p{print $2; exit}' "$_gb_rehash")
fi
if [ -n "$_gb_newsha" ]; then
_gb_obj=$(printf '%s' "$_gb_obj" | sed 's/"sha256":"[^"]*"/"sha256":"'"$_gb_newsha"'"/')
fi
printf '%s\n' "$_gb_obj" >>"$_gb_ent"
fi
;;
esac
continue
fi
case "$_gb_line" in
' "entries": ['*)
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_in_entries=1
continue
;;
' "scrubbed": ['*)
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_scrub_records_json "$_gb_records" | _gb_scrub_join >>"$_gb_tmp"
_gb_in_scrubbed=1
continue
;;
' ]')
[ "$_gb_in_scrubbed" -eq 1 ] && _gb_in_scrubbed=0
;;
esac
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
done <"$_gb_manifest"
rm -f "$_gb_ent"
mv "$_gb_tmp" "$_gb_manifest"
} }
@@ -1,39 +1,17 @@
# shellcheck shell=sh # shellcheck shell=sh
# #
# gitbackup -- the visibility gate (spec "Гейт видимости"). # gitbackup -- the visibility gate: gb_visibility_ok. With no encryption, this
# # is all that keeps /etc/shadow, host keys and PSKs out of a public repo:
# There is no encryption anywhere in this project, so this is the only thing # public is exit 4 with no override. Needs lib.sh and remoteurl.sh.
# standing between a public repository and the root password hash, private
# dropbear host keys, WPA PSK and WireGuard keys it would carry. A public
# repository is exit 4 with no way to override it -- not a warning.
#
# Sourced, never executed: nothing here runs at load time. gb_visibility_ok
# calls gb_uci_get, gb_log, gb_die and gb_json_bool (lib.sh) and gb_parse_url/
# gb_provider (remoteurl.sh), so a caller must source both first, same
# convention as device.sh.
#
# GB_STATE_DIR overrides the cache directory (default /var/run/gitbackup),
# the same variable usr/sbin/gitbackup already uses -- tests point it at a
# tmp directory, the router never sets it.
# gb_visibility_ok <url> # gb_visibility_ok <url> -- anonymous provider API probe:
# # 0 not visible anonymously (404) -- proceed.
# Anonymously asks the provider's API whether <url>'s repository is visible # 3 inconclusive (offline, 5xx, unknown status). Never 0: "could not
# to a passer-by, and returns: # check" must not be treated as "private".
# 0 not visible anonymously (404) or the operator explicitly accepted the # 4 visible anonymously (200) -- refused.
# risk for a provider that cannot be checked at all -- proceed. # Dies 2 on an unparsable URL, or on provider=generic without
# 3 inconclusive: the network is down, the API answered 5xx, or answered # gitbackup.origin.acknowledged=1 (nothing to check; silence is not proof).
# with an HTTP status this gate does not recognize. Deliberately NOT 0: # Definite answers are cached for 24h.
# a caller that cannot tell "verified private" from "could not check"
# apart would push on a repository nobody actually confirmed is closed
# to the world. Reuses the shared network/auth exit code (interfaces.md,
# "Коды выхода") rather than inventing a fifth one.
# 4 visible anonymously (HTTP 200) -- refused, unconditionally.
# Dies with exit 2 -- invalid configuration, same as every other config
# check in this package -- when <url> does not parse, or when the remote is
# provider=generic without gitbackup.origin.acknowledged=1 (nothing there
# can ever be checked, so silence would mean "assume private" on zero
# evidence, exactly the failure mode this whole gate exists to prevent).
gb_visibility_ok() { gb_visibility_ok() {
_gb_url="$1" _gb_url="$1"
_gb_parsed=$(gb_parse_url "$_gb_url") || _gb_parsed=$(gb_parse_url "$_gb_url") ||
@@ -86,9 +64,7 @@ gb_visibility_ok() {
_gb_result=0 _gb_result=0
;; ;;
*) *)
# Inconclusive: never cache a guess, so the very next call (this run # Inconclusive: never cached, so the next call retries.
# or the next) tries the network again instead of trusting a stale
# "could not tell" for a whole day.
return 3 return 3
;; ;;
esac esac
@@ -98,15 +74,8 @@ gb_visibility_ok() {
return "$_gb_result" return "$_gb_result"
} }
# _gb_visibility_api_url <provider> <scheme> <host> <port> <owner> <repo> # Anonymous endpoint answering 200 (visible) / 404 (not); https even for ssh
# # remotes. Returns 1 for a provider with no known API.
# The anonymous, unauthenticated read-only endpoint each provider answers
# with 200 (visible) or 404 (not visible/does not exist) for -- spec
# "Гейт видимости" and "Проверенные факты 25.12.4 → Провайдеры", each row
# measured against the real API, not guessed. Always https: git access may
# be over ssh, but every one of these platforms serves its API over https on
# the same host. Returns 1 for a provider with no known API (generic, or
# anything gitbackup.origin.provider names that this gate does not recognize).
_gb_visibility_api_url() { _gb_visibility_api_url() {
case "$1" in case "$1" in
github) github)
@@ -127,14 +96,7 @@ _gb_visibility_api_url() {
esac esac
} }
# _gb_hostport_for_api <scheme> <host> <port> -- host[:port] for the same # Keeps the port only from an https remote; an ssh port is not the API port.
# server's HTTPS API.
#
# Only a port that was itself already on an https:// remote is trustworthy
# here. A scp-like or ssh:// remote's port is dropbear/openssh's, which a
# self-hosted Gitea/Forgejo/GitLab install commonly runs on a different port
# than its web UI (e.g. ssh on 2222, https on the default 443) -- carrying
# it over would probe the wrong port.
_gb_hostport_for_api() { _gb_hostport_for_api() {
if [ "$1" = https ] && [ "${3:-0}" != 0 ]; then if [ "$1" = https ] && [ "${3:-0}" != 0 ]; then
printf '%s:%s\n' "$2" "$3" printf '%s:%s\n' "$2" "$3"
@@ -143,18 +105,9 @@ _gb_hostport_for_api() {
fi fi
} }
# _gb_visibility_probe <api-url> # 0 on 200, 1 on 404 (private and nonexistent look the same), 2 otherwise.
# # No -q: uclient-fetch prints "HTTP error <code>" only when not quiet, and
# Returns 0 when the repository is visible anonymously (HTTP 200), 1 when it # exit 8 alone does not tell 404 from other statuses.
# is not (HTTP 404 -- private and nonexistent are the same answer here on
# purpose, spec: "приватный анонимно неотличим от несуществующего"), 2 for
# anything else: another HTTP status, a timeout, a refused connection, a TLS
# failure, or offline. -q is deliberately not passed to uclient-fetch: the
# only place it prints "HTTP error <code>" is to stderr, and only when not
# quiet -- verified against uclient/uclient-fetch.c (header_done_cb's default
# case; net/error branches for connection/timeout/TLS return non-8 codes and
# are folded into the same "inconclusive" outcome here as any other network
# failure).
_gb_visibility_probe() { _gb_visibility_probe() {
_gb_probe_msg=$(uclient-fetch --timeout=10 -O /dev/null "$1" 2>&1 >/dev/null) _gb_probe_msg=$(uclient-fetch --timeout=10 -O /dev/null "$1" 2>&1 >/dev/null)
_gb_probe_rc=$? _gb_probe_rc=$?
-29
View File
@@ -1,29 +0,0 @@
# This file exists only so `owlab build` can find package/gitbackup/Makefile --
# `owlab build` runs the OpenWrt SDK, which is NOT how gitbackup is actually released
# (see /owfeed.yml and /tools/stage.sh: `owfeed build` is the primary, fast path for a
# noarch package with nothing to compile). This one stays because the Makefile is still
# a valid, if slower, way for anyone to build the package themselves.
#
# owlab's build command looks for an OpenWrt package Makefile in its config
# directory and that directory's IMMEDIATE children only (read from source:
# owfeed/owlab/cmd/owlab/build.go, findPackage) -- it does not walk two
# levels down from a repo root the way the real OpenWrt SDK's own feed
# scanner does. The repository's own top-level owlab.yaml is what
# `owlab up`/`sync`/`test`/`exec` use for the live router; owlab.yaml
# discovery walks upward from the working directory and stops at the FIRST
# file it finds, so this one only has to exist here to be picked up before
# that search ever reaches the repository root. It repeats the release the
# top-level file tests against rather than deferring to it, because there is
# no way to defer: discovery does not chain two config files together.
#
# cd package/gitbackup && owlab build --release 25.12.4
version: 1
project:
name: gitbackup
routers:
- id: owrt2512
release: "25.12.4"
ports: { http: 8029, ssh: 2230 }
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5 PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=38 PKG_RELEASE:=39
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c ARCH_HEXCODE=1b0c
endif endif
PKG_SOURCE_VERSION:=7862188a4a1fe17d7d275f5ecfa7c24acc5d2049 PKG_SOURCE_VERSION:=003afc294e0a56b8d92b967af5684dcd33a23bf3
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5 PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=41 PKG_RELEASE:=42
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c ARCH_HEXCODE=1b0c
endif endif
PKG_SOURCE_VERSION:=7862188a4a1fe17d7d275f5ecfa7c24acc5d2049 PKG_SOURCE_VERSION:=003afc294e0a56b8d92b967af5684dcd33a23bf3
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkeasefull PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.22 PKG_SOURCE_DATE:=3.0.22
PKG_RELEASE:=40 PKG_RELEASE:=41
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip PKG_HASH:=skip
endif endif
PKG_SOURCE_VERSION:=7862188a4a1fe17d7d275f5ecfa7c24acc5d2049 PKG_SOURCE_VERSION:=003afc294e0a56b8d92b967af5684dcd33a23bf3
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/ PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH) PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede PKG_NAME:=luci-app-daede
PKG_VERSION:=1.15 PKG_VERSION:=1.15
PKG_RELEASE:=44 PKG_RELEASE:=45
PKG_MAINTAINER:=kenzok8 PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -45,6 +45,7 @@ const DEFAULT_TEMPLATE =
' }\n' + ' }\n' +
' routing {\n' + ' routing {\n' +
' request {\n' + ' request {\n' +
' qname(suffix:googleapis.cn) -> fallbackdns\n' +
' qname(geosite:cn) -> cndns\n' + ' qname(geosite:cn) -> cndns\n' +
' fallback: fallbackdns\n' + ' fallback: fallbackdns\n' +
' }\n' + ' }\n' +
@@ -70,6 +71,7 @@ const DEFAULT_TEMPLATE =
' l4proto(udp) && dport(123) -> direct\n' + ' l4proto(udp) && dport(123) -> direct\n' +
' domain(connectivitycheck.gstatic.com) -> direct\n' + ' domain(connectivitycheck.gstatic.com) -> direct\n' +
' domain(msftconnecttest.com) -> direct\n' + ' domain(msftconnecttest.com) -> direct\n' +
' domain(suffix:googleapis.cn) -> proxy\n' +
' dip(geoip:cn) -> direct\n' + ' dip(geoip:cn) -> direct\n' +
' domain(geosite:cn) -> direct\n' + ' domain(geosite:cn) -> direct\n' +
' fallback: proxy\n' + ' fallback: proxy\n' +
@@ -704,7 +706,7 @@ function renderDaeEditor() {
save.disabled = true; save.disabled = true;
flashStatus(_('Validating…')); flashStatus(_('Validating…'));
fs.write(VALIDATE_PATH, textarea.value, 384) fs.write(VALIDATE_PATH, textarea.value, 384)
.then(function() { return fs.exec('/usr/bin/dae', ['validate', '-c', VALIDATE_PATH]); }) .then(function() { return fs.exec('/usr/bin/env', ['DAE_LOCATION_ASSET=/usr/share/v2ray', '/usr/bin/dae', 'validate', '-c', VALIDATE_PATH]); })
.then(function(res) { .then(function(res) {
if (res && res.code !== 0) { if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0]; const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
@@ -53,6 +53,7 @@ DAE_INIT="/etc/init.d/dae"
if [ -f "$DAE_INIT" ]; then if [ -f "$DAE_INIT" ]; then
sed -i 's/--disable-timestamp//' "$DAE_INIT" sed -i 's/--disable-timestamp//' "$DAE_INIT"
sed -i '/procd_append_param command *$/d' "$DAE_INIT" sed -i '/procd_append_param command *$/d' "$DAE_INIT"
sed -i 's|^[[:space:]]*"$PROG" validate -c "$config_file"| DAE_LOCATION_ASSET="/usr/share/v2ray" "$PROG" validate -c "$config_file"|' "$DAE_INIT"
sed -i 's|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray".*|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"|' "$DAE_INIT" sed -i 's|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray".*|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"|' "$DAE_INIT"
fi fi
@@ -271,6 +271,7 @@ generate() {
echo " routing {" echo " routing {"
echo " request {" echo " request {"
[ "$block_ads" = "1" ] && echo " qname(geosite:category-ads-all) -> reject" [ "$block_ads" = "1" ] && echo " qname(geosite:category-ads-all) -> reject"
echo " qname(suffix:googleapis.cn) -> fallbackdns"
echo " qname(geosite:cn) -> cndns" echo " qname(geosite:cn) -> cndns"
echo " fallback: fallbackdns" echo " fallback: fallbackdns"
echo " }" echo " }"
@@ -295,6 +296,7 @@ generate() {
# OS connectivity checks direct (avoid captive-portal false positives) # OS connectivity checks direct (avoid captive-portal false positives)
echo " domain(connectivitycheck.gstatic.com) -> direct" echo " domain(connectivitycheck.gstatic.com) -> direct"
echo " domain(msftconnecttest.com) -> direct" echo " domain(msftconnecttest.com) -> direct"
echo " domain(suffix:googleapis.cn) -> ${fallback}"
if [ "$cn_direct" = "1" ]; then if [ "$cn_direct" = "1" ]; then
echo " dip(geoip:cn) -> direct" echo " dip(geoip:cn) -> direct"
echo " domain(geosite:cn) -> direct" echo " domain(geosite:cn) -> direct"
@@ -308,7 +310,7 @@ generate() {
# overwrite live config only if validation passes # overwrite live config only if validation passes
local out local out
out="$("$DAE_BIN" validate -c "$TMP_GEN" 2>&1)" out="$(DAE_LOCATION_ASSET="/usr/share/v2ray" "$DAE_BIN" validate -c "$TMP_GEN" 2>&1)"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "validate failed: $(echo "$out" | head -1)" >&2 echo "validate failed: $(echo "$out" | head -1)" >&2
rm -f "$TMP_GEN" rm -f "$TMP_GEN"
@@ -23,7 +23,7 @@
"/tmp/luci-app-daede.backup.log": [ "read" ], "/tmp/luci-app-daede.backup.log": [ "read" ],
"/bin/pidof dae": [ "exec" ], "/bin/pidof dae": [ "exec" ],
"/bin/pidof daed": [ "exec" ], "/bin/pidof daed": [ "exec" ],
"/usr/bin/dae validate -c /tmp/dae-validate.dae": [ "exec" ], "/usr/bin/env DAE_LOCATION_ASSET=/usr/share/v2ray /usr/bin/dae validate -c /tmp/dae-validate.dae": [ "exec" ],
"/usr/bin/dae --version": [ "exec" ], "/usr/bin/dae --version": [ "exec" ],
"/bin/rm -f /tmp/dae-validate.dae": [ "exec" ], "/bin/rm -f /tmp/dae-validate.dae": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh dae": [ "exec" ], "/usr/share/luci-app-daede/pkg-info.sh dae": [ "exec" ],
+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=0.1.45 PKG_VERSION:=0.1.45
PKG_RELEASE:=35 PKG_RELEASE:=36
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH ifdef SOURCE_DATE_EPOCH
+3
View File
@@ -9,6 +9,7 @@ LuCI **Firewall Live View** — client-side JS view polling `ubus fwlive poll` (
| `Makefile` | `LUCI_TITLE`, `LUCI_DEPENDS`, includes `luci.mk` | | `Makefile` | `LUCI_TITLE`, `LUCI_DEPENDS`, includes `luci.mk` |
| `htdocs/luci-static/resources/view/status/fwlive.js` | LuCI view (`view.extend`) | | `htdocs/luci-static/resources/view/status/fwlive.js` | LuCI view (`view.extend`) |
| `htdocs/luci-static/resources/fwlive/log.js` | Parser/filter module (mirror of repo `core/fwlive-log.js`) | | `htdocs/luci-static/resources/fwlive/log.js` | Parser/filter module (mirror of repo `core/fwlive-log.js`) |
| `htdocs/luci-static/resources/fwlive/proto.js` | Protocol filter pair (select + custom field; typed custom wins) |
| `htdocs/luci-static/resources/fwlive/constants.js` | Shared view constants (`baseclass.extend` module) | | `htdocs/luci-static/resources/fwlive/constants.js` | Shared view constants (`baseclass.extend` module) |
| `htdocs/luci-static/resources/fwlive/css.js` | Inline stylesheet string (`styleText` for `E('style', …)`) | | `htdocs/luci-static/resources/fwlive/css.js` | Inline stylesheet string (`styleText` for `E('style', …)`) |
| `htdocs/luci-static/resources/fwlive/tint.js` | Row-tint paint helpers (`baseclass.extend` module) | | `htdocs/luci-static/resources/fwlive/tint.js` | Row-tint paint helpers (`baseclass.extend` module) |
@@ -24,10 +25,12 @@ LuCI **Firewall Live View** — client-side JS view polling `ubus fwlive poll` (
| `root/usr/share/luci/menu.d/*.json` | Menu entry (`admin/status/fwlive`) | | `root/usr/share/luci/menu.d/*.json` | Menu entry (`admin/status/fwlive`) |
| `root/usr/share/rpcd/acl.d/*.json` | ubus ACL (read + write for logging enable/disable) | | `root/usr/share/rpcd/acl.d/*.json` | ubus ACL (read + write for logging enable/disable) |
| `root/usr/libexec/rpcd/fwlive` | rpcd plugin (`rules`, `poll`, `resolve`, `logging_status`, `enable_wan_logging`, `disable_wan_logging`) | | `root/usr/libexec/rpcd/fwlive` | rpcd plugin (`rules`, `poll`, `resolve`, `logging_status`, `enable_wan_logging`, `disable_wan_logging`) |
| `root/usr/libexec/fwlive-adaptive-cap.sh` | Layer 1 adaptive poll cap (sourced by `rpcd/fwlive`) |
| `root/usr/libexec/fwlive-logging.sh` | WAN zone logging helpers | | `root/usr/libexec/fwlive-logging.sh` | WAN zone logging helpers |
| `/etc/fwlive/wan-log-baseline` | Written on first **Enable logging**; restored on uninstall (`prerm`). Disable of a pre-existing/foreign log bit is not snapshotted, so uninstall will not restore that bit. | | `/etc/fwlive/wan-log-baseline` | Written on first **Enable logging**; restored on uninstall (`prerm`). Disable of a pre-existing/foreign log bit is not snapshotted, so uninstall will not restore that bit. |
| `root/usr/libexec/fwlive-log-filter.sh` | Server-side firewall-only filter (`isFirewallEvent` parity) | | `root/usr/libexec/fwlive-log-filter.sh` | Server-side firewall-only filter (`isFirewallEvent` parity) |
| `root/usr/libexec/fwlive-is-firewall-event.sh` | Shared filter logic (sourced by filter + tests) | | `root/usr/libexec/fwlive-is-firewall-event.sh` | Shared filter logic (sourced by filter + tests) |
| `root/usr/libexec/fwlive-is-firewall-event.awk` | **Generated** standalone classifier from `CLASSIFY_SPEC`; loaded by `fwlive-is-firewall-event.sh` |
| `po/templates/luci-app-fwlive.pot` | i18n template (English msgid scaffolding) | | `po/templates/luci-app-fwlive.pot` | i18n template (English msgid scaffolding) |
No `luasrc/` — modern JS-only app. No `luasrc/` — modern JS-only app.
@@ -16,9 +16,32 @@
* Modules must not mutate state. host is cleared then rebuilt (idempotent replace). * Modules must not mutate state. host is cleared then rebuilt (idempotent replace).
*/ */
function chipFieldLabel(spec) {
switch (spec.key) {
case 'q':
return _('Search');
case 'action':
return _('Action');
case 'interface':
return _('Interface');
case 'proto':
return _('Proto');
case 'src':
return _('Source');
case 'dst':
return _('Destination');
case 'sport':
return _('Source port');
case 'dport':
return _('Destination port');
default:
return spec.label || spec.key;
}
}
function chipValueNodes(spec, val) { function chipValueNodes(spec, val) {
const key = spec.key; const key = spec.key;
const label = spec.label || key; const label = chipFieldLabel(spec);
const p = log.parseFilterValue(val); const p = log.parseFilterValue(val);
if (!p.value) return ['']; if (!p.value) return [''];
@@ -12,7 +12,10 @@
* luciUrl, firewallZonesPath, firewallZonesUrl, firewallZonesLink * luciUrl, firewallZonesPath, firewallZonesUrl, firewallZonesLink
* *
* Filter-aware helpers (require an onFilterClick callback): * Filter-aware helpers (require an onFilterClick callback):
* filterLink, addrFilterLink, ruleAdminPath, ruleAdminLink, ifaceLink * filterLink, addrFilterLink, ruleAdminLink, ifaceLink
*
* Constant path helper:
* ruleAdminPath
* *
* No host element — all functions return DOM nodes or strings. * No host element — all functions return DOM nodes or strings.
* May require fwlive.log for formatCell and fwlive.hostname for cache reads. * May require fwlive.log for formatCell and fwlive.hostname for cache reads.
@@ -90,7 +90,7 @@ return baseclass.extend({
CLASSIFY_SPEC: CLASSIFY_SPEC, CLASSIFY_SPEC: CLASSIFY_SPEC,
TCP_FLAG_TAIL: TCP_FLAG_TAIL:
/\b((?:SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*)(?:\s+[A-Z][A-Z0-9_]*=[^\s]+)*\s*$/i, /\b((?:SYN|ACK|FIN|RST|PSH|URG|ECE|CWR)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG|ECE|CWR))*)(?:\s+[A-Z][A-Z0-9_]*=[^\s]+)*\s*$/i,
NETFILTER_KV_GLUE: NETFILTER_KV_GLUE, NETFILTER_KV_GLUE: NETFILTER_KV_GLUE,
kvHas: function (msg, key) { kvHas: function (msg, key) {
@@ -105,7 +105,8 @@ return baseclass.extend({
MAX_DATE_SECONDS: 8640000000000, MAX_DATE_SECONDS: 8640000000000,
normalizeNetfilterMessage: function (message) { normalizeNetfilterMessage: function (message) {
return (message || '').replace(this.NETFILTER_KV_GLUE, '$1 '); if (typeof message !== 'string') message = '';
return message.replace(this.NETFILTER_KV_GLUE, '$1 ');
}, },
parseKeyValueLog: function (message) { parseKeyValueLog: function (message) {
@@ -69,6 +69,7 @@ function blockerCode(state) {
blockers.indexOf('nf_log_ipv6_missing') >= 0 blockers.indexOf('nf_log_ipv6_missing') >= 0
) )
return 'nf_log_missing'; return 'nf_log_missing';
if (blockers.length > 0) return 'unknown';
return ''; return '';
} }
@@ -92,6 +93,63 @@ function appendLoggingNotice(host, state) {
host.appendChild(E('span', { 'class': 'fwlive-logging-notice' }, [state.loggingNotice])); host.appendChild(E('span', { 'class': 'fwlive-logging-notice' }, [state.loggingNotice]));
} }
function appendBlockerStatus(host, blocker, st) {
if (blocker === 'no_wan_zone') {
host.appendChild(
E(
'span',
{ 'class': 'fwlive-logging-status' },
labelWithZoneCandidates(_('WAN logging unavailable: no WAN zone'), st)
)
);
host.appendChild(links.firewallZonesLink());
return;
}
if (blocker === 'nf_log_missing') {
host.appendChild(
E('span', { 'class': 'fwlive-logging-status' }, [
_('WAN logging unavailable: missing kernel log modules')
])
);
return;
}
if (blocker === 'unknown') {
host.appendChild(
E('span', { 'class': 'fwlive-logging-status' }, [_('WAN logging unavailable')])
);
}
}
function appendWanLogDisableControl(host, state, callbacks) {
const st = state.loggingStatus;
const limit = st.wan_log_limit || _('default 10/minute');
const busy = !!state.loggingBusy;
const children = busy
? [_('Disabling…')]
: [
E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, ['']),
E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]),
E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)])
];
host.appendChild(
E(
'button',
{
'class': 'cbi-button fwlive-log-merged',
'type': 'button',
'title': _('WAN logging on (%s). Click to disable.').format(limit),
'disabled': busy ? '' : null,
'click': function () {
callbacks.onDisable();
}
},
children
)
);
}
function renderToolbar(host, state, callbacks) { function renderToolbar(host, state, callbacks) {
host.innerHTML = ''; host.innerHTML = '';
const st = state.loggingStatus; const st = state.loggingStatus;
@@ -103,54 +161,15 @@ function renderToolbar(host, state, callbacks) {
host.style.display = 'contents'; host.style.display = 'contents';
const blocker = blockerCode(state); const blocker = blockerCode(state);
if (blocker === 'no_wan_zone') { if (blocker) appendBlockerStatus(host, blocker, st);
host.appendChild(
E(
'span',
{ 'class': 'fwlive-logging-status' },
labelWithZoneCandidates(_('WAN logging unavailable: no WAN zone'), st)
)
);
host.appendChild(links.firewallZonesLink());
appendLoggingNotice(host, state);
return;
}
if (blocker === 'nf_log_missing') {
host.appendChild(
E('span', { 'class': 'fwlive-logging-status' }, [
_('WAN logging unavailable: missing kernel log modules')
])
);
appendLoggingNotice(host, state);
return;
}
const limit = st.wan_log_limit || _('default 10/minute');
if (st.wan_log) { if (st.wan_log) {
const busy = !!state.loggingBusy; appendWanLogDisableControl(host, state, callbacks);
const children = busy appendLoggingNotice(host, state);
? [_('Disabling…')] return;
: [ }
E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, ['']),
E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]), if (blocker) {
E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)])
];
host.appendChild(
E(
'button',
{
'class': 'cbi-button fwlive-log-merged',
'type': 'button',
'title': _('WAN logging on (%s). Click to disable.').format(limit),
'disabled': busy ? '' : null,
'click': function () {
callbacks.onDisable();
}
},
children
)
);
appendLoggingNotice(host, state); appendLoggingNotice(host, state);
return; return;
} }
@@ -277,6 +296,16 @@ function buildEmptyStateNodes(state, callbacks) {
return nodes; return nodes;
} }
if (blocker === 'unknown') {
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('WAN logging unavailable')]));
nodes.push(
E('p', {}, [
_('This router reported a logging blocker that Live View does not recognize yet.')
])
);
return nodes;
}
if (st && st.wan_log) { if (st && st.wan_log) {
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Waiting for firewall events')])); nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Waiting for firewall events')]));
nodes.push( nodes.push(
@@ -312,7 +312,8 @@ function buildExpansionRow(row, state, columns) {
E('td', { 'colspan': String(columns.length) }, [ E('td', { 'colspan': String(columns.length) }, [
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]), E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
E('pre', { 'class': 'fwlive-msg-expand-body' }, [ E('pre', { 'class': 'fwlive-msg-expand-body' }, [
log.formatMessageDisplay(row.message, 'wrap') || '—' /* oneline is uncapped; wrap ellipsizes at 240 */
log.formatMessageDisplay(row.message, 'oneline') || '—'
]) ])
]) ])
]); ]);
@@ -156,6 +156,8 @@ return view.extend({
hostnameFailed: null, hostnameFailed: null,
resolveInFlight: false, resolveInFlight: false,
resolveGeneration: 0, resolveGeneration: 0,
/* Coalesce hostname-cache paints deferred while tablePaused. */
resolvePaintPending: false,
lastPollError: false, lastPollError: false,
lastRulesError: null, lastRulesError: null,
followLive: true, followLive: true,
@@ -166,6 +168,7 @@ return view.extend({
loggingStatus: null, loggingStatus: null,
loggingBusy: false, loggingBusy: false,
loggingNotice: '', loggingNotice: '',
_loggingNoticeFromToggle: false,
/* Session-only dismiss of first-run consent (Not now without checkbox). */ /* Session-only dismiss of first-run consent (Not now without checkbox). */
consentDismissedSession: false, consentDismissedSession: false,
_loggingToolbarSig: '', _loggingToolbarSig: '',
@@ -212,7 +215,7 @@ return view.extend({
parts.push('maxraw=%s'.format(encodeURIComponent(this.manualFetchLines))); parts.push('maxraw=%s'.format(encodeURIComponent(this.manualFetchLines)));
} }
if (this.viewMode === 'detailed') parts.push('view=detailed'); if (this.viewMode === 'detailed') parts.push('view=detailed');
location.hash = parts.join('&'); history.replaceState(history.state, '', '#' + parts.join('&'));
}, },
hashEntries() { hashEntries() {
@@ -739,7 +742,7 @@ return view.extend({
const status = await callFwliveLoggingStatus(); const status = await callFwliveLoggingStatus();
if (this.viewDisposed) return; if (this.viewDisposed) return;
this.loggingStatus = status; this.loggingStatus = status;
this.loggingNotice = ''; if (!this._loggingNoticeFromToggle) this.loggingNotice = '';
this.weakDevice = !!(this.loggingStatus && this.loggingStatus.weak_device === true); this.weakDevice = !!(this.loggingStatus && this.loggingStatus.weak_device === true);
} catch (_e) { } catch (_e) {
if (this.viewDisposed) return; if (this.viewDisposed) return;
@@ -761,17 +764,20 @@ return view.extend({
this.loggingBusy = true; this.loggingBusy = true;
this.loggingNotice = ''; this.loggingNotice = '';
this._loggingNoticeFromToggle = false;
opts.initialUi(); opts.initialUi();
try { try {
const res = await opts.call(); const res = await opts.call();
if (!res || !res.ok) { if (!res || !res.ok) {
this.loggingNotice = opts.failureNotice(res); this.loggingNotice = opts.failureNotice(res);
this._loggingNoticeFromToggle = true;
await this.loadLoggingStatus(); await this.loadLoggingStatus();
return; return;
} }
this.loggingNotice = opts.successNotice(res); this.loggingNotice = opts.successNotice(res);
this._loggingNoticeFromToggle = !!this.loggingNotice;
if (opts.onSuccess) opts.onSuccess(res); if (opts.onSuccess) opts.onSuccess(res);
if (this.loggingStatus && typeof opts.wanLog === 'boolean') if (this.loggingStatus && typeof opts.wanLog === 'boolean')
this.loggingStatus = Object.assign({}, this.loggingStatus, { this.loggingStatus = Object.assign({}, this.loggingStatus, {
@@ -780,8 +786,11 @@ return view.extend({
await this.loadLoggingStatus(); await this.loadLoggingStatus();
} catch (_e) { } catch (_e) {
this.loggingNotice = opts.catchNotice(); this.loggingNotice = opts.catchNotice();
this._loggingNoticeFromToggle = true;
await this.loadLoggingStatus(); await this.loadLoggingStatus();
} finally { } finally {
/* Survive only the refresh owned by this toggle. */
this._loggingNoticeFromToggle = false;
this.loggingBusy = false; this.loggingBusy = false;
this.updateEmptyStateUi(); this.updateEmptyStateUi();
this.updateLoggingToolbarUi(); this.updateLoggingToolbarUi();
@@ -803,6 +812,14 @@ return view.extend({
return _( return _(
'Another change is staged for the firewall; apply or revert it first.' 'Another change is staged for the firewall; apply or revert it first.'
); );
if (res && res.error === 'no_wan_zone')
return _('No WAN zone found; cannot toggle logging without one.');
if (res && res.error === 'lock_failed')
return _('Could not acquire the logging lock.');
if (res && res.error === 'baseline_snapshot_failed')
return _('Could not snapshot the current logging state.');
if (res && res.error === 'firewall_reload_failed')
return _('The firewall did not reload; saved and live logging may differ.');
return _('Could not enable logging.'); return _('Could not enable logging.');
}, },
successNotice: (res) => successNotice: (res) =>
@@ -826,6 +843,12 @@ return view.extend({
return _( return _(
'Another change is staged for the firewall; apply or revert it first.' 'Another change is staged for the firewall; apply or revert it first.'
); );
if (res && res.error === 'no_wan_zone')
return _('No WAN zone found; cannot toggle logging without one.');
if (res && res.error === 'lock_failed')
return _('Could not acquire the logging lock.');
if (res && res.error === 'firewall_reload_failed')
return _('The firewall did not reload; saved and live logging may differ.');
return _('Could not disable logging.'); return _('Could not disable logging.');
}, },
successNotice: (res) => (res.changed ? _('WAN drop/reject logging is off.') : ''), successNotice: (res) => (res.changed ? _('WAN drop/reject logging is off.') : ''),
@@ -1363,23 +1386,28 @@ return view.extend({
this.updateSummaryUi(); this.updateSummaryUi();
}, },
updateSummaryUi() { syncEmptyScrollVisibility(rowCount) {
const card = document.getElementById('fwlive-summary'); const hideTable = this.summaryMode && !this.summaryRowsShown;
const scroll = document.getElementById('fwlive-scroll'); const scroll = document.getElementById('fwlive-scroll');
const empty = document.getElementById('fwlive-empty'); const empty = document.getElementById('fwlive-empty');
if (scroll) {
if (!scroll.style) scroll.style = { display: '' };
scroll.style.display = hideTable ? 'none' : '';
}
if (empty) {
if (!empty.style) empty.style = { display: '' };
empty.style.display = hideTable ? 'none' : rowCount ? 'none' : 'block';
}
},
updateSummaryUi() {
const card = document.getElementById('fwlive-summary');
const toggle = document.getElementById('fwlive-summary-rows'); const toggle = document.getElementById('fwlive-summary-rows');
if (card) { if (card) {
if (!card.style) card.style = { display: '' }; if (!card.style) card.style = { display: '' };
card.style.display = this.summaryMode ? 'block' : 'none'; card.style.display = this.summaryMode ? 'block' : 'none';
} }
if (scroll) { this.syncEmptyScrollVisibility(this.filteredRows().length);
if (!scroll.style) scroll.style = { display: '' };
scroll.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (empty) {
if (!empty.style) empty.style = { display: '' };
empty.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (toggle) { if (toggle) {
toggle.textContent = this.summaryRowsShown ? _('Hide rows') : _('Show rows'); toggle.textContent = this.summaryRowsShown ? _('Hide rows') : _('Show rows');
toggle.setAttribute('aria-pressed', this.summaryRowsShown ? 'true' : 'false'); toggle.setAttribute('aria-pressed', this.summaryRowsShown ? 'true' : 'false');
@@ -1400,13 +1428,12 @@ return view.extend({
this.summaryData = null; this.summaryData = null;
this.updateSummaryUi(); this.updateSummaryUi();
if (this.tablePaused) { if (this.tablePaused) {
const empty = document.getElementById('fwlive-empty'); this.syncEmptyScrollVisibility(this.filteredRows().length);
if (empty) {
const rows = this.filteredRows();
empty.style.display = rows.length ? 'none' : 'block';
}
this.updateStatus(); this.updateStatus();
} else this.renderRows(true); } else {
this.resolvePaintPending = false;
this.renderRows(true);
}
}, },
onSummaryRowsToggle() { onSummaryRowsToggle() {
@@ -1420,6 +1447,16 @@ return view.extend({
this.ensureRenderScheduler().schedule(!!force); this.ensureRenderScheduler().schedule(!!force);
}, },
scheduleResolvePaint() {
if (this.tablePaused) {
this.resolvePaintPending = true;
this.updateStatus();
return;
}
this.resolvePaintPending = false;
this.scheduleRenderRows(true);
},
updateFloodBanner() { updateFloodBanner() {
const el = document.getElementById('fwlive-flood'); const el = document.getElementById('fwlive-flood');
if (!el) return; if (!el) return;
@@ -1601,7 +1638,10 @@ return view.extend({
this.requestPoll() this.requestPoll()
.then(() => { .then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */ /* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch === this.currentPollEpoch()) this.renderRows(true); if (epoch === this.currentPollEpoch()) {
this.resolvePaintPending = false;
this.renderRows(true);
}
}) })
.catch(function () {}); .catch(function () {});
} }
@@ -1767,7 +1807,7 @@ return view.extend({
} }
this.updateAdaptiveBanner(); this.updateAdaptiveBanner();
if (updated) this.scheduleRenderRows(true); if (updated) this.scheduleResolvePaint();
} catch (_e) { } catch (_e) {
/* resolve unavailable — show IPs */ /* resolve unavailable — show IPs */
} finally { } finally {
@@ -1894,7 +1934,6 @@ return view.extend({
if (!el || typeof el.querySelector !== 'function') return; if (!el || typeof el.querySelector !== 'function') return;
const body = el.querySelector('tbody'); const body = el.querySelector('tbody');
const empty = document.getElementById('fwlive-empty');
const scroll = document.getElementById('fwlive-scroll'); const scroll = document.getElementById('fwlive-scroll');
this.updateHash(this.readFilters()); this.updateHash(this.readFilters());
@@ -1908,6 +1947,7 @@ return view.extend({
if (!paint) { if (!paint) {
this.updateFloodBanner(); this.updateFloodBanner();
this.syncEmptyScrollVisibility(rows.length);
this.updateStatus(rows); this.updateStatus(rows);
return; return;
} }
@@ -1916,7 +1956,7 @@ return view.extend({
const prevScroll = scroll ? scroll.scrollTop : 0; const prevScroll = scroll ? scroll.scrollTop : 0;
if (empty) empty.style.display = rows.length ? 'none' : 'block'; this.syncEmptyScrollVisibility(rows.length);
this.updateStatus(rows); this.updateStatus(rows);
this.renderFilterChips(); this.renderFilterChips();
@@ -2068,7 +2108,18 @@ return view.extend({
else if (this.summaryMode) { else if (this.summaryMode) {
this.renderSummary(); this.renderSummary();
this.updateStatus(); this.updateStatus();
} else this.scheduleRenderRows(); if (this.summaryRowsShown) {
const forceHostnamePaint = this.resolvePaintPending;
this.resolvePaintPending = false;
this.scheduleRenderRows(forceHostnamePaint);
}
} else {
/* Stale resume skips renderRows(true); catch-up polls must still
* flush coalesced hostname paints. */
const forceHostnamePaint = this.resolvePaintPending;
this.resolvePaintPending = false;
this.scheduleRenderRows(forceHostnamePaint);
}
try { try {
await this.resolveHostnamesForEntries(this.filteredRows()); await this.resolveHostnamesForEntries(this.filteredRows());
+51 -9
View File
@@ -16,7 +16,7 @@ msgstr ""
msgid "%d matching · %d/%d stored" msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:467
msgid "%s: %s" msgid "%s: %s"
msgstr "%s: %s" msgstr "%s: %s"
@@ -83,7 +83,7 @@ msgstr "Klassisch (grün/rot)"
msgid "Classic uses green/red; Accessible uses teal/orange" msgid "Classic uses green/red; Accessible uses teal/orange"
msgstr "Klassisch verwendet grün/rot; Barrierefrei verwendet teal/orange" msgstr "Klassisch verwendet grün/rot; Barrierefrei verwendet teal/orange"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:116 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:169
msgid "Clear all" msgid "Clear all"
msgstr "Alle löschen" msgstr "Alle löschen"
@@ -124,6 +124,11 @@ msgstr "Häufig"
msgid "Connection lost — retrying…" msgid "Connection lost — retrying…"
msgstr "Connection lost — retrying…" msgstr "Connection lost — retrying…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:818
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:849
msgid "Could not acquire the logging lock."
msgstr "Die Protokollierungssperre konnte nicht erworben werden."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609
msgid "Could not disable logging." msgid "Could not disable logging."
msgstr "Protokollierung konnte nicht deaktiviert werden." msgstr "Protokollierung konnte nicht deaktiviert werden."
@@ -132,6 +137,10 @@ msgstr "Protokollierung konnte nicht deaktiviert werden."
msgid "Could not refresh logging status; showing the last known state." msgid "Could not refresh logging status; showing the last known state."
msgstr "Could not refresh logging status; showing the last known state." msgstr "Could not refresh logging status; showing the last known state."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:820
msgid "Could not snapshot the current logging state."
msgstr "Der aktuelle Protokollierungszustand konnte nicht gesichert werden."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573
msgid "Could not enable logging." msgid "Could not enable logging."
msgstr "Protokollierung konnte nicht aktiviert werden." msgstr "Protokollierung konnte nicht aktiviert werden."
@@ -167,6 +176,10 @@ msgstr "Ziel"
msgid "Destination IP contains (! to exclude)" msgid "Destination IP contains (! to exclude)"
msgstr "Ziel-IP enthält (! zum Ausschließen)" msgstr "Ziel-IP enthält (! zum Ausschließen)"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:36
msgid "Destination port"
msgstr "Ziel-Port"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692
msgid "Destination port (! to exclude)" msgid "Destination port (! to exclude)"
msgstr "Ziel-Port (! zum Ausschließen)" msgstr "Ziel-Port (! zum Ausschließen)"
@@ -201,6 +214,11 @@ msgstr "Für eine responsive Tabelle auf einem schwachen Gerät sollte Limit auf
msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work." msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work."
msgstr "Beim Wechseln des Tabs wird das Polling pausiert; bei der Rückkehr erfolgt eine einmalige Aufholabfrage. Hostnamen sind standardmäßig deaktiviert, da Auflösungen zusätzliche Arbeit verursachen." msgstr "Beim Wechseln des Tabs wird das Polling pausiert; bei der Rückkehr erfolgt eine einmalige Aufholabfrage. Hostnamen sind standardmäßig deaktiviert, da Auflösungen zusätzliche Arbeit verursachen."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:822
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:851
msgid "The firewall did not reload; saved and live logging may differ."
msgstr "Die Firewall wurde nicht neu geladen; gespeicherte und aktive Protokollierung können voneinander abweichen."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250
msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior."
msgstr "Der Protokollring des Routers kann ältere Ereignisse verdrängen, bevor fwlive sie liest. fwlive kann verdrängte Einträge nicht wiederherstellen und ändert das Weiterleitungsverhalten nicht." msgstr "Der Protokollring des Routers kann ältere Ereignisse verdrängen, bevor fwlive sie liest. fwlive kann verdrängte Einträge nicht wiederherstellen und ändert das Weiterleitungsverhalten nicht."
@@ -244,7 +262,7 @@ msgstr "Enabling…"
msgid "Exclude" msgid "Exclude"
msgstr "Ausschließen" msgstr "Ausschließen"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Exclude instead" msgid "Exclude instead"
msgstr "Stattdessen ausschließen" msgstr "Stattdessen ausschließen"
@@ -322,7 +340,7 @@ msgstr ""
"If the WAN is quiet, wait for probes or use the optional ping check in " "If the WAN is quiet, wait for probes or use the optional ping check in "
"Help / the enabling-logs guide." "Help / the enabling-logs guide."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Include instead" msgid "Include instead"
msgstr "Stattdessen einschließen" msgstr "Stattdessen einschließen"
@@ -399,6 +417,13 @@ msgstr ""
msgid "No WAN zone found" msgid "No WAN zone found"
msgstr "No WAN zone found" msgstr "No WAN zone found"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:816
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:847
msgid "No WAN zone found; cannot toggle logging without one."
msgstr ""
"Keine WAN-Zone gefunden; Protokollierung kann ohne eine solche nicht "
"umgeschaltet werden."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163
msgid "Not now" msgid "Not now"
msgstr "Not now" msgstr "Not now"
@@ -454,7 +479,7 @@ msgstr "Protokoll — häufige Werte"
msgid "Quick search" msgid "Quick search"
msgstr "Schnellsuche" msgstr "Schnellsuche"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:91 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:128
msgid "Remove filter" msgid "Remove filter"
msgstr "Filter entfernen" msgstr "Filter entfernen"
@@ -512,6 +537,10 @@ msgstr "Server hat diese Abfrage gekürzt (adaptives Limit)."
msgid "SPort" msgid "SPort"
msgstr "Quell-Port" msgstr "Quell-Port"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:22
msgid "Search"
msgstr "Suche"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650
msgid "Show hostnames" msgid "Show hostnames"
msgstr "Hostnamen anzeigen" msgstr "Hostnamen anzeigen"
@@ -532,6 +561,10 @@ msgstr "Quelle"
msgid "Source IP contains (! to exclude)" msgid "Source IP contains (! to exclude)"
msgstr "Quell-IP enthält (! zum Ausschließen)" msgstr "Quell-IP enthält (! zum Ausschließen)"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:34
msgid "Source port"
msgstr "Quell-Port"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690
msgid "Source port (! to exclude)" msgid "Source port (! to exclude)"
msgstr "Quell-Port (! zum Ausschließen)" msgstr "Quell-Port (! zum Ausschließen)"
@@ -554,6 +587,10 @@ msgstr ""
"Die Tabelle aktualisiert sich automatisch, wenn Ihre Firewall Verkehr " "Die Tabelle aktualisiert sich automatisch, wenn Ihre Firewall Verkehr "
"protokolliert. Pause nutzen, wenn es zu schnell wird." "protokolliert. Pause nutzen, wenn es zu schnell wird."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:303
msgid "This router reported a logging blocker that Live View does not recognize yet."
msgstr "Dieser Router hat eine Protokollierungsblockade gemeldet, die Live View noch nicht kennt."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551
msgid "Theme tint fallback" msgid "Theme tint fallback"
msgstr "Theme-Farb-Fallback" msgstr "Theme-Farb-Fallback"
@@ -618,6 +655,11 @@ msgstr "WAN-Protokollierung: EIN"
msgid "WAN logging on (%s). Click to disable." msgid "WAN logging on (%s). Click to disable."
msgstr "WAN-Protokollierung: EIN (%s). Klicken zum Deaktivieren." msgstr "WAN-Protokollierung: EIN (%s). Klicken zum Deaktivieren."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:120
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:300
msgid "WAN logging unavailable"
msgstr "WAN-Protokollierung nicht verfügbar"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77
msgid "WAN logging unavailable: missing kernel log modules" msgid "WAN logging unavailable: missing kernel log modules"
msgstr "WAN-Protokollierung nicht verfügbar: fehlende Kernel-Log-Module" msgstr "WAN-Protokollierung nicht verfügbar: fehlende Kernel-Log-Module"
@@ -647,7 +689,7 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full" msgid "buffer full"
msgstr "Puffer voll" msgstr "Puffer voll"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:63
msgid "contains" msgid "contains"
msgstr "enthält" msgstr "enthält"
@@ -655,7 +697,7 @@ msgstr "enthält"
msgid "default 10/minute" msgid "default 10/minute"
msgstr "Standard 10/Minute" msgstr "Standard 10/Minute"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:28 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:52
msgid "is" msgid "is"
msgstr "ist" msgstr "ist"
@@ -667,8 +709,8 @@ msgstr "Lade Puffer"
msgid "loading…" msgid "loading…"
msgstr "loading…" msgstr "loading…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:37 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:61
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:44 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:68
msgid "not" msgid "not"
msgstr "nicht" msgstr "nicht"
+49 -9
View File
@@ -17,7 +17,7 @@ msgstr ""
msgid "%d matching · %d/%d stored" msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:467
msgid "%s: %s" msgid "%s: %s"
msgstr "%s: %s" msgstr "%s: %s"
@@ -88,7 +88,7 @@ msgstr ""
"Классический — зелёный/красный; доступный — " "Классический — зелёный/красный; доступный — "
"бирюзовый/оранжевый" "бирюзовый/оранжевый"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:116 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:169
msgid "Clear all" msgid "Clear all"
msgstr "Очистить всё" msgstr "Очистить всё"
@@ -129,6 +129,11 @@ msgstr "Частые"
msgid "Connection lost — retrying…" msgid "Connection lost — retrying…"
msgstr "Connection lost — retrying…" msgstr "Connection lost — retrying…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:818
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:849
msgid "Could not acquire the logging lock."
msgstr "Не удалось получить блокировку журналирования."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609
msgid "Could not disable logging." msgid "Could not disable logging."
msgstr "Не удалось отключить журналирование." msgstr "Не удалось отключить журналирование."
@@ -137,6 +142,10 @@ msgstr "Не удалось отключитÑ
msgid "Could not refresh logging status; showing the last known state." msgid "Could not refresh logging status; showing the last known state."
msgstr "Could not refresh logging status; showing the last known state." msgstr "Could not refresh logging status; showing the last known state."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:820
msgid "Could not snapshot the current logging state."
msgstr "Не удалось сохранить снимок текущего состояния журналирования."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573
msgid "Could not enable logging." msgid "Could not enable logging."
msgstr "Не удалось включить журналирование." msgstr "Не удалось включить журналирование."
@@ -169,6 +178,10 @@ msgstr "Назначение"
msgid "Destination IP contains (! to exclude)" msgid "Destination IP contains (! to exclude)"
msgstr "IP назначения содержит (! для исключения)" msgstr "IP назначения содержит (! для исключения)"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:36
msgid "Destination port"
msgstr "Порт назначения"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692
msgid "Destination port (! to exclude)" msgid "Destination port (! to exclude)"
msgstr "Порт назначения (! для исключения)" msgstr "Порт назначения (! для исключения)"
@@ -203,6 +216,11 @@ msgstr "Для отзывчивой таблицы на слабом устро
msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work." msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work."
msgstr "При переключении вкладки опрос приостанавливается; при возврате выполняется один догоняющий опрос. Имена хостов по умолчанию отключены, поскольку их разрешение создаёт дополнительную нагрузку." msgstr "При переключении вкладки опрос приостанавливается; при возврате выполняется один догоняющий опрос. Имена хостов по умолчанию отключены, поскольку их разрешение создаёт дополнительную нагрузку."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:822
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:851
msgid "The firewall did not reload; saved and live logging may differ."
msgstr "Брандмауэр не перезагрузился; сохранённые и активные настройки журналирования могут различаться."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250
msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior."
msgstr "Кольцевой журнал маршрутизатора может вытеснить старые события до того, как fwlive их прочитает. fwlive не может восстановить вытесненные записи и не изменяет пересылку." msgstr "Кольцевой журнал маршрутизатора может вытеснить старые события до того, как fwlive их прочитает. fwlive не может восстановить вытесненные записи и не изменяет пересылку."
@@ -246,7 +264,7 @@ msgstr "Enabling…"
msgid "Exclude" msgid "Exclude"
msgstr "Исключить" msgstr "Исключить"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Exclude instead" msgid "Exclude instead"
msgstr "Исключить вместо этого" msgstr "Исключить вместо этого"
@@ -324,7 +342,7 @@ msgstr ""
"If the WAN is quiet, wait for probes or use the optional ping check in " "If the WAN is quiet, wait for probes or use the optional ping check in "
"Help / the enabling-logs guide." "Help / the enabling-logs guide."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Include instead" msgid "Include instead"
msgstr "Включить вместо этого" msgstr "Включить вместо этого"
@@ -400,6 +418,11 @@ msgstr ""
msgid "No WAN zone found" msgid "No WAN zone found"
msgstr "No WAN zone found" msgstr "No WAN zone found"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:816
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:847
msgid "No WAN zone found; cannot toggle logging without one."
msgstr "WAN-зона не найдена; без неё нельзя переключить журналирование."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163
msgid "Not now" msgid "Not now"
msgstr "Not now" msgstr "Not now"
@@ -455,7 +478,7 @@ msgstr "Протокол — частые значения"
msgid "Quick search" msgid "Quick search"
msgstr "Быстрый поиск" msgstr "Быстрый поиск"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:91 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:128
msgid "Remove filter" msgid "Remove filter"
msgstr "Удалить фильтр" msgstr "Удалить фильтр"
@@ -514,6 +537,10 @@ msgstr "Сервер сократил этот опрос (адаптивный
msgid "SPort" msgid "SPort"
msgstr "Порт ист." msgstr "Порт ист."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:22
msgid "Search"
msgstr "Поиск"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650
msgid "Show hostnames" msgid "Show hostnames"
msgstr "Показывать имена " msgstr "Показывать имена "
@@ -534,6 +561,10 @@ msgstr "Источник"
msgid "Source IP contains (! to exclude)" msgid "Source IP contains (! to exclude)"
msgstr "IP источника содержит (! для исключения)" msgstr "IP источника содержит (! для исключения)"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:34
msgid "Source port"
msgstr "Порт источника"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690
msgid "Source port (! to exclude)" msgid "Source port (! to exclude)"
msgstr "Порт источника (! для исключения)" msgstr "Порт источника (! для исключения)"
@@ -557,6 +588,10 @@ msgstr ""
"файрвол пишет в журнал. Используйте Паузу, если " "файрвол пишет в журнал. Используйте Паузу, если "
"слишком быстро." "слишком быстро."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:303
msgid "This router reported a logging blocker that Live View does not recognize yet."
msgstr "Этот маршрутизатор сообщил о блоке журналирования, который Live View пока не распознаёт."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551
msgid "Theme tint fallback" msgid "Theme tint fallback"
msgstr "Запасной цвет темы" msgstr "Запасной цвет темы"
@@ -621,6 +656,11 @@ msgstr "WAN-журнал: ВКЛ"
msgid "WAN logging on (%s). Click to disable." msgid "WAN logging on (%s). Click to disable."
msgstr "WAN-журнал: ВКЛ (%s). Нажмите, чтобы отключить." msgstr "WAN-журнал: ВКЛ (%s). Нажмите, чтобы отключить."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:120
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:300
msgid "WAN logging unavailable"
msgstr "WAN-журналирование недоступно"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77
msgid "WAN logging unavailable: missing kernel log modules" msgid "WAN logging unavailable: missing kernel log modules"
msgstr "" msgstr ""
@@ -652,7 +692,7 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full" msgid "buffer full"
msgstr "буфер заполнен" msgstr "буфер заполнен"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:63
msgid "contains" msgid "contains"
msgstr "содержит" msgstr "содержит"
@@ -660,7 +700,7 @@ msgstr "содержит"
msgid "default 10/minute" msgid "default 10/minute"
msgstr "по умолчанию 10/мин" msgstr "по умолчанию 10/мин"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:28 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:52
msgid "is" msgid "is"
msgstr "есть" msgstr "есть"
@@ -672,8 +712,8 @@ msgstr "загрузка буфера"
msgid "loading…" msgid "loading…"
msgstr "loading…" msgstr "loading…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:37 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:61
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:44 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:68
msgid "not" msgid "not"
msgstr "не" msgstr "не"
File diff suppressed because it is too large. Load diff
+49 -9
View File
@@ -16,7 +16,7 @@ msgstr ""
msgid "%d matching · %d/%d stored" msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:467
msgid "%s: %s" msgid "%s: %s"
msgstr "%s: %s" msgstr "%s: %s"
@@ -77,7 +77,7 @@ msgstr "经"
msgid "Classic uses green/red; Accessible uses teal/orange" msgid "Classic uses green/red; Accessible uses teal/orange"
msgstr "经" msgstr "经"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:116 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:169
msgid "Clear all" msgid "Clear all"
msgstr "æ" msgstr "æ"
@@ -115,6 +115,11 @@ msgstr "常用"
msgid "Connection lost — retrying…" msgid "Connection lost — retrying…"
msgstr "Connection lost — retrying…" msgstr "Connection lost — retrying…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:818
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:849
msgid "Could not acquire the logging lock."
msgstr "无法获取日志记录锁。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609
msgid "Could not disable logging." msgid "Could not disable logging."
msgstr "无法" msgstr "无法"
@@ -123,6 +128,10 @@ msgstr "无法"
msgid "Could not refresh logging status; showing the last known state." msgid "Could not refresh logging status; showing the last known state."
msgstr "Could not refresh logging status; showing the last known state." msgstr "Could not refresh logging status; showing the last known state."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:820
msgid "Could not snapshot the current logging state."
msgstr "无法快照当前日志记录状态。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573
msgid "Could not enable logging." msgid "Could not enable logging."
msgstr "无法开启日志记录。" msgstr "无法开启日志记录。"
@@ -155,6 +164,10 @@ msgstr "目标地址"
msgid "Destination IP contains (! to exclude)" msgid "Destination IP contains (! to exclude)"
msgstr "目标 IP å" msgstr "目标 IP å"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:36
msgid "Destination port"
msgstr "目标端口"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692
msgid "Destination port (! to exclude)" msgid "Destination port (! to exclude)"
msgstr "目标端口(! 表示排除)" msgstr "目标端口(! 表示排除)"
@@ -187,6 +200,11 @@ msgstr "要让弱设备上的表格保持响应,请将 Limit 保持在 250 行
msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work." msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work."
msgstr "切换到其他标签页会暂停轮询;返回时执行一次追赶轮询。主机名默认关闭,因为解析会增加工作量。" msgstr "切换到其他标签页会暂停轮询;返回时执行一次追赶轮询。主机名默认关闭,因为解析会增加工作量。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:822
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:851
msgid "The firewall did not reload; saved and live logging may differ."
msgstr "防火墙未能重新加载;已保存的日志设置与当前生效状态可能不一致。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250
msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior."
msgstr "路由器日志环形缓冲区可能在 fwlive 读取前淘汰较旧事件。fwlive 无法恢复已淘汰的条目,也不会改变转发行为。" msgstr "路由器日志环形缓冲区可能在 fwlive 读取前淘汰较旧事件。fwlive 无法恢复已淘汰的条目,也不会改变转发行为。"
@@ -230,7 +248,7 @@ msgstr "Enabling…"
msgid "Exclude" msgid "Exclude"
msgstr "排除" msgstr "排除"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Exclude instead" msgid "Exclude instead"
msgstr "改为排除" msgstr "改为排除"
@@ -305,7 +323,7 @@ msgstr ""
"If the WAN is quiet, wait for probes or use the optional ping check in " "If the WAN is quiet, wait for probes or use the optional ping check in "
"Help / the enabling-logs guide." "Help / the enabling-logs guide."
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105
msgid "Include instead" msgid "Include instead"
msgstr "改为å" msgstr "改为å"
@@ -377,6 +395,11 @@ msgstr "在 /etc/config/firewall 中未找到 WAN 防火墙区域。请在以下
msgid "No WAN zone found" msgid "No WAN zone found"
msgstr "No WAN zone found" msgstr "No WAN zone found"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:816
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:847
msgid "No WAN zone found; cannot toggle logging without one."
msgstr "未找到 WAN 区域;没有它无法切换日志记录。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163
msgid "Not now" msgid "Not now"
msgstr "Not now" msgstr "Not now"
@@ -432,7 +455,7 @@ msgstr "协议 — 常用值"
msgid "Quick search" msgid "Quick search"
msgstr "快速搜索" msgstr "快速搜索"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:91 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:128
msgid "Remove filter" msgid "Remove filter"
msgstr "移除筛选" msgstr "移除筛选"
@@ -487,6 +510,10 @@ msgstr "服务器已截断本次轮询(自适应上限)。"
msgid "SPort" msgid "SPort"
msgstr "源端口" msgstr "源端口"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:22
msgid "Search"
msgstr "搜索"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650
msgid "Show hostnames" msgid "Show hostnames"
msgstr "显示主机名" msgstr "显示主机名"
@@ -507,6 +534,10 @@ msgstr "源地址"
msgid "Source IP contains (! to exclude)" msgid "Source IP contains (! to exclude)"
msgstr "源 IP å" msgstr "源 IP å"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:34
msgid "Source port"
msgstr "源端口"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690
msgid "Source port (! to exclude)" msgid "Source port (! to exclude)"
msgstr "源端口(! 表示排除)" msgstr "源端口(! 表示排除)"
@@ -524,6 +555,10 @@ msgid ""
"if it moves too fast." "if it moves too fast."
msgstr "防火墙写" msgstr "防火墙写"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:303
msgid "This router reported a logging blocker that Live View does not recognize yet."
msgstr "此路由器报告了 Live View 尚无法识别的日志阻止项。"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551
msgid "Theme tint fallback" msgid "Theme tint fallback"
msgstr "主题着色回退" msgstr "主题着色回退"
@@ -587,6 +622,11 @@ msgstr "WAN 日志:开"
msgid "WAN logging on (%s). Click to disable." msgid "WAN logging on (%s). Click to disable."
msgstr "WAN 日志:开(%s)。点击可" msgstr "WAN 日志:开(%s)。点击可"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:120
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:300
msgid "WAN logging unavailable"
msgstr "WAN 日志不可用"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77
msgid "WAN logging unavailable: missing kernel log modules" msgid "WAN logging unavailable: missing kernel log modules"
msgstr "WAN 日志不可用:缺少å" msgstr "WAN 日志不可用:缺少å"
@@ -616,7 +656,7 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full" msgid "buffer full"
msgstr "缓冲区已满" msgstr "缓冲区已满"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:63
msgid "contains" msgid "contains"
msgstr "包含" msgstr "包含"
@@ -624,7 +664,7 @@ msgstr "包含"
msgid "default 10/minute" msgid "default 10/minute"
msgstr "默认 10 条/分钟" msgstr "默认 10 条/分钟"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:28 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:52
msgid "is" msgid "is"
msgstr "是" msgstr "是"
@@ -636,8 +676,8 @@ msgstr "正在加载缓冲区"
msgid "loading…" msgid "loading…"
msgstr "loading…" msgstr "loading…"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:37 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:61
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:44 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:68
msgid "not" msgid "not"
msgstr "非" msgstr "非"
@@ -406,11 +406,13 @@ maybe_snapshot_wan_log_baseline() {
# Restore WAN zone log from the install-time baseline (package prerm). # Restore WAN zone log from the install-time baseline (package prerm).
# No-op when baseline is missing. Returns 1 on failure; baseline file is # No-op when baseline is missing. Returns 1 on failure; baseline file is
# kept until restore commits successfully. # kept until restore commits and the post-restore firewall reload succeeds.
# #
# Hold the logging lock across the current-value read, equality cleanup, # Hold the logging lock across the current-value read, equality check,
# commit, and baseline unlink — otherwise a concurrent enable can snapshot # and commit — otherwise a concurrent enable can snapshot the old baseline
# the old baseline (or race the unlink) and lose the only restore value. # (or race the unlink) and lose the only restore value. Reload runs
# without the lock (BusyBox flock has no -w); unlink after a successful
# reload.
restore_wan_log_baseline() { restore_wan_log_baseline() {
path="$(wan_log_baseline_path)" path="$(wan_log_baseline_path)"
[ -f "$path" ] || return 0 [ -f "$path" ] || return 0
@@ -428,8 +430,18 @@ restore_wan_log_baseline() {
fi fi
current=$(wan_zone_log_value "$zone") current=$(wan_zone_log_value "$zone")
if [ "${current:-}" = "${baseline:-}" ]; then if [ "${current:-}" = "${baseline:-}" ]; then
rm -f "$path" # UCI already matches; live fw4 may still be stale after a
# previous reload failure. Retry reload before dropping the
# marker.
if firewall_changes_pending; then
release_wan_log_lock
logger -t fwlive "WAN log baseline restore skipped: firewall changes pending" 2>/dev/null || true
return 1
fi
release_wan_log_lock release_wan_log_lock
if ! restore_wan_log_after_reload "$path" "$zone" "$baseline"; then
return 1
fi
return 0 return 0
fi fi
zone_json=$(json_null_or_string "$zone") zone_json=$(json_null_or_string "$zone")
@@ -453,9 +465,36 @@ restore_wan_log_baseline() {
logger -t fwlive "WAN log baseline restore: commit gate failed" 2>/dev/null || true logger -t fwlive "WAN log baseline restore: commit gate failed" 2>/dev/null || true
return 1 return 1
fi fi
release_wan_log_lock
if ! restore_wan_log_after_reload "$path" "$zone" "$baseline"; then
return 1
fi
return 0
}
# Reload without the logging lock (BusyBox flock has no -w). Re-acquire
# before unlinking so a concurrent enable cannot snapshot-skip then leave
# UCI off the saved baseline while this path still deletes the marker.
restore_wan_log_after_reload() {
path="$1"
zone="$2"
baseline="$3"
if ! reload_firewall; then
logger -t fwlive "WAN log baseline restored; firewall reload failed" 2>/dev/null || true
return 1
fi
if ! acquire_wan_log_lock; then
logger -t fwlive "WAN log baseline restore: lock unavailable after reload" 2>/dev/null || true
return 1
fi
current=$(wan_zone_log_value "$zone")
if [ "${current:-}" != "${baseline:-}" ]; then
release_wan_log_lock
logger -t fwlive "WAN log baseline restore: post-reload verify raced" 2>/dev/null || true
return 1
fi
rm -f "$path" rm -f "$path"
release_wan_log_lock release_wan_log_lock
reload_firewall || logger -t fwlive "WAN log baseline restored; firewall reload failed" 2>/dev/null || true
return 0 return 0
} }
@@ -469,6 +508,11 @@ wan_filter_log_decimal() {
case "$_log_val" in case "$_log_val" in
''|*[!0-9]*) return 1 ;; ''|*[!0-9]*) return 1 ;;
esac esac
# Bitmask option. Reject oversized digit runs before $(( )) so a
# 20-digit UCI value cannot kill dash or wrap on BusyBox.
if [ "${#_log_val}" -gt 10 ]; then
return 1
fi
while [ "${_log_val#0}" != "$_log_val" ]; do while [ "${_log_val#0}" != "$_log_val" ]; do
_log_val=${_log_val#0} _log_val=${_log_val#0}
done done
@@ -698,10 +742,16 @@ restore_wan_zone_log() {
# below must be able to undo only our rollback staging without reverting # below must be able to undo only our rollback staging without reverting
# the other writer's change. # the other writer's change.
committed=$(wan_zone_log_value "$zone") committed=$(wan_zone_log_value "$zone")
# Fail closed if set/delete never staged. Swallowed rc plus an empty
# changes list would make `uci commit` succeed as a no-op.
if [ -z "$previous" ]; then if [ -z "$previous" ]; then
uci -q delete "firewall.${zone}.log" 2>/dev/null || true if ! uci -q delete "firewall.${zone}.log" 2>/dev/null; then
return 1
fi
else else
uci -q set "firewall.${zone}.log=${previous}" 2>/dev/null || true if ! uci -q set "firewall.${zone}.log=${previous}" 2>/dev/null; then
return 1
fi
fi fi
_staged=$(uci -q changes firewall 2>/dev/null || true) _staged=$(uci -q changes firewall 2>/dev/null || true)
_foreign=$(wan_log_foreign_staged_lines "$zone" "$_staged") _foreign=$(wan_log_foreign_staged_lines "$zone" "$_staged")
@@ -1126,6 +1176,27 @@ run_logging_selftest() {
return 1 return 1
fi fi
# Oversized/malformed digit runs reject before arithmetic.
# Do not assert a host-specific wrap integer.
if wan_filter_log_decimal '12345678901234567890' >/dev/null; then
echo 'wan_filter_log_decimal 20-digit: expected reject' >&2
return 1
fi
if wan_filter_log_decimal '12a3' >/dev/null; then
echo 'wan_filter_log_decimal malformed: expected reject' >&2
return 1
fi
got=$(wan_filter_log_target_value '12345678901234567890')
if [ "$got" != '1' ]; then
echo "enable oversized log: expected 1 got $got" >&2
return 1
fi
got=$(wan_filter_log_clear_value '12345678901234567890')
if [ -n "$got" ]; then
echo "disable oversized log: expected empty got $got" >&2
return 1
fi
# Enable/disable parity around multi-bit values. # Enable/disable parity around multi-bit values.
got=$(wan_filter_log_target_value '2') got=$(wan_filter_log_target_value '2')
if [ "$got" != '3' ]; then if [ "$got" != '3' ]; then
+15 -5
View File
@@ -221,7 +221,7 @@ nft_dump_fields() {
next next
print prefix "\t" comment print prefix "\t" comment
} }
' ' || return 1
} }
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats # pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
@@ -313,10 +313,14 @@ enrich_rules_dump() {
if [ -z "$_nft_tsv" ]; then if [ -z "$_nft_tsv" ]; then
_enrich_error='mktemp_failed' _enrich_error='mktemp_failed'
else else
nft_dump_fields <"$_rules_dump" >"$_nft_tsv" # awk under set -eu must not abort the plugin before JSON.
map_from_nft_stream labeled <"$_nft_tsv" if nft_dump_fields <"$_rules_dump" >"$_nft_tsv"; then
if [ -z "$_map_overflow" ]; then map_from_nft_stream labeled <"$_nft_tsv"
map_from_nft_stream unlabeled <"$_nft_tsv" if [ -z "$_map_overflow" ]; then
map_from_nft_stream unlabeled <"$_nft_tsv"
fi
else
_enrich_error='tsv_failed'
fi fi
rm -f "$_nft_tsv" rm -f "$_nft_tsv"
fi fi
@@ -486,6 +490,12 @@ fetch_firewall_logs() {
case "$out" in case "$out" in
*'"error":'*) _filter_ok=0 ;; *'"error":'*) _filter_ok=0 ;;
esac esac
# Zero-exit JSON without a numeric messages_received is not a healthy
# sample. The merge fallback of 0 must not clear hot/shed.
case "$out" in
*'"messages_received":'[0-9]*) ;;
*) _filter_ok=0 ;;
esac
end_cs=$(fwlive_adaptive_clock_cs) end_cs=$(fwlive_adaptive_clock_cs)
# Uptime wrap / skew: end < start — do not record as cold. # Uptime wrap / skew: end < start — do not record as cold.
if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then
-39
View File
@@ -1,39 +0,0 @@
#
# Copyright (C) 2026 VizzleTF
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gitbackup
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:=VizzleTF <vizzlef@gmail.com>
LUCI_TITLE:=Automatic configuration backup to a Git repository
# gitbackup itself (package/gitbackup) is where usr/sbin/gitbackup, the
# rpcd plugin and every shell module live -- this app is client-side JS
# only (ticket 11 acceptance criterion: "ни строки Lua, ни luci-compat, ни
# luci-lib-ipkg -- ни в коде, ни в зависимостях") and has nothing to render
# without it.
LUCI_DEPENDS:=+gitbackup
# Every byte here is POSIX shell (menu.d/acl.d JSON is not shell, but is not
# compiled either), plain JSON and client-side JS -- not one compiled byte,
# same reasoning as package/gitbackup's own PKGARCH:=all (spec, "Упаковка").
# LUCI_PKGARCH already defaults to "all" via luci.mk's own
# `LUCI_PKGARCH?=$(if $(realpath src/Makefile),,all)` (no src/Makefile here),
# but ticket 11's acceptance criterion asks for it spelled out explicitly.
LUCI_PKGARCH:=all
# This repository is a standalone third-party app, not a checkout living
# inside the openwrt/luci monorepo (unlike e.g. applications/luci-app-ttyd,
# which can say `include $(TOPDIR)/feeds/luci/luci.mk is two
# directories up from it there) -- so the helper has to be referenced by
# its real location in a buildroot that has the "luci" feed checked out,
# exactly as spec "Упаковка" names it.
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,211 @@
'use strict';
'require rpc';
'require ui';
// Remote URL parsing mirrors remoteurl.sh: the Settings form reacts to it
// live, so it cannot be an rpc round trip. Plain ES5 for the buildbot's jsmin.c.
//
// Every E() child is passed as an ARRAY: LuCI's dom.append() turns array
// items into text nodes but assigns a bare string to innerHTML, and diff
// bodies, commit subjects and error reasons come off the backup remote.
// tests/dom_text_children.test.mjs enforces this in every view.
var callStatus = rpc.declare({
object: 'luci.gitbackup',
method: 'status'
});
var callHistory = rpc.declare({
object: 'luci.gitbackup',
method: 'history',
params: [ 'limit' ]
});
var callTest = rpc.declare({
object: 'luci.gitbackup',
method: 'test'
});
// Same three forms as gb_parse_url: https, ssh:// and scp-like.
function gbParseRemote(url) {
var m;
url = url || '';
m = url.match(/^https:\/\/([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (!m)
m = url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (!m)
m = url.match(/^(?:[^@/:]+@)?([^/:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
return m ? { host: m[1], owner: m[2], repo: m[3] } : null;
}
function gbProvider(url, option) {
var parsed;
if (option && option !== 'auto')
return option;
parsed = gbParseRemote(url);
switch (parsed ? parsed.host : '') {
case 'github.com':
return 'github';
case 'gitlab.com':
return 'gitlab';
case 'bitbucket.org':
return 'bitbucket';
case 'codeberg.org':
return 'gitea';
default:
return 'generic';
}
}
function gbWebBase(parsed, provider) {
switch (provider) {
case 'github':
return 'https://github.com/' + parsed.owner + '/' + parsed.repo;
case 'bitbucket':
return 'https://bitbucket.org/' + parsed.owner + '/' + parsed.repo;
case 'gitlab':
case 'gitea':
return 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo;
default:
return null;
}
}
var GB_DEPLOY_KEY_PATH = {
github: '/settings/keys/new',
gitlab: '/-/settings/repository',
gitea: '/settings/keys',
bitbucket: '/admin/access-keys/'
};
var GB_COMMIT_PATH = {
github: '/commit/',
gitlab: '/-/commit/',
gitea: '/commit/',
bitbucket: '/commits/'
};
function gbDeployKeyLink(url, option) {
var parsed = gbParseRemote(url);
var provider = gbProvider(url, option);
var base;
if (!parsed)
return { kind: 'unparsed' };
base = gbWebBase(parsed, provider);
return base ?
{ kind: 'link', url: base + GB_DEPLOY_KEY_PATH[provider] } :
{ kind: 'generic', host: parsed.host };
}
function gbCommitUrl(url, option, sha) {
var parsed = gbParseRemote(url);
var provider = gbProvider(url, option);
var base = (parsed && sha) ? gbWebBase(parsed, provider) : null;
return base ? base + GB_COMMIT_PATH[provider] + sha : null;
}
var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-view [hidden] { display: none; }',
'.gitbackup-caption, .gitbackup-card-hint, .gitbackup-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-hint-ok { color: var(--success-color-high, #2e7d32); }',
'.gitbackup-hint-warn { color: var(--warn-color-high, #b45f06); }',
'.gitbackup-hint-error { color: var(--error-color-high, #c62828); }',
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
'.gitbackup-log { max-height: 320px; overflow: auto; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
'.gitbackup-leak-list { margin: .3em 0; padding-left: 1.2em; color: var(--text-color-high, #333); }',
'.gitbackup-diff { max-height: 420px; overflow: auto; background: var(--background-color-low, #f5f5f5); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
'.gitbackup-diffline-add { display: block; color: var(--success-color-high, #2e7d32); }',
'.gitbackup-diffline-del { display: block; color: var(--error-color-high, #c62828); }',
'.gitbackup-diffline-change { display: block; color: var(--warn-color-high, #b45f06); }',
'.gitbackup-diffline-hunk { display: block; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-meta { display: block; font-weight: bold; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-ctx { display: block; color: var(--text-color-high, #333); }'
];
function gbUnifiedLineClass(line) {
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
return 'gitbackup-diffline-meta';
if (line.charAt(0) === '+')
return 'gitbackup-diffline-add';
if (line.charAt(0) === '-')
return 'gitbackup-diffline-del';
if (line.indexOf('@@') === 0)
return 'gitbackup-diffline-hunk';
if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0)
return 'gitbackup-diffline-meta';
return 'gitbackup-diffline-ctx';
}
function gbManifestLineClass(line) {
if (line.indexOf('+ ') === 0)
return 'gitbackup-diffline-add';
if (line.indexOf('- ') === 0)
return 'gitbackup-diffline-del';
if (line.indexOf('~ ') === 0)
return 'gitbackup-diffline-change';
return 'gitbackup-diffline-ctx';
}
function gbBuildPre(text, classify) {
if (!text)
return E('p', { 'class': 'gitbackup-card-hint' }, [ _('No differences.') ]);
return E('pre', { 'class': 'gitbackup-diff' }, text.split('\n').map(function(line) {
return E('span', { 'class': classify(line) }, [ line + '\n' ]);
}));
}
return L.Class.extend({
callStatus: callStatus,
callHistory: callHistory,
callTest: callTest,
css: GB_CSS,
provider: gbProvider,
deployKeyLink: gbDeployKeyLink,
commitUrl: gbCommitUrl,
commitTime: function(subject) {
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
return m ? m[1] : null;
},
leakList: function() {
return E('ul', { 'class': 'gitbackup-leak-list' }, [
E('li', {}, [ '/etc/shadow' ]),
E('li', {}, [ _('dropbear private host keys') ]),
E('li', {}, [ 'authorized_keys' ]),
E('li', {}, [ _('WPA pre-shared keys (Wi-Fi passwords)') ]),
E('li', {}, [ _('WireGuard private keys') ]),
E('li', {}, [ _('PPPoE credentials') ]),
E('li', {}, [ 'uhttpd.key' ])
]);
},
// ' ' text nodes between buttons match LuCI's own modal spacing.
modalButtons: function(dismissLabel, actions) {
var children = [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ dismissLabel || _('Close') ]) ];
(actions || []).forEach(function(a) { children.push(' ', a); });
return E('div', { 'class': 'right' }, children);
},
unified: function(text) {
return gbBuildPre(text, gbUnifiedLineClass);
},
manifest: function(text) {
return gbBuildPre(text, gbManifestLineClass);
}
});
@@ -1,142 +0,0 @@
'use strict';
// gitbackup -- shared diff viewer (ticket 23, spec "Не пиши второй
// просмотрщик diff"). Before this file existed, history.js was the only
// place in the project that rendered a diff into a colored <pre>; ticket 23
// adds a second place that needs one (Overview's "Configuration" card, R124/
// R125's own config_diff indicator, which used to show only the bare
// differs/does-not-differ flag and threw the actual text away). A second,
// separately-typed copy of history.js's own gbDiffLineClass/gbBuildDiffPre
// would be exactly the "третья реализация" ticket 23 explicitly forbids, so
// both call sites use this file instead.
//
// This is deliberately TWO renderers, not one that auto-detects its input,
// because the two rpcd methods that feed them answer genuinely different
// questions in genuinely different formats -- collapsing them into one
// "smart" parser would have to guess which format it was looking at from
// the text alone, and a manifest line that happened to start with "+" or
// "-" (this format's own add/remove marker) would be indistinguishable from
// a coincidence:
//
// unified(text) -- for `diff` (gbrpc_diff, two already-committed shas,
// History's per-commit view): plain `git diff --end-of-options <a> <b>`
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form) -- real
// unified-diff syntax, "diff --git a/X b/X" headers, "@@ ... @@" hunks,
// body lines prefixed ' '/'+'/'-'.
//
// manifest(text) -- for `config_diff` (gbrpc_config_diff, no params,
// Overview's live-drift check): cmd_diff's OTHER, zero-argument form,
// which never calls `git diff` at all -- it walks both manifests'
// entries field by field (_gb_diff_compare/_gb_diff_describe_change,
// usr/sbin/gitbackup) and prints one line per changed path: "+ path
// (type)" (added), "- path (type)" (removed), or "~ path: field
// a->b; ..." (changed) -- the only way this project can show a chmod/
// chown a real `git diff` never sees at all (same blob, same tree entry
// mode class either way; interfaces.md, ticket 17/D03).
//
// No content goes through innerHTML anywhere below -- every line is its own
// text node built through E(), the same "чужие данные никогда не
// становятся разметкой" rule history.js's own diff viewer already followed
// (that file's own header comment, out of this ticket's zone, read before
// writing this).
//
// That holds only because every child below is handed to E() INSIDE AN
// ARRAY, and this file originally got that wrong. LuCI's dom.append()
// branches on the shape of its `children` argument: the Array branch is the
// one that runs each string through document.createTextNode(), while a bare
// string falls all the way through to `node.innerHTML = '' + children` and
// is parsed as markup. `E('span', attrs, line)` therefore rendered a diff
// line AS HTML -- and the text here is `git diff` output, i.e. the contents
// of files fetched from the backup remote, which anyone with push access to
// that repository controls byte for byte. `E('span', attrs, [ line ])` is
// the fix, and the bracket is load-bearing, not punctuation: nothing else
// escapes on the way here (gb_json_esc escapes only what JSON needs, LuCI's
// String.format('%s') does not escape at all, and a LuCI page carries no
// CSP). tests/dom_text_children.test.mjs holds the whole view directory to
// this, so a future call site cannot quietly drop the brackets again.
// css -- the class names both renderers below emit, meant to be spliced
// into each CALLING view's own <style> block (rule 1 of the style guide:
// every view returns its styles inside its own tree, never through
// document.head.appendChild -- a shared module has no tree of its own to
// return one in). A plain exported array of rule strings, not a <style>
// this file injects itself, keeps that rule intact while still giving both
// call sites the exact same rules instead of two hand-copied approximations
// of them.
var GB_DIFFVIEW_CSS = [
'.gitbackup-diff { max-height: 420px; overflow: auto; background: var(--background-color-low, #f5f5f5); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
'.gitbackup-diffline-add { display: block; color: var(--success-color-high, #2e7d32); }',
'.gitbackup-diffline-del { display: block; color: var(--error-color-high, #c62828); }',
'.gitbackup-diffline-change { display: block; color: var(--warn-color-high, #b45f06); }',
'.gitbackup-diffline-hunk { display: block; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-meta { display: block; font-weight: bold; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-ctx { display: block; color: var(--text-color-high, #333); }'
];
// gbUnifiedLineClass <line> -- one prefixed class per line of a real `git
// diff` (spec: "Diff-viewer -- свой <pre> с префиксованными классами и
// токенами --success-*/--error-*. Никаких внешних библиотек подсветки").
// Moved here byte-for-byte from history.js's own gbDiffLineClass.
function gbUnifiedLineClass(line) {
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
return 'gitbackup-diffline-meta';
if (line.charAt(0) === '+')
return 'gitbackup-diffline-add';
if (line.charAt(0) === '-')
return 'gitbackup-diffline-del';
if (line.indexOf('@@') === 0)
return 'gitbackup-diffline-hunk';
if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0)
return 'gitbackup-diffline-meta';
return 'gitbackup-diffline-ctx';
}
// gbManifestLineClass <line> -- one prefixed class per line of cmd_diff's
// zero-argument, manifest-field form: "+ "/"- " mark a whole path added or
// removed (green/red, same coloring a real diff's own +/- lines get),
// "~ " marks a path whose fields changed in place (warn, since it is
// neither a pure addition nor a pure removal -- e.g. a chmod), anything
// else (there should be nothing else) falls back to plain context styling.
function gbManifestLineClass(line) {
if (line.indexOf('+ ') === 0)
return 'gitbackup-diffline-add';
if (line.indexOf('- ') === 0)
return 'gitbackup-diffline-del';
if (line.indexOf('~ ') === 0)
return 'gitbackup-diffline-change';
return 'gitbackup-diffline-ctx';
}
// gbBuildPre <text> <classify> <emptyText> -- shared DOM builder: an empty
// or blank <text> renders <emptyText> as a plain hint paragraph (spec: "diff
// на неизменившейся системе печатает пустой результат" -- both cmd_diff
// shapes agree on this), anything else becomes one <span> text node per
// line inside a scrollable <pre class="gitbackup-diff">, classified by
// <classify>.
function gbBuildPre(text, classify, emptyText) {
var lines = (text || '').split('\n');
if (!lines.length || (lines.length === 1 && !lines[0]))
return E('p', { 'class': 'gitbackup-card-hint' }, [ emptyText ]);
return E('pre', { 'class': 'gitbackup-diff' }, lines.map(function(line) {
return E('span', { 'class': classify(line) }, [ line + '\n' ]);
}));
}
return L.Class.extend({
css: GB_DIFFVIEW_CSS,
// unified(text) -- History's per-commit diff (two already-committed
// shas): real `git diff` output.
unified: function(text) {
return gbBuildPre(text, gbUnifiedLineClass, _('No differences.'));
},
// manifest(text) -- Overview's live-drift diff (config_diff): cmd_diff's
// own "+ path (type)" / "- path (type)" / "~ path: field a->b; ..."
// lines.
manifest: function(text) {
return gbBuildPre(text, gbManifestLineClass, _('No differences.'));
}
});
@@ -2,50 +2,10 @@
'require view'; 'require view';
'require rpc'; 'require rpc';
'require ui'; 'require ui';
'require view.gitbackup.diffview as diffview'; 'require view.gitbackup.common as common';
'require view.gitbackup.oplog as oplog'; 'require view.gitbackup.oplog as oplog';
/* global diffview, oplog */ /* global common, oplog */
// See overview.js's own identical comment: the project's eslint config // openwrt/luci's eslint config only knows stock module names as globals.
// (openwrt/luci's own) only knows a fixed list of stock LuCI module names
// as globals, not this app's own diffview.js/oplog.js aliases.
// gitbackup -- History (ticket 14, spec "Решения по реализации -> LuCI ->
// History", "Формат коммита", "Восстановление"). Client-side JS only,
// luci-base only, same jsmin-conservative style (plain ES5, `var`/
// `function`, no let/const/arrow/template literals, every statement
// semicolon-terminated) as overview.js (ticket 11) and paths.js (ticket 13),
// both read before writing this file -- their style/rpcd/poller-cleanup
// patterns are reused here rather than reinvented.
//
// Brief, verbatim: "History -- последние N коммитов своей ветки -- дата из
// subject, SHA, список изменившихся путей. Клик -> diff в ui.showModal.
// Кнопка Restore с двойным подтверждением: первый экран показывает список
// перезаписываемых файлов, второй требует ввести имя устройства; если
// board.json выбранного коммита не совпадает с текущим, на первом же
// экране -- красное предупреждение о том, что имена интерфейсов и wireless
// не подойдут."
//
// ---------------------------------------------------------------------
// rpcd bindings -- copied from root/usr/share/rpcd/acl.d/luci-app-
// gitbackup.json and usr/libexec/rpcd/luci.gitbackup's own gbrpc_*
// handlers (package/gitbackup, out of this ticket's zone). This view adds
// no rpcd method of its own: `status` (device name for the restore
// confirmation gate), `history` (the commit list), `diff` (two already-
// committed shas -- interfaces.md, ticket 13's own "Чем пользоваться":
// "diff -- твоё") and `restore` (write tier) are the whole surface it
// needs.
// ---------------------------------------------------------------------
var callStatus = rpc.declare({
object: 'luci.gitbackup',
method: 'status'
});
var callHistory = rpc.declare({
object: 'luci.gitbackup',
method: 'history',
params: [ 'limit' ]
});
var callDiff = rpc.declare({ var callDiff = rpc.declare({
object: 'luci.gitbackup', object: 'luci.gitbackup',
@@ -53,52 +13,16 @@ var callDiff = rpc.declare({
params: [ 'from', 'to' ] params: [ 'from', 'to' ]
}); });
var callLog = rpc.declare({
object: 'luci.gitbackup',
method: 'log',
params: [ 'lines' ]
});
var callRestore = rpc.declare({ var callRestore = rpc.declare({
object: 'luci.gitbackup', object: 'luci.gitbackup',
method: 'restore', method: 'restore',
params: [ 'device', 'commit', 'dry_run', 'force', 'with_packages' ] params: [ 'device', 'commit', 'dry_run', 'force', 'with_packages' ]
}); });
// "Последние N коммитов" (brief, verbatim) -- one fixed page size, not a // Every `history` call is a real git fetch.
// user-adjustable control: the brief never asks for pagination, and
// `history` already runs a real `git fetch` per call (interfaces.md,
// ticket 11-13: "Долгие вызовы (config_diff, history) не вешаются на
// первичную загрузку страницы"), so a bigger N only means a slower single
// load, not a cheaper one.
var GB_HISTORY_LIMIT = 30; var GB_HISTORY_LIMIT = 30;
// --------------------------------------------------------------------- // path_prefix is a template, so look for the marker, not a fixed prefix.
// Pure helpers -- no DOM access, easy to reason about and to keep in sync
// with the shell side by inspection.
// ---------------------------------------------------------------------
// gbCommitTime <subject> -- the leading "YYYY-MM-DD HH:MM" a backup
// commit's subject always starts with (spec "Формат коммита", G03: "по
// списку коммитов в вебе видно, когда снят каждый, без открывания") --
// same extraction overview.js's own gbCommitTime already does for the
// single most recent commit; duplicated here rather than shared because
// each view here is its own self-contained file, same convention
// paths.js's own private GB_CSS/gbSequentialMap already follow.
function gbCommitTime(subject) {
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
return m ? m[1] : null;
}
// gbShortenRepoPath <path> -- a path as it appears in this device's own
// branch tree (e.g. "devices/r1/files/etc/config/network",
// "devices/r1/meta/board.json", "devices/r1/manifest.json") rewritten to
// what an operator actually recognizes: the real absolute path under
// "files/", or a short "meta/..." / "manifest.json" label for anything
// else. The device-specific "devices/<id>/" prefix itself is never
// hard-coded here -- collect.sh's own `path_prefix` (gitbackup.main.
// path_prefix) can be a custom template, so this looks for the "/files/"
// or "/meta/" marker instead of assuming a fixed prefix shape.
function gbShortenRepoPath(path) { function gbShortenRepoPath(path) {
var idx = path.indexOf('/files/'); var idx = path.indexOf('/files/');
if (idx !== -1) if (idx !== -1)
@@ -114,12 +38,6 @@ function gbShortenRepoPath(path) {
return path; return path;
} }
// gbSummarizeList <items[]> <max> -- "a, b, c" or, past <max> entries,
// "a, b, c and N more" -- the same "до трёх... больше трёх -- и ещё N"
// convention the commit subject itself already uses (spec "Формат
// коммита"), applied here to `changed[]` (history's own full per-commit
// path list, which for a device's very first backup commit is every file
// it has -- unabridged, unlike the subject's own three-item cap).
function gbSummarizeList(items, max) { function gbSummarizeList(items, max) {
if (!items.length) if (!items.length)
return ''; return '';
@@ -128,21 +46,6 @@ function gbSummarizeList(items, max) {
return items.slice(0, max).join(', ') + ' ' + _('and %d more').format(items.length - max); return items.slice(0, max).join(', ') + ' ' + _('and %d more').format(items.length - max);
} }
// ---------------------------------------------------------------------
// Diff parsing -- `diff` (gbrpc_diff) hands back plain `git diff <a> <b>`
// text, unrestricted (interfaces.md: "diff принимает два уже закоммиченных
// sha... и даёт git diff между ними"). Everything below reads that text
// only; no assumption is made beyond git's own documented unified-diff
// output shape (`diff --git a/X b/X`, optional "new file mode"/"deleted
// file mode", "--- "/"+++ " headers, "@@ ... @@" hunks, body lines
// prefixed ' '/'+'/'-').
// ---------------------------------------------------------------------
// gbDiffSections <text> -- one entry per "diff --git " block: { header,
// body[] } (body = every following line up to, not including, the next
// "diff --git " line). The one grouping both the file list and the
// board.json comparison below are built from, so the two can never
// disagree about where one file's diff ends and the next begins.
function gbDiffSections(text) { function gbDiffSections(text) {
var lines = (text || '').split('\n'); var lines = (text || '').split('\n');
var sections = []; var sections = [];
@@ -161,18 +64,11 @@ function gbDiffSections(text) {
return sections; return sections;
} }
// gbSectionPath <header> -- the "b/..." side of a "diff --git a/X b/X"
// line. Git always fills in the same path on both sides for a plain add
// or delete (no rename detection requested), so this never needs the
// "a/" side at all.
function gbSectionPath(header) { function gbSectionPath(header) {
var m = header.match(/^diff --git a\/(.+) b\/(.+)$/); var m = header.match(/^diff --git a\/(.+) b\/(.+)$/);
return m ? m[2] : null; return m ? m[2] : null;
} }
// gbSectionStatus <section> -- 'added' | 'deleted' | 'modified', from
// git's own "new file mode" / "deleted file mode" body lines (present
// only for those two cases).
function gbSectionStatus(section) { function gbSectionStatus(section) {
var i; var i;
for (i = 0; i < section.body.length; i++) { for (i = 0; i < section.body.length; i++) {
@@ -184,28 +80,14 @@ function gbSectionStatus(section) {
return 'modified'; return 'modified';
} }
// gbDiffFileList <text> -- [{ path, status }], one per changed file.
function gbDiffFileList(text) { function gbDiffFileList(text) {
return gbDiffSections(text).map(function(s) { return gbDiffSections(text).map(function(s) {
return { path: gbSectionPath(s.header), status: gbSectionStatus(s) }; return { path: gbSectionPath(s.header), status: gbSectionStatus(s) };
}).filter(function(f) { return f.path; }); }).filter(function(f) { return f.path; });
} }
// gbBoardMismatch <text> -- true when the diff between the current branch // The same two fields restore.sh checks against the live board; restore.sh
// tip and the commit about to be restored changed meta/board.json's own // stays the real gate, this is only the up-front warning.
// "model" or "target" field. Mirrors restore.sh's own authoritative check
// (_gb_restore_check_board: "model" plus "release.target", the same two
// fields device.sh's board strategy and the armsr/generic-target case
// both rely on -- interfaces.md's own note that `model` is `null` on
// those targets) rather than "board.json changed at all": board.json also
// carries a plain `hostname` field, and flagging every rename as a board
// mismatch would train an operator to ignore the warning. This can only
// approximate restore.sh's live `ubus call system board` comparison with
// the closest thing a read-only rpcd surface exposes -- the most recent
// commit's own meta/board.json, i.e. what the last successful backup run
// saw. restore.sh itself remains the actual, authoritative gate (it always
// re-checks against the live board and refuses without --force); this is
// only the up-front warning the brief asks for.
function gbBoardMismatch(text) { function gbBoardMismatch(text) {
var sections = gbDiffSections(text); var sections = gbDiffSections(text);
var i, j, path, line, ch; var i, j, path, line, ch;
@@ -230,30 +112,12 @@ function gbBoardMismatch(text) {
return false; return false;
} }
// gbFilesPath <path> -- the real absolute filesystem path for an entry
// under ".../files/...", or null for anything else (meta/*, manifest.json).
// `restore` (restore.sh's own gb_restore) only ever writes manifest
// entries -- i.e. the "files/" tree -- back onto the router; it never
// touches meta/board.json, manifest.json or any other bookkeeping file on
// disk. A changed-file list built straight from `gbDiffFileList` would
// therefore mislabel bookkeeping-only commits (a manifest.json-only diff,
// say) as "these files will be overwritten" when nothing on disk would
// actually change.
function gbFilesPath(path) { function gbFilesPath(path) {
var idx = path.indexOf('/files/'); var idx = path.indexOf('/files/');
return idx === -1 ? null : path.substring(idx + '/files'.length); return idx === -1 ? null : path.substring(idx + '/files'.length);
} }
// gbRestorePlan <diffText> -- [{ path, action }], action is 'create' (the // A file only in the tip is left out: restore has no delete pass.
// path exists in the target commit but not in the current branch tip, so
// restore will write it fresh) or 'overwrite' (exists in both, content
// differs). A file that exists in the current tip but NOT in the target
// ('deleted' in git-diff terms, moving tip -> target) is deliberately
// left out: restore.sh has no delete pass at all (spec: "разложить файлы,
// затем применить mode/uid/gid... пересоздать симлинки" -- nothing about
// removing a file the target commit does not mention), so that path would
// simply be left untouched by a real restore, not "removed", and listing
// it as something that will be "overwritten" would be flatly wrong.
function gbRestorePlan(diffText) { function gbRestorePlan(diffText) {
var files = gbDiffFileList(diffText); var files = gbDiffFileList(diffText);
var plan = []; var plan = [];
@@ -271,40 +135,8 @@ function gbRestorePlan(diffText) {
return plan; return plan;
} }
// The colored-<pre> diff viewer itself moved to diffview.js (ticket 23: // restore.sh's success printf goes to /dev/null, so the gb_log line is what
// "не пиши второй просмотрщик diff") once Overview needed one too, for a // counts. Checked against tests/fixtures/terminal-markers.tsv.
// different diff format (config_diff's manifest-field lines, not a real
// `git diff`) -- see that file's own header comment for why this is two
// explicit renderers in one shared place rather than one that guesses.
// handleViewDiff below calls diffview.unified(res.diff): `diff` (gbrpc_diff,
// two already-committed shas) is real `git diff --end-of-options <a> <b>`
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form), exactly
// what diffview.unified expects.
// ---------------------------------------------------------------------
// Restore log classification -- restore.sh's own gb_log calls (all of
// them: success, every refusal, every fetch/checkout failure) go through
// `logger -t gitbackup` (lib.sh's gb_log), which `log`/logread already
// tag-filters -- the exact same mechanism overview.js's own live-log
// poller already reads for `run`/`test`. Restore's plain `printf` on
// success ("restored %s from %s") is NOT captured here: gbrpc_restore
// backgrounds the CLI with its stdout sent to /dev/null (same shape as
// run/test), so only the gb_log line right before it ("gb_restore:
// restored $device from $target on $branch") is ever visible through this
// poller -- reason this regex keys on that line, not the printf one.
// ---------------------------------------------------------------------
// Ticket 23: 'writing one or more files failed' used to sit here instead of
// the line below, and never matched anything restore.sh actually logs --
// found while tracing ticket 19's own change (restore.sh, _gb_restore:
// "a partially-restored router with correct permissions... always tells
// the operator the truth (below) with a non-zero exit" prints
// "gb_restore: the following paths were NOT written:<list>" via gb_log,
// never the old string) against this poller's own terminal-line set. With
// the wrong string here, a partial restore (ticket 19's own headline
// outcome -- content written, permissions applied, exit 1) never matched
// GB_RESTORE_TERMINAL_RE at all and this poller ran all the way to its own
// 60s-idle/5-minute ceiling before saying anything, exactly the
// "непонятно, чем кончилось" ticket 23 exists to fix.
var GB_RESTORE_TERMINAL_RE = new RegExp( var GB_RESTORE_TERMINAL_RE = new RegExp(
[ [
'gb_restore: restored .+ from .+ on ', 'gb_restore: restored .+ from .+ on ',
@@ -320,24 +152,8 @@ var GB_RESTORE_TERMINAL_RE = new RegExp(
].join('|') ].join('|')
); );
function gbRestoreLogSuccess(line) {
return line.indexOf('gb_restore: restored ') !== -1;
}
// gbClassifyRestoreLog <line> -- the human-readable, three-severity outcome
// behind GB_RESTORE_TERMINAL_RE's own match (ticket 23: "каждая долгая
// операция заканчивается явным сообщением... причина неудачи написана
// человеческим языком"). 'partial' is its own kind, deliberately distinct
// from both 'success' and 'error' -- ticket 19's restore.sh always applies
// permissions to whatever DID get written even when something else did not
// (spec: "частичный успех... обязан быть виден и отличаться от полного"),
// and folding it into either extreme would misreport which one actually
// happened. 'blocked' covers the one refusal that is a safety check working
// as intended (a board mismatch neither this view's own heuristic nor the
// operator's confirmation caught), kept apart from a genuine 'error' (a
// network/fetch/config problem) since the next step for each is different.
function gbClassifyRestoreLog(line) { function gbClassifyRestoreLog(line) {
if (gbRestoreLogSuccess(line)) if (line.indexOf('gb_restore: restored ') !== -1)
return { kind: 'success', message: _('Restore finished. This router now matches the backup you selected.') }; return { kind: 'success', message: _('Restore finished. This router now matches the backup you selected.') };
if (line.indexOf('the following paths were NOT written:') !== -1) if (line.indexOf('the following paths were NOT written:') !== -1)
@@ -373,11 +189,6 @@ function gbClassifyRestoreLog(line) {
return { kind: 'error', message: _('Restore failed -- see the log below for details.') }; return { kind: 'error', message: _('Restore failed -- see the log below for details.') };
} }
// gbRestoreSeverity <kind> -- gbClassifyRestoreLog's own four kinds
// collapsed to the three severities showRestoreResult below actually
// styles: 'success' -> ok, 'partial'/'blocked' -> warn (something is worth
// a second look, but this is not the network/config failures 'error'
// means), 'error' (and anything unrecognized) -> error.
function gbRestoreSeverity(kind) { function gbRestoreSeverity(kind) {
if (kind === 'success') if (kind === 'success')
return 'ok'; return 'ok';
@@ -387,10 +198,6 @@ function gbRestoreSeverity(kind) {
} }
var GB_CSS = [ var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-banner { border-radius: 4px; padding: .6em 1em; margin: 0 0 .75em; background: var(--error-color-medium, #f44336); color: var(--on-error-color, #fff); }',
'.gitbackup-banner p { margin: .2em 0; }',
'.gitbackup-history-list { list-style: none; margin: .75em 0; padding: 0; }', '.gitbackup-history-list { list-style: none; margin: .75em 0; padding: 0; }',
'.gitbackup-history-row { border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .9em; margin: 0 0 .6em; background: var(--background-color-low, #f5f5f5); }', '.gitbackup-history-row { border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .9em; margin: 0 0 .6em; background: var(--background-color-low, #f5f5f5); }',
'.gitbackup-history-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .5em 1em; }', '.gitbackup-history-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .5em 1em; }',
@@ -406,37 +213,17 @@ var GB_CSS = [
'.gitbackup-restore-action-create { color: var(--success-color-high, #2e7d32); }', '.gitbackup-restore-action-create { color: var(--success-color-high, #2e7d32); }',
'.gitbackup-restore-action-overwrite { color: var(--warn-color-high, #b45f06); }', '.gitbackup-restore-action-overwrite { color: var(--warn-color-high, #b45f06); }',
'.gitbackup-confirm-input { width: 100%; box-sizing: border-box; margin: .5em 0; }', '.gitbackup-confirm-input { width: 100%; box-sizing: border-box; margin: .5em 0; }',
'.gitbackup-modal-actions { display: flex; justify-content: flex-end; gap: .5em; margin-top: 1em; }',
'.gitbackup-log { max-height: 320px; overflow: auto; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
// gitbackup-hint-ok/-warn/-error -- same three severities settings.js's
// own copy already uses (ticket 12); reused here (ticket 23) for
// showRestoreResult's own success/partial-or-blocked/error message.
'.gitbackup-hint-ok { color: var(--success-color-high, #2e7d32); }',
'.gitbackup-hint-warn { color: var(--warn-color-high, #b45f06); }',
'.gitbackup-hint-error { color: var(--error-color-high, #c62828); }',
'.gitbackup-restore-outcome { font-weight: bold; }' '.gitbackup-restore-outcome { font-weight: bold; }'
]; ];
return view.extend({ return view.extend({
// Not a config form -- see overview.js's own identical comment. Rule 11
// of the spec's style guide: the stock Save/Apply/Reset row is switched
// off through this documented hook, never hidden with CSS.
handleSaveApply: null, handleSaveApply: null,
handleSave: null, handleSave: null,
handleReset: null, handleReset: null,
// history is a git fetch; render() starts it after the first paint.
load: function() { load: function() {
// `status` only (device name for the second restore-confirmation return L.resolveDefault(common.callStatus(), null);
// screen) -- local file reads, same "cheap" bucket overview.js's
// own load() puts status/log/service in. `history` itself is
// deliberately NOT here: it is a real `git fetch` against the
// configured remote with no timeout of its own (interfaces.md,
// tickets 11/13: "Долгие вызовы... не вешаются на первичную
// загрузку страницы" -- measured live at several seconds against a
// real GitHub remote). render() below starts it once the rest of
// the page is already on screen, same pattern overview.js's own
// refreshHistory() already uses.
return L.resolveDefault(callStatus(), null);
}, },
render: function(status) { render: function(status) {
@@ -447,37 +234,18 @@ return view.extend({
self._commits = null; self._commits = null;
view = E('div', { 'class': 'gitbackup-view' }, [ view = E('div', { 'class': 'gitbackup-view' }, [
E('style', { 'type': 'text/css' }, [ GB_CSS.concat(diffview.css).join('\n') ]), E('style', { 'type': 'text/css' }, [ common.css.concat(GB_CSS).join('\n') ]),
E('h2', {}, [ _('Git Backup - History / Restore') ]), E('h2', {}, [ _('Git Backup - History / Restore') ]),
// Ticket 22: found-in-the-field complaint was "restore exists
// but nobody looks for it on a tab called History" -- this
// hint is the fix's cheap half (the tab rename above is the
// other): it states the round-trip capability up front, in
// plain text, with no hover required, before a single commit
// row has even loaded.
E('p', { 'class': 'gitbackup-card-hint' }, E('p', { 'class': 'gitbackup-card-hint' },
[ _('You can restore this router to any backup listed below, using that backup\'s own "Restore" button. Click "View diff" first to see exactly what it would change.') ]), [ _('You can restore this router to any backup listed below, using that backup\'s own "Restore" button. Click "View diff" first to see exactly what it would change.') ]),
E('div', { 'id': 'gitbackup-history-body' }, [ E('div', { 'id': 'gitbackup-history-body' }, [
E('p', { 'class': 'gitbackup-card-hint' }, [ _('Loading…') ]) E('p', { 'class': 'gitbackup-card-hint' }, [ _('Loading…') ])
]) ])
// Ticket 23: the old page-level "#gitbackup-restore-log" <pre>
// that used to live here is gone -- the confirmation modal
// itself now stays open for the whole restore and shows the
// live log and the final outcome in place (see
// handleConfirmRestore/showRestoreProgress/showRestoreResult
// below), instead of hiding itself immediately and leaving an
// operator to notice a page element they were never told to
// watch (spec: "модальное окно... не должно просто
// закрываться... а в конце говорит, что восстановлено").
]); ]);
// Fired once render() has already returned the tree above, exactly
// like overview.js's own refreshHistory() -- see load()'s own
// comment for why `history` cannot run inside load() itself.
self.refreshHistory(); self.refreshHistory();
return view; return view;
@@ -486,7 +254,7 @@ return view.extend({
refreshHistory: function() { refreshHistory: function() {
var self = this; var self = this;
return L.resolveDefault(callHistory(GB_HISTORY_LIMIT), null).then(function(res) { return L.resolveDefault(common.callHistory(GB_HISTORY_LIMIT), null).then(function(res) {
self._commits = (res && res.commits) || null; self._commits = (res && res.commits) || null;
self._historyErr = (!res) ? self._historyErr = (!res) ?
_('Could not reach the remote repository.') : _('Could not reach the remote repository.') :
@@ -525,7 +293,7 @@ return view.extend({
buildHistoryRow: function(commit, parentSha) { buildHistoryRow: function(commit, parentSha) {
var self = this; var self = this;
var time = gbCommitTime(commit.subject); var time = common.commitTime(commit.subject);
var changed = (commit.changed || []).map(gbShortenRepoPath); var changed = (commit.changed || []).map(gbShortenRepoPath);
return E('li', { 'class': 'gitbackup-history-row' }, [ return E('li', { 'class': 'gitbackup-history-row' }, [
@@ -549,19 +317,8 @@ return view.extend({
]); ]);
}, },
// handleViewDiff <commit> <parentSha> -- the per-commit diff (spec: // The previous row is the only parent; the oldest row's is off the page.
// "Клик -> diff в ui.showModal"), i.e. what THAT backup changed versus
// the one right before it. `history`'s commits are newest-first and
// this branch's own push machinery (gitio.sh's gb_commit_push) always
// commits with exactly one parent, so the previous ROW's sha (already
// known client-side) is that parent -- no extra round trip needed to
// find it. The oldest row on screen has no such neighbour: its actual
// parent (if any) sits outside this page's own GB_HISTORY_LIMIT
// window, and fabricating a diff against the wrong commit would be
// worse than admitting this view simply does not know it.
handleViewDiff: function(commit, parentSha, ev) { handleViewDiff: function(commit, parentSha, ev) {
var self = this;
if (!parentSha) { if (!parentSha) {
ui.addNotification(null, E('p', {}, ui.addNotification(null, E('p', {},
[ _('No earlier backup loaded to compare against -- this is the oldest commit in this list.') ]), 'info'); [ _('No earlier backup loaded to compare against -- this is the oldest commit in this list.') ]), 'info');
@@ -574,27 +331,18 @@ return view.extend({
if (!res || typeof res.diff !== 'string') { if (!res || typeof res.diff !== 'string') {
ui.showModal(_('Diff'), [ ui.showModal(_('Diff'), [
E('p', {}, [ _('Could not load the diff: %s').format((res && res.reason) || _('unknown error')) ]), E('p', {}, [ _('Could not load the diff: %s').format((res && res.reason) || _('unknown error')) ]),
E('div', { 'class': 'gitbackup-modal-actions' }, [ common.modalButtons()
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
])
]); ]);
return; return;
} }
ui.showModal(_('Diff: %s').format(gbCommitTime(commit.subject) || commit.sha.substring(0, 12)), [ ui.showModal(_('Diff: %s').format(common.commitTime(commit.subject) || commit.sha.substring(0, 12)), [
diffview.unified(res.diff), common.unified(res.diff),
E('div', { 'class': 'gitbackup-modal-actions' }, [ common.modalButtons()
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
])
]); ]);
}); });
}, },
// handleRestoreClick <commit> -- step 0: fetch the one diff this whole
// confirmation flow is built on (current branch tip -> the commit
// about to be restored) before showing anything, so the first
// confirmation screen (spec: "первый экран показывает список
// перезаписываемых файлов") is never a guess.
handleRestoreClick: function(commit, ev) { handleRestoreClick: function(commit, ev) {
var self = this; var self = this;
var headSha = (self._commits && self._commits[0] && self._commits[0].sha) || commit.sha; var headSha = (self._commits && self._commits[0] && self._commits[0].sha) || commit.sha;
@@ -610,23 +358,19 @@ return view.extend({
}); });
}, },
// showRestoreConfirm1 -- first confirmation screen (spec, verbatim
// above): the list of files that will actually be written, and, when
// applicable, the board-mismatch warning ahead of any second screen --
// never after.
showRestoreConfirm1: function(commit, plan, mismatch) { showRestoreConfirm1: function(commit, plan, mismatch) {
var self = this; var self = this;
var body = []; var body = [];
if (mismatch) { if (mismatch) {
body.push(E('div', { 'class': 'gitbackup-banner' }, [ body.push(E('div', { 'class': 'alert-message error' }, [
E('p', {}, [ _('This backup was taken on a different router model than the one it would be restored onto. Interface names and wireless radios from that hardware will not match this one -- restoring anyway may leave the network unreachable until the configuration is fixed by hand.') ]) E('p', {}, [ _('This backup was taken on a different router model than the one it would be restored onto. Interface names and wireless radios from that hardware will not match this one -- restoring anyway may leave the network unreachable until the configuration is fixed by hand.') ])
])); ]));
} }
body.push(E('p', {}, body.push(E('p', {},
[ _('Restoring the backup from %s (%s) will write the following files:') [ _('Restoring the backup from %s (%s) will write the following files:')
.format(gbCommitTime(commit.subject) || commit.sha, commit.sha.substring(0, 12)) ])); .format(common.commitTime(commit.subject) || commit.sha, commit.sha.substring(0, 12)) ]));
if (plan.length) { if (plan.length) {
body.push(E('ul', { 'class': 'gitbackup-restore-files' }, plan.map(function(p) { body.push(E('ul', { 'class': 'gitbackup-restore-files' }, plan.map(function(p) {
@@ -640,8 +384,7 @@ return view.extend({
[ _('No file differs from the current commit -- restoring will not change anything on disk.') ])); [ _('No file differs from the current commit -- restoring will not change anything on disk.') ]));
} }
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [ body.push(common.modalButtons(_('Cancel'), [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
E('button', { E('button', {
'class': 'cbi-button cbi-button-negative', 'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(self, 'showRestoreConfirm2', commit, mismatch) 'click': ui.createHandlerFn(self, 'showRestoreConfirm2', commit, mismatch)
@@ -651,14 +394,6 @@ return view.extend({
ui.showModal(_('Restore backup?'), body); ui.showModal(_('Restore backup?'), body);
}, },
// showRestoreConfirm2 -- second confirmation screen (spec, verbatim:
// "второй требует ввести имя устройства"): the operator has to type
// this device's own name back, the same friction a destructive cloud
// console action usually asks for. `status.device` (gitbackup status's
// own `device` field, interfaces.md tickets 01/08-10) is the device
// this router itself resolves to -- and the one `restore` is about to
// be called for, since History only ever shows this router's own
// branch.
showRestoreConfirm2: function(commit, mismatch, ev) { showRestoreConfirm2: function(commit, mismatch, ev) {
var self = this; var self = this;
var device = self._status.device || ''; var device = self._status.device || '';
@@ -683,50 +418,29 @@ return view.extend({
ui.showModal(_('Confirm restore'), [ ui.showModal(_('Confirm restore'), [
E('p', {}, [ _('Type this device\'s name (%s) to confirm restoring it.').format(device || _('(device name not configured)')) ]), E('p', {}, [ _('Type this device\'s name (%s) to confirm restoring it.').format(device || _('(device name not configured)')) ]),
input, input,
E('div', { 'class': 'gitbackup-modal-actions' }, [ common.modalButtons(_('Cancel'), [ confirmBtn ])
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
confirmBtn
])
]); ]);
}, },
// handleConfirmRestore -- fires `restore` (write tier) and switches to // `force` goes along when the board warning was shown, otherwise
// oplog.js's shared bounded live-log poller (ticket 23: "не изобретать // restore.sh refuses the mismatch outright.
// четвёртый механизм" -- the exact pattern overview.js's own
// handleRun/handleTest use for run/test). The call itself only starts
// the CLI in the background (gbrpc_restore always answers
// `{started:true}` immediately) and can take a while for real (network
// fetch of the target commit, sha256 verification of every file), so
// this must not block the page.
//
// Ticket 23's own headline complaint about restore specifically:
// the confirmation modal used to call ui.hideModal() right here and
// let the page's own <pre> carry the live log silently underneath --
// closing the one dialog that had the operator's attention just as an
// irreversible, file-system-rewriting operation started, then saying
// nothing further unless it failed to even START. It no longer hides
// at all: showRestoreProgress below replaces this same dialog's
// content in place, and stays open, showing the live log, until
// showRestoreResult replaces it again with the actual outcome --
// success, partial, or failure, spelled out in words (spec: "пусть
// остаётся, показывает ход и в конце говорит, что восстановлено, что
// не удалось и что делать дальше").
//
// `force` is passed whenever the board-mismatch warning fired on the
// previous screen -- without it restore.sh's own
// _gb_restore_check_board refuses outright (exit 4), which would turn
// an operator's informed "restore anyway" into a silent no-op visible
// only by reading the log by hand.
handleConfirmRestore: function(commit, mismatch, ev) { handleConfirmRestore: function(commit, mismatch, ev) {
var self = this; var self = this;
var device = self._status.device || ''; var device = self._status.device || '';
self.showRestoreProgress(); // No Cancel: restore.sh cannot be interrupted once it writes.
ui.showModal(_('Restoring…'), [
E('p', { 'class': 'spinning' },
[ _('Restoring the backup -- this can take a while. Do not power off the router.') ]),
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-restore-modal-log' }, [ '' ])
]);
return oplog.start({ return oplog.run({
fetch: function() { return L.resolveDefault(callLog(500), null).then(function(res) { return (res && res.text) || ''; }); }, call: function() {
return callRestore(device, commit.sha, undefined, mismatch ? true : undefined);
},
pre: 'gitbackup-restore-modal-log',
terminalRe: GB_RESTORE_TERMINAL_RE, terminalRe: GB_RESTORE_TERMINAL_RE,
onProgress: function(add) { self.appendRestoreProgress(add); },
onFinish: function(line) { onFinish: function(line) {
self.showRestoreResult(gbClassifyRestoreLog(line)); self.showRestoreResult(gbClassifyRestoreLog(line));
self.refreshHistory(); self.refreshHistory();
@@ -737,59 +451,15 @@ return view.extend({
message: _('No result from the restore after a while -- check the log below or the syslog by hand.') message: _('No result from the restore after a while -- check the log below or the syslog by hand.')
}); });
self.refreshHistory(); self.refreshHistory();
},
onFail: function(reason) {
self.showRestoreResult({ kind: 'error', message: reason ?
_('Could not start the restore: %s').format(reason) :
_('Could not start the restore.') });
} }
}).then(function() {
return callRestore(device, commit.sha, undefined, mismatch ? true : undefined);
}).then(function(res) {
if (!res || res.started !== true) {
oplog.stop();
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore.') });
}
}).catch(function(e) {
oplog.stop();
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore: %s').format(e.message) });
}); });
}, },
// showRestoreProgress -- replaces the confirmation modal's own content
// with a spinner and an empty live-log pane, IN THE SAME DIALOG (no
// ui.hideModal() anywhere in this whole flow) -- see
// handleConfirmRestore's own comment for why staying open matters here
// specifically. No Close/Cancel control on purpose: restore.sh has no
// interrupt handling once it starts writing, so a "Cancel" here could
// only ever stop watching, never stop the restore itself, and offering
// it would suggest otherwise.
showRestoreProgress: function() {
ui.showModal(_('Restoring…'), [
E('p', { 'class': 'spinning' },
[ _('Restoring the backup -- this can take a while. Do not power off the router.') ]),
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-restore-modal-log' }, [ '' ])
]);
},
appendRestoreProgress: function(add) {
var pre = document.getElementById('gitbackup-restore-modal-log');
if (!pre)
return;
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
pre.scrollTop = pre.scrollHeight;
},
// showRestoreResult <cls> -- the final screen of the restore flow:
// <cls> is gbClassifyRestoreLog's own {kind, message} (or the
// onTimeout/could-not-start shapes handleConfirmRestore builds inline
// above, both the same shape). Reads the progress pane's own
// accumulated text (still in the DOM at this point, since this is the
// same ui.showModal call that is about to replace it) rather than
// keeping a second copy of it in JS state.
//
// Ticket 23's own explicit ask beyond "say what happened": "что делать
// дальше" for the one outcome that actually has a next step worth
// naming -- 'partial' (ticket 19's own headline case: content written,
// permissions applied, one or more paths failed) points at the log
// right below it, already showing exactly which paths and why.
showRestoreResult: function(cls) { showRestoreResult: function(cls) {
var pre = document.getElementById('gitbackup-restore-modal-log'); var pre = document.getElementById('gitbackup-restore-modal-log');
var fullLog = pre ? pre.textContent : ''; var fullLog = pre ? pre.textContent : '';
@@ -805,9 +475,7 @@ return view.extend({
if (fullLog) if (fullLog)
body.push(E('pre', { 'class': 'gitbackup-log' }, [ fullLog ])); body.push(E('pre', { 'class': 'gitbackup-log' }, [ fullLog ]));
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [ body.push(common.modalButtons());
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
]));
ui.showModal(title, body); ui.showModal(title, body);
} }
@@ -1,145 +1,124 @@
'use strict'; 'use strict';
'require poll'; 'require poll';
'require rpc';
// run/test/restore are backgrounded by rpcd ({started:true}); the only way to
// see how they went is to tail `log` until a terminal line appears. LuCI hands
// every view the same instance: one operation at a time per view.
var callLog = rpc.declare({
object: 'luci.gitbackup',
method: 'log',
params: [ 'lines' ]
});
function gbFetchLog() {
return L.resolveDefault(callLog(500), null).then(function(res) {
return (res && res.text) || '';
});
}
// gitbackup -- shared bounded live-log poller (ticket 23, spec "Не
// изобретать четвёртый механизм"). Before this file existed, overview.js
// (run/test), history.js (restore) and settings.js (test) each carried
// their own, separately-typed copy of the exact same state machine: fetch
// a log tail, diff it against the line count from the previous tick, feed
// the new lines to a caller-supplied terminal-line regex, and give up
// after 60s of no new output or a hard 5-minute ceiling regardless --
// interfaces.md's own record of tickets 11-14 already names this bound
// three times over ("терминальная строка лога, простой в 60 секунд,
// жёсткий потолок в 5 минут"), copied rather than shared. Ticket 23's own
// acceptance criterion ("механизм поллинга один на все три view, а не
// четыре разных") asks for exactly one implementation of that shape; this
// is it.
//
// LuCI's own require() resolves a module ONCE per page and hands back the
// same already-instantiated singleton to every caller (luci.js:
// `compileClass`'s `new _class()`, cached in `classes[name]`) -- so every
// one of the three views above sharing this same instance is safe only
// because each of them is its own full page load (classic multi-page LuCI
// tears down the whole JS context on tab switch) AND never runs two such
// pollers from itself at once (the button(s) a poller was started from
// stay disabled for its entire run). start() below resets every piece of
// this instance's state, so a second, later operation on the very same
// page (e.g. overview.js's "Backup now" followed later by its own "Test
// connection") reuses the same instance cleanly once the first one has
// actually stopped.
return L.Class.extend({ return L.Class.extend({
// start(opts) -- begins polling opts.fetch() every opts.interval callLog: callLog,
// seconds (default 2, matching every prior copy of this poller).
// opts.fetch() must resolve to the log's current text tail (a // Resolves only once the operation is over, so a createHandlerFn button
// rejected fetch is treated as "no output this tick", same as an // stays disabled until then. Exactly one of opts.onFinish/onTimeout/onFail
// empty string, and repeated failures alone can still trip the idle // fires.
// bound below). Returns a promise that resolves once the FIRST fetch run: function(opts) {
// (establishing the starting line count) has completed and polling
// has actually started -- callers await this before firing the
// backgrounded CLI call itself, exactly like every prior copy of this
// poller did, so a very fast operation's own first output line is
// never missed.
//
// opts.terminalRe (RegExp, required) -- tested against each newly
// arrived line; a match stops the poller and fires opts.onFinish.
// opts.onProgress(addedText), if given, fires on every tick that saw
// new output at all, with only the lines new since the previous tick
// (already newline-joined, blank lines dropped) -- never the whole
// accumulated text, so a caller appending it to a growing <pre> never
// duplicates a line.
// opts.onFinish(matchedLine), if given, fires exactly once, after the
// poller has already stopped itself, with the one line that matched
// opts.terminalRe.
// opts.onTimeout(), if given, fires exactly once, after the poller has
// already stopped itself, when opts.idleLimit ticks (default 30 == 60s
// at the default 2s interval) passed with no new output at all, when
// opts.tickLimit ticks (default 150 == 5 minutes) is reached
// regardless, or when opts.fetch() itself keeps rejecting for that
// same idle span -- three different reasons this project's own
// interfaces.md has always treated as one bound ("простой... либо
// потолок"), not three outcomes a caller has to tell apart.
start: function(opts) {
var self = this; var self = this;
var pre = opts.pre ? document.getElementById(opts.pre) : null;
self._opts = opts;
self._idle = 0; self._idle = 0;
self._ticks = 0; self._ticks = 0;
self._lines = 0; self._lines = 0;
self._done = false;
return opts.fetch().then(function(text) { if (pre) {
text = text || ''; pre.hidden = false;
self._lines = text ? text.split('\n').length : 0; pre.textContent = '';
}
if (!self._bound) return new Promise(function(resolve) {
self._bound = L.bind(self._tick, self); self._opts = opts;
self._resolve = resolve;
poll.remove(self._bound); // Baseline before the call, or the operation's first line is missed.
poll.add(self._bound, opts.interval || 2); gbFetchLog().then(function(text) {
self._lines = text ? text.split('\n').length : 0;
if (!self._bound)
self._bound = L.bind(self._tick, self);
poll.remove(self._bound);
poll.add(self._bound, 2);
return opts.call();
}).then(function(res) {
if (!res || res.started !== true)
throw new Error((res && res.reason) || '');
}).catch(function(e) {
self._end(opts.onFail, e.message);
});
}); });
}, },
// stop() -- idempotent; safe to call whether or not a poll is
// currently running (every call site here does, on the "could not
// even start the operation" path, whether or not start() got as far
// as poll.add).
stop: function() { stop: function() {
if (this._bound) if (this._bound)
poll.remove(this._bound); poll.remove(this._bound);
}, },
_append: function(add) {
var pre = this._opts.pre ? document.getElementById(this._opts.pre) : null;
if (!pre)
return;
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
pre.scrollTop = pre.scrollHeight;
},
_end: function(cb, arg) {
if (this._done)
return;
this._done = true;
this.stop();
if (cb)
cb(arg);
this._resolve();
},
_tick: function() { _tick: function() {
var self = this; var self = this;
var opts = self._opts; var opts = self._opts;
var idleLimit = opts.idleLimit || 30;
var tickLimit = opts.tickLimit || 150;
self._ticks++; self._ticks++;
return opts.fetch().then(function(text) { return callLog(500).then(function(res) {
var lines, newLines, add, finished, matchedLine, i; var lines = ((res && res.text) || '').split('\n');
var newLines = (lines.length >= self._lines) ? lines.slice(self._lines) : lines;
text = text || ''; var add = newLines.filter(function(l) { return l; }).join('\n');
lines = text.split('\n'); var matched = null;
newLines = (lines.length >= self._lines) ? lines.slice(self._lines) : lines; var i;
add = newLines.filter(function(l) { return l; }).join('\n');
finished = false;
matchedLine = null;
self._lines = lines.length; self._lines = lines.length;
if (add) { if (add) {
self._idle = 0; self._idle = 0;
if (opts.onProgress) self._append(add);
opts.onProgress(add);
for (i = 0; i < newLines.length; i++) { for (i = 0; i < newLines.length; i++) {
if (opts.terminalRe.test(newLines[i])) { if (opts.terminalRe.test(newLines[i]))
finished = true; matched = newLines[i];
matchedLine = newLines[i];
}
} }
} else { } else {
self._idle++; self._idle++;
} }
if (finished) { if (matched !== null)
self.stop(); self._end(opts.onFinish, matched);
if (opts.onFinish) else if (self._idle >= 30 || self._ticks >= 150)
opts.onFinish(matchedLine); self._end(opts.onTimeout);
} else if (self._idle >= idleLimit || self._ticks >= tickLimit) {
self.stop();
if (opts.onTimeout)
opts.onTimeout();
}
}, function() { }, function() {
// A single failed poll must not spin forever either -- same self._end(opts.onTimeout);
// convention every prior copy of this poller used (count it
// as a big idle jump rather than a distinct error path).
self._idle += idleLimit;
if (self._idle >= idleLimit) {
self.stop();
if (opts.onTimeout)
opts.onTimeout();
}
}); });
} }
}); });
File diff suppressed because it is too large. Load diff
@@ -2,91 +2,12 @@
'require view'; 'require view';
'require rpc'; 'require rpc';
'require ui'; 'require ui';
'require fs'; 'require view.gitbackup.common as common';
/* global common */
// gitbackup -- Paths (ticket 13, spec "Решения по реализации -> LuCI -> Paths", // Writes back `entries`, never `effective` (sysupgrade -l): a directory line
// "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade"). Client-side // must stay a directory line, or files created after the save stop being
// JS only, luci-base only -- same constraint and the same jsmin-conservative // backed up. Path validation lives in paths.sh only.
// style (plain ES5, `var`/`function`, no let/const/arrow/template literals,
// every statement semicolon-terminated) as overview.js (ticket 11), read
// before writing this file.
//
// The whole point of this screen (spec/brief, verbatim): "Расширение списка --
// редактирование /etc/sysupgrade.conf, а не отдельный UCI-список. То, что
// пользователь добавил ради бэкапа, автоматически переживёт реальный
// sysupgrade." That file is owned entirely by package/gitbackup's paths.sh and
// this file's own rpcd bridge (usr/libexec/rpcd/luci.gitbackup) -- both out of
// this ticket's own zone in the original cut, but the read/write asymmetry
// documented below was later closed in that same package/gitbackup zone, and
// this view was updated alongside it.
//
// ---------------------------------------------------------------------
// Raw vs. entries vs. effective -- the defect this file used to work
// around instead of fixing, closed by making `list_paths` carry three
// sets explicitly:
//
// - `paths` -- the RAW, unexpanded content of /etc/sysupgrade.conf
// (gb_paths_list), comments and blank lines included.
// Not used by this view at all (see `entries` below).
// - `entries` -- (ticket 26) `paths`, filtered down to genuine path
// entries: comments (sysupgrade.conf's own syntax --
// a line starting with '#', including a commented-out
// EXAMPLE such as "# /etc/openvpn/", which backs up
// nothing) and blank lines are dropped by
// gb_paths_entries server-side. This is what this view
// lists and edits, and the only thing `set_paths` ever
// writes back -- a directory line stays a directory
// line across any number of edits, so anything added
// to it later keeps being covered by a real sysupgrade
// the same way it always was, instead of being frozen
// into the exact file list that happened to exist on
// the day of the last save here. Before this field
// existed, this view built its editable list straight
// from `paths`, so a stock router's own header
// comments and example lines rendered as four bogus,
// removable entries -- and the first save from this
// view (any Add or Remove click) silently erased them,
// because `set_paths` used to write back only what it
// was sent and never looked at the rest of the file.
// That erasure is now prevented server-side
// (gb_paths_replace_entries, paths.sh) rather than by
// this view trying to resend lines it was never shown
// in the first place -- see that function's own header
// comment for why the fix belongs there.
// - `effective` -- plain `sysupgrade -l`'s own wider union (spec:
// "(/etc/sysupgrade.conf ∪ /lib/upgrade/keep.d/*) ∪
// изменённые conffiles"), already recursively expanded
// to individual files/symlinks. Read-only: used only
// to size the real backup set (the 2 MB warning has to
// mean what `sysupgrade -b` will actually build, not
// just the lines a human typed) and to show what is
// already covered without anyone having added it here.
//
// Earlier, `list_paths` returned only the effective set under the name
// `paths`, so this view's own edits were built on top of the ALREADY
// EXPANDED list -- the first save after a human added a directory line
// via the CLI would write that directory's individual files straight
// back to sysupgrade.conf, permanently losing the one-line-per-directory
// convenience. Editing the raw set instead removes the problem instead of
// working around it.
//
// A second, related gap this same change closes: `set_paths`'s own
// server-side check used to duplicate only two of paths.sh's
// gb_paths_validate rules (absolute, the fixed blacklist), leaving a
// space in the path and a nonexistent path to this view's own JS to
// catch -- which could never reliably tell a dangling symlink (a
// legitimate entry, e.g. /etc/resolv.conf) from a genuinely missing path,
// because no rpcd ACL anywhere grants `fs.lstat` (confirmed live: every
// attempt at fs.lstat here failed with rpcd's own -32002 "Access denied",
// browser console, root login included -- only `fs.stat` is reachable).
// `set_paths` now routes every entry through gb_paths_validate itself,
// which runs ON the router and sees the real filesystem, so it gets `-e
// || -L` right without needing that ACL grant at all. This view keeps its
// own quick client-side checks (gbPathReject below) as a fast first pass
// that avoids an obviously-doomed round trip, but the round trip -- and
// its `rejected[]` response -- is the actual, authoritative gate; this
// view no longer attempts its own existence check at all.
// ---------------------------------------------------------------------
var callListPaths = rpc.declare({ var callListPaths = rpc.declare({
object: 'luci.gitbackup', object: 'luci.gitbackup',
@@ -104,127 +25,16 @@ var callSetPaths = rpc.declare({
params: [ 'paths' ] params: [ 'paths' ]
}); });
// gb_paths_size_kb (paths.sh) rounds bytes up to a whole KB: awk's
// `(s + 1023) / 1024` truncated to an integer is exactly `ceil(s / 1024)`.
// No rpcd method exposes that number directly, so this view derives the
// same total the same way paths.sh does: sum the byte size of every entry
// in `list_paths`'s "effective" set -- the same full set `sysupgrade -l`
// (and `run`'s own pre-flight space check) actually measures, not just
// the raw lines a human typed -- using `fs.stat`, deliberately not
// `fs.lstat`, and not only because of the ACL note above. gb_paths_size_kb's
// own test is plain `[ -f "$path" ]`, which -- confirmed live against this
// stand's busybox ash (a symlink to a regular file passes `-f`) -- FOLLOWS
// a symlink to its target, same as `fs.stat`; only a target that is not a
// regular file (a directory, or a dangling symlink) makes `-f` false and
// the entry contributes 0. Using `fs.lstat` here would size the symlink's
// own few bytes instead of its target's real content and disagree with
// the shell-side total for the exact same set.
var GB_PATHS_WARN_KB = 2048; var GB_PATHS_WARN_KB = 2048;
// gbPathReject <path> -- null when the path is worth submitting to // sysupgrade.conf directory lines commonly carry a trailing slash.
// `set_paths`, otherwise one human-readable reason, worded identically to
// package/gitbackup's paths.sh (gb_paths_validate) for the checks that
// module owns and that do not need a filesystem round trip: absolute, no
// space, not in the fixed blacklist. Order matches gb_paths_validate's own
// (absolute first, everything else assumes it). This is a fast first pass
// only -- `set_paths` re-validates every entry server-side (gb_paths_validate
// itself, including existence) and is the actual gate; a path that slips
// past this function is still caught there and reported through
// commitPaths's own `rejected[]` handling below.
function gbPathReject(path) {
if (path.charAt(0) !== '/')
return _('%s: not an absolute path').format(path);
if (path.indexOf(' ') !== -1)
return _('%s: contains a space -- sysupgrade.conf lines become find(1) arguments and cannot quote one').format(path);
if (path === '/etc/gitbackup' || path.indexOf('/etc/gitbackup/') === 0)
return _('%s: reserved for gitbackup itself').format(path);
if (path === '/proc' || path.indexOf('/proc/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/sys' || path.indexOf('/sys/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/tmp' || path.indexOf('/tmp/') === 0)
return _('%s: cleared on every reboot').format(path);
return null;
}
// gbSequentialMap <items[]> <fn> -- like items.map(fn), but runs one call at
// a time and only starts the next once the previous settles, instead of
// firing every call at once. Confirmed necessary live on the owlab stand:
// stock `uhttpd` there runs with `-n 3` (three concurrent ubus-bridge
// requests, the same limit a real low-memory router ships with, not
// something this dev stand loosened), and firing one `fs.stat` per entry of
// even the 13-line clean-image default set at once left every later ubus
// call queued behind it indefinitely -- confirmed by watching the network
// panel: no request for a later call was even sent until an earlier one's
// slot freed, and with enough of them in flight the rest of the page (this
// view's own next list_paths/set_paths call, even an unrelated view) stayed
// stuck on "Loading view..." until the batch drained. `Promise.all` over
// every path was the original, incorrect implementation.
function gbSequentialMap(items, fn) {
var results = [];
var i = 0;
function next() {
if (i >= items.length)
return Promise.resolve(results);
return fn(items[i]).then(function(res) {
results.push(res);
i++;
return next();
});
}
return next();
}
// gbSumSizes <paths[]> -- { kb, unknown }. `unknown` counts entries whose
// size could not be read at all (permission, or the entry vanished between
// `list_paths` answering and this running) so the total is never silently
// wrong without saying so. Called with the "effective" set, never the raw
// one -- see the file-header comment.
function gbSumSizes(paths) {
return gbSequentialMap(paths, function(p) {
return fs.stat(p).then(function(st) { return st; }, function() { return null; });
}).then(function(stats) {
var bytes = 0;
var unknown = 0;
var i;
for (i = 0; i < stats.length; i++) {
if (stats[i] && stats[i].type === 'file')
bytes += stats[i].size || 0;
else if (!stats[i])
unknown++;
}
return { kb: Math.ceil(bytes / 1024), unknown: unknown };
});
}
// gbCoveredBy <rawEntry> <effectivePath> -- true when <effectivePath> is
// exactly <rawEntry>, or lies underneath it as a directory. <rawEntry>'s
// own trailing slash (if any -- sysupgrade.conf directory lines commonly
// carry one) is normalized away first so both spellings of the same
// directory match the same children.
function gbCoveredBy(rawEntry, effectivePath) { function gbCoveredBy(rawEntry, effectivePath) {
var prefix = rawEntry.replace(/\/+$/, '') + '/'; var prefix = rawEntry.replace(/\/+$/, '') + '/';
return effectivePath === rawEntry || effectivePath.indexOf(prefix) === 0; return effectivePath === rawEntry || effectivePath.indexOf(prefix) === 0;
} }
// gbAutomaticEntries <rawPaths[]> <effectivePaths[]> -- the "effective" set // Effective entries no raw line explains: kept by /lib/upgrade/keep.d or a
// entries not explained by anything a human put in the raw list (not equal // changed package conffile without the operator adding them.
// to a raw entry, not a descendant of a raw directory entry) -- i.e. only
// there because /lib/upgrade/keep.d/* or a changed package conffile
// already covers it. Shown read-only, separately from the editable raw
// list, so the operator can see the difference between "I added this" and
// "this is already covered without me doing anything".
function gbAutomaticEntries(rawPaths, effectivePaths) { function gbAutomaticEntries(rawPaths, effectivePaths) {
return effectivePaths.filter(function(p) { return effectivePaths.filter(function(p) {
var i; var i;
@@ -264,21 +74,12 @@ function gbBuildPathsList(self, paths) {
})); }));
} }
function gbBuildSizeBox(kb, unknown) { function gbBuildSizeBox(kb) {
var children = []; var children = [
var text; E('p', { 'class': 'gitbackup-size' }, [ _('Total size of the backup set: %d KB').format(kb) ])
];
if (kb == null) if (kb > GB_PATHS_WARN_KB) {
text = _('Calculating the backup set size…');
else
text = _('Total size of the backup set: %d KB').format(kb);
if (unknown)
text += ' ' + _('(some entries could not be measured and are left out of this total)');
children.push(E('p', { 'class': 'gitbackup-size' }, [ text ]));
if (kb != null && kb > GB_PATHS_WARN_KB) {
children.push(E('div', { 'class': 'gitbackup-warn' }, [ children.push(E('div', { 'class': 'gitbackup-warn' }, [
E('p', {}, [ _('This is over 2 MB. A real "sysupgrade -b" builds this archive entirely in RAM, and past this size it can fail on a low-memory device. This is only a warning -- nothing here is blocked, trim the list above if that matters on this device.') ]) E('p', {}, [ _('This is over 2 MB. A real "sysupgrade -b" builds this archive entirely in RAM, and past this size it can fail on a low-memory device. This is only a warning -- nothing here is blocked, trim the list above if that matters on this device.') ])
])); ]));
@@ -330,11 +131,7 @@ function gbBuildAuditBox(self, audit) {
} }
var GB_CSS = [ var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-caption { color: var(--text-color-medium, #666); }',
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
'.gitbackup-actions input[type="text"] { flex: 1 1 260px; min-width: 200px; }', '.gitbackup-actions input[type="text"] { flex: 1 1 260px; min-width: 200px; }',
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-path-list { list-style: none; margin: .5em 0; padding: 0; }', '.gitbackup-path-list { list-style: none; margin: .5em 0; padding: 0; }',
'.gitbackup-path-row { display: flex; align-items: center; justify-content: space-between; gap: .75em; padding: .4em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); }', '.gitbackup-path-row { display: flex; align-items: center; justify-content: space-between; gap: .75em; padding: .4em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); }',
'.gitbackup-path-row:last-child { border-bottom: none; }', '.gitbackup-path-row:last-child { border-bottom: none; }',
@@ -346,9 +143,6 @@ var GB_CSS = [
]; ];
return view.extend({ return view.extend({
// Not a config form -- see overview.js's own identical comment. Rule 11
// of the spec's style guide: the stock Save/Apply/Reset row is switched
// off through this documented hook, never hidden with CSS.
handleSaveApply: null, handleSaveApply: null,
handleSave: null, handleSave: null,
handleReset: null, handleReset: null,
@@ -360,25 +154,25 @@ return view.extend({
]); ]);
}, },
applyData: function(data) {
this._paths = (data[0] && data[0].entries) || [];
this._effective = (data[0] && data[0].effective) || [];
this._sizeKb = (data[0] && data[0].size_kb) || 0;
this._audit = (data[1] && data[1].paths) || [];
},
render: function(data) { render: function(data) {
var self = this; var self = this;
var view; var view;
self._paths = (data[0] && data[0].entries) || []; self.applyData(data);
self._effective = (data[0] && data[0].effective) || []; self._body = E('div');
self._audit = (data[1] && data[1].paths) || [];
self._sizeKb = null;
self._sizeUnknown = 0;
view = E('div', { 'class': 'gitbackup-view' }, [ view = E('div', { 'class': 'gitbackup-view' }, [
E('style', { 'type': 'text/css' }, [ GB_CSS.join('\n') ]), E('style', { 'type': 'text/css' }, [ common.css.concat(GB_CSS).join('\n') ]),
E('h2', {}, [ _('Git Backup - Paths') ]), E('h2', {}, [ _('Git Backup - Paths') ]),
// Spec, verbatim: the caption is itself a feature, not a footnote
// -- this edits the same file the router's own firmware-upgrade
// tool reads, so anything listed here survives a real sysupgrade
// on its own, whether or not gitbackup itself ever runs again.
E('p', { 'class': 'gitbackup-caption' }, E('p', { 'class': 'gitbackup-caption' },
[ _('These paths also survive a firmware upgrade: they are written straight into /etc/sysupgrade.conf, the same file the router\'s own sysupgrade reads, not a separate list of this tool\'s own. A directory you add here stays a directory -- anything added to it later is covered too, exactly like a real sysupgrade would.') ]), [ _('These paths also survive a firmware upgrade: they are written straight into /etc/sysupgrade.conf, the same file the router\'s own sysupgrade reads, not a separate list of this tool\'s own. A directory you add here stays a directory -- anything added to it later is covered too, exactly like a real sysupgrade would.') ]),
@@ -402,66 +196,34 @@ return view.extend({
}, [ _('Add path') ]) }, [ _('Add path') ])
]), ]),
E('div', { 'id': 'gitbackup-paths-body' }) self._body
]); ]);
self.renderBody(); self.renderBody();
self.refreshSizes();
return view; return view;
}, },
// Rebuilds the size summary, editable path list, automatic-coverage box
// and audit box from self._paths/self._effective/self._audit -- called
// after every load and every successful edit (commitPaths/refresh
// below). Small N (13 keep.d entries on a clean stand plus whatever has
// been added), so a full rebuild rather than per-row DOM patching is
// simple and cheap enough.
renderBody: function() { renderBody: function() {
var self = this; var self = this;
var body = document.getElementById('gitbackup-paths-body'); // A node reference, not getElementById: render() calls this before
var automatic; // its tree is attached to the document.
var body = self._body;
if (!body) var automatic = gbAutomaticEntries(self._paths, self._effective);
return;
automatic = gbAutomaticEntries(self._paths, self._effective);
body.textContent = ''; body.textContent = '';
body.appendChild(gbBuildSizeBox(self._sizeKb, self._sizeUnknown)); body.appendChild(gbBuildSizeBox(self._sizeKb));
body.appendChild(gbBuildPathsList(self, self._paths)); body.appendChild(gbBuildPathsList(self, self._paths));
body.appendChild(gbBuildAutomaticBox(automatic)); body.appendChild(gbBuildAutomaticBox(automatic));
body.appendChild(gbBuildAuditBox(self, self._audit)); body.appendChild(gbBuildAuditBox(self, self._audit));
}, },
refreshSizes: function() {
var self = this;
var effective = self._effective;
return gbSumSizes(effective).then(function(res) {
// A slower stat batch landing after the effective set moved on
// (another edit already completed) would show a total for a
// set that is no longer displayed -- discard it instead.
if (self._effective !== effective)
return;
self._sizeKb = res.kb;
self._sizeUnknown = res.unknown;
self.renderBody();
});
},
refresh: function() { refresh: function() {
var self = this; var self = this;
return Promise.all([ return self.load().then(function(data) {
L.resolveDefault(callListPaths(), null), self.applyData(data);
L.resolveDefault(callAuditPaths(), null)
]).then(function(data) {
self._paths = (data[0] && data[0].entries) || [];
self._effective = (data[0] && data[0].effective) || [];
self._audit = (data[1] && data[1].paths) || [];
self.renderBody(); self.renderBody();
return self.refreshSizes();
}); });
}, },
@@ -475,14 +237,7 @@ return view.extend({
btn.disabled = busy; btn.disabled = busy;
}, },
// commitPaths <newList> [addedPath] -- the one path to `set_paths`. // Resolves false when addedPath itself was rejected.
// Reports every backend-side rejection by name and reason (spec: "не
// молча"), then always refreshes from `list_paths` regardless of outcome
// -- the effective set on disk is the only thing worth trusting after a
// write, never this view's own optimistic guess at what changed.
// Resolves to true only when `addedPath` (if given) was not among the
// rejected entries, so callers adding a single new path know whether to
// also clear the input field.
commitPaths: function(newList, addedPath) { commitPaths: function(newList, addedPath) {
var self = this; var self = this;
@@ -492,8 +247,8 @@ return view.extend({
var i; var i;
for (i = 0; i < rejected.length; i++) { for (i = 0; i < rejected.length; i++) {
ui.addNotification(null, E('p', {}, // The reason already starts with the path.
[ _('%s: %s').format(rejected[i].path, rejected[i].reason) ]), 'error'); ui.addNotification(null, E('p', {}, [ rejected[i].reason ]), 'error');
if (addedPath && rejected[i].path === addedPath) if (addedPath && rejected[i].path === addedPath)
ok = false; ok = false;
} }
@@ -506,18 +261,10 @@ return view.extend({
}); });
}, },
// handleAddPath -- only the fast, local checks (non-empty, not already
// present, gbPathReject's syntax-only rules) happen before this ever
// calls `set_paths`. Existence is deliberately NOT checked here: it
// needs a real filesystem, `set_paths` already checks it authoritatively
// server-side (gb_paths_validate), and a client-side attempt at the same
// question would need `fs.lstat`, which no rpcd ACL here grants -- see
// the file-header comment.
handleAddPath: function(ev) { handleAddPath: function(ev) {
var self = this; var self = this;
var input = document.getElementById('gitbackup-paths-add-input'); var input = document.getElementById('gitbackup-paths-add-input');
var path = input ? input.value.replace(/^\s+|\s+$/g, '') : ''; var path = input ? input.value.trim() : '';
var reason;
if (!path) { if (!path) {
ui.addNotification(null, E('p', {}, [ _('Enter a path first.') ]), 'error'); ui.addNotification(null, E('p', {}, [ _('Enter a path first.') ]), 'error');
@@ -529,12 +276,6 @@ return view.extend({
return; return;
} }
reason = gbPathReject(path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
self.setAddBusy(true); self.setAddBusy(true);
return self.commitPaths(self._paths.concat([ path ]), path).then(function(ok) { return self.commitPaths(self._paths.concat([ path ]), path).then(function(ok) {
@@ -544,19 +285,12 @@ return view.extend({
}); });
}, },
handleAddFromAudit: function(ev, path) { // createHandlerFn passes bound args first, the event last.
var self = this; handleAddFromAudit: function(path, ev) {
var reason = gbPathReject(path); return this.commitPaths(this._paths.concat([ path ]), path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
return self.commitPaths(self._paths.concat([ path ]), path);
}, },
handleRemovePath: function(ev, path) { handleRemovePath: function(path, ev) {
var self = this; var self = this;
var idx = self._paths.indexOf(path); var idx = self._paths.indexOf(path);
var next; var next;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-client PKG_NAME:=luci-app-openvpn-client
PKG_VERSION:=20250227 PKG_VERSION:=20250227
PKG_RELEASE:=2 PKG_RELEASE:=3
LUCI_TITLE:=LuCI support for OpenVPN Client LUCI_TITLE:=LuCI support for OpenVPN Client
LUCI_DEPENDS:=+openvpn-openssl +kmod-tun LUCI_DEPENDS:=+openvpn-openssl +kmod-tun
@@ -129,11 +129,11 @@ start_instance() {
cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/iptables_add.sh cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/iptables_add.sh
${TMP_OVPN_PATH}/${cfgid}/iptables_del.sh ${TMP_OVPN_PATH}/${cfgid}/iptables_del.sh
#允许服务端配置静态路由表访问内网 #允许服务端配置静态路由表访问内网
iptables -w -t nat -I postrouting_rule -s \$(cat ${TMP_OVPN_PATH}/${cfgid}/network)/\$(cat ${TMP_OVPN_PATH}/${cfgid}/mask) -m comment --comment "${c_dev}" -j MASQUERADE iptables -w -t nat -I POSTROUTING -s \$(cat ${TMP_OVPN_PATH}/${cfgid}/network)/\$(cat ${TMP_OVPN_PATH}/${cfgid}/mask) -m comment --comment "${c_dev}" -j MASQUERADE
iptables -w -t nat -I postrouting_rule -o ${c_dev} -m comment --comment "${c_dev}" -j MASQUERADE iptables -w -t nat -I POSTROUTING -o ${c_dev} -m comment --comment "${c_dev}" -j MASQUERADE
iptables -w -I input_rule -s \$(cat ${TMP_OVPN_PATH}/${cfgid}/network)/\$(cat ${TMP_OVPN_PATH}/${cfgid}/mask) -m comment --comment "${c_dev}" -j ${c_allow_access_action} iptables -w -I INPUT -s \$(cat ${TMP_OVPN_PATH}/${cfgid}/network)/\$(cat ${TMP_OVPN_PATH}/${cfgid}/mask) -m comment --comment "${c_dev}" -j ${c_allow_access_action}
iptables -w -I forwarding_rule -i ${c_dev} -m comment --comment "${c_dev}" -j ACCEPT iptables -w -I FORWARD -i ${c_dev} -m comment --comment "${c_dev}" -j ACCEPT
iptables -w -I forwarding_rule -o ${c_dev} -m comment --comment "${c_dev}" -j ACCEPT iptables -w -I FORWARD -o ${c_dev} -m comment --comment "${c_dev}" -j ACCEPT
EOF EOF
cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/iptables_del.sh cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/iptables_del.sh
@@ -143,9 +143,9 @@ start_instance() {
\$1 -w -D \$2 \$index 2>/dev/null \$1 -w -D \$2 \$index 2>/dev/null
done done
} }
ipt_del "iptables -w" "input_rule" ipt_del "iptables -w" "INPUT"
ipt_del "iptables -w" "forwarding_rule" ipt_del "iptables -w" "FORWARD"
ipt_del "iptables -w -t nat" "postrouting_rule" ipt_del "iptables -w -t nat" "POSTROUTING"
EOF EOF
cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/ipchange.sh cat <<-EOF >> ${TMP_OVPN_PATH}/${cfgid}/ipchange.sh
@@ -162,6 +162,7 @@ start_instance() {
cat <<-EOF >> /var/etc/${CONFIG}.include cat <<-EOF >> /var/etc/${CONFIG}.include
${TMP_OVPN_PATH}/${cfgid}/iptables_add.sh ${TMP_OVPN_PATH}/${cfgid}/iptables_add.sh
exit 0
EOF EOF
local c_routes=$(uci -q get ${CONFIG}.${cfgid}.routes) local c_routes=$(uci -q get ${CONFIG}.${cfgid}.routes)
@@ -5,8 +5,15 @@ uci -q batch <<-EOF >/dev/null
set firewall.openvpn_client=include set firewall.openvpn_client=include
set firewall.openvpn_client.type=script set firewall.openvpn_client.type=script
set firewall.openvpn_client.path=/var/etc/luci-app-openvpn-client.include set firewall.openvpn_client.path=/var/etc/luci-app-openvpn-client.include
set firewall.openvpn_client.reload=1
EOF EOF
if [ -x "/sbin/fw3" ] && [ ! -L "/sbin/fw3" ]; then
uci -q set firewall.openvpn_client.reload='1'
else
uci -q delete firewall.openvpn_client.reload
uci -q delete firewall.openvpn_client.fw4_compatible
fi
uci commit firewall
rm -rf /tmp/luci-*cache rm -rf /tmp/luci-*cache
exit 0 exit 0
+2 -2
View File
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only # SPDX-License-Identifier: GPL-2.0-only
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_VERSION:=1.0.7 PKG_VERSION:=1.0.9
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_LICENSE:=GPL-2.0-only AND CC0-1.0 AND MIT AND LicenseRef-Public-Domain AND LicenseRef-fnOS-Trademark PKG_LICENSE:=GPL-2.0-only AND CC0-1.0 AND MIT AND LicenseRef-Public-Domain AND LicenseRef-fnOS-Trademark
LUCI_MAINTAINER:=coolsnowwolf <coolsnowwolf@gmail.com> LUCI_MAINTAINER:=coolsnowwolf <coolsnowwolf@gmail.com>
+9 -5
View File
@@ -14,15 +14,19 @@ memory usage; no per-client MAC history is stored. No MAC is sent off-router.
Data comes from [WH-2099/macdb](https://github.com/WH-2099/macdb), which combines Data comes from [WH-2099/macdb](https://github.com/WH-2099/macdb), which combines
IEEE MA-L, MA-M and MA-S assignments. Longest-prefix matching supports 24/28/36 IEEE MA-L, MA-M and MA-S assignments. Longest-prefix matching supports 24/28/36
bits. Multicast, locally administered/randomized and malformed MACs are ignored. bits. Locally administered unicast MACs (first octet ending in 2, 6, A or E)
Unknown vendors, absent/random MACs and brands without a bundled icon use a use a generic phone SVG without an OUI lookup, taking precedence over fnOS
small generic desktop SVG. It is an original GPL-2.0-only asset, not a brand logo. and vendor icons. This is a display rule for private/randomized MACs, not proof
that the device is a phone. Multicast and malformed MACs are not looked up.
Unknown vendors, absent/invalid MACs and brands without a bundled icon use a
small generic desktop SVG. Both generic SVGs are original GPL-2.0-only assets,
not brand logos.
The icon identifies the registered MAC vendor: a PC using an Intel NIC can show The icon identifies the registered MAC vendor: a PC using an Intel NIC can show
Intel rather than the motherboard manufacturer. DHCP hostnames are not used to Intel rather than the motherboard manufacturer. DHCP hostnames are not used to
guess brands. guess brands.
Hosts with an open TCP port 5666 or 5667 in the existing LAN web-probe cache Hosts with an open TCP port 5666 or 5667 in the existing LAN web-probe cache
show the fnOS logo instead of the OUI icon. The probe must be ready and match show the fnOS logo instead of the OUI icon, except for private MACs. The probe must be ready and match
the current IP and MAC; any active IPv4 address of a merged client can qualify. the current IP and MAC; any active IPv4 address of a merged client can qualify.
This is a port-based display heuristic, not OS authentication. The vector logo This is a port-based display heuristic, not OS authentication. The vector logo
was traced from the supplied `fnos.PNG`; see `NOTICE.fnos`. was traced from the supplied `fnos.PNG`; see `NOTICE.fnos`.
@@ -60,7 +64,7 @@ Run the regression checks with `python3 tools/test_generate.py` and
## OEM devices and supplemental brands ## OEM devices and supplemental brands
Midea and TCL SVGs supplement the Simple Icons subset. Their individual sources, Midea, TCL and Haier SVGs supplement the Simple Icons subset. Their individual sources,
authors and hashes are recorded in `tools/supplemental.json` and the packaged authors and hashes are recorded in `tools/supplemental.json` and the packaged
`NOTICE.supplemental`. These are not claimed to be Simple Icons assets. `NOTICE.supplemental`. These are not claimed to be Simple Icons assets.
@@ -1,4 +1,4 @@
Midea, TCL and GIGABYTE are supplemental SVGs, not Simple Icons assets. Midea, TCL, GIGABYTE and Haier are supplemental SVGs, not Simple Icons assets.
Sources and attribution: Sources and attribution:
{ {
"midea": { "midea": {
@@ -18,5 +18,11 @@ Sources and attribution:
"sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849", "sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849",
"author": "Koman90", "author": "Koman90",
"license": "Public domain simple shape/text logo; trademark retained" "license": "Public domain simple shape/text logo; trademark retained"
},
"haier": {
"source": "https://commons.wikimedia.org/wiki/File:Haier_logo.svg",
"sha256": "d71f652fdfb405198761e118e5c4b84fa1de76d9696bb1c13b4980c76ecb6be4",
"author": "Haier",
"license": "Public domain simple shape/text logo; trademark retained"
} }
} }
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="824px" height="256px" viewBox="0 0 824 256" enable-background="new 0 0 824 256" xml:space="preserve">
<g>
<g>
<path fill="#005BAA" d="M712.652,171.532c0.348-3.178,0.552-6.388,0.552-9.638c0-49.205-40.776-89.07-91.083-89.07
c-50.293,0-91.063,39.865-91.063,89.07c0,49.189,40.771,89.039,91.063,89.039c38.259,0,70.963-23.021,84.481-55.729l-39.257-6.878
c-8.696,15.914-25.151,26.634-43.96,26.634c-24.967,0-45.642-18.778-49.801-43.429H712.652z M623.386,109.79
c21.319,0,39.528,13.729,46.96,33.163h-93.977C583.781,123.52,602.03,109.79,623.386,109.79z"/>
<rect x="460.066" y="76.555" fill="#005BAA" width="46.512" height="171.18"/>
<path fill="#005BAA" d="M483.339,6.758c-14.358,0-25.95,11.655-25.95,25.953c0,14.276,11.592,25.9,25.95,25.9
c14.3,0,25.917-11.624,25.917-25.9C509.256,18.413,497.639,6.758,483.339,6.758"/>
<polygon fill="#005BAA" points="175.389,21.671 175.389,113.822 75.133,113.822 75.133,21.671 24.291,21.671 24.291,247.582
75.133,247.582 75.133,155.427 175.389,155.427 175.389,247.582 226.273,247.582 226.273,21.671 "/>
<path fill="#005BAA" d="M385.318,76.634v9.185c-13.519-8.191-29.361-12.896-46.336-12.896c-49.308,0-89.3,39.84-89.3,89.006
c0,49.156,39.992,89.006,89.3,89.006c16.534,0,31.981-4.491,45.262-12.27v8.918h45.384V76.634H385.318z M341.25,213.408
c-28.777,0-52.04-23.027-52.04-51.459c0-28.456,23.262-51.5,52.04-51.5c28.77,0,52.071,23.044,52.071,51.5
C393.32,190.381,370.019,213.408,341.25,213.408z"/>
<path fill="#005BAA" d="M809.376,60.657c-42.832,7.472-75.396,44.736-75.896,89.679v97.398h44.535l0.023-99.229
c0-22.716,17.053-41.463,39.032-44.153L809.376,60.657z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -7,12 +7,13 @@
var base = document.currentScript.src.replace(/[^/]*$/, ''); var base = document.currentScript.src.replace(/[^/]*$/, '');
var database, pending, devices = Object.create(null); var database, pending, devices = Object.create(null);
function normalize(mac) { function normalize(mac, allowLocal) {
if (typeof mac !== 'string' || !/^(?:[\da-f]{12}|(?:[\da-f]{2}:){5}[\da-f]{2}|(?:[\da-f]{2}-){5}[\da-f]{2})$/i.test(mac)) if (typeof mac !== 'string' || !/^(?:[\da-f]{12}|(?:[\da-f]{2}:){5}[\da-f]{2}|(?:[\da-f]{2}-){5}[\da-f]{2})$/i.test(mac))
return null; return null;
mac = mac.replace(/[:-]/g, '').toUpperCase(); mac = mac.replace(/[:-]/g, '').toUpperCase();
// Neither locally administered (randomized) nor multicast addresses have a reliable OUI. // Neither locally administered (randomized) nor multicast addresses have a reliable OUI.
return (parseInt(mac.slice(0, 2), 16) & 3) ? null : mac; var flags = parseInt(mac.slice(0, 2), 16);
return (flags & 1) || (!allowLocal && (flags & 2)) ? null : mac;
} }
function load() { function load() {
@@ -20,7 +21,7 @@
// One local request per page; failed requests are cached too, so polling never retries. // One local request per page; failed requests are cached too, so polling never retries.
pending = new Promise(function(resolve) { pending = new Promise(function(resolve) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('GET', base + 'vendors-451e5311befc.json', true); xhr.open('GET', base + 'vendors-d337209f8c00.json', true);
xhr.timeout = 5000; xhr.timeout = 5000;
xhr.onload = function() { xhr.onload = function() {
try { try {
@@ -74,13 +75,20 @@
} }
fallback(); fallback();
node.insertBefore(icon, node.firstChild); node.insertBefore(icon, node.firstChild);
var unicast = normalize(mac, true);
if (unicast && (parseInt(unicast.slice(0, 2), 16) & 2)) {
icon.title = 'Private MAC';
icon.onerror = fallback;
icon.src = base + 'phone.svg';
return;
}
if (fnos === true) { if (fnos === true) {
icon.title = 'fnOS / FygoOS'; icon.title = 'fnOS / FygoOS';
icon.onerror = fallback; icon.onerror = fallback;
icon.src = base + 'fnos.svg'; icon.src = base + 'fnos.svg';
return; return;
} }
// Random, malformed or absent addresses use the fallback without an OUI request. // Malformed or absent addresses use the fallback without an OUI request.
if (!normalize(mac)) if (!normalize(mac))
return; return;
function apply() { function apply() {
@@ -0,0 +1,2 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="1.5" width="12" height="21" rx="2.5"/><path d="M10 4h4m-3 15.5h2"/></svg>

After

Width:  |  Height:  |  Size: 291 B

@@ -1 +0,0 @@
{"vendors":[["apple","Apple"],["asus","ASUS"],["acer","Acer"],["msi","MSI"],["dell","Dell"],["hp","HP"],["lenovo","Lenovo"],["samsung","Samsung"],["huawei","Huawei"],["honor","HONOR"],["xiaomi","Xiaomi"],["oppo","OPPO"],["vivo","vivo"],["oneplus","OnePlus"],["motorola","Motorola"],["nokia","Nokia"],["sony","Sony"],["lg","LG"],["google","Google"],["microsoft","Microsoft"],["intel","Intel"],["nvidia","NVIDIA"],["broadcom","Broadcom"],["mediatek","MediaTek"],["fujitsu","Fujitsu"],["toshiba","Toshiba"],["panasonic","Panasonic"],["sharp","Sharp"],["raspberrypi","Raspberry Pi"],["framework","Framework"],["amazon","Amazon"],["tplink","TP-Link"],["ubiquiti","Ubiquiti"],["netgear","NETGEAR"],["linksys","Linksys"],["synology","Synology"],["qnap","QNAP"],["midea","Midea"],["tcl","TCL"],["gigabyte","GIGABYTE"],["vmware","VMware"]],"prefixes":{"00000E":24,"000039":25,"000095":16,"000097":4,"0000E2":2,"0000EB":26,"0000F0":7,"000124":2,"000144":4,"00014A":16,"0001E6":5,"0001E7":5,"000278":7,"0002A5":5,"0002B3":20,"0002EE":15,"000347":20,"000393":0,"0003FF":19,"00040F":1,"00041F":16,"000423":20,"00044B":21,"00047D":14,"0004EA":5,"000502":0,"000569":40,"0005B5":22,"000600":25,"00065B":4,"0007AB":7,"0007E9":20,"000802":5,"00080D":25,"000874":4,"000883":5,"0008C7":5,"000918":7,"00095B":33,"000A00":23,"000A27":0,"000A28":14,"000A57":5,"000A95":0,"000AD9":16,"000AE0":24,"000AEB":31,"000AF7":22,"000B5D":24,"000B97":26,"000BCD":5,"000BDB":4,"000BE1":15,"000C29":40,"000C43":23,"000C6E":1,"000C76":3,"000CE7":23,"000CF1":20,"000D2E":26,"000D3A":19,"000D56":4,"000D57":24,"000D61":39,"000D93":0,"000D9D":5,"000DAE":7,"000DB6":22,"000DE5":7,"000E07":16,"000E0C":20,"000E1F":38,"000E35":20,"000E7B":25,"000E7F":5,"000EA6":1,"000EB3":5,"000EC7":14,"000EED":15,"000F12":26,"000F1F":4,"000F20":5,"000F61":5,"000FB5":33,"000FBB":15,"000FDE":16,"000FEA":39,"001018":22,"00101E":26,"001055":24,"001083":5,"00108C":24,"0010B3":15,"0010DC":3,"0010E3":5,"0010FA":0,"001109":3,"00110A":5,"001111":20,"001124":0,"00112F":1,"001132":35,"001143":4,"001175":20,"001185":5,"00119F":15,"0011D8":1,"00123F":4,"001247":7,"001248":4,"00125A":19,"001262":15,"001267":26,"001279":5,"0012EE":16,"0012F0":20,"0012FB":7,"0012FE":6,"001302":20,"001315":16,"001320":20,"001321":5,"001343":26,"001370":15,"001372":4,"001377":7,"0013A9":16,"0013CE":20,"0013D3":3,"0013D4":1,"0013E8":20,"0013FD":15,"001422":4,"001451":0,"00146C":33,"001478":31,"0014A7":15,"0014C2":5,"001500":20,"001517":20,"00152A":15,"001530":4,"00155D":19,"001560":5,"00156D":32,"001599":7,"0015A0":15,"0015B7":25,"0015B9":7,"0015C1":16,"0015C5":4,"0015DE":15,"0015F2":1,"001617":3,"001620":16,"001632":7,"001635":5,"00164E":15,"00166B":7,"00166C":7,"00166F":20,"001676":20,"0016B8":16,"0016BC":15,"0016CB":0,"0016DB":7,"0016E6":39,"0016EA":20,"0016EB":20,"0016F0":4,"001708":5,"001731":1,"001735":20,"001742":24,"00174B":15,"00175C":27,"0017A4":5,"0017A5":23,"0017B0":15,"0017C9":7,"0017D5":7,"0017F2":0,"0017FA":19,"00180F":15,"001813":16,"001842":15,"00184D":33,"001871":5,"001882":8,"001885":14,"00188B":4,"00188D":15,"0018AF":7,"0018C5":15,"0018DE":20,"0018F3":1,"0018FE":5,"00192D":15,"00194F":15,"001963":16,"001979":15,"001987":26,"00198F":15,"001999":24,"0019B7":15,"0019B9":4,"0019BB":5,"0019C5":16,"0019D1":20,"0019D2":20,"0019DB":3,"0019E0":31,"0019E3":0,"001A11":18,"001A16":15,"001A4B":5,"001A4D":39,"001A75":16,"001A80":16,"001A89":15,"001A8A":7,"001A92":1,"001AA0":4,"001ADC":15,"001B21":20,"001B2F":33,"001B33":15,"001B59":16,"001B63":0,"001B77":20,"001B78":5,"001B98":7,"001BAF":15,"001BD3":26,"001BE9":22,"001BEE":15,"001BFC":1,"001C14":40,"001C23":4,"001C35":15,"001C43":7,"001C50":38,"001C62":17,"001C7E":25,"001C9A":15,"001CA4":16,"001CB3":0,"001CBF":20,"001CC0":20,"001CC4":5,"001CD4":15,"001CD6":15,"001CEE":27,"001D09":4,"001D0D":16,"001D0F":31,"001D25":7,"001D28":16,"001D3B":15,"001D40":20,"001D4F":0,"001D60":1,"001D6E":15,"001D7D":39,"001D92":3,"001D98":15,"001DBA":16,"001DD8":19,"001DE0":20,"001DE1":20,"001DE9":15,"001DF6":7,"001DFD":15,"001E0B":5,"001E10":8,"001E2A":33,"001E3A":15,"001E3B":15,"001E45":16,"001E4F":4,"001E52":0,"001E64":20,"001E65":20,"001E67":20,"001E75":17,"001E7D":7,"001E8C":1,"001EA3":15,"001EA4":15,"001EC2":0,"001EC9":4,"001EDC":16,"001EE1":7,"001EE2":7,"001F00":15,"001F01":15,"001F29":5,"001F33":33,"001F3B":20,"001F3C":20,"001F5B":0,"001F5C":15,"001F5D":15,"001F6B":17,"001F92":14,"001FA7":16,"001FC6":1,"001FCC":7,"001FCD":7,"001FCF":3,"001FD0":39,"001FDE":15,"001FDF":15,"001FE3":17,"001FE4":16,"001FF3":0,"002075":14,"00207B":20,"0020D9":26,"0020ED":39,"002108":15,"002109":15,"002119":7,"002127":31,"00214C":7,"00215A":5,"00215C":20,"00215D":20,"00216A":20,"00216B":20,"002170":4,"002185":3,"00219B":4,"00219E":16,"0021AA":15,"0021AB":15,"0021D1":7,"0021D2":7,"0021E9":0,"0021FB":17,"0021FC":15,"0021FE":15,"002215":1,"002219":4,"00223F":33,"002241":0,"002248":19,"002264":5,"002265":15,"002266":15,"002298":16,"0022A1":8,"0022A6":16,"0022A9":17,"0022DE":11,"0022F3":27,"0022FA":20,"0022FB":20,"0Line truncated
@@ -0,0 +1 @@
{"vendors":[["apple","Apple"],["asus","ASUS"],["acer","Acer"],["msi","MSI"],["dell","Dell"],["hp","HP"],["lenovo","Lenovo"],["samsung","Samsung"],["huawei","Huawei"],["honor","HONOR"],["xiaomi","Xiaomi"],["oppo","OPPO"],["vivo","vivo"],["oneplus","OnePlus"],["motorola","Motorola"],["nokia","Nokia"],["sony","Sony"],["lg","LG"],["google","Google"],["microsoft","Microsoft"],["intel","Intel"],["nvidia","NVIDIA"],["broadcom","Broadcom"],["mediatek","MediaTek"],["fujitsu","Fujitsu"],["toshiba","Toshiba"],["panasonic","Panasonic"],["sharp","Sharp"],["raspberrypi","Raspberry Pi"],["framework","Framework"],["amazon","Amazon"],["tplink","TP-Link"],["ubiquiti","Ubiquiti"],["netgear","NETGEAR"],["linksys","Linksys"],["synology","Synology"],["qnap","QNAP"],["midea","Midea"],["tcl","TCL"],["gigabyte","GIGABYTE"],["vmware","VMware"],["haier","Haier / \u6d77\u5c14"]],"prefixes":{"00000E":24,"000039":25,"000095":16,"000097":4,"0000E2":2,"0000EB":26,"0000F0":7,"000124":2,"000144":4,"00014A":16,"0001E6":5,"0001E7":5,"000278":7,"0002A5":5,"0002B3":20,"0002EE":15,"000347":20,"000393":0,"0003FF":19,"00040F":1,"00041F":16,"000423":20,"00044B":21,"00047D":14,"0004EA":5,"000502":0,"000569":40,"0005B5":22,"000600":25,"00065B":4,"0007A8":41,"0007AB":7,"0007E9":20,"000802":5,"00080D":25,"000874":4,"000883":5,"0008C7":5,"000918":7,"00095B":33,"000A00":23,"000A27":0,"000A28":14,"000A57":5,"000A95":0,"000AD9":16,"000AE0":24,"000AEB":31,"000AF7":22,"000B5D":24,"000B97":26,"000BCD":5,"000BDB":4,"000BE1":15,"000C29":40,"000C43":23,"000C6E":1,"000C76":3,"000CE7":23,"000CF1":20,"000D2E":26,"000D3A":19,"000D56":4,"000D57":24,"000D61":39,"000D93":0,"000D9D":5,"000DAE":7,"000DB6":22,"000DE5":7,"000E07":16,"000E0C":20,"000E1F":38,"000E35":20,"000E7B":25,"000E7F":5,"000EA6":1,"000EB3":5,"000EC7":14,"000EED":15,"000F12":26,"000F1F":4,"000F20":5,"000F61":5,"000FB5":33,"000FBB":15,"000FDE":16,"000FEA":39,"001018":22,"00101E":26,"001055":24,"001083":5,"00108C":24,"0010B3":15,"0010DC":3,"0010E3":5,"0010FA":0,"001109":3,"00110A":5,"001111":20,"001124":0,"00112F":1,"001132":35,"001143":4,"001175":20,"001185":5,"00119F":15,"0011D8":1,"00123F":4,"001247":7,"001248":4,"00125A":19,"001262":15,"001267":26,"001279":5,"0012EE":16,"0012F0":20,"0012FB":7,"0012FE":6,"001302":20,"001315":16,"001320":20,"001321":5,"001343":26,"001370":15,"001372":4,"001377":7,"0013A9":16,"0013CE":20,"0013D3":3,"0013D4":1,"0013E8":20,"0013FD":15,"001422":4,"001451":0,"00146C":33,"001478":31,"0014A7":15,"0014C2":5,"001500":20,"001517":20,"00152A":15,"001530":4,"00155D":19,"001560":5,"00156D":32,"001599":7,"0015A0":15,"0015B7":25,"0015B9":7,"0015C1":16,"0015C5":4,"0015DE":15,"0015F2":1,"001617":3,"001620":16,"001632":7,"001635":5,"00164E":15,"00166B":7,"00166C":7,"00166F":20,"001676":20,"0016B8":16,"0016BC":15,"0016CB":0,"0016DB":7,"0016E6":39,"0016EA":20,"0016EB":20,"0016F0":4,"001708":5,"001731":1,"001735":20,"001742":24,"00174B":15,"00175C":27,"0017A4":5,"0017A5":23,"0017B0":15,"0017C9":7,"0017D5":7,"0017F2":0,"0017FA":19,"00180F":15,"001813":16,"001842":15,"00184D":33,"001871":5,"001882":8,"001885":14,"00188B":4,"00188D":15,"0018AF":7,"0018C5":15,"0018DE":20,"0018F3":1,"0018FE":5,"00192D":15,"00194F":15,"001963":16,"001979":15,"001987":26,"00198F":15,"001999":24,"0019B7":15,"0019B9":4,"0019BB":5,"0019C5":16,"0019D1":20,"0019D2":20,"0019DB":3,"0019E0":31,"0019E3":0,"001A11":18,"001A16":15,"001A4B":5,"001A4D":39,"001A75":16,"001A80":16,"001A89":15,"001A8A":7,"001A92":1,"001AA0":4,"001ADC":15,"001B21":20,"001B2F":33,"001B33":15,"001B59":16,"001B63":0,"001B77":20,"001B78":5,"001B98":7,"001BAF":15,"001BD3":26,"001BE9":22,"001BEE":15,"001BFC":1,"001C14":40,"001C23":4,"001C35":15,"001C43":7,"001C50":38,"001C62":17,"001C7E":25,"001C9A":15,"001CA4":16,"001CB3":0,"001CBF":20,"001CC0":20,"001CC4":5,"001CD4":15,"001CD6":15,"001CEE":27,"001D09":4,"001D0D":16,"001D0F":31,"001D25":7,"001D28":16,"001D3B":15,"001D40":20,"001D4F":0,"001D60":1,"001D6E":15,"001D7D":39,"001D92":3,"001D98":15,"001DBA":16,"001DD8":19,"001DE0":20,"001DE1":20,"001DE9":15,"001DF6":7,"001DFD":15,"001E0B":5,"001E10":8,"001E2A":33,"001E3A":15,"001E3B":15,"001E45":16,"001E4F":4,"001E52":0,"001E64":20,"001E65":20,"001E67":20,"001E75":17,"001E7D":7,"001E8C":1,"001EA3":15,"001EA4":15,"001EC2":0,"001EC9":4,"001EDC":16,"001EE1":7,"001EE2":7,"001F00":15,"001F01":15,"001F29":5,"001F33":33,"001F3B":20,"001F3C":20,"001F5B":0,"001F5C":15,"001F5D":15,"001F6B":17,"001F92":14,"001FA7":16,"001FC6":1,"001FCC":7,"001FCD":7,"001FCF":3,"001FD0":39,"001FDE":15,"001FDF":15,"001FE3":17,"001FE4":16,"001FF3":0,"002075":14,"00207B":20,"0020D9":26,"0020ED":39,"002108":15,"002109":15,"002119":7,"002127":31,"00214C":7,"00215A":5,"00215C":20,"00215D":20,"00216A":20,"00216B":20,"002170":4,"002185":3,"00219B":4,"00219E":16,"0021AA":15,"0021AB":15,"0021D1":7,"0021D2":7,"0021E9":0,"0021FB":17,"0021FC":15,"0021FE":15,"002215":1,"002219":4,"00223F":33,"002241":0,"002248":19,"002264":5,"002265":15,"002266":15,"002298":16,"0022A1":8,"0022A6":16,"0022A9":17,"0022Line truncated
+21 -3
View File
@@ -2,10 +2,10 @@
"simple_icons": "https://github.com/simple-icons/simple-icons/tree/11.15.0", "simple_icons": "https://github.com/simple-icons/simple-icons/tree/11.15.0",
"macdb": { "macdb": {
"url": "https://github.com/WH-2099/macdb", "url": "https://github.com/WH-2099/macdb",
"sha256": "dab68f6728825b5e6e894e537ffbc82d2d387eb2ba3d8dd313962dcef8076979" "sha256": "98c197dd8a59c30b89866de97c1e8833fcb4456c753693c447f7403c5d753eb0"
}, },
"database": "vendors-451e5311befc.json", "database": "vendors-d337209f8c00.json",
"assignments": 8637, "assignments": 8712,
"supplemental_icons": { "supplemental_icons": {
"midea": { "midea": {
"source": "https://commons.wikimedia.org/wiki/File:Midea.svg", "source": "https://commons.wikimedia.org/wiki/File:Midea.svg",
@@ -24,6 +24,12 @@
"sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849", "sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849",
"author": "Koman90", "author": "Koman90",
"license": "Public domain simple shape/text logo; trademark retained" "license": "Public domain simple shape/text logo; trademark retained"
},
"haier": {
"source": "https://commons.wikimedia.org/wiki/File:Haier_logo.svg",
"sha256": "d71f652fdfb405198761e118e5c4b84fa1de76d9696bb1c13b4980c76ecb6be4",
"author": "Haier",
"license": "Public domain simple shape/text logo; trademark retained"
} }
}, },
"aliases": { "aliases": {
@@ -88,6 +94,18 @@
"google": [ "google": [
"Google, Inc." "Google, Inc."
], ],
"haier": [
"Beijing Haier IC Design Co.,Ltd",
"Haier",
"Haier Group Technologies Ltd",
"Haier cloud Health Technology (Qingdao) Co., Ltd",
"QING DAO HAIER TELECOM CO.,LTD.",
"Qingdao Haier Intelligent Home Appliance Technology Co.,Ltd",
"Qingdao Haier Multimedia Limited. ",
"Qingdao Haier Technology Co.,Ltd",
"Qingdao Haier Technology Co.Ltd",
"Qingdao HaierTechnology Co.,Ltd"
],
"honor": [ "honor": [
"Honor Device Co., Ltd." "Honor Device Co., Ltd."
], ],
+6
View File
@@ -16,5 +16,11 @@
"sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849", "sha256": "49693ef6522f58af4e059afe18c1fb3ddc8e6a5b15ac724d6a7493f47d84d849",
"author": "Koman90", "author": "Koman90",
"license": "Public domain simple shape/text logo; trademark retained" "license": "Public domain simple shape/text logo; trademark retained"
},
"haier": {
"source": "https://commons.wikimedia.org/wiki/File:Haier_logo.svg",
"sha256": "d71f652fdfb405198761e118e5c4b84fa1de76d9696bb1c13b4980c76ecb6be4",
"author": "Haier",
"license": "Public domain simple shape/text logo; trademark retained"
} }
} }
+11
View File
@@ -30,6 +30,17 @@ class Aliases(unittest.TestCase):
self.assertEqual(self.brand('TCL King Electrical Appliances(Huizhou)Co.,Ltd'), 'tcl') self.assertEqual(self.brand('TCL King Electrical Appliances(Huizhou)Co.,Ltd'), 'tcl')
self.assertEqual(self.brand('Hui Zhou Gaoshengda Technology Co.,LTD'), 'tcl') self.assertEqual(self.brand('Hui Zhou Gaoshengda Technology Co.,LTD'), 'tcl')
def test_haier(self):
for name in ['Qingdao Haier Technology Co.,Ltd',
'Qingdao HaierTechnology Co.,Ltd',
'QING DAO HAIER TELECOM CO.,LTD.', 'Haier']:
self.assertEqual(self.brand(name), 'haier')
self.assertIsNone(self.brand('Unrelated Haier Module Supplier'))
data = json.loads(next((ROOT / 'htdocs/luci-static/resources/oui').glob('vendors-*.json')).read_text())
mac = '34:29:EF:89:41:3C'.replace(':', '')
self.assertEqual(data['vendors'][data['prefixes'][mac[:6]]][0], 'haier')
self.assertEqual(data['vendors'][data['prefixes']['0439CB']][0], 'haier')
def test_vmware(self): def test_vmware(self):
self.assertEqual(self.brand('VMware, Inc.'), 'vmware') self.assertEqual(self.brand('VMware, Inc.'), 'vmware')
self.assertIsNone(self.brand('VMware Unrelated Devices')) self.assertIsNone(self.brand('VMware Unrelated Devices'))
+14 -2
View File
@@ -26,6 +26,18 @@ function setup(fail = false) {
for (const mac of [null, '', '02:11:22:00:00:00', '01:11:22:00:00:00', 'FF:FF:FF:FF:FF:FF', '001122', '00:11-22:33:44:55']) for (const mac of [null, '', '02:11:22:00:00:00', '01:11:22:00:00:00', 'FF:FF:FF:FF:FF:FF', '001122', '00:11-22:33:44:55'])
t.oui.decorate(t.node(), mac); t.oui.decorate(t.node(), mac);
assert.equal(t.requests(), 0, 'invalid or private MACs must not fetch data'); assert.equal(t.requests(), 0, 'invalid or private MACs must not fetch data');
for (let octet = 0; octet < 256; octet++) {
const mac = octet.toString(16).padStart(2, '0') + ':11:22:33:44:55';
if (!(octet & 3)) continue;
const n = t.node(); t.oui.decorate(n, mac);
assert(n.children[0].src.endsWith((octet & 3) === 2 ? '/phone.svg' : '/computer.svg'));
}
for (const mac of ['a6-11-22-33-44-55', 'AE1122334455', 'BA:71:BE:00:00:01']) {
const n = t.node(); t.oui.decorate(n, mac, true);
assert(n.children[0].src.endsWith('/phone.svg'), 'private MAC must take precedence over fnOS');
n.children[0].onerror(); assert(n.children[0].src.endsWith('/computer.svg'));
}
assert.equal(t.requests(), 0, 'phone icons must not fetch the OUI database');
const cases = [['00:11:22:00:00:00','ASUS'],['00-11-22-35-00-00','Apple'],['001122334455','ASUS'],['00:11:22:40:00:00',null],['00:AB:CD:00:00:00',null]]; const cases = [['00:11:22:00:00:00','ASUS'],['00-11-22-35-00-00','Apple'],['001122334455','ASUS'],['00:11:22:40:00:00',null],['00:AB:CD:00:00:00',null]];
const nodes = cases.map(([mac]) => { const n=t.node(); t.oui.decorate(n,mac); return n; }); const nodes = cases.map(([mac]) => { const n=t.node(); t.oui.decorate(n,mac); return n; });
await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
@@ -42,7 +54,7 @@ function setup(fail = false) {
assert.equal(other.children[0].title,'Unknown vendor','OEM overrides must not affect other devices in the same OUI'); assert.equal(other.children[0].title,'Unknown vendor','OEM overrides must not affect other devices in the same OUI');
assert.equal(unsafe.children[0].title,'Unknown vendor'); assert.equal(unsafe.children[0].title,'Unknown vendor');
const fnos=t.node(); const fnos=t.node();
t.oui.decorate(fnos,'BA:71:BE:00:00:01',true); t.oui.decorate(fnos,'B8:71:BE:00:00:01',true);
assert.equal(fnos.children[0].title,'fnOS / FygoOS'); assert.equal(fnos.children[0].title,'fnOS / FygoOS');
assert(fnos.children[0].src.endsWith('/fnos.svg')); assert(fnos.children[0].src.endsWith('/fnos.svg'));
const overridden=t.node(); const overridden=t.node();
@@ -71,7 +83,7 @@ function setup(fail = false) {
enabled = true; enabled = true;
const host = view.renderHostname('<img src=x>', '00:11:22:00:00:00'); const host = view.renderHostname('<img src=x>', '00:11:22:00:00:00');
assert.equal(host.children[0].text, '<img src=x>'); assert.equal(host.children[0].text, '<img src=x>');
assert.equal(scripts[0].src, '/luci-static/resources/oui/oui.js?v=8'); assert.equal(scripts[0].src, '/luci-static/resources/oui/oui.js?v=10');
view.renderHostname('host2', '00:11:22:00:00:01'); view.renderHostname('host2', '00:11:22:00:00:01');
assert.equal(scripts.length, 1); assert.equal(scripts.length, 1);
console.log('OUI runtime and optional integration checks passed'); console.log('OUI runtime and optional integration checks passed');
+8
View File
@@ -295,5 +295,13 @@
"patterns": [ "patterns": [
"^vmware(?: inc)?$" "^vmware(?: inc)?$"
] ]
},
{
"slug": "haier",
"name": "Haier / \u6d77\u5c14",
"patterns": [
"^(?:(?:qing ?dao|beijing) )?haier(?: |technology|$)"
],
"supplemental": true
} }
] ]
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.16 PKG_VERSION:=26.9.16
PKG_RELEASE:=289 PKG_RELEASE:=290
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -1470,6 +1470,10 @@ function gen_config(var)
end end
end end
if to_node then if to_node then
local chained_tag = outbound.tag .. " -> " .. to_node[".name"] .. (to_node.remarks and ":" .. to_node.remarks or "")
for _, o in ipairs(outbounds_table) do
if o.tag == chained_tag then return chained_tag, last_insert_outbound end
end
local to_outbound local to_outbound
if to_node.type ~= "sing-box" then if to_node.type ~= "sing-box" then
local tag = to_node[".name"] local tag = to_node[".name"]
@@ -1531,7 +1535,7 @@ function gen_config(var)
local outbound, exist local outbound, exist
if node.protocol == "_urltest" then if node.protocol == "_urltest" then
outbound, exist = gen_urltest_outbound(node) outbound, exist = gen_urltest_outbound(node)
if exist then if exist and not node.chain_proxy then
return outbound.tag return outbound.tag
end end
elseif node.protocol == "_iface" then elseif node.protocol == "_iface" then
@@ -1559,7 +1563,7 @@ function gen_config(var)
end end
if outbound then if outbound then
local default_outbound_tag, last_insert_outbound = set_outbound_detour(node, outbound, outbounds) local default_outbound_tag, last_insert_outbound = set_outbound_detour(node, outbound, outbounds)
table.insert(outbounds, outbound) if not exist then table.insert(outbounds, outbound) end
if last_insert_outbound then if last_insert_outbound then
table.insert(outbounds, last_insert_outbound) table.insert(outbounds, last_insert_outbound)
end end
@@ -81,7 +81,7 @@ run_ipt2socks() {
[ -n "$tcp_tproxy" ] || _extra_param="${_extra_param} -R" [ -n "$tcp_tproxy" ] || _extra_param="${_extra_param} -R"
flag="${flag}_TCP_UDP" flag="${flag}_TCP_UDP"
_extra_param="${_extra_param} -o 60 -n 65535 -v" _extra_param="${_extra_param} -o 60 -n 65535 -v"
ln_run "$(first_type ipt2socks)" "ipt2socks_${flag}" $log_file -l $local_port -b 0.0.0.0 -s $socks_address -p $socks_port ${_extra_param} ln_run "$(first_type ipt2socks)" "ipt2socks_${flag}" $log_file -l $local_port -b 0.0.0.0 -B :: -s $socks_address -p $socks_port ${_extra_param}
} }
run_singbox() { run_singbox() {
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=LuCI support for SSR Mudb Server
LUCI_DEPENDS:=+libsodium +luci-lib-jsonc +python3 LUCI_DEPENDS:=+libsodium +luci-lib-jsonc +python3
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=20211211 PKG_VERSION:=20211211
PKG_RELEASE:=3 PKG_RELEASE:=4
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
@@ -49,6 +49,7 @@ gen_include() {
iptables-restore -n <<-EOT iptables-restore -n <<-EOT
$(extract_rules filter) $(extract_rules filter)
EOT EOT
exit 0
EOF EOF
return 0 return 0
} }
@@ -5,9 +5,16 @@ uci -q batch <<-EOF >/dev/null
set firewall.ssr_mudb_server=include set firewall.ssr_mudb_server=include
set firewall.ssr_mudb_server.type=script set firewall.ssr_mudb_server.type=script
set firewall.ssr_mudb_server.path=/var/etc/ssr_mudb_server.include set firewall.ssr_mudb_server.path=/var/etc/ssr_mudb_server.include
set firewall.ssr_mudb_server.reload=1
EOF EOF
if [ -x "/sbin/fw3" ] && [ ! -L "/sbin/fw3" ]; then
uci -q set firewall.ssr_mudb_server.reload='1'
else
uci -q delete firewall.ssr_mudb_server.reload
uci -q delete firewall.ssr_mudb_server.fw4_compatible
fi
uci commit firewall
chmod a+x /usr/share/ssr_mudb_server/* >/dev/null 2>&1 chmod a+x /usr/share/ssr_mudb_server/* >/dev/null 2>&1
cp -rf /usr/bin/python3 /usr/bin/python cp -rf /usr/bin/python3 /usr/bin/python
rm -rf /tmp/luci-*cache rm -rf /tmp/luci-*cache
+1 -1
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=196 PKG_VERSION:=196
PKG_RELEASE:=74 PKG_RELEASE:=75
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \ CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
@@ -12,6 +12,9 @@ local json = require "luci.jsonc"
local uci = require "luci.model.uci".cursor() local uci = require "luci.model.uci".cursor()
local translate = i18n.translate local translate = i18n.translate
-- ============================================================
-- 常量
-- ============================================================
local CLASH_API_PORT = "16756" local CLASH_API_PORT = "16756"
local COMPONENT_HELPER = "/usr/share/shadowsocksr/update_components.sh" local COMPONENT_HELPER = "/usr/share/shadowsocksr/update_components.sh"
local SERVER_DETECT_CACHE = "/tmp/ssrplus_server_detect.json" local SERVER_DETECT_CACHE = "/tmp/ssrplus_server_detect.json"
@@ -30,10 +33,34 @@ local SUPPORTED_GEO_COMPONENTS = {
{ name = "v2ray_geosite", require = nil } { name = "v2ray_geosite", require = nil }
} }
-- ============================================================
-- 通用工具函数
-- ============================================================
local function trim(value) local function trim(value)
return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "") return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end end
local function shell_quote(value)
return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'"
end
function uci_get_config(section, key)
if section ~= "global" then
return nil
end
local val = luci.sys.exec(string.format(
'uci -q get shadowsocksr.@global[0].%s 2>/dev/null', key))
val = val:gsub("%s+$", "")
if val == "" then
return nil
end
return val
end
function isdirectory(dirname)
return nixio.fs.stat(dirname, "type") == "dir"
end
local function parse_nonnegative_int(value) local function parse_nonnegative_int(value)
local number = tonumber(value) local number = tonumber(value)
if not number then if not number then
@@ -65,6 +92,49 @@ local function normalize_client_ip(value)
return "" return ""
end end
local function urlencode(str)
if not str then return "" end
return tostring(str):gsub("[^%w%-_%.~]", function(c)
return string.format("%%%02X", string.byte(c))
end)
end
local function is_ipv6_address(addr)
addr = tostring(addr or "")
return addr ~= "" and addr:find(":", 1, true) ~= nil
end
local function is_local_target(addr)
addr = tostring(addr or ""):lower()
if addr == "" then
return false
end
if addr == "localhost" or addr == "::1" or addr:match("%.local$") then
return true
end
if is_ipv6_address(addr) then
return addr:match("^fe[89ab]") ~= nil or addr:match("^fc") ~= nil or addr:match("^fd") ~= nil
end
local o1, o2 = addr:match("^(%d+)%.(%d+)%.")
o1 = tonumber(o1)
o2 = tonumber(o2)
if not o1 or not o2 then
return false
end
return o1 == 10
or o1 == 127
or (o1 == 169 and o2 == 254)
or (o1 == 172 and o2 >= 16 and o2 <= 31)
or (o1 == 192 and o2 == 168)
end
-- ============================================================
-- CSV 处理(Clash 客户端规则)
-- ============================================================
local function get_clash_client_rule_csv_path(sid) local function get_clash_client_rule_csv_path(sid)
sid = trim(sid) sid = trim(sid)
if sid == "" then if sid == "" then
@@ -167,6 +237,13 @@ local function write_clash_client_rules_csv(sid, rows)
return nixio.fs.writefile(csv_path, table.concat(lines, "\n") .. "\n") return nixio.fs.writefile(csv_path, table.concat(lines, "\n") .. "\n")
end end
local function read_clash_client_rules(sid)
return read_clash_client_rules_csv(sid)
end
-- ============================================================
-- LAN 客户端收集
-- ============================================================
local function collect_lan_clients() local function collect_lan_clients()
local clients = {} local clients = {}
local seen = {} local seen = {}
@@ -191,10 +268,9 @@ local function collect_lan_clients()
return clients return clients
end end
local function read_clash_client_rules(sid) -- ============================================================
return read_clash_client_rules_csv(sid) -- 延迟探测
end -- ============================================================
local function normalize_ping_ms(value, scale) local function normalize_ping_ms(value, scale)
local num = tonumber(value) local num = tonumber(value)
if not num or num <= 0 then if not num or num <= 0 then
@@ -247,46 +323,6 @@ local function detect_tls_handshake_ms(domain, port, path, resolve_host, server_
return nil return nil
end end
local function urlencode(str)
if not str then return "" end
return tostring(str):gsub("[^%w%-_%.~]", function(c)
return string.format("%%%02X", string.byte(c))
end)
end
local function is_ipv6_address(addr)
addr = tostring(addr or "")
return addr ~= "" and addr:find(":", 1, true) ~= nil
end
local function is_local_target(addr)
addr = tostring(addr or ""):lower()
if addr == "" then
return false
end
if addr == "localhost" or addr == "::1" or addr:match("%.local$") then
return true
end
if is_ipv6_address(addr) then
return addr:match("^fe[89ab]") ~= nil or addr:match("^fc") ~= nil or addr:match("^fd") ~= nil
end
local o1, o2 = addr:match("^(%d+)%.(%d+)%.")
o1 = tonumber(o1)
o2 = tonumber(o2)
if not o1 or not o2 then
return false
end
return o1 == 10
or o1 == 127
or (o1 == 169 and o2 == 254)
or (o1 == 172 and o2 >= 16 and o2 <= 31)
or (o1 == 192 and o2 == 168)
end
local function detect_tcp_connect_ms(domain, port) local function detect_tcp_connect_ms(domain, port)
if not domain or domain == "" or not port or port <= 0 then if not domain or domain == "" or not port or port <= 0 then
return nil return nil
@@ -312,6 +348,9 @@ local function detect_tcp_connect_ms(domain, port)
return nil return nil
end end
-- ============================================================
-- Clash 相关
-- ============================================================
local function get_clash_secret(sid) local function get_clash_secret(sid)
return sid .. "_ssrplus_clash" return sid .. "_ssrplus_clash"
end end
@@ -561,6 +600,9 @@ local function save_detect_cache_entry(sid, data)
end) end)
end end
-- ============================================================
-- 组件 / Geo 状态读取与写 JSON
-- ============================================================
local function read_component_state(component, action) local function read_component_state(component, action)
if not SUPPORTED_COMPONENTS[component] then if not SUPPORTED_COMPONENTS[component] then
return nil, 400, "unsupported_component" return nil, 400, "unsupported_component"
@@ -632,10 +674,9 @@ local function write_geo_json(data)
}) })
end end
local function shell_quote(value) -- ============================================================
return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'" -- 路由注册
end -- ============================================================
function index() function index()
if not nixio.fs.access("/etc/config/shadowsocksr") then if not nixio.fs.access("/etc/config/shadowsocksr") then
call("act_reset") call("act_reset")
@@ -644,6 +685,8 @@ function index()
page = entry({"admin", "services", "shadowsocksr"}, alias("admin", "services", "shadowsocksr", "client"), _("ShadowSocksR Plus+"), 10) page = entry({"admin", "services", "shadowsocksr"}, alias("admin", "services", "shadowsocksr", "client"), _("ShadowSocksR Plus+"), 10)
page.dependent = true page.dependent = true
page.acl_depends = { "luci-app-ssr-plus" } page.acl_depends = { "luci-app-ssr-plus" }
-- 页面
entry({"admin", "services", "shadowsocksr", "client"}, cbi("shadowsocksr/client"), _("SSR Client"), 10).leaf = true entry({"admin", "services", "shadowsocksr", "client"}, cbi("shadowsocksr/client"), _("SSR Client"), 10).leaf = true
entry({"admin", "services", "shadowsocksr", "servers"}, arcombine(cbi("shadowsocksr/servers"), cbi("shadowsocksr/client-config")), _("Servers Nodes"), 20).leaf = true entry({"admin", "services", "shadowsocksr", "servers"}, arcombine(cbi("shadowsocksr/servers"), cbi("shadowsocksr/client-config")), _("Servers Nodes"), 20).leaf = true
entry({"admin", "services", "shadowsocksr", "control"}, cbi("shadowsocksr/control"), _("Access Control"), 30).leaf = true entry({"admin", "services", "shadowsocksr", "control"}, cbi("shadowsocksr/control"), _("Access Control"), 30).leaf = true
@@ -651,31 +694,41 @@ function index()
entry({"admin", "services", "shadowsocksr", "server"}, arcombine(cbi("shadowsocksr/server"), cbi("shadowsocksr/server-config")), _("SSR Server"), 60).leaf = true entry({"admin", "services", "shadowsocksr", "server"}, arcombine(cbi("shadowsocksr/server"), cbi("shadowsocksr/server-config")), _("SSR Server"), 60).leaf = true
entry({"admin", "services", "shadowsocksr", "component"}, cbi("shadowsocksr/component"), _("Component Update"), 65).leaf = true entry({"admin", "services", "shadowsocksr", "component"}, cbi("shadowsocksr/component"), _("Component Update"), 65).leaf = true
entry({"admin", "services", "shadowsocksr", "status"}, form("shadowsocksr/status"), _("Status"), 70).leaf = true entry({"admin", "services", "shadowsocksr", "status"}, form("shadowsocksr/status"), _("Status"), 70).leaf = true
entry({"admin", "services", "shadowsocksr", "log"}, form("shadowsocksr/log"), _("Log"), 80).leaf = true
-- 状态 / 操作
entry({"admin", "services", "shadowsocksr", "check"}, call("check_status")) entry({"admin", "services", "shadowsocksr", "check"}, call("check_status"))
entry({"admin", "services", "shadowsocksr", "refresh"}, call("refresh_data")) entry({"admin", "services", "shadowsocksr", "refresh"}, call("refresh_data"))
entry({"admin", "services", "shadowsocksr", "subscribe"}, call("subscribe")) entry({"admin", "services", "shadowsocksr", "subscribe"}, call("subscribe"))
entry({"admin", "services", "shadowsocksr", "component_local_status"}, call("component_local_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_set_mirror"}, call("component_set_mirror")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_status"}, call("component_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_upgrade"}, call("component_upgrade")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_component"}, call("geo_component")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_local_status"}, call("geo_local_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_status"}, call("geo_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_upgrade"}, call("geo_upgrade")).leaf = true
entry({"admin", "services", "shadowsocksr", "checkport"}, call("check_port")) entry({"admin", "services", "shadowsocksr", "checkport"}, call("check_port"))
entry({"admin", "services", "shadowsocksr", "log"}, form("shadowsocksr/log"), _("Log"), 80).leaf = true
entry({"admin", "services", "shadowsocksr", "get_log"}, call("get_log")).leaf = true entry({"admin", "services", "shadowsocksr", "get_log"}, call("get_log")).leaf = true
entry({"admin", "services", "shadowsocksr", "clear_log"}, call("clear_log")).leaf = true entry({"admin", "services", "shadowsocksr", "clear_log"}, call("clear_log")).leaf = true
entry({"admin", "services", "shadowsocksr", "run"}, call("act_status")) entry({"admin", "services", "shadowsocksr", "run"}, call("act_status"))
entry({"admin", "services", "shadowsocksr", "ping"}, call("act_ping")) entry({"admin", "services", "shadowsocksr", "ping"}, call("act_ping"))
entry({"admin", "services", "shadowsocksr", "save_order"}, call("save_order")).leaf = true
entry({"admin", "services", "shadowsocksr", "delete_node"}, call("act_delete_node")).leaf = true
entry({"admin", "services", "shadowsocksr", "add_subscribe_item"}, call("add_subscribe_item")).leaf = true
entry({"admin", "services", "shadowsocksr", "delete_subscribe_item"}, call("delete_subscribe_item")).leaf = true
entry({"admin", "services", "shadowsocksr", "toggle_subscribe_item_enabled"}, call("toggle_subscribe_item_enabled")).leaf = true
entry({"admin", "services", "shadowsocksr", "reset"}, call("act_reset")) entry({"admin", "services", "shadowsocksr", "reset"}, call("act_reset"))
entry({"admin", "services", "shadowsocksr", "restart"}, call("act_restart")) entry({"admin", "services", "shadowsocksr", "restart"}, call("act_restart"))
entry({"admin", "services", "shadowsocksr", "delete"}, call("act_delete")) entry({"admin", "services", "shadowsocksr", "delete"}, call("act_delete"))
entry({"admin", "services", "shadowsocksr", "save_order"}, call("save_order")).leaf = true
entry({"admin", "services", "shadowsocksr", "delete_node"}, call("act_delete_node")).leaf = true
-- 订阅
entry({"admin", "services", "shadowsocksr", "add_subscribe_item"}, call("add_subscribe_item")).leaf = true
entry({"admin", "services", "shadowsocksr", "delete_subscribe_item"}, call("delete_subscribe_item")).leaf = true
entry({"admin", "services", "shadowsocksr", "toggle_subscribe_item_enabled"}, call("toggle_subscribe_item_enabled")).leaf = true
-- 组件
entry({"admin", "services", "shadowsocksr", "component_local_status"}, call("component_local_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_set_mirror"}, call("component_set_mirror")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_status"}, call("component_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "component_upgrade"}, call("component_upgrade")).leaf = true
-- Geo
entry({"admin", "services", "shadowsocksr", "geo_component"}, call("geo_component")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_local_status"}, call("geo_local_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_status"}, call("geo_status")).leaf = true
entry({"admin", "services", "shadowsocksr", "geo_upgrade"}, call("geo_upgrade")).leaf = true
-- Clash
entry({"admin", "services", "shadowsocksr", "clash_panel"}, call("clash_panel")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_panel"}, call("clash_panel")).leaf = true
entry({"admin", "services", "shadowsocksr", "clash_groups"}, call("clash_groups")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_groups"}, call("clash_groups")).leaf = true
entry({"admin", "services", "shadowsocksr", "clash_switch"}, call("clash_switch")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_switch"}, call("clash_switch")).leaf = true
@@ -684,9 +737,18 @@ function index()
entry({"admin", "services", "shadowsocksr", "clash_client_policies"}, call("clash_client_policies")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_client_policies"}, call("clash_client_policies")).leaf = true
entry({"admin", "services", "shadowsocksr", "clash_client_rule_save"}, call("clash_client_rule_save")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_client_rule_save"}, call("clash_client_rule_save")).leaf = true
entry({"admin", "services", "shadowsocksr", "clash_client_rule_clear"}, call("clash_client_rule_clear")).leaf = true entry({"admin", "services", "shadowsocksr", "clash_client_rule_clear"}, call("clash_client_rule_clear")).leaf = true
-- 证书
entry({"admin", "services", "shadowsocksr", "fetch_certsha256"}, call("fetch_certsha256")).leaf = true entry({"admin", "services", "shadowsocksr", "fetch_certsha256"}, call("fetch_certsha256")).leaf = true
entry({"admin", "services", "shadowsocksr", "fetch_certbyname"}, call("fetch_certbyname")).leaf = true entry({"admin", "services", "shadowsocksr", "fetch_certbyname"}, call("fetch_certbyname")).leaf = true
--[[Backup]]
-- Dashboard
entry({"admin", "services", "shadowsocksr", "dashboard_type"}, call("action_dashboard_type"))
entry({"admin", "services", "shadowsocksr", "switch_dashboard"}, call("action_switch_dashboard"))
entry({"admin", "services", "shadowsocksr", "delete_dashboard"}, call("action_delete_dashboard"))
entry({"admin", "services", "shadowsocksr", "default_dashboard"}, call("action_default_dashboard"))
-- 备份
entry({"admin", "services", "shadowsocksr", "backup"}, call("create_backup")).leaf = true entry({"admin", "services", "shadowsocksr", "backup"}, call("create_backup")).leaf = true
entry({'admin', 'services', "shadowsocksr", 'ip'}, call('check_ip')) -- 获取ip情况 entry({'admin', 'services', "shadowsocksr", 'ip'}, call('check_ip')) -- 获取ip情况
end end
@@ -746,6 +808,9 @@ function check_ip()
luci.http.write_json(e) luci.http.write_json(e)
end end
-- ============================================================
-- 订阅
-- ============================================================
function subscribe() function subscribe()
nixio.fs.remove(SERVER_DETECT_CACHE) nixio.fs.remove(SERVER_DETECT_CACHE)
local sid = luci.http.formvalue("sid") or "" local sid = luci.http.formvalue("sid") or ""
@@ -1004,6 +1069,175 @@ function toggle_subscribe_item_enabled()
luci.http.write_json({ ret = 1, sid = sid, field = field, value = value }) luci.http.write_json({ ret = 1, sid = sid, field = field, value = value })
end end
-- ============================================================
-- Dashboard 面板管理
-- ============================================================
function action_switch_dashboard()
local switch_name = luci.http.formvalue("name")
local switch_type = luci.http.formvalue("type")
local state = luci.sys.call(string.format(
'/usr/share/shadowsocksr/update_dashboard.sh "%s" "%s" >/dev/null 2>&1',
switch_name, switch_type))
-- 写日志
local f = io.open("/var/log/ssrplus.log", "a")
if f then
local result
if tonumber(state) == 0 then
result = os.date("%Y-%m-%d %H:%M:%S: ") ..
string.format("Control Panel【%s - %s】Download Successful!", switch_name, switch_type)
else
result = os.date("%Y-%m-%d %H:%M:%S: ") ..
string.format("Control Panel【%s - %s】Download Error!", switch_name, switch_type)
end
f:write(result .. "\n")
f:close()
end
if tonumber(state) == 0 then
local target
if switch_type == "Official" then
target = "Official"
else
target = "Meta"
end
if switch_name == "Dashboard" then
luci.sys.call(string.format(
'uci -q set shadowsocksr.@global[0].dashboard_type="%s"', target))
elseif switch_name == "Yacd" then
luci.sys.call(string.format(
'uci -q set shadowsocksr.@global[0].yacd_type="%s"', target))
else
luci.sys.call(
'uci -q set shadowsocksr.@global[0].dashboard_type="Official"')
end
luci.sys.call('uci -q commit shadowsocksr')
end
luci.http.prepare_content("application/json")
luci.http.write_json({
download_state = state;
})
end
function action_delete_dashboard()
local delete_name = luci.http.formvalue("name")
local delete_path = string.format("/usr/share/shadowsocksr/ui/%s", string.lower(delete_name))
local panels = {
"/usr/share/shadowsocksr/ui/dashboard",
"/usr/share/shadowsocksr/ui/yacd",
"/usr/share/shadowsocksr/ui/metacubexd",
"/usr/share/shadowsocksr/ui/zashboard"
}
local existing_panels = 0
for _, path in ipairs(panels) do
if isdirectory(path) then
existing_panels = existing_panels + 1
end
end
if existing_panels <= 1 then
luci.http.prepare_content("application/json")
luci.http.write_json({
delete_state = 0,
error = "Cannot delete the last remaining dashboard"
})
return
end
local state = luci.sys.call(string.format("rm -rf '%s' >/dev/null 2>&1", delete_path)) == 0 and 1 or 0
if tonumber(state) == 1 then
if delete_name == "Dashboard" then
luci.sys.call('uci -q set shadowsocksr.@global[0].dashboard_type="Official"')
luci.sys.call('uci -q commit shadowsocksr')
elseif delete_name == "Yacd" then
luci.sys.call('uci -q set shadowsocksr.@global[0].yacd_type="Official"')
luci.sys.call('uci -q commit shadowsocksr')
end
-- 检查 default_dashboard
local default_dashboard = luci.sys.exec(
'uci -q get shadowsocksr.@global[0].default_dashboard 2>/dev/null')
default_dashboard = default_dashboard:gsub("%s+$", "")
if default_dashboard == string.lower(delete_name) then
luci.sys.call('uci -q set shadowsocksr.@global[0].default_dashboard=""')
luci.sys.call('uci -q commit shadowsocksr')
end
end
luci.http.prepare_content("application/json")
luci.http.write_json({
delete_state = state;
})
end
function action_dashboard_type()
local dashboard_type = luci.sys.exec(
'uci -q get shadowsocksr.@global[0].dashboard_type 2>/dev/null')
dashboard_type = dashboard_type:gsub("%s+$", "")
if dashboard_type == "" then
dashboard_type = "Official"
end
local yacd_type = luci.sys.exec(
'uci -q get shadowsocksr.@global[0].yacd_type 2>/dev/null')
yacd_type = yacd_type:gsub("%s+$", "")
if yacd_type == "" then
yacd_type = "Official"
end
local default_dashboard = luci.sys.exec(
'uci -q get shadowsocksr.@global[0].default_dashboard 2>/dev/null')
default_dashboard = default_dashboard:gsub("%s+$", "")
if not isdirectory("/usr/share/shadowsocksr/ui/" .. default_dashboard) then
default_dashboard = ""
end
luci.http.prepare_content("application/json")
luci.http.write_json({
dashboard_type = dashboard_type,
yacd_type = yacd_type,
yacd = isdirectory("/usr/share/shadowsocksr/ui/yacd"),
dashboard = isdirectory("/usr/share/shadowsocksr/ui/dashboard"),
metacubexd = isdirectory("/usr/share/shadowsocksr/ui/metacubexd"),
zashboard = isdirectory("/usr/share/shadowsocksr/ui/zashboard"),
default_dashboard = default_dashboard;
})
end
function action_default_dashboard()
local default_dashboard = luci.http.formvalue("name")
if not default_dashboard
or (default_dashboard ~= "Dashboard"
and default_dashboard ~= "Yacd"
and default_dashboard ~= "Metacubexd"
and default_dashboard ~= "Zashboard") then
luci.http.status(500, "Set Failed")
return
end
if not isdirectory("/usr/share/shadowsocksr/ui/" .. string.lower(default_dashboard)) then
luci.http.status(500, "Set Failed")
return
end
luci.sys.call(string.format(
'uci -q set shadowsocksr.@global[0].default_dashboard="%s"',
string.lower(default_dashboard)))
luci.sys.call('uci -q commit shadowsocksr')
luci.http.prepare_content("application/json")
luci.http.write_json({
default_dashboard = default_dashboard;
})
end
-- ============================================================
-- 组件更新
-- ============================================================
function component_status() function component_status()
local component = luci.http.formvalue("component") local component = luci.http.formvalue("component")
local data, status, err = read_component_state(component, "info") local data, status, err = read_component_state(component, "info")
@@ -1071,6 +1305,9 @@ function component_upgrade()
write_component_json(data) write_component_json(data)
end end
-- ============================================================
-- Geo 更新
-- ============================================================
function geo_status() function geo_status()
local component = luci.http.formvalue("component") local component = luci.http.formvalue("component")
local data, status, err = read_geo_state(component, "info") local data, status, err = read_geo_state(component, "info")
@@ -1127,10 +1364,13 @@ function geo_upgrade()
data.can_upgrade = info.can_upgrade data.can_upgrade = info.can_upgrade
end end
end end
write_geo_json(data) write_geo_json(data)
end end
-- ============================================================
-- 状态
-- ============================================================
function act_status() function act_status()
local e = {} local e = {}
e.running = global_client_running() e.running = global_client_running()
@@ -1141,6 +1381,88 @@ function act_status()
luci.http.write_json(e) luci.http.write_json(e)
end end
function check_status()
local e = {}
local target = luci.http.formvalue("set") or ""
e.ret = luci.sys.call("curl -m 3 -sS -o /dev/null http://www." .. target .. ".com >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end
function refresh_data()
local set = luci.http.formvalue("set")
local retstring = loadstring("return " .. luci.sys.exec("/usr/bin/lua /usr/share/shadowsocksr/update.lua " .. set))()
luci.http.prepare_content("application/json")
luci.http.write_json(retstring)
end
function check_port()
local retstring = "<br /><br />"
local s
local server_name = ""
local uci = require "luci.model.uci".cursor()
local use_nft = use_fw4_backend()
uci:foreach("shadowsocksr", "servers", function(s)
if s.type == "clash" then
retstring = retstring .. string.format("<font><b style='color:gray'>[%s] Clash panel node.</b></font><br />", s.alias or s[".name"])
return
end
if s.alias then
server_name = s.alias
elseif s.server and s.server_port then
server_name = s.server .. ":" .. s.server_port
end
-- 临时加入 set
local is_ipv6 = is_ipv6_address(s.server)
local iret = false
if not is_ipv6 then
if use_nft then
iret = luci.sys.call("nft add element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null") == 0
else
iret = luci.sys.call("ipset add ss_spec_wan_ac " .. s.server .. " 2>/dev/null") == 0
end
end
-- TCP 测试
local socket = nixio.socket(is_ipv6 and "inet6" or "inet", "stream")
socket:setopt("socket", "rcvtimeo", 3)
socket:setopt("socket", "sndtimeo", 3)
local ret = socket:connect(s.server, s.server_port)
socket:close()
if ret then
retstring = retstring .. string.format("<font><b style='color:green'>[%s] OK.</b></font><br />", server_name)
else
retstring = retstring .. string.format("<font><b style='color:red'>[%s] Error.</b></font><br />", server_name)
end
-- 删除临时 set
if iret then
if use_nft then
luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null")
else
luci.sys.call("ipset del ss_spec_wan_ac " .. s.server)
end
end
end)
luci.http.prepare_content("application/json")
luci.http.write_json({ret = retstring})
end
function get_log()
luci.http.write(luci.sys.exec("[ -f '/var/log/ssrplus.log' ] && cat /var/log/ssrplus.log"))
end
function clear_log()
luci.sys.call("echo '' > /var/log/ssrplus.log")
end
-- ============================================================
-- Clash 面板
-- ============================================================
function clash_panel() function clash_panel()
local sid = luci.http.formvalue("sid") local sid = luci.http.formvalue("sid")
if uci:get("shadowsocksr", sid) ~= "servers" or uci:get("shadowsocksr", sid, "type") ~= "clash" then if uci:get("shadowsocksr", sid) ~= "servers" or uci:get("shadowsocksr", sid, "type") ~= "clash" then
@@ -1379,6 +1701,9 @@ function clash_client_rule_clear()
}) })
end end
-- ============================================================
-- 证书
-- ============================================================
function fetch_certsha256() function fetch_certsha256()
local function fetch_cert_sha256(host, port, sni, timeout, http3) local function fetch_cert_sha256(host, port, sni, timeout, http3)
if not host then return "" end if not host then return "" end
@@ -1443,16 +1768,16 @@ function fetch_certbyname()
port = tonumber(port) or 443 port = tonumber(port) or 443
sni = sni or host sni = sni or host
timeout = tonumber(timeout) or 5 timeout = tonumber(timeout) or 5
local cmd = string.format( local cmd = string.format(
"timeout %d openssl s_client -connect %s:%d -servername %s -showcerts </dev/null 2>/dev/null " .. "timeout %d openssl s_client -connect %s:%d -servername %s -showcerts </dev/null 2>/dev/null " ..
"| openssl x509 -noout -subject 2>/dev/null " .. "| openssl x509 -noout -subject 2>/dev/null " ..
"| awk '{gsub(/^.*=[[:space:]]*/, \"\"); gsub(/,.*$/, \"\"); print}'", "| awk '{gsub(/^.*=[[:space:]]*/, \"\"); gsub(/,.*$/, \"\"); print}'",
timeout, host, port, sni timeout, host, port, sni
) )
local out = trim(luci.sys.exec(cmd)) local out = trim(luci.sys.exec(cmd))
if out == "" then if out == "" then
return "" return ""
end end
@@ -1475,6 +1800,9 @@ function fetch_certbyname()
luci.http.write_json(data ~= "" and { code = 1, data = data } or { code = 0 }) luci.http.write_json(data ~= "" and { code = 1, data = data } or { code = 0 })
end end
-- ============================================================
-- Ping / 探测
-- ============================================================
function act_ping() function act_ping()
local e = {} local e = {}
local domain = luci.http.formvalue("domain") local domain = luci.http.formvalue("domain")
@@ -1593,7 +1921,7 @@ function act_ping()
end end
end end
else else
-- 3. 非 WebSocket 节点的探测逻辑 (TCP / ICMP / UDP) -- 非 WebSocket 节点的探测逻辑 (TCP / ICMP / UDP)
local socket = nixio.socket("inet", "stream") local socket = nixio.socket("inet", "stream")
if socket then if socket then
socket:setopt("socket", "rcvtimeo", 3) socket:setopt("socket", "rcvtimeo", 3)
@@ -1643,7 +1971,7 @@ function act_ping()
end end
end end
-- 4. 清理防火墙规则 -- 清理防火墙规则
if iret then if iret then
if use_nft then if use_nft then
luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. domain .. " } 2>/dev/null") luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. domain .. " } 2>/dev/null")
@@ -1668,77 +1996,9 @@ function act_ping()
luci.http.write_json(e) luci.http.write_json(e)
end end
function check_status() -- ============================================================
local e = {} -- 重启 / 重置 / 删除
local target = luci.http.formvalue("set") or "" -- ============================================================
e.ret = luci.sys.call("curl -m 3 -sS -o /dev/null http://www." .. target .. ".com >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end
function refresh_data()
local set = luci.http.formvalue("set")
local retstring = loadstring("return " .. luci.sys.exec("/usr/bin/lua /usr/share/shadowsocksr/update.lua " .. set))()
luci.http.prepare_content("application/json")
luci.http.write_json(retstring)
end
function check_port()
local retstring = "<br /><br />"
local s
local server_name = ""
local uci = require "luci.model.uci".cursor()
local use_nft = use_fw4_backend()
uci:foreach("shadowsocksr", "servers", function(s)
if s.type == "clash" then
retstring = retstring .. string.format("<font><b style='color:gray'>[%s] Clash panel node.</b></font><br />", s.alias or s[".name"])
return
end
if s.alias then
server_name = s.alias
elseif s.server and s.server_port then
server_name = s.server .. ":" .. s.server_port
end
-- 临时加入 set
local is_ipv6 = is_ipv6_address(s.server)
local iret = false
if not is_ipv6 then
if use_nft then
iret = luci.sys.call("nft add element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null") == 0
else
iret = luci.sys.call("ipset add ss_spec_wan_ac " .. s.server .. " 2>/dev/null") == 0
end
end
-- TCP 测试
local socket = nixio.socket(is_ipv6 and "inet6" or "inet", "stream")
socket:setopt("socket", "rcvtimeo", 3)
socket:setopt("socket", "sndtimeo", 3)
local ret = socket:connect(s.server, s.server_port)
socket:close()
if ret then
retstring = retstring .. string.format("<font><b style='color:green'>[%s] OK.</b></font><br />", server_name)
else
retstring = retstring .. string.format("<font><b style='color:red'>[%s] Error.</b></font><br />", server_name)
end
-- 删除临时 set
if iret then
if use_nft then
luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null")
else
luci.sys.call("ipset del ss_spec_wan_ac " .. s.server)
end
end
end)
luci.http.prepare_content("application/json")
luci.http.write_json({ret = retstring})
end
function act_reset() function act_reset()
luci.sys.call("/etc/init.d/shadowsocksr reset >/dev/null 2>&1") luci.sys.call("/etc/init.d/shadowsocksr reset >/dev/null 2>&1")
luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr")) luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr"))
@@ -1754,14 +2014,9 @@ function act_delete()
luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr", "servers")) luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr", "servers"))
end end
function get_log() -- ============================================================
luci.http.write(luci.sys.exec("[ -f '/var/log/ssrplus.log' ] && cat /var/log/ssrplus.log")) -- 备份
end -- ============================================================
function clear_log()
luci.sys.call("echo '' > /var/log/ssrplus.log")
end
function create_backup() function create_backup()
local backup_files = { local backup_files = {
"/etc/config/shadowsocksr", "/etc/config/shadowsocksr",
@@ -2,6 +2,40 @@
<% <%
local clash_nodes = self.clash_nodes or {} local clash_nodes = self.clash_nodes or {}
local current_sid = self.map:get(section, "global_server") or "nil" local current_sid = self.map:get(section, "global_server") or "nil"
local panel_defs = {
{ name = "zashboard", label = "Zashboard", dir = "/usr/share/shadowsocksr/ui/zashboard" },
{ name = "metacubexd", label = "Metacubexd", dir = "/usr/share/shadowsocksr/ui/metacubexd" },
{ name = "yacd", label = "Yacd", dir = "/usr/share/shadowsocksr/ui/yacd" },
{ name = "dashboard", label = "Dashboard", dir = "/usr/share/shadowsocksr/ui/dashboard" },
}
local installed_panels = {}
for _, def in ipairs(panel_defs) do
if nixio.fs.stat(def.dir, "type") == "dir" then
installed_panels[#installed_panels + 1] = {
name = def.name,
label = def.label,
path = "/ui/" .. def.name .. "/"
}
end
end
local default_dashboard = luci.sys.exec(
'uci -q get shadowsocksr.@global[0].default_dashboard 2>/dev/null'):gsub("%s+$", "")
local default_panel = ""
if default_dashboard ~= "" then
for _, p in ipairs(installed_panels) do
if p.name == default_dashboard then
default_panel = p.name
break
end
end
end
if default_panel == "" and #installed_panels > 0 then
default_panel = installed_panels[1].name
end
-%> -%>
<div id="ssr-clash-main-panel-wrap"> <div id="ssr-clash-main-panel-wrap">
<style type="text/css"> <style type="text/css">
@@ -228,7 +262,91 @@ local current_sid = self.map:get(section, "global_server") or "nil"
filter: brightness(1.08); filter: brightness(1.08);
} }
/* 手机端优化 */ #ssr-panel-chooser-mask {
display: none;
position: fixed;
inset: 0;
background: var(--ssr-clash-mask-bg);
z-index: 2100;
}
#ssr-panel-chooser {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow: auto;
background: var(--ssr-clash-modal-bg);
color: var(--ssr-clash-modal-fg);
border-radius: 10px;
box-shadow: var(--ssr-clash-modal-shadow);
border: 1px solid var(--ssr-clash-modal-border);
z-index: 2101;
padding: 14px 16px 16px;
}
#ssr-panel-chooser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
#ssr-panel-chooser-title {
font-size: 16px;
font-weight: 600;
color: var(--ssr-clash-modal-fg);
}
#ssr-panel-chooser-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ssr-panel-chooser-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--ssr-clash-field-border);
border-radius: 8px;
background: var(--ssr-clash-field-bg);
color: var(--ssr-clash-field-fg);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.ssr-panel-chooser-item:hover {
background: rgba(31, 111, 235, 0.10);
border-color: #1f6feb;
}
.ssr-panel-chooser-item .ssr-panel-name {
font-weight: 600;
font-size: 14px;
}
.ssr-panel-chooser-item .ssr-panel-url {
font-size: 12px;
color: var(--ssr-clash-modal-muted);
word-break: break-all;
}
.ssr-panel-chooser-item .ssr-panel-tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: #1f6feb;
color: #fff;
white-space: nowrap;
}
#ssr-clash-main-buttons { #ssr-clash-main-buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -248,6 +366,14 @@ local current_sid = self.map:get(section, "global_server") or "nil"
transform: none; transform: none;
} }
#ssr-panel-chooser {
top: 8px;
left: 8px;
width: calc(100vw - 16px);
max-height: calc(100vh - 24px);
transform: none;
}
#ssr-clash-modal-header { #ssr-clash-modal-header {
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
@@ -262,7 +388,7 @@ local current_sid = self.map:get(section, "global_server") or "nil"
justify-content: flex-start; justify-content: flex-start;
margin-left: 0; margin-left: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
#ssr-clash-modal-header #ssr-clash-rules-add { #ssr-clash-modal-header #ssr-clash-rules-add {
@@ -395,6 +521,103 @@ local current_sid = self.map:get(section, "global_server") or "nil"
<% end -%> <% end -%>
}; };
var MIHOMO_PANEL_PORT = 16756;
var MIHOMO_PANEL_DEFAULT = '<%=default_panel%>';
var installedPanels = [
<%
for i, p in ipairs(installed_panels) do
-%>
{ name: '<%=p.name%>', label: '<%=p.label%>', path: '<%=p.path%>' }<%= (i < #installed_panels) and "," or "" %>
<% end -%>
];
function buildMihomoPanelUrl(path) {
var host = window.location.hostname;
if (host.indexOf(':') >= 0) {
host = '[' + host + ']';
}
return 'http://' + host + ':' + MIHOMO_PANEL_PORT + path;
}
function openPanelUrl(path) {
if (!path) {
return false;
}
window.open(buildMihomoPanelUrl(path), '_blank');
return false;
}
function showPanelChooser() {
var mask = document.getElementById('ssr-panel-chooser-mask');
var modal = document.getElementById('ssr-panel-chooser');
var listEl = document.getElementById('ssr-panel-chooser-list');
if (!mask || !modal || !listEl) {
return false;
}
listEl.innerHTML = '';
if (!installedPanels.length) {
var empty = document.createElement('div');
empty.style.color = 'var(--ssr-clash-modal-muted)';
empty.style.padding = '12px 0';
empty.textContent = '<%:No dashboard panel installed. Please install one first.%>';
listEl.appendChild(empty);
} else {
installedPanels.forEach(function(panel) {
var item = document.createElement('div');
item.className = 'ssr-panel-chooser-item';
var info = document.createElement('div');
info.style.flex = '1 1 auto';
info.style.minWidth = '0';
var nameEl = document.createElement('div');
nameEl.className = 'ssr-panel-name';
nameEl.textContent = panel.label;
var urlEl = document.createElement('div');
urlEl.className = 'ssr-panel-url';
urlEl.textContent = buildMihomoPanelUrl(panel.path);
info.appendChild(nameEl);
info.appendChild(urlEl);
item.appendChild(info);
if (panel.name === MIHOMO_PANEL_DEFAULT) {
var tag = document.createElement('span');
tag.className = 'ssr-panel-tag';
tag.textContent = '<%:Default%>';
item.appendChild(tag);
}
item.onclick = function() {
closePanelChooser();
openPanelUrl(panel.path);
};
listEl.appendChild(item);
});
}
mask.style.display = 'block';
modal.style.display = 'block';
return false;
}
function closePanelChooser() {
var mask = document.getElementById('ssr-panel-chooser-mask');
var modal = document.getElementById('ssr-panel-chooser');
if (mask) mask.style.display = 'none';
if (modal) modal.style.display = 'none';
return false;
}
window.ssrClashOpenPanelChooser = function() {
return showPanelChooser();
};
function setPanelStatus(text, color) { function setPanelStatus(text, color) {
var statusEl = document.getElementById('ssr-clash-modal-status'); var statusEl = document.getElementById('ssr-clash-modal-status');
if (!statusEl) { if (!statusEl) {
@@ -718,7 +941,7 @@ local current_sid = self.map:get(section, "global_server") or "nil"
select.onchange = function() { select.onchange = function() {
setPanelStatus('<%:Switching...%>', '#0072c3'); setPanelStatus('<%:Switching...%>', '#0072c3');
window.ssrXHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "clash_switch")%>', { window.ssrXHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "clash_switch")%>', {
sid: currentPanelSid, sid: currentPanelSid,
group: group.name, group: group.name,
name: select.value name: select.value
@@ -919,7 +1142,7 @@ local current_sid = self.map:get(section, "global_server") or "nil"
var resetBtn = document.getElementById('ssr-clash-main-reset-defaults'); var resetBtn = document.getElementById('ssr-clash-main-reset-defaults');
var panelBtn = document.getElementById('ssr-clash-main-open'); var panelBtn = document.getElementById('ssr-clash-main-open');
var btnContainer = document.getElementById('ssr-clash-main-buttons'); var btnContainer = document.getElementById('ssr-clash-main-buttons');
if (!select || !row || !customRulesBtn || !resetBtn || !panelBtn) { if (!select || !row || !customRulesBtn || !resetBtn) {
return; return;
} }
@@ -928,13 +1151,13 @@ local current_sid = self.map:get(section, "global_server") or "nil"
row.style.display = ''; row.style.display = '';
customRulesBtn.setAttribute('data-sid', sid); customRulesBtn.setAttribute('data-sid', sid);
resetBtn.setAttribute('data-sid', sid); resetBtn.setAttribute('data-sid', sid);
panelBtn.setAttribute('data-sid', sid); if (panelBtn) panelBtn.setAttribute('data-sid', sid);
if (btnContainer) btnContainer.style.display = 'flex'; if (btnContainer) btnContainer.style.display = 'flex';
} else { } else {
row.style.display = 'none'; row.style.display = 'none';
customRulesBtn.setAttribute('data-sid', ''); customRulesBtn.setAttribute('data-sid', '');
resetBtn.setAttribute('data-sid', ''); resetBtn.setAttribute('data-sid', '');
panelBtn.setAttribute('data-sid', ''); if (panelBtn) panelBtn.setAttribute('data-sid', '');
if (btnContainer) btnContainer.style.display = 'none'; if (btnContainer) btnContainer.style.display = 'none';
} }
} }
@@ -980,9 +1203,7 @@ local current_sid = self.map:get(section, "global_server") or "nil"
if (panelBtn) { if (panelBtn) {
panelBtn.onclick = function() { panelBtn.onclick = function() {
var sid = this.getAttribute('data-sid'); return window.ssrClashOpenPanelChooser();
if (!sid) return false;
return openPanel(sid);
}; };
} }
@@ -1060,6 +1281,15 @@ local current_sid = self.map:get(section, "global_server") or "nil"
modalMask.onclick = closePanel; modalMask.onclick = closePanel;
} }
var chooserMask = document.getElementById('ssr-panel-chooser-mask');
var chooserCloseBtn = document.getElementById('ssr-panel-chooser-close');
if (chooserMask) {
chooserMask.onclick = closePanelChooser;
}
if (chooserCloseBtn) {
chooserCloseBtn.onclick = closePanelChooser;
}
updateClashPanelMain(); updateClashPanelMain();
}, 0); }, 0);
})(); })();
@@ -1080,6 +1310,7 @@ local current_sid = self.map:get(section, "global_server") or "nil"
value="<%:Reset Default Proxies Rules%>" value="<%:Reset Default Proxies Rules%>"
title="<%:Reset Default Proxies Rules%>" title="<%:Reset Default Proxies Rules%>"
data-sid="<%=current_sid%>" /> data-sid="<%=current_sid%>" />
<% if #installed_panels > 0 then %>
<input <input
type="button" type="button"
id="ssr-clash-main-open" id="ssr-clash-main-open"
@@ -1087,6 +1318,16 @@ local current_sid = self.map:get(section, "global_server") or "nil"
value="<%:Mihomo Pannel%>" value="<%:Mihomo Pannel%>"
title="<%:Mihomo Pannel%>" title="<%:Mihomo Pannel%>"
data-sid="<%=current_sid%>" /> data-sid="<%=current_sid%>" />
<% end %>
</div>
<div id="ssr-panel-chooser-mask"></div>
<div id="ssr-panel-chooser" role="dialog" aria-modal="true" aria-labelledby="ssr-panel-chooser-title">
<div id="ssr-panel-chooser-header">
<div id="ssr-panel-chooser-title"><%:Select Dashboard Panel%></div>
<input type="button" id="ssr-panel-chooser-close" class="btn cbi-button" value="<%:Close%>" />
</div>
<div id="ssr-panel-chooser-list"></div>
</div> </div>
<div id="ssr-clash-modal-mask"></div> <div id="ssr-clash-modal-mask"></div>
@@ -345,18 +345,110 @@
queryLocalGeoInfo(); queryLocalGeoInfo();
} }
function selectedGeoComponent() { function makeDashboardBtn(label, fn, args) {
var selector = document.getElementById('geo-selector'); var argStr = args.map(function(a){ return "'" + a + "'"; }).join(', ');
return selector ? selector.value : 'country_mmdb'; return '<input type="button" class="btn cbi-button cbi-button-apply" value="' + label + '" onclick="return ' + fn + '(this, ' + argStr + ')"/>';
} }
function geoHelp(component) { window.switch_dashboard = window.switch_dashboard || function(btn, n, type) {
return geoMeta[component] ? geoMeta[component].help : ''; btn.disabled = true;
btn.value = '<%:Downloading File...%>';
XHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "switch_dashboard")%>', {name: n, type: type}, function(x, status) {
if (!x || x.status != 200) return;
if (status.download_state == "0") {
var isUpdate = (n == "Metacubexd" || n == "Zashboard");
var nextLabel, nextType;
if (isUpdate) {
nextLabel = '<%:Update Successful%> - ' + '<%:Update Version%>';
nextType = 'Official';
} else {
nextLabel = '<%:Switch Successful%> - ' + (type == "Meta" ? '<%:Switch To Official Version%>' : '<%:Switch To Meta Version%>');
nextType = type == "Meta" ? 'Official' : 'Meta';
}
document.getElementById('switch_dashboard_' + n).innerHTML = makeDashboardBtn(nextLabel, 'switch_dashboard', [n, nextType]);
document.getElementById('default_dashboard_' + n).firstElementChild.disabled = false;
document.getElementById('delete_dashboard_' + n).firstElementChild.disabled = false;
} else {
btn.value = status.download_state == "2" ? '<%:Unzip Error%>'
: (n == "Metacubexd" || n == "Zashboard") ? '<%:Update Failed%>' : '<%:Switch Failed%>';
}
btn.disabled = false;
});
return false;
};
window.delete_dashboard = window.delete_dashboard || function(btn, n) {
if (!confirm("<%:Are you sure you want to delete this panel?%>")) return false;
btn.disabled = true;
XHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "delete_dashboard")%>', {name: n}, function(x, status) {
if (!x || x.status != 200) { btn.disabled = false; return; }
if (status.delete_state == "1") {
var defBtn = document.getElementById('default_dashboard_' + n).firstElementChild;
if (defBtn.disabled) defBtn.value = '<%:Set to Default%>';
defBtn.disabled = true;
} else btn.disabled = false;
});
return false;
};
window.default_dashboard = window.default_dashboard || function(btn, n) {
btn.disabled = true;
XHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "default_dashboard")%>', {name: n}, function(x, status) {
if (!x || x.status != 200) { btn.disabled = false; return; }
btn.value = '<%:Default%>';
document.querySelectorAll('[id^="default_dashboard_"]').forEach(function(el) {
var b = el.firstElementChild;
if (b && b !== btn && b.value === '<%:Default%>') {
b.disabled = false;
b.value = '<%:Set to Default%>';
}
});
});
return false;
};
function initDashboardPanels() {
['Dashboard', 'Yacd', 'Metacubexd', 'Zashboard'].forEach(function(name) {
var switchEl = document.getElementById('switch_dashboard_' + name);
var defaultEl = document.getElementById('default_dashboard_' + name);
var deleteEl = document.getElementById('delete_dashboard_' + name);
if (!switchEl || !defaultEl || !deleteEl) {
return;
}
XHR.get('<%=luci.dispatcher.build_url("admin", "services", "shadowsocksr", "dashboard_type")%>', null, function(x, status) {
if (!x || x.status != 200) return;
var switchLabel;
if (name == "Metacubexd") {
switchLabel = ['<%:Update Version%>', 'Official'];
} else if (name == "Zashboard") {
switchLabel = ['<%:Update Version%>', 'Official'];
} else {
var isMeta = (name == "Dashboard" ? status.dashboard_type : status.yacd_type) == "Meta";
switchLabel = isMeta
? ['<%:Switch To Official Version%>', 'Official']
: ['<%:Switch To Meta Version%>', 'Meta'];
}
switchEl.innerHTML = makeDashboardBtn(switchLabel[0], 'switch_dashboard', [name, switchLabel[1]]);
if (status.default_dashboard == name.toLowerCase()) {
defaultEl.innerHTML = makeDashboardBtn('<%:Default%>', 'default_dashboard', [name]);
defaultEl.firstElementChild.disabled = true;
}
if (!status[name.toLowerCase()]) {
defaultEl.firstElementChild.disabled = true;
deleteEl.firstElementChild.disabled = true;
}
});
});
} }
window.setTimeout(function() { window.setTimeout(function() {
onComponentChange(); onComponentChange();
loadGeoComponent() loadGeoComponent();
initDashboardPanels();
}, 0); }, 0);
//]]> //]]>
</script> </script>
@@ -367,6 +459,7 @@
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 10px; margin-top: 10px;
align-items: center;
} }
.component-upgrade-card { .component-upgrade-card {
@@ -383,8 +476,8 @@
} }
.component-selector { .component-selector {
min-width: 180px;
width: 100%; width: 100%;
min-width: 180px;
} }
.component-layout { .component-layout {
@@ -427,6 +520,59 @@
color: #444; color: #444;
} }
.dashboard-update-grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 8px 24px;
align-items: center;
}
.dashboard-update-grid-label {
display: flex;
align-items: center;
min-height: 42px;
}
.dashboard-update-grid-label .component-select-label strong {
font-size: 18px;
font-weight: normal;
color: #444;
}
.dashboard-update-grid-label-first {
min-height: 52px;
}
.dashboard-update-grid-label-first .component-select-label strong {
font-size: 13px;
font-weight: bold;
}
.dashboard-update-grid-actions {
min-height: 42px;
}
.dashboard-update-grid-actions .ssr-clash-dashboard-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.dashboard-update-grid-actions .ssr-clash-dashboard-actions .cbi-button {
width: 140px;
margin: 0;
white-space: nowrap;
}
html[data-darkmode="true"] .dashboard-update-grid-label .component-select-label {
color: #aaa;
}
html[data-darkmode="true"] .dashboard-update-grid-label .component-select-label strong {
color: #e0e0e0;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.component-layout { .component-layout {
flex-direction: column; flex-direction: column;
@@ -436,6 +582,33 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.dashboard-update-grid {
grid-template-columns: 1fr;
gap: 4px;
}
.dashboard-update-grid-label {
min-height: auto;
margin: 12px 0 4px;
}
.dashboard-update-grid-label-first {
min-height: auto;
}
.dashboard-update-grid-actions {
min-height: auto;
margin-bottom: 8px;
}
.dashboard-update-grid-actions .ssr-clash-dashboard-actions .cbi-button {
width: 100%;
}
.dashboard-update-grid-actions-empty {
display: none;
}
} }
</style> </style>
@@ -521,3 +694,83 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset class="cbi-section">
<legend><%:Dashboard Update%></legend>
<div class="cbi-value">
<div class="cbi-value-field component-upgrade-card">
<div class="dashboard-update-grid">
<div class="dashboard-update-grid-label dashboard-update-grid-label-first">
<div class="component-select-label">
<strong><%:Panel Version Management%></strong>
</div>
</div>
<div class="dashboard-update-grid-actions dashboard-update-grid-actions-empty"></div>
<div class="dashboard-update-grid-label">
<div class="component-select-label">
<strong><%:Yacd Panel%></strong>
</div>
</div>
<div class="dashboard-update-grid-actions">
<div class="ssr-clash-dashboard-actions">
<div id="switch_dashboard_Yacd"><%:Collecting data...%></div>
<div id="delete_dashboard_Yacd">
<input type="button" class="btn cbi-button cbi-button-reset" value="<%:Delete%>" onclick="return delete_dashboard(this, 'Yacd')"/>
</div>
<div id="default_dashboard_Yacd">
<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Set to Default%>" onclick="return default_dashboard(this, 'Yacd')"/>
</div>
</div>
</div>
<div class="dashboard-update-grid-label">
<div class="component-select-label">
<strong><%:Dashboard Panel%></strong>
</div>
</div>
<div class="dashboard-update-grid-actions">
<div class="ssr-clash-dashboard-actions">
<div id="switch_dashboard_Dashboard"><%:Collecting data...%></div>
<div id="delete_dashboard_Dashboard">
<input type="button" class="btn cbi-button cbi-button-reset" value="<%:Delete%>" onclick="return delete_dashboard(this, 'Dashboard')"/>
</div>
<div id="default_dashboard_Dashboard">
<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Set to Default%>" onclick="return default_dashboard(this, 'Dashboard')"/>
</div>
</div>
</div>
<div class="dashboard-update-grid-label">
<div class="component-select-label">
<strong><%:Zashboard Panel%></strong>
</div>
</div>
<div class="dashboard-update-grid-actions">
<div class="ssr-clash-dashboard-actions">
<div id="switch_dashboard_Zashboard"><%:Collecting data...%></div>
<div id="delete_dashboard_Zashboard">
<input type="button" class="btn cbi-button cbi-button-reset" value="<%:Delete%>" onclick="return delete_dashboard(this, 'Zashboard')"/>
</div>
<div id="default_dashboard_Zashboard">
<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Set to Default%>" onclick="return default_dashboard(this, 'Zashboard')"/>
</div>
</div>
</div>
<div class="dashboard-update-grid-label">
<div class="component-select-label">
<strong><%:Metacubexd Panel%></strong>
</div>
</div>
<div class="dashboard-update-grid-actions">
<div class="ssr-clash-dashboard-actions">
<div id="switch_dashboard_Metacubexd"><%:Collecting data...%></div>
<div id="delete_dashboard_Metacubexd">
<input type="button" class="btn cbi-button cbi-button-reset" value="<%:Delete%>" onclick="return delete_dashboard(this, 'Metacubexd')"/>
</div>
<div id="default_dashboard_Metacubexd">
<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Set to Default%>" onclick="return default_dashboard(this, 'Metacubexd')"/>
</div>
</div>
</div>
</div>
</div>
</div>
</fieldset>
+203 -105
View File
@@ -100,16 +100,16 @@ msgstr ""
msgid "Accept LAN Access" msgid "Accept LAN Access"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:649 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:692
msgid "Access Control" msgid "Access Control"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1118 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1359
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:302 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:302
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1098 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1339
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:349 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:349
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:357 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:357
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:256 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:256
@@ -130,11 +130,11 @@ msgstr ""
msgid "Additional Version" msgid "Additional Version"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:452 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:625
msgid "Address" msgid "Address"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:650 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:693
msgid "Advanced Settings" msgid "Advanced Settings"
msgstr "" msgstr ""
@@ -164,11 +164,11 @@ msgstr ""
msgid "All Ports" msgid "All Ports"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1046 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1267
msgid "All client proxy rules cleared and applied." msgid "All client proxy rules cleared and applied."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1046 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1267
msgid "All client proxy rules cleared." msgid "All client proxy rules cleared."
msgstr "" msgstr ""
@@ -242,6 +242,10 @@ msgstr ""
msgid "Are you sure you want to delete subscribe link: %s ?" msgid "Are you sure you want to delete subscribe link: %s ?"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:381
msgid "Are you sure you want to delete this panel?"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/backup_restore.htm:133 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/backup_restore.htm:133
msgid "Are you sure you want to restore the client to default settings?" msgid "Are you sure you want to restore the client to default settings?"
msgstr "" msgstr ""
@@ -374,8 +378,8 @@ msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:480 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:653
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:515 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:688
msgid "Check Update" msgid "Check Update"
msgstr "" msgstr ""
@@ -419,13 +423,13 @@ msgstr ""
msgid "Clang.CN.CIDR" msgid "Clang.CN.CIDR"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:380 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:419
msgid "Clash" msgid "Clash"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua:112 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua:112
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:893 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1116
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1096 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1337
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:3 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:3
msgid "Clash Panel" msgid "Clash Panel"
msgstr "" msgstr ""
@@ -446,11 +450,11 @@ msgstr ""
msgid "Clash/Mihomo" msgid "Clash/Mihomo"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1101 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1342
msgid "Clear All Rules" msgid "Clear All Rules"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1035 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1256
msgid "Clear all custom client proxy rules and restore empty state?" msgid "Clear all custom client proxy rules and restore empty state?"
msgstr "" msgstr ""
@@ -469,23 +473,24 @@ msgstr ""
msgid "Click to the page" msgid "Click to the page"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1115 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1356
msgid "Client" msgid "Client"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1001 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1222
msgid "Client rules exported to CSV file." msgid "Client rules exported to CSV file."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1025 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1246
msgid "Client rules imported from CSV file. Click Save to apply." msgid "Client rules imported from CSV file. Click Save to apply."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1110 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1351
msgid "Client supports manual IP or IP/CIDR input." msgid "Client supports manual IP or IP/CIDR input."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1107 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1328
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1348
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:251 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:251
msgid "Close" msgid "Close"
msgstr "" msgstr ""
@@ -500,17 +505,21 @@ msgstr ""
msgid "Cloudflare DNS (1.1.1.1)" msgid "Cloudflare DNS (1.1.1.1)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:477 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:650
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:512 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:685
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:716
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:732
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:748
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:764
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:21 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:21
msgid "Collecting data..." msgid "Collecting data..."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:464 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:637
msgid "Component" msgid "Component"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:652 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:695
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:5 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:5
msgid "Component Update" msgid "Component Update"
msgstr "" msgstr ""
@@ -553,12 +562,12 @@ msgstr ""
msgid "Copy SSR to clipboard successfully." msgid "Copy SSR to clipboard successfully."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:443 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:616
msgid "Core Components" msgid "Core Components"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:24 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:24
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:503 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:676
msgid "Country MMDB" msgid "Country MMDB"
msgstr "" msgstr ""
@@ -586,8 +595,8 @@ msgstr ""
msgid "Custom" msgid "Custom"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1073 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1303
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1074 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1304
msgid "Custom Client Proxies Rules" msgid "Custom Client Proxies Rules"
msgstr "" msgstr ""
@@ -623,11 +632,11 @@ msgstr ""
msgid "Custom YAML imported successfully: %s" msgid "Custom YAML imported successfully: %s"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:784 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1007
msgid "Custom client proxy rules saved and applied." msgid "Custom client proxy rules saved and applied."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:784 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1007
msgid "Custom client proxy rules saved." msgid "Custom client proxy rules saved."
msgstr "" msgstr ""
@@ -658,7 +667,15 @@ msgstr ""
msgid "DTLS 1.2" msgid "DTLS 1.2"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:499 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:727
msgid "Dashboard Panel"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:699
msgid "Dashboard Update"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:672
msgid "Database" msgid "Database"
msgstr "" msgstr ""
@@ -674,6 +691,10 @@ msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1566 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1566
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1578 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1578
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1592 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1592
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:591
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:398
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:401
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:437
msgid "Default" msgid "Default"
msgstr "" msgstr ""
@@ -702,17 +723,17 @@ msgid ""
"proxy protocol." "proxy protocol."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:373 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:499
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41
msgid "Defaults Cleared!" msgid "Defaults Cleared!"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:373 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:499
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41
msgid "Defaults Restored!" msgid "Defaults Restored!"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1110 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1351
msgid "" msgid ""
"Define per-client Mihomo proxy targets. Rules are injected as SRC-IP-CIDR " "Define per-client Mihomo proxy targets. Rules are injected as SRC-IP-CIDR "
"entries before the original Clash rules." "entries before the original Clash rules."
@@ -722,7 +743,11 @@ msgstr ""
msgid "Delay (ms)" msgid "Delay (ms)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:665 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:888
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:718
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:734
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:750
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:766
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:319 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:319
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:196 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:196
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:328 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:328
@@ -800,7 +825,7 @@ msgstr ""
msgid "Downlink Capacity(Default:Mbps)" msgid "Downlink Capacity(Default:Mbps)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:451 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:624
msgid "Download Source" msgid "Download Source"
msgstr "" msgstr ""
@@ -808,6 +833,10 @@ msgstr ""
msgid "Download failed" msgid "Download failed"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:355
msgid "Downloading File..."
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:971 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:971
msgid "Dual-stack Listening Socket" msgid "Dual-stack Listening Socket"
msgstr "" msgstr ""
@@ -840,7 +869,7 @@ msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:54 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:54
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server.lua:101 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server.lua:101
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:676 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:676
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1114 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1355
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:184 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:184
msgid "Enable" msgid "Enable"
msgstr "" msgstr ""
@@ -1014,7 +1043,7 @@ msgstr ""
msgid "Expecting: %s" msgid "Expecting: %s"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1102 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1343
msgid "Export Client Rules" msgid "Export Client Rules"
msgstr "" msgstr ""
@@ -1026,7 +1055,7 @@ msgstr ""
msgid "FORCE BRUTAL" msgid "FORCE BRUTAL"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1048 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1269
msgid "Failed to clear all client proxy rules." msgid "Failed to clear all client proxy rules."
msgstr "" msgstr ""
@@ -1055,7 +1084,7 @@ msgstr ""
msgid "Failed to query geo database information." msgid "Failed to query geo database information."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:786 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1009
msgid "Failed to save custom client proxy rules." msgid "Failed to save custom client proxy rules."
msgstr "" msgstr ""
@@ -1166,16 +1195,16 @@ msgstr ""
msgid "Gecko Packet Size (min)" msgid "Gecko Packet Size (min)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:491 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:664
msgid "Geo Database Update" msgid "Geo Database Update"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:500 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:673
msgid "Geo Resource" msgid "Geo Resource"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:14 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:14
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:455 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:628
msgid "GitHub Direct" msgid "GitHub Direct"
msgstr "" msgstr ""
@@ -1310,7 +1339,7 @@ msgstr ""
msgid "Httpupgrade Path" msgid "Httpupgrade Path"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:420 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:459
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:467 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:467
msgid "Hysteria2" msgid "Hysteria2"
msgstr "" msgstr ""
@@ -1369,7 +1398,7 @@ msgstr ""
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1103 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1344
msgid "Import Client Rules" msgid "Import Client Rules"
msgstr "" msgstr ""
@@ -1572,8 +1601,8 @@ msgstr ""
msgid "Listen only on the given interface or, if unspecified, on all" msgid "Listen only on the given interface or, if unspecified, on all"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:794 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1017
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:847 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1070
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:84 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:84
msgid "Loading..." msgid "Loading..."
msgstr "" msgstr ""
@@ -1591,7 +1620,7 @@ msgstr ""
msgid "Local addresses" msgid "Local addresses"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:666 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:697
msgid "Log" msgid "Log"
msgstr "" msgstr ""
@@ -1646,18 +1675,22 @@ msgstr ""
msgid "Maximum packet size the socks5 server can receive from external" msgid "Maximum packet size the socks5 server can receive from external"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:376 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:759
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:379 msgid "Metacubexd Panel"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:382 msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:388
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:404 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:415
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:418
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:421
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:427
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:443
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:13 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:13
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:470 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:643
msgid "Mihomo" msgid "Mihomo"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1087 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1318
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1088 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1319
msgid "Mihomo Pannel" msgid "Mihomo Pannel"
msgstr "" msgstr ""
@@ -1735,10 +1768,10 @@ msgstr ""
msgid "NOT RUNNING" msgid "NOT RUNNING"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:414 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:453
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:398 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:398
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:17
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:471 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:644
msgid "NaiveProxy" msgid "NaiveProxy"
msgstr "" msgstr ""
@@ -1790,10 +1823,14 @@ msgstr ""
msgid "No custom YAML file was selected." msgid "No custom YAML file was selected."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:550 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:773
msgid "No custom client proxy rules yet." msgid "No custom client proxy rules yet."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:565
msgid "No dashboard panel installed. Please install one first."
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/refresh.htm:21 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/refresh.htm:21
msgid "No new data!" msgid "No new data!"
msgstr "" msgstr ""
@@ -1806,7 +1843,7 @@ msgstr ""
msgid "No subscription items found" msgid "No subscription items found"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:691 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:914
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:32 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:32
msgid "No switchable proxy groups found." msgid "No switchable proxy groups found."
msgstr "" msgstr ""
@@ -1929,7 +1966,7 @@ msgid "Only when Socks5 Auth Mode is password valid, Not mandatory."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:28 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:28
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:504 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:677
msgid "OpenClash GeoSite Database" msgid "OpenClash GeoSite Database"
msgstr "" msgstr ""
@@ -1953,7 +1990,7 @@ msgstr ""
msgid "Package Manager" msgid "Package Manager"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:465 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:638
msgid "Package Name" msgid "Package Name"
msgstr "" msgstr ""
@@ -1970,6 +2007,10 @@ msgstr ""
msgid "Panel" msgid "Panel"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:705
msgid "Panel Version Management"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:540 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:540
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:95 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:95
msgid "Password" msgid "Password"
@@ -2016,7 +2057,7 @@ msgstr ""
msgid "Plugin Opts" msgid "Plugin Opts"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1116 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1357
msgid "Policy" msgid "Policy"
msgstr "" msgstr ""
@@ -2121,22 +2162,22 @@ msgstr ""
msgid "RST Backup" msgid "RST Backup"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:442 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:481
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:7 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:7
msgid "RUNNING" msgid "RUNNING"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:435 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:474
msgid "RUNNING in %s (%s) Mode" msgid "RUNNING in %s (%s) Mode"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:439 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:478
msgid "RUNNING in %s Mode" msgid "RUNNING in %s Mode"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:680 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:903
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:828 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1051
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:878 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1101
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:93 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:93
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:291 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:291
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:253 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:253
@@ -2156,7 +2197,7 @@ msgid "Realm URL"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:860 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:860
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:348 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:474
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17
msgid "Reapply" msgid "Reapply"
msgstr "" msgstr ""
@@ -2206,14 +2247,14 @@ msgstr ""
msgid "Reload YAML" msgid "Reload YAML"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:341 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:467
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:10 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:10
msgid "Reloading YAML..." msgid "Reloading YAML..."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:135 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:135
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:136 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:136
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1117 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1358
msgid "Remarks" msgid "Remarks"
msgstr "" msgstr ""
@@ -2234,14 +2275,14 @@ msgstr ""
msgid "Reserved bytes(optional)" msgid "Reserved bytes(optional)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1080 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1310
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1081 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1311
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:62 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:62
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:63 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:63
msgid "Reset Default Proxies Rules" msgid "Reset Default Proxies Rules"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:376 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:502
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:44 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:44
msgid "Reset Defaults Failed!" msgid "Reset Defaults Failed!"
msgstr "" msgstr ""
@@ -2251,7 +2292,7 @@ msgstr ""
msgid "Reset complete" msgid "Reset complete"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:362 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:488
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:30 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:30
msgid "Reset saved Mihomo proxy-group selections and restore YAML defaults?" msgid "Reset saved Mihomo proxy-group selections and restore YAML defaults?"
msgstr "" msgstr ""
@@ -2260,7 +2301,7 @@ msgstr ""
msgid "Reset to defaults" msgid "Reset to defaults"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:366 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:492
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:34 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:34
msgid "Resetting Defaults..." msgid "Resetting Defaults..."
msgstr "" msgstr ""
@@ -2300,7 +2341,7 @@ msgstr ""
msgid "Running Mode" msgid "Running Mode"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:416 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:455
msgid "SOCKS5" msgid "SOCKS5"
msgstr "" msgstr ""
@@ -2308,20 +2349,20 @@ msgstr ""
msgid "SS URL base64 sstr format not recognized." msgid "SS URL base64 sstr format not recognized."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:647 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:690
msgid "SSR Client" msgid "SSR Client"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:5 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:5
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:468 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:641
msgid "SSR Plus+ Main Program" msgid "SSR Plus+ Main Program"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:651 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:694
msgid "SSR Server" msgid "SSR Server"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1106 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1347
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:195 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:195
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:327 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:327
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:352 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:352
@@ -2346,7 +2387,7 @@ msgstr ""
msgid "Save failed:" msgid "Save failed:"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:763 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:986
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:81 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:81
msgid "Saving..." msgid "Saving..."
msgstr "" msgstr ""
@@ -2355,6 +2396,10 @@ msgstr ""
msgid "Second" msgid "Second"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1327
msgid "Select Dashboard Panel"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1744 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1744
msgid "Self-signed Certificate" msgid "Self-signed Certificate"
msgstr "" msgstr ""
@@ -2394,7 +2439,7 @@ msgstr ""
msgid "Server failsafe auto swith and custom update settings" msgid "Server failsafe auto swith and custom update settings"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:648 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:691
msgid "Servers Nodes" msgid "Servers Nodes"
msgstr "" msgstr ""
@@ -2406,6 +2451,15 @@ msgstr ""
msgid "Session Ticket" msgid "Session Ticket"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:387
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:403
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:721
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:737
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:753
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:769
msgid "Set to Default"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:407 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:407
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:615 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:615
msgid "Shadow-TLS" msgid "Shadow-TLS"
@@ -2415,15 +2469,15 @@ msgstr ""
msgid "Shadow-TLS ChainPoxy type" msgid "Shadow-TLS ChainPoxy type"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:377 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:416
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:392 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:392
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:462 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:462
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:61 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:61
msgid "ShadowSocks" msgid "ShadowSocks"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:389 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:428
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:391 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:430
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:395 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:395
msgid "ShadowSocks-Rust" msgid "ShadowSocks-Rust"
msgstr "" msgstr ""
@@ -2432,7 +2486,7 @@ msgstr ""
msgid "ShadowSocks-rust Version" msgid "ShadowSocks-rust Version"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:644 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:685
msgid "ShadowSocksR Plus+" msgid "ShadowSocksR Plus+"
msgstr "" msgstr ""
@@ -2440,7 +2494,7 @@ msgstr ""
msgid "ShadowSocksR Plus+ Settings" msgid "ShadowSocksR Plus+ Settings"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:418 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:457
msgid "ShadowTLS" msgid "ShadowTLS"
msgstr "" msgstr ""
@@ -2449,7 +2503,7 @@ msgstr ""
msgid "Shadowsocks Password" msgid "Shadowsocks Password"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:385 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:424
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:389 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:389
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:64 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:64
msgid "ShadowsocksR" msgid "ShadowsocksR"
@@ -2536,7 +2590,7 @@ msgstr ""
msgid "Starting subscription update..." msgid "Starting subscription update..."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:653 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:696
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:130 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:130
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:132 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:132
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:134 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:134
@@ -2622,21 +2676,39 @@ msgid ""
"a maximum length of 3 bytes." "a maximum length of 3 bytes."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:373
msgid "Switch Failed"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
msgid "Switch Successful"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:432
msgid "Switch To Meta Version"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:431
msgid "Switch To Official Version"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:104 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:104
msgid "Switch check cycly(second)" msgid "Switch check cycly(second)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:730 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:953
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:71 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:71
msgid "Switch failed." msgid "Switch failed."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:727 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:950
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:68 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:68
msgid "Switched successfully." msgid "Switched successfully."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:720 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:943
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:61 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:61
msgid "Switching..." msgid "Switching..."
msgstr "" msgstr ""
@@ -2690,7 +2762,7 @@ msgstr ""
msgid "TLS is used to verify the leaf certificate name." msgid "TLS is used to verify the leaf certificate name."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:383 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:422
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:404 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:404
msgid "TUIC" msgid "TUIC"
msgstr "" msgstr ""
@@ -2759,8 +2831,8 @@ msgid ""
"GitHub release page." "GitHub release page."
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:816 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1039
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:871 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1094
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89
msgid "This Clash total node is not active. Apply this node first." msgid "This Clash total node is not active. Apply this node first."
msgstr "" msgstr ""
@@ -2814,7 +2886,7 @@ msgstr ""
msgid "Transport Protocol" msgid "Transport Protocol"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:412 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:451
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:461 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:461
msgid "Trojan" msgid "Trojan"
msgstr "" msgstr ""
@@ -2905,6 +2977,10 @@ msgstr ""
msgid "Unsupported package manager" msgid "Unsupported package manager"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:372
msgid "Unzip Error"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:197 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:197
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:332 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:332
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:333 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:333
@@ -2915,6 +2991,10 @@ msgstr ""
msgid "Update All Subscribe Servers" msgid "Update All Subscribe Servers"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:373
msgid "Update Failed"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:511 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:511
msgid "Update Interval(min)" msgid "Update Interval(min)"
msgstr "" msgstr ""
@@ -2923,6 +3003,16 @@ msgstr ""
msgid "Update Mode" msgid "Update Mode"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:362
msgid "Update Successful"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:362
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:425
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:427
msgid "Update Version"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:482 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:482
msgid "Update cycle (Day/Week)" msgid "Update cycle (Day/Week)"
msgstr "" msgstr ""
@@ -2951,8 +3041,8 @@ msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:481 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:654
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:516 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:689
msgid "Upgrade" msgid "Upgrade"
msgstr "" msgstr ""
@@ -3058,12 +3148,12 @@ msgid "Using incorrect encryption mothod may causes service fail to start"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:33 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:33
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:505 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:678
msgid "V2Ray GeoIP Databases" msgid "V2Ray GeoIP Databases"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:37 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:37
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:506 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:679
msgid "V2Ray GeoSite Databases" msgid "V2Ray GeoSite Databases"
msgstr "" msgstr ""
@@ -3175,7 +3265,7 @@ msgstr ""
msgid "XTLS" msgid "XTLS"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:406 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:445
msgid "Xray" msgid "Xray"
msgstr "" msgstr ""
@@ -3188,24 +3278,32 @@ msgid "Xray Noise Packets"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:9 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:9
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:469 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:642
msgid "Xray-core" msgid "Xray-core"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:351 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:477
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:20 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:20
msgid "YAML Reload Failed!" msgid "YAML Reload Failed!"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:348 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:474
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17
msgid "YAML Reloaded!" msgid "YAML Reloaded!"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:711
msgid "Yacd Panel"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:273 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:273
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:743
msgid "Zashboard Panel"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:157 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:157
msgid "adblock_url" msgid "adblock_url"
msgstr "" msgstr ""
+203 -105
View File
@@ -104,16 +104,16 @@ msgstr "一个合法的文件路径。在运行之前,此文件必须不存在
msgid "Accept LAN Access" msgid "Accept LAN Access"
msgstr "接受局域网访问" msgstr "接受局域网访问"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:649 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:692
msgid "Access Control" msgid "Access Control"
msgstr "访问控制" msgstr "访问控制"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1118 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1359
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:302 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:302
msgid "Actions" msgid "Actions"
msgstr "操作" msgstr "操作"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1098 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1339
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:349 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:349
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:357 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:357
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:256 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:256
@@ -134,11 +134,11 @@ msgstr "添加中..."
msgid "Additional Version" msgid "Additional Version"
msgstr "附加版本" msgstr "附加版本"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:452 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:625
msgid "Address" msgid "Address"
msgstr "地址" msgstr "地址"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:650 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:693
msgid "Advanced Settings" msgid "Advanced Settings"
msgstr "高级设置" msgstr "高级设置"
@@ -168,11 +168,11 @@ msgstr "全部"
msgid "All Ports" msgid "All Ports"
msgstr "所有端口(默认)" msgstr "所有端口(默认)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1046 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1267
msgid "All client proxy rules cleared and applied." msgid "All client proxy rules cleared and applied."
msgstr "所有客户端代理规则已清空并生效。" msgstr "所有客户端代理规则已清空并生效。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1046 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1267
msgid "All client proxy rules cleared." msgid "All client proxy rules cleared."
msgstr "所有客户端代理规则已清空。" msgstr "所有客户端代理规则已清空。"
@@ -246,6 +246,10 @@ msgstr "定时模式"
msgid "Are you sure you want to delete subscribe link: %s ?" msgid "Are you sure you want to delete subscribe link: %s ?"
msgstr "确定要删除此订阅链接:%s 吗?" msgstr "确定要删除此订阅链接:%s 吗?"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:381
msgid "Are you sure you want to delete this panel?"
msgstr "确定要删除此面板吗?"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/backup_restore.htm:133 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/backup_restore.htm:133
msgid "Are you sure you want to restore the client to default settings?" msgid "Are you sure you want to restore the client to default settings?"
msgstr "是否真的要恢复客户端默认配置?" msgstr "是否真的要恢复客户端默认配置?"
@@ -378,8 +382,8 @@ msgstr "切换检查重试次数"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:480 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:653
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:515 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:688
msgid "Check Update" msgid "Check Update"
msgstr "检查更新" msgstr "检查更新"
@@ -423,13 +427,13 @@ msgstr ""
msgid "Clang.CN.CIDR" msgid "Clang.CN.CIDR"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:380 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:419
msgid "Clash" msgid "Clash"
msgstr "Clash" msgstr "Clash"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua:112 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua:112
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:893 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1116
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1096 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1337
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:3 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:3
msgid "Clash Panel" msgid "Clash Panel"
msgstr "Clash 面板" msgstr "Clash 面板"
@@ -450,11 +454,11 @@ msgstr "Clash YAML 路径"
msgid "Clash/Mihomo" msgid "Clash/Mihomo"
msgstr "Clash/Mihomo" msgstr "Clash/Mihomo"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1101 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1342
msgid "Clear All Rules" msgid "Clear All Rules"
msgstr "清空所有规则" msgstr "清空所有规则"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1035 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1256
msgid "Clear all custom client proxy rules and restore empty state?" msgid "Clear all custom client proxy rules and restore empty state?"
msgstr "清空所有自定义客户端代理规则,并恢复为空白状态吗?" msgstr "清空所有自定义客户端代理规则,并恢复为空白状态吗?"
@@ -473,23 +477,24 @@ msgstr "点击此处前往管理"
msgid "Click to the page" msgid "Click to the page"
msgstr "点击前往" msgstr "点击前往"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1115 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1356
msgid "Client" msgid "Client"
msgstr "客户端" msgstr "客户端"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1001 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1222
msgid "Client rules exported to CSV file." msgid "Client rules exported to CSV file."
msgstr "客户端规则已导出为 CSV 文件。" msgstr "客户端规则已导出为 CSV 文件。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1025 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1246
msgid "Client rules imported from CSV file. Click Save to apply." msgid "Client rules imported from CSV file. Click Save to apply."
msgstr "客户端规则已从 CSV 文件导入,请点击保存生效。" msgstr "客户端规则已从 CSV 文件导入,请点击保存生效。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1110 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1351
msgid "Client supports manual IP or IP/CIDR input." msgid "Client supports manual IP or IP/CIDR input."
msgstr "客户端支持手动输入 IP 或 IP/CIDR。" msgstr "客户端支持手动输入 IP 或 IP/CIDR。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1107 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1328
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1348
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:251 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:251
msgid "Close" msgid "Close"
msgstr "关闭" msgstr "关闭"
@@ -504,17 +509,21 @@ msgstr ""
msgid "Cloudflare DNS (1.1.1.1)" msgid "Cloudflare DNS (1.1.1.1)"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:477 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:650
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:512 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:685
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:716
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:732
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:748
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:764
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:21 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:21
msgid "Collecting data..." msgid "Collecting data..."
msgstr "正在收集数据中..." msgstr "正在收集数据中..."
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:464 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:637
msgid "Component" msgid "Component"
msgstr "组件" msgstr "组件"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:652 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:695
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:5 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:5
msgid "Component Update" msgid "Component Update"
msgstr "组件升级" msgstr "组件升级"
@@ -557,12 +566,12 @@ msgstr "使用订阅转换模板 URL 将订阅转换为 Clash/Mihomo YAML。"
msgid "Copy SSR to clipboard successfully." msgid "Copy SSR to clipboard successfully."
msgstr "成功复制 SSR 网址到剪贴板。" msgstr "成功复制 SSR 网址到剪贴板。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:443 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:616
msgid "Core Components" msgid "Core Components"
msgstr "核心组件" msgstr "核心组件"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:24 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:24
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:503 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:676
msgid "Country MMDB" msgid "Country MMDB"
msgstr "Country MMDB" msgstr "Country MMDB"
@@ -590,8 +599,8 @@ msgstr "当前版本"
msgid "Custom" msgid "Custom"
msgstr "自定义" msgstr "自定义"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1073 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1303
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1074 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1304
msgid "Custom Client Proxies Rules" msgid "Custom Client Proxies Rules"
msgstr "自定义客户端代理规则" msgstr "自定义客户端代理规则"
@@ -628,11 +637,11 @@ msgstr "自定义端口"
msgid "Custom YAML imported successfully: %s" msgid "Custom YAML imported successfully: %s"
msgstr "自定义 YAML 导入成功:%s" msgstr "自定义 YAML 导入成功:%s"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:784 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1007
msgid "Custom client proxy rules saved and applied." msgid "Custom client proxy rules saved and applied."
msgstr "自定义客户端代理规则已保存并生效。" msgstr "自定义客户端代理规则已保存并生效。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:784 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1007
msgid "Custom client proxy rules saved." msgid "Custom client proxy rules saved."
msgstr "自定义客户端代理规则已保存。" msgstr "自定义客户端代理规则已保存。"
@@ -663,7 +672,15 @@ msgstr ""
msgid "DTLS 1.2" msgid "DTLS 1.2"
msgstr "DTLS 1.2 数据包" msgstr "DTLS 1.2 数据包"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:499 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:727
msgid "Dashboard Panel"
msgstr "Dashboard 面板"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:699
msgid "Dashboard Update"
msgstr "Dashboard 更新"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:672
msgid "Database" msgid "Database"
msgstr "数据库" msgstr "数据库"
@@ -679,6 +696,10 @@ msgstr "解密"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1566 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1566
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1578 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1578
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1592 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1592
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:591
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:398
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:401
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:437
msgid "Default" msgid "Default"
msgstr "默认" msgstr "默认"
@@ -709,17 +730,17 @@ msgstr ""
"默认值:16。填负数时,如 -1,不使用 Mux 模块承载 UDP 流量。将使用代理协议原本" "默认值:16。填负数时,如 -1,不使用 Mux 模块承载 UDP 流量。将使用代理协议原本"
"的 UDP 传输方式。" "的 UDP 传输方式。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:373 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:499
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41
msgid "Defaults Cleared!" msgid "Defaults Cleared!"
msgstr "已清空默认状态!" msgstr "已清空默认状态!"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:373 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:499
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:41
msgid "Defaults Restored!" msgid "Defaults Restored!"
msgstr "默认值已恢复!" msgstr "默认值已恢复!"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1110 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1351
msgid "" msgid ""
"Define per-client Mihomo proxy targets. Rules are injected as SRC-IP-CIDR " "Define per-client Mihomo proxy targets. Rules are injected as SRC-IP-CIDR "
"entries before the original Clash rules." "entries before the original Clash rules."
@@ -731,7 +752,11 @@ msgstr ""
msgid "Delay (ms)" msgid "Delay (ms)"
msgstr "延迟(ms)" msgstr "延迟(ms)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:665 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:888
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:718
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:734
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:750
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:766
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:319 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/server_table.htm:319
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:196 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:196
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:328 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:328
@@ -809,7 +834,7 @@ msgstr "国内 DNS 服务器"
msgid "Downlink Capacity(Default:Mbps)" msgid "Downlink Capacity(Default:Mbps)"
msgstr "下行链路容量(默认:Mbps)" msgstr "下行链路容量(默认:Mbps)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:451 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:624
msgid "Download Source" msgid "Download Source"
msgstr "下载源" msgstr "下载源"
@@ -817,6 +842,10 @@ msgstr "下载源"
msgid "Download failed" msgid "Download failed"
msgstr "下载失败" msgstr "下载失败"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:355
msgid "Downloading File..."
msgstr "文件下载中..."
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:971 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:971
msgid "Dual-stack Listening Socket" msgid "Dual-stack Listening Socket"
msgstr "双栈 Socket 监听" msgstr "双栈 Socket 监听"
@@ -849,7 +878,7 @@ msgstr "编辑服务器配置"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:54 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:54
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server.lua:101 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server.lua:101
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:676 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:676
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1114 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1355
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:184 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:184
msgid "Enable" msgid "Enable"
msgstr "启用" msgstr "启用"
@@ -1023,7 +1052,7 @@ msgstr "示例:"
msgid "Expecting: %s" msgid "Expecting: %s"
msgstr "应为:%s" msgstr "应为:%s"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1102 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1343
msgid "Export Client Rules" msgid "Export Client Rules"
msgstr "批量导出客户端规则" msgstr "批量导出客户端规则"
@@ -1035,7 +1064,7 @@ msgstr "解压失败"
msgid "FORCE BRUTAL" msgid "FORCE BRUTAL"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1048 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1269
msgid "Failed to clear all client proxy rules." msgid "Failed to clear all client proxy rules."
msgstr "清空所有客户端代理规则失败。" msgstr "清空所有客户端代理规则失败。"
@@ -1064,7 +1093,7 @@ msgstr "获取组件信息失败。"
msgid "Failed to query geo database information." msgid "Failed to query geo database information."
msgstr "获取 Geo 数据库信息失败。" msgstr "获取 Geo 数据库信息失败。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:786 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1009
msgid "Failed to save custom client proxy rules." msgid "Failed to save custom client proxy rules."
msgstr "保存自定义客户端代理规则失败。" msgstr "保存自定义客户端代理规则失败。"
@@ -1175,16 +1204,16 @@ msgstr "Gecko 包大小(最大)"
msgid "Gecko Packet Size (min)" msgid "Gecko Packet Size (min)"
msgstr "Gecko 包大小(最小)" msgstr "Gecko 包大小(最小)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:491 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:664
msgid "Geo Database Update" msgid "Geo Database Update"
msgstr "Geo 数据库更新" msgstr "Geo 数据库更新"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:500 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:673
msgid "Geo Resource" msgid "Geo Resource"
msgstr "Geo 资源" msgstr "Geo 资源"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:14 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/component.lua:14
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:455 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:628
msgid "GitHub Direct" msgid "GitHub Direct"
msgstr "GitHub 直连" msgstr "GitHub 直连"
@@ -1319,7 +1348,7 @@ msgstr "HTTPUpgrade 主机名"
msgid "Httpupgrade Path" msgid "Httpupgrade Path"
msgstr "HTTPUpgrade 路径" msgstr "HTTPUpgrade 路径"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:420 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:459
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:467 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:467
msgid "Hysteria2" msgid "Hysteria2"
msgstr "" msgstr ""
@@ -1380,7 +1409,7 @@ msgstr "如果你使用自签证书,请选择"
msgid "Import" msgid "Import"
msgstr "导入配置信息" msgstr "导入配置信息"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1103 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1344
msgid "Import Client Rules" msgid "Import Client Rules"
msgstr "批量导入客户端规则" msgstr "批量导入客户端规则"
@@ -1583,8 +1612,8 @@ msgstr "Linux 内核 TUN 虚拟网卡需要系统支持和 root 权限。"
msgid "Listen only on the given interface or, if unspecified, on all" msgid "Listen only on the given interface or, if unspecified, on all"
msgstr "仅监听指定的接口,未指定则监听全部。" msgstr "仅监听指定的接口,未指定则监听全部。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:794 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1017
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:847 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1070
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:84 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:84
msgid "Loading..." msgid "Loading..."
msgstr "加载中..." msgstr "加载中..."
@@ -1602,7 +1631,7 @@ msgstr "本机服务端"
msgid "Local addresses" msgid "Local addresses"
msgstr "本地地址" msgstr "本地地址"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:666 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:697
msgid "Log" msgid "Log"
msgstr "日志" msgstr "日志"
@@ -1659,18 +1688,22 @@ msgstr "最大分片数"
msgid "Maximum packet size the socks5 server can receive from external" msgid "Maximum packet size the socks5 server can receive from external"
msgstr "socks5 服务器可以从外部接收的最大数据包大小(单位:字节)" msgstr "socks5 服务器可以从外部接收的最大数据包大小(单位:字节)"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:376 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:759
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:379 msgid "Metacubexd Panel"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:382 msgstr "Metacubexd 面板"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:388
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:404 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:415
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:418
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:421
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:427
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:443
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:13 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:13
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:470 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:643
msgid "Mihomo" msgid "Mihomo"
msgstr "Mihomo" msgstr "Mihomo"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1087 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1318
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1088 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1319
msgid "Mihomo Pannel" msgid "Mihomo Pannel"
msgstr "Mihomo 面板" msgstr "Mihomo 面板"
@@ -1750,10 +1783,10 @@ msgstr ""
msgid "NOT RUNNING" msgid "NOT RUNNING"
msgstr "未运行" msgstr "未运行"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:414 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:453
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:398 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:398
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:17
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:471 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:644
msgid "NaiveProxy" msgid "NaiveProxy"
msgstr "" msgstr ""
@@ -1805,10 +1838,14 @@ msgstr "没有可用的核心(Shadowsocks, Mihomo 或 Xray)可以导入此
msgid "No custom YAML file was selected." msgid "No custom YAML file was selected."
msgstr "没有选择自定义 YAML 文件。" msgstr "没有选择自定义 YAML 文件。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:550 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:773
msgid "No custom client proxy rules yet." msgid "No custom client proxy rules yet."
msgstr "暂无自定义客户端代理规则。" msgstr "暂无自定义客户端代理规则。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:565
msgid "No dashboard panel installed. Please install one first."
msgstr "未安装 Dashboard 面板,请先安装至少一个面板。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/refresh.htm:21 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/refresh.htm:21
msgid "No new data!" msgid "No new data!"
msgstr "你已经是最新数据,无需更新!" msgstr "你已经是最新数据,无需更新!"
@@ -1821,7 +1858,7 @@ msgstr "没有上传证书。"
msgid "No subscription items found" msgid "No subscription items found"
msgstr "没有找到任何订阅项" msgstr "没有找到任何订阅项"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:691 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:914
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:32 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:32
msgid "No switchable proxy groups found." msgid "No switchable proxy groups found."
msgstr "未找到可切换的代理组。" msgstr "未找到可切换的代理组。"
@@ -1945,7 +1982,7 @@ msgid "Only when Socks5 Auth Mode is password valid, Not mandatory."
msgstr "仅当 Socks5 认证方式为 Password 时有效,非必填。" msgstr "仅当 Socks5 认证方式为 Password 时有效,非必填。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:28 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:28
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:504 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:677
msgid "OpenClash GeoSite Database" msgid "OpenClash GeoSite Database"
msgstr "OpenClash GeoSite 数据库" msgstr "OpenClash GeoSite 数据库"
@@ -1969,7 +2006,7 @@ msgstr ""
msgid "Package Manager" msgid "Package Manager"
msgstr "包管理器" msgstr "包管理器"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:465 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:638
msgid "Package Name" msgid "Package Name"
msgstr "组件包名称" msgstr "组件包名称"
@@ -1986,6 +2023,10 @@ msgstr "数据包 | Rand 长度"
msgid "Panel" msgid "Panel"
msgstr "面板" msgstr "面板"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:705
msgid "Panel Version Management"
msgstr "面板版本管理"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:540 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:540
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:95 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:95
msgid "Password" msgid "Password"
@@ -2032,7 +2073,7 @@ msgstr "请填写 Clash 订阅 URL 或本地 YAML 路径中的至少一项。"
msgid "Plugin Opts" msgid "Plugin Opts"
msgstr "插件参数" msgstr "插件参数"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1116 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1357
msgid "Policy" msgid "Policy"
msgstr "策略" msgstr "策略"
@@ -2137,22 +2178,22 @@ msgstr ""
msgid "RST Backup" msgid "RST Backup"
msgstr "恢复备份" msgstr "恢复备份"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:442 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:481
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:7 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/status.htm:7
msgid "RUNNING" msgid "RUNNING"
msgstr "运行中" msgstr "运行中"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:435 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:474
msgid "RUNNING in %s (%s) Mode" msgid "RUNNING in %s (%s) Mode"
msgstr "运行中,当前为 %s(%s)模式" msgstr "运行中,当前为 %s(%s)模式"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:439 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:478
msgid "RUNNING in %s Mode" msgid "RUNNING in %s Mode"
msgstr "运行中,当前为 %s 模式" msgstr "运行中,当前为 %s 模式"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:680 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:903
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:828 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1051
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:878 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1101
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:93 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:93
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:291 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:291
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:253 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe.htm:253
@@ -2172,7 +2213,7 @@ msgid "Realm URL"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:860 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:860
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:348 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:474
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17
msgid "Reapply" msgid "Reapply"
msgstr "重新应用" msgstr "重新应用"
@@ -2222,14 +2263,14 @@ msgstr "发布包名"
msgid "Reload YAML" msgid "Reload YAML"
msgstr "重载 YAML" msgstr "重载 YAML"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:341 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:467
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:10 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:10
msgid "Reloading YAML..." msgid "Reloading YAML..."
msgstr "正在重载 YAML..." msgstr "正在重载 YAML..."
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:135 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:135
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:136 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:136
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1117 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1358
msgid "Remarks" msgid "Remarks"
msgstr "备注" msgstr "备注"
@@ -2250,14 +2291,14 @@ msgstr "获取 TLS 证书链指纹需要 OpenSSL 支持。"
msgid "Reserved bytes(optional)" msgid "Reserved bytes(optional)"
msgstr "保留字节(可选)" msgstr "保留字节(可选)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1080 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1310
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1081 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1311
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:62 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:62
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:63 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:63
msgid "Reset Default Proxies Rules" msgid "Reset Default Proxies Rules"
msgstr "重置代理分组规则" msgstr "重置代理分组规则"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:376 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:502
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:44 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:44
msgid "Reset Defaults Failed!" msgid "Reset Defaults Failed!"
msgstr "重置默认值失败!" msgstr "重置默认值失败!"
@@ -2267,7 +2308,7 @@ msgstr "重置默认值失败!"
msgid "Reset complete" msgid "Reset complete"
msgstr "重置完成" msgstr "重置完成"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:362 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:488
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:30 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:30
msgid "Reset saved Mihomo proxy-group selections and restore YAML defaults?" msgid "Reset saved Mihomo proxy-group selections and restore YAML defaults?"
msgstr "要清空已保存的 Mihomo 分组选择,并恢复为 YAML 默认值吗?" msgstr "要清空已保存的 Mihomo 分组选择,并恢复为 YAML 默认值吗?"
@@ -2276,7 +2317,7 @@ msgstr "要清空已保存的 Mihomo 分组选择,并恢复为 YAML 默认值
msgid "Reset to defaults" msgid "Reset to defaults"
msgstr "恢复出厂设置" msgstr "恢复出厂设置"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:366 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:492
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:34 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:34
msgid "Resetting Defaults..." msgid "Resetting Defaults..."
msgstr "正在重置默认值..." msgstr "正在重置默认值..."
@@ -2316,7 +2357,7 @@ msgstr "运行中"
msgid "Running Mode" msgid "Running Mode"
msgstr "运行模式" msgstr "运行模式"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:416 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:455
msgid "SOCKS5" msgid "SOCKS5"
msgstr "SOCKS5" msgstr "SOCKS5"
@@ -2324,20 +2365,20 @@ msgstr "SOCKS5"
msgid "SS URL base64 sstr format not recognized." msgid "SS URL base64 sstr format not recognized."
msgstr "无法识别 SS URL 的 Base64 格式。" msgstr "无法识别 SS URL 的 Base64 格式。"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:647 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:690
msgid "SSR Client" msgid "SSR Client"
msgstr "客户端" msgstr "客户端"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:5 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:5
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:468 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:641
msgid "SSR Plus+ Main Program" msgid "SSR Plus+ Main Program"
msgstr "SSR Plus+ 主程序" msgstr "SSR Plus+ 主程序"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:651 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:694
msgid "SSR Server" msgid "SSR Server"
msgstr "服务端" msgstr "服务端"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1106 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1347
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:195 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:195
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:327 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:327
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:352 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:352
@@ -2363,7 +2404,7 @@ msgstr "保存当前订阅设置"
msgid "Save failed:" msgid "Save failed:"
msgstr "保存失败:" msgstr "保存失败:"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:763 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:986
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:81 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:81
msgid "Saving..." msgid "Saving..."
msgstr "保存中..." msgstr "保存中..."
@@ -2372,6 +2413,10 @@ msgstr "保存中..."
msgid "Second" msgid "Second"
msgstr "周期" msgstr "周期"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1327
msgid "Select Dashboard Panel"
msgstr "选择 Dashboard 面板"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1744 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:1744
msgid "Self-signed Certificate" msgid "Self-signed Certificate"
msgstr "自签证书" msgstr "自签证书"
@@ -2411,7 +2456,7 @@ msgstr "服务端类型"
msgid "Server failsafe auto swith and custom update settings" msgid "Server failsafe auto swith and custom update settings"
msgstr "服务器节点故障自动切换/广告屏蔽/中国大陆 IP 段数据库更新设置" msgstr "服务器节点故障自动切换/广告屏蔽/中国大陆 IP 段数据库更新设置"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:648 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:691
msgid "Servers Nodes" msgid "Servers Nodes"
msgstr "服务器节点" msgstr "服务器节点"
@@ -2423,6 +2468,15 @@ msgstr "服务器节点订阅与管理"
msgid "Session Ticket" msgid "Session Ticket"
msgstr "会话凭据" msgstr "会话凭据"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:387
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:403
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:721
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:737
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:753
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:769
msgid "Set to Default"
msgstr "设为默认"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:407 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:407
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:615 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:615
msgid "Shadow-TLS" msgid "Shadow-TLS"
@@ -2432,15 +2486,15 @@ msgstr ""
msgid "Shadow-TLS ChainPoxy type" msgid "Shadow-TLS ChainPoxy type"
msgstr "代理链类型" msgstr "代理链类型"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:377 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:416
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:392 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:392
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:462 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:462
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:61 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:61
msgid "ShadowSocks" msgid "ShadowSocks"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:389 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:428
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:391 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:430
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:395 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:395
msgid "ShadowSocks-Rust" msgid "ShadowSocks-Rust"
msgstr "" msgstr ""
@@ -2449,7 +2503,7 @@ msgstr ""
msgid "ShadowSocks-rust Version" msgid "ShadowSocks-rust Version"
msgstr "ShadowSocks-rust 版本" msgstr "ShadowSocks-rust 版本"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:644 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:685
msgid "ShadowSocksR Plus+" msgid "ShadowSocksR Plus+"
msgstr "" msgstr ""
@@ -2457,7 +2511,7 @@ msgstr ""
msgid "ShadowSocksR Plus+ Settings" msgid "ShadowSocksR Plus+ Settings"
msgstr "ShadowSocksR Plus+ 设置" msgstr "ShadowSocksR Plus+ 设置"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:418 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:457
msgid "ShadowTLS" msgid "ShadowTLS"
msgstr "ShadowTLS" msgstr "ShadowTLS"
@@ -2466,7 +2520,7 @@ msgstr "ShadowTLS"
msgid "Shadowsocks Password" msgid "Shadowsocks Password"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:385 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:424
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:389 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:389
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:64 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/server-config.lua:64
msgid "ShadowsocksR" msgid "ShadowsocksR"
@@ -2553,7 +2607,7 @@ msgstr "开始探测"
msgid "Starting subscription update..." msgid "Starting subscription update..."
msgstr "正在开始更新订阅..." msgstr "正在开始更新订阅..."
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:653 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:696
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:130 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:130
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:132 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:132
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:134 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:134
@@ -2641,21 +2695,39 @@ msgstr ""
"支持以“,”分隔的十进制数字,或者经过 Base64 编码的字符串,其最大长度为 3 个字" "支持以“,”分隔的十进制数字,或者经过 Base64 编码的字符串,其最大长度为 3 个字"
"节。" "节。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:373
msgid "Switch Failed"
msgstr "切换失败"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
msgid "Switch Successful"
msgstr "切换成功"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:432
msgid "Switch To Meta Version"
msgstr "切换至 Meta 版本"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:365
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:431
msgid "Switch To Official Version"
msgstr "切换至官方版本"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:104 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:104
msgid "Switch check cycly(second)" msgid "Switch check cycly(second)"
msgstr "自动切换检查周期(秒)" msgstr "自动切换检查周期(秒)"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:730 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:953
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:71 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:71
msgid "Switch failed." msgid "Switch failed."
msgstr "切换失败。" msgstr "切换失败。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:727 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:950
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:68 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:68
msgid "Switched successfully." msgid "Switched successfully."
msgstr "切换成功。" msgstr "切换成功。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:720 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:943
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:61 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:61
msgid "Switching..." msgid "Switching..."
msgstr "正在切换..." msgstr "正在切换..."
@@ -2709,7 +2781,7 @@ msgstr "TLS 主机名"
msgid "TLS is used to verify the leaf certificate name." msgid "TLS is used to verify the leaf certificate name."
msgstr "TLS 用于验证 leaf 证书的 name。" msgstr "TLS 用于验证 leaf 证书的 name。"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:383 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:422
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:404 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:404
msgid "TUIC" msgid "TUIC"
msgstr "TUIC" msgstr "TUIC"
@@ -2788,8 +2860,8 @@ msgstr ""
"在线升级只跟踪 Mihomo 最新稳定版,并根据当前架构从官方 GitHub 发布页下载匹配" "在线升级只跟踪 Mihomo 最新稳定版,并根据当前架构从官方 GitHub 发布页下载匹配"
"的 Linux 压缩包。" "的 Linux 压缩包。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:816 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1039
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:871 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:1094
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89
msgid "This Clash total node is not active. Apply this node first." msgid "This Clash total node is not active. Apply this node first."
msgstr "该 Clash 总节点当前未启用,请先应用该节点。" msgstr "该 Clash 总节点当前未启用,请先应用该节点。"
@@ -2843,7 +2915,7 @@ msgstr "传输协议"
msgid "Transport Protocol" msgid "Transport Protocol"
msgstr "传输协议" msgstr "传输协议"
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:412 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:451
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:461 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:461
msgid "Trojan" msgid "Trojan"
msgstr "Trojan" msgstr "Trojan"
@@ -2934,6 +3006,10 @@ msgstr "不支持的组件"
msgid "Unsupported package manager" msgid "Unsupported package manager"
msgstr "不支持的包管理器" msgstr "不支持的包管理器"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:372
msgid "Unzip Error"
msgstr "解压失败"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:197 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:197
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:332 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:332
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:333 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/subscribe_actions_footer.htm:333
@@ -2944,6 +3020,10 @@ msgstr "更新"
msgid "Update All Subscribe Servers" msgid "Update All Subscribe Servers"
msgstr "更新所有订阅节点" msgstr "更新所有订阅节点"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:373
msgid "Update Failed"
msgstr "更新失败"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:511 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:511
msgid "Update Interval(min)" msgid "Update Interval(min)"
msgstr "更新间隔(分钟)" msgstr "更新间隔(分钟)"
@@ -2952,6 +3032,16 @@ msgstr "更新间隔(分钟)"
msgid "Update Mode" msgid "Update Mode"
msgstr "更新模式" msgstr "更新模式"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:362
msgid "Update Successful"
msgstr "更新成功"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:362
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:425
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:427
msgid "Update Version"
msgstr "更新版本"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:482 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua:482
msgid "Update cycle (Day/Week)" msgid "Update cycle (Day/Week)"
msgstr "更新周期(天/周)" msgstr "更新周期(天/周)"
@@ -2980,8 +3070,8 @@ msgstr "更新中..."
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:173
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:326
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:481 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:654
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:516 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:689
msgid "Upgrade" msgid "Upgrade"
msgstr "升级" msgstr "升级"
@@ -3089,12 +3179,12 @@ msgid "Using incorrect encryption mothod may causes service fail to start"
msgstr "输入不正确的参数组合可能会导致服务无法启动" msgstr "输入不正确的参数组合可能会导致服务无法启动"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:33 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:33
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:505 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:678
msgid "V2Ray GeoIP Databases" msgid "V2Ray GeoIP Databases"
msgstr "V2Ray GeoIP 数据库" msgstr "V2Ray GeoIP 数据库"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:37 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:37
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:506 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:679
msgid "V2Ray GeoSite Databases" msgid "V2Ray GeoSite Databases"
msgstr "V2Ray GeoSite 数据库" msgstr "V2Ray GeoSite 数据库"
@@ -3206,7 +3296,7 @@ msgstr "XHTTP 路径"
msgid "XTLS" msgid "XTLS"
msgstr "" msgstr ""
#: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:406 #: applications/luci-app-ssr-plus/luasrc/controller/shadowsocksr.lua:445
msgid "Xray" msgid "Xray"
msgstr "Xray" msgstr "Xray"
@@ -3219,24 +3309,32 @@ msgid "Xray Noise Packets"
msgstr "Xray 噪声数据包" msgstr "Xray 噪声数据包"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:9 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:9
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:469 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:642
msgid "Xray-core" msgid "Xray-core"
msgstr "Xray-core" msgstr "Xray-core"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:351 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:477
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:20 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:20
msgid "YAML Reload Failed!" msgid "YAML Reload Failed!"
msgstr "YAML 重载失败!" msgstr "YAML 重载失败!"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:348 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:474
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel_button.htm:17
msgid "YAML Reloaded!" msgid "YAML Reloaded!"
msgstr "YAML 已重载!" msgstr "YAML 已重载!"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:711
msgid "Yacd Panel"
msgstr "Yacd 面板"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:273 #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:273
msgid "Yes" msgid "Yes"
msgstr "是" msgstr "是"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:743
msgid "Zashboard Panel"
msgstr "Zashboard 面板"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:157 #: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:157
msgid "adblock_url" msgid "adblock_url"
msgstr "广告屏蔽更新 URL" msgstr "广告屏蔽更新 URL"
+38 -4
View File
@@ -611,7 +611,9 @@ prepare_clash_runtime_config() {
local enable_fake_ip local enable_fake_ip
local socks5_auth local socks5_auth
local socks5_user local socks5_user
local socks5_pass local default_dashboard
local yacd_type
local dashboard_type
cache_file="$(get_clash_cache_file "$sid")" cache_file="$(get_clash_cache_file "$sid")"
workdir="$(get_clash_workdir "$sid")" workdir="$(get_clash_workdir "$sid")"
@@ -635,11 +637,41 @@ prepare_clash_runtime_config() {
cp -f "$cache_file" "$raw_file" cp -f "$cache_file" "$raw_file"
filter_clash_yaml_proxies "$raw_file" filter_clash_yaml_proxies "$raw_file"
default_dashboard=$(uci_get_by_type global default_dashboard "zashboard")
yacd_type=$(uci_get_by_type global yacd_type "Meta")
dashboard_type=$(uci_get_by_type global dashboard_type "Official")
case "$default_dashboard" in
dashboard)
if [ "$dashboard_type" = "Official" ]; then
EXTERNAL_UI_URL="https://codeload.github.com/ayanamist/clash-dashboard/zip/refs/heads/gh-pages"
else
EXTERNAL_UI_URL="https://codeload.github.com/MetaCubeX/Razord-meta/zip/refs/heads/gh-pages"
fi
;;
yacd)
if [ "$yacd_type" = "Official" ]; then
EXTERNAL_UI_URL="https://codeload.github.com/haishanh/yacd/zip/refs/heads/gh-pages"
else
EXTERNAL_UI_URL="https://codeload.github.com/MetaCubeX/Yacd-meta/zip/refs/heads/gh-pages"
fi
;;
metacubexd)
EXTERNAL_UI_URL="https://codeload.github.com/MetaCubeX/metacubexd/zip/refs/heads/gh-pages"
;;
zashboard|*)
EXTERNAL_UI_URL="https://codeload.github.com/Zephyruso/zashboard/zip/refs/heads/gh-pages-cdn-fonts"
default_dashboard="zashboard"
;;
esac
cat >"$overlay_file" <<-EOF cat >"$overlay_file" <<-EOF
redir-port: $local_port redir-port: $local_port
tproxy-port: $local_port tproxy-port: $local_port
external-controller: 127.0.0.1:$CLASH_API_PORT external-controller: 0.0.0.0:$CLASH_API_PORT
secret: $clash_secret secret: ""
external-ui: /usr/share/shadowsocksr/ui
external-ui-name: $default_dashboard
external-ui-url: $EXTERNAL_UI_URL
allow-lan: true allow-lan: true
bind-address: 0.0.0.0 bind-address: 0.0.0.0
routing-mark: $CLASH_OUTBOUND_MARK routing-mark: $CLASH_OUTBOUND_MARK
@@ -2476,7 +2508,7 @@ Start_Run() {
return 1 return 1
} }
clash_workdir="$(get_clash_workdir "$GLOBAL_SERVER")" clash_workdir="$(get_clash_workdir "$GLOBAL_SERVER")"
ln_start_bin "$mihomo_bin" ssr-retcp -d "$clash_workdir" -f "$clash_workdir/config.yaml" SAFE_PATHS="/usr/share/shadowsocksr/" ln_start_bin "$mihomo_bin" ssr-retcp -d "$clash_workdir" -f "$clash_workdir/config.yaml"
echolog "Main node: $($mihomo_bin -v 2>&1 | head -1 | awk '{print $1, $3}') (Clash) Started!" echolog "Main node: $($mihomo_bin -v 2>&1 | head -1 | awk '{print $1, $3}') (Clash) Started!"
;; ;;
socks5) socks5)
@@ -2502,11 +2534,13 @@ load_config() {
else else
GLOBAL_SERVER=$switch_server GLOBAL_SERVER=$switch_server
fi fi
# 全局 socks5 注释掉后配合 advanced.lua 中取消注释 -- Server Selection 选项
if [ "$(uci_get_by_type socks5_proxy enabled 0)" = "1" ]; then if [ "$(uci_get_by_type socks5_proxy enabled 0)" = "1" ]; then
uci -q set "$NAME.@socks5_proxy[0].server=same" uci -q set "$NAME.@socks5_proxy[0].server=same"
else else
uci -q set "$NAME.@socks5_proxy[0].server=nil" uci -q set "$NAME.@socks5_proxy[0].server=nil"
fi fi
# 全局 3proxy 注释掉后配合 advanced.lua 中取消注释 -- Server Selection 选项
if [ "$(uci_get_by_type http_proxy enabled 0)" = "1" ]; then if [ "$(uci_get_by_type http_proxy enabled 0)" = "1" ]; then
uci -q set "$NAME.@http_proxy[0].server=same" uci -q set "$NAME.@http_proxy[0].server=same"
else else
@@ -92,6 +92,18 @@ if [ -s "/etc/config/shadowsocksr" ]; then
uci -q set shadowsocksr.@global[0].component_mirror='direct' uci -q set shadowsocksr.@global[0].component_mirror='direct'
fi fi
if ! uci -q get shadowsocksr.@global[0].yacd_type > /dev/null; then
uci -q set shadowsocksr.@global[0].yacd_type='Meta'
fi
if ! uci -q get shadowsocksr.@global[0].dashboard_type > /dev/null; then
uci -q set shadowsocksr.@global[0].dashboard_type='Official'
fi
if ! uci -q get shadowsocksr.@global[0].default_dashboard > /dev/null; then
uci -q set shadowsocksr.@global[0].default_dashboard='zashboard'
fi
if ! uci -q get shadowsocksr.@global[0].filter_aaaa > /dev/null; then if ! uci -q get shadowsocksr.@global[0].filter_aaaa > /dev/null; then
legacy_filter_aaaa="$(uci -q get shadowsocksr.@global[0].mosdns_ipv6)" legacy_filter_aaaa="$(uci -q get shadowsocksr.@global[0].mosdns_ipv6)"
uci -q set shadowsocksr.@global[0].filter_aaaa="${legacy_filter_aaaa:-1}" uci -q set shadowsocksr.@global[0].filter_aaaa="${legacy_filter_aaaa:-1}"
@@ -15,6 +15,9 @@ config global
option switch_try_count '3' option switch_try_count '3'
option default_node_local_port '1234' option default_node_local_port '1234'
option component_mirror 'direct' option component_mirror 'direct'
option yacd_type 'Meta'
option dashboard_type 'Official'
option default_dashboard 'zashboard'
option gfwlist_url 'https://fastly.jsdelivr.net/gh/YW5vbnltb3Vz/domain-list-community@release/gfwlist.txt' option gfwlist_url 'https://fastly.jsdelivr.net/gh/YW5vbnltb3Vz/domain-list-community@release/gfwlist.txt'
option chnroute_url 'https://ispip.clang.cn/all_cn.txt' option chnroute_url 'https://ispip.clang.cn/all_cn.txt'
option adblock_url 'https://anti-ad.net/anti-ad-for-dnsmasq.conf' option adblock_url 'https://anti-ad.net/anti-ad-for-dnsmasq.conf'
Whitespace-only changes.
@@ -0,0 +1 @@
board.zash.run.place
@@ -0,0 +1,30 @@
# Third-party notices
## Earth textures
The Earth day, night, bump, roughness, and cloud textures in
`src/assets/images/earth/` are resized/combined derivatives of the Solar System
Scope planet textures. `background.jpg` preserves the downloaded 2K stars image.
- Source: https://www.solarsystemscope.com/textures/
- 2K stars source: https://www.solarsystemscope.com/textures/download/2k_stars.jpg
- Creator: Solar System Scope
- License: Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
The node-material treatment is adapted from the Three.js WebGPU TSL Earth
example (Three.js, MIT License):
https://threejs.org/examples/webgpu_tsl_earth.html
## DB-IP City Lite
The application can download `dbip-city-lite@1.0.16` on explicit user request.
The database is not bundled with this application. It is downloaded into and
queried from the user's browser storage.
- Source: https://www.npmjs.com/package/dbip-city-lite/v/1.0.16
- Data provider: DB-IP.com, https://db-ip.com/db/lite.php
- License: Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
IP geolocation data provided by DB-IP.com.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
.earth-globe-card--expanded[data-v-dede773a]{z-index:60;border-radius:0;flex-direction:column;display:flex;position:fixed;inset:0}@media (width>=769px){.home-page.sidebar-expanded{left:16rem}.home-page.sidebar-collapsed{left:4.5rem}}
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./earth-renderer-Dh_x8rMR.js","./palette-DPyxzM37.js"])))=>i.map(i=>d[i]);
import{$ as e,A as t,B as n,D as r,E as i,F as a,G as o,H as s,I as c,J as l,K as u,L as ee,M as te,O as d,P as f,Q as p,R as m,S as ne,T as re,U as ie,V as h,W as g,X as _,Y as ae,Z as oe,a as se,at as v,b as ce,c as y,ct as b,d as le,et as ue,f as de,g as x,h as S,i as fe,it as C,j as pe,k as me,l as w,lt as T,m as E,n as he,nt as D,o as O,ot as k,p as ge,q as _e,r as ve,rt as ye,s as be,st as xe,t as Se,u as Ce,w as we,x as Te,y as Ee,z as A}from"./index-DC9m9n9t.js";import{t as De}from"./palette-DPyxzM37.js";var Oe=T(Ce(),1),j=e=>{try{return Oe.parse(e).toNormalizedString()}catch{return null}},M=e=>{let t=e.trim();if(!t)return null;let n=j(t);if(n)return n;if(t.startsWith(`[`)){let e=t.indexOf(`]`);if(e>1)return j(t.slice(1,e))}let r=t.lastIndexOf(`:`);return r>0&&/^\d+$/.test(t.slice(r+1))?j(t.slice(0,r)):null},N=e=>{let t=w(),n=[];for(let r of e){let e=M(t.destination(r)),i=t.hostname(r).trim().replace(/\.$/,``);!e&&!i||n.push({destinationIP:e,resolutionHost:i.toLowerCase(),upload:Math.max(0,r.uploadSpeed),download:Math.max(0,r.downloadSpeed),host:i||e||``,downloaded:Math.max(0,t.download(r)),direct:t.isDirect(r)})}return n},P=({latitude:e,longitude:t})=>`${e.toFixed(4)},${t.toFixed(4)}`,F=(...e)=>e.flat().sort((e,t)=>t.downloaded-e.downloaded).slice(0,5),I=e=>e!==null&&e.latitude!==null&&e.longitude!==null&&Number.isFinite(e.latitude)&&Number.isFinite(e.longitude)&&e.latitude>=-90&&e.latitude<=90&&e.longitude>=-180&&e.longitude<=180,L=(e,t,n)=>{let r,i;if(n&&I(n))r=n.latitude,i=n.longitude;else if(t&&I(t))r=t.latitude,i=t.longitude;else return null;return{ip:e,latitude:r,longitude:i,city:n?.city.trim()||t?.city||``,country:t?.country||n?.country.trim()||``}},ke=async(e,t,n,r,i,a)=>{let o=j(t);if(!o)return{routes:[],origin:null};let s=N(e),c=[...new Set(s.filter(e=>!e.destinationIP).map(e=>e.resolutionHost))],l=a&&c.length>0?await a(c):{};for(let e of s)e.destinationIP||=j(l[e.resolutionHost]??``);let u=s.filter(e=>e.destinationIP!==null),ee=new Set([o]);for(let e of u)ee.add(e.destinationIP);let te=await r([...ee],n),d=L(o,te[o],i);if(!d)return{routes:[],origin:null};let f=new Map;for(let e of u){let t=te[e.destinationIP];if(!t)continue;let n=[{...d,role:`origin`},{...t,role:`destination`}],r=n.map(e=>`${e.role}:${P(e)}`).join(`>`),i=f.get(r);i?(i.direct&&=e.direct,i.connections+=1,i.upload+=e.upload,i.download+=e.download,i.topHosts=F(i.topHosts,e.host?[{host:e.host,downloaded:e.downloaded}]:[])):f.set(r,{key:r,path:n,direct:e.direct,connections:1,upload:e.upload,download:e.download,topHosts:e.host?[{host:e.host,downloaded:e.downloaded}]:[]})}return{routes:[...f.values()],origin:d}},Ae=617e5;u(),e(),v(),f();var je={class:`flex items-center justify-between`},Me={class:`text-base-content/60 text-xs font-semibold tracking-wider uppercase`},Ne={class:`flex items-center gap-1`},Pe=[`aria-label`,`title`,`aria-pressed`],Fe=[`aria-label`,`title`,`aria-pressed`],Ie=[`aria-label`,`title`,`aria-pressed`],Le={class:`pointer-events-none absolute top-2 right-2 left-2 flex flex-wrap gap-1.5 text-xs`},Re={class:`bg-base-100/75 pointer-events-auto flex h-8 items-center gap-1.5 rounded-lg px-2 shadow backdrop-blur-md`},ze={class:`text-base-content/60`},Be={class:`font-mono`},Ve=[`aria-label`],He=[`aria-label`],Ue={class:`pointer-events-none absolute right-2 bottom-2 left-2 flex items-end justify-between gap-2`},We=[`aria-label`],Ge={class:`text-base-content/70 whitespace-nowrap`},Ke={key:0,class:`pointer-events-none absolute inset-0 flex items-center justify-center p-4`},qe={class:`bg-base-100/90 max-w-sm rounded-xl p-4 text-center text-sm shadow-xl backdrop-blur-md`},Je={class:`text-error`},Ye={key:1,class:`pointer-events-none absolute inset-0 flex items-center justify-center`},Xe={class:`bg-base-100/85 flex items-center gap-2 rounded-xl px-4 py-3 text-sm shadow-xl backdrop-blur-md`},Ze={key:2,class:`pointer-events-none absolute inset-0 flex items-center justify-center p-4`},Qe={class:`bg-base-100/90 pointer-events-auto max-w-md rounded-xl p-4 text-sm shadow-xl backdrop-blur-md`},$e={class:`font-semibold`},et={class:`text-base-content/70 mt-1.5 leading-5`},tt={key:0,class:`text-warning mt-1.5 text-xs`},nt={class:`mt-3 flex items-center gap-2`},rt={class:`link text-xs`,href:`https://db-ip.com/db/lite.php`,target:`_blank`,rel:`noopener noreferrer`},it={key:3,class:`pointer-events-none absolute inset-0 flex items-center justify-center p-4`},at={class:`bg-base-100/90 pointer-events-auto w-80 max-w-full rounded-xl p-4 text-sm shadow-xl backdrop-blur-md`},ot={class:`flex items-center justify-between gap-3`},st={class:`font-semibold`},ct={class:`text-base-content/60 text-xs`},lt=[`value`],ut={key:4,class:`pointer-events-none absolute inset-0 flex items-center justify-center p-4`},dt={class:`bg-base-100/90 pointer-events-auto max-w-sm rounded-xl p-4 text-center text-sm shadow-xl backdrop-blur-md`},ft={class:`text-error`},pt={key:5,class:`pointer-events-none absolute inset-0 flex items-center justify-center p-4`},mt={class:Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more