diff --git a/airconnect/Makefile b/airconnect/Makefile index 15bb9ce6..6a70902f 100644 --- a/airconnect/Makefile +++ b/airconnect/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=airconnect -PKG_VERSION:=1.11.1 +PKG_VERSION:=1.11.2 PKG_RELEASE=1 PKG_SOURCE:=AirConnect-$(PKG_VERSION).zip diff --git a/dae/Makefile b/dae/Makefile index 3294cece..0188e0ca 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae PKG_VERSION:=2026.08.05 -PKG_RELEASE:=32 +PKG_RELEASE:=33 -PKG_SOURCE:=dae-src-2026.08.05-d2174ac8b937.tar.gz +PKG_SOURCE:=dae-src-2026.08.05-479f43645df5.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/daed/Makefile b/daed/Makefile index 17c89ace..fa2dd429 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed PKG_VERSION:=2026.08.05 -PKG_RELEASE:=43 +PKG_RELEASE:=44 -PKG_SOURCE:=daed-src-2026.08.05-4cab0ccb366a.tar.gz +PKG_SOURCE:=daed-src-2026.08.05-c389546b8c1f.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip @@ -142,6 +142,9 @@ define Package/daed/install $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) $(CURDIR)/files/daed.init $(1)/etc/init.d/daed + + $(INSTALL_DIR) $(1)/lib/upgrade/keep.d + $(INSTALL_DATA) $(CURDIR)/files/daed.keep $(1)/lib/upgrade/keep.d/daed endef $(eval $(call GoBinPackage,daed)) diff --git a/daed/files/daed.keep b/daed/files/daed.keep new file mode 100644 index 00000000..77fa4257 --- /dev/null +++ b/daed/files/daed.keep @@ -0,0 +1 @@ +/etc/daed/wing.db-wal diff --git a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch index e25462cf..985a832e 100644 --- a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch +++ b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch @@ -1,6 +1,4 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: kenzok8 -Date: Tue, 21 Jul 2026 02:53:42 +0800 Subject: [PATCH] daed: strictly reconcile subscription updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/daed/patches/0008-daed-open-sqlite-database-in-WAL-mode.patch b/daed/patches/0008-daed-open-sqlite-database-in-WAL-mode.patch new file mode 100644 index 00000000..fe69ac2b --- /dev/null +++ b/daed/patches/0008-daed-open-sqlite-database-in-WAL-mode.patch @@ -0,0 +1,73 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] daed: open sqlite database in WAL mode +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +订阅自动更新写库时,WebUI 登录查询被锁 5 秒后报 database is locked。 + +开 WAL 并把 busy_timeout 提到 30 秒,读不再被写事务阻塞;WAL 附带的两个文件跟主库一样收紧到 0640。 +--- + db/db.go | 17 ++++++++++++----- + pkg/sqlite/sqlite_mipsarch.go | 4 +++- + pkg/sqlite/sqlite_others.go | 4 +++- + 3 files changed, 18 insertions(+), 7 deletions(-) + +diff --git a/db/db.go b/db/db.go +index a79022b..6145443 100644 +--- a/db/db.go ++++ b/db/db.go +@@ -50,13 +50,20 @@ func InitDatabase(configDir string) (err error) { + ); err != nil { + return err + } +- if fi, err := os.Stat(path); err != nil { +- return err +- } else if fi.Mode()&0037 > 0 { +- // Too open, chmod it to 0640. +- if err = os.Chmod(path, 0640); err != nil { ++ for _, p := range []string{path, path + "-wal", path + "-shm"} { ++ fi, err := os.Stat(p) ++ if os.IsNotExist(err) { ++ continue ++ } ++ if err != nil { + return err + } ++ if fi.Mode()&0037 > 0 { ++ // Too open, chmod it to 0640. ++ if err = os.Chmod(p, 0640); err != nil { ++ return err ++ } ++ } + } + + return nil +diff --git a/pkg/sqlite/sqlite_mipsarch.go b/pkg/sqlite/sqlite_mipsarch.go +index 7b8c1b4..8c275e5 100644 +--- a/pkg/sqlite/sqlite_mipsarch.go ++++ b/pkg/sqlite/sqlite_mipsarch.go +@@ -10,6 +10,8 @@ import ( + "gorm.io/gorm" + ) + ++const pragmas = "?_journal_mode=WAL&_busy_timeout=30000&_synchronous=FULL" ++ + func Open(dsn string) gorm.Dialector { +- return sqlite.Open(dsn) ++ return sqlite.Open(dsn + pragmas) + } +diff --git a/pkg/sqlite/sqlite_others.go b/pkg/sqlite/sqlite_others.go +index 4598651..83d8b80 100644 +--- a/pkg/sqlite/sqlite_others.go ++++ b/pkg/sqlite/sqlite_others.go +@@ -7,6 +7,8 @@ import ( + "gorm.io/gorm" + ) + ++const pragmas = "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(FULL)" ++ + func Open(dsn string) gorm.Dialector { +- return sqlite.Open(dsn) ++ return sqlite.Open(dsn + pragmas) + } diff --git a/daed/patches/0009-daed-reload-dae-outside-the-database-transaction.patch b/daed/patches/0009-daed-reload-dae-outside-the-database-transaction.patch new file mode 100644 index 00000000..2572e35f --- /dev/null +++ b/daed/patches/0009-daed-reload-dae-outside-the-database-transaction.patch @@ -0,0 +1,166 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] daed: reload dae outside the database transaction +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +配置生效前先提交读事务,reload 完再用短事务写运行状态。 + +原来整个 reload 都在事务里,最长可占着数据库锁一百多秒;写状态时按 ID 重查仍存在的分组,避免把 reload 期间被删的分组重新插回去。 +--- + graphql/service/config/mutation_utils.go | 77 ++++++++++++++++-------- + 1 file changed, 53 insertions(+), 24 deletions(-) + +diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go +index feb94d3..59f0ebd 100644 +--- a/graphql/service/config/mutation_utils.go ++++ b/graphql/service/config/mutation_utils.go +@@ -270,28 +270,12 @@ func restartAfterReloadTimeout() { + }() + } + +-func runTransaction(ctx context.Context, noLoad bool) (n int32, err error) { +- tx := db.BeginTx(ctx) +- if tx.Error != nil { +- return 0, tx.Error +- } +- n, err = runLocked(tx, noLoad) +- if err != nil { +- tx.Rollback() +- return 0, err +- } +- if err = tx.Commit().Error; err != nil { +- return 0, err +- } +- return n, nil +-} +- + func Run(ctx context.Context, noLoad bool) (n int32, err error) { + if ok := runLock.TryLock(); !ok { + return 0, fmt.Errorf("the last request didn't complete; make a cup of tea and take a break") + } + defer runLock.Unlock() +- return runTransaction(ctx, noLoad) ++ return runLocked(ctx, noLoad) + } + + func ApplyIfRunning(ctx context.Context) error { +@@ -307,11 +291,11 @@ func ApplyIfRunning(ctx context.Context) error { + if err != nil || !modified { + return err + } +- _, err = runTransaction(ctx, false) ++ _, err = runLocked(ctx, false) + return err + } + +-func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) { ++func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { + //// Dry run. + if noLoad { + ch := make(chan error) +@@ -336,18 +320,38 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) { + } + + // Running -> false ++ tx := db.BeginTx(ctx) ++ if tx.Error != nil { ++ return 0, tx.Error ++ } + var sys db.System +- if err = d.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil { ++ if err = tx.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil { ++ tx.Rollback() + return 0, err + } +- if err = d.Model(&sys).Updates(map[string]interface{}{ ++ if err = tx.Model(&sys).Updates(map[string]interface{}{ + "running": false, + }).Error; err != nil { ++ tx.Rollback() ++ return 0, err ++ } ++ if err = tx.Commit().Error; err != nil { + return 0, err + } + return 1, nil + } + ++ d := db.BeginTx(ctx) ++ if d.Error != nil { ++ return 0, d.Error ++ } ++ readTx := d ++ defer func() { ++ if readTx != nil { ++ readTx.Rollback() ++ } ++ }() ++ + //// Run selected global+dns+routing. + /// Get them from database and parse them to daeConfig. + var mConfig db.Config +@@ -513,6 +517,11 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) { + c.Node = append(c.Node, daeConfig.KeyableString(fmt.Sprintf("%v:%v", node.uniqueName, node.dbNode.Link))) + } + ++ if err = readTx.Commit().Error; err != nil { ++ return 0, err ++ } ++ readTx = nil ++ + /// Reload with current config. + chReloadCallback := make(chan error) + reloadMsg := &dae.ReloadMessage{ +@@ -536,20 +545,37 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) { + } + + // Save running status ++ tx := db.BeginTx(context.WithoutCancel(ctx)) ++ if tx.Error != nil { ++ return 0, tx.Error ++ } ++ defer func() { ++ if err != nil { ++ tx.Rollback() ++ } ++ }() + var sys db.System +- if err = d.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil { ++ if err = tx.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil { + return 0, err + } + var gvs uint + var gids []string ++ groupIds := make([]uint, 0, len(groups)) + for _, g := range groups { + gvs += g.Version + gids = append(gids, fmt.Sprintf("%x", g.ID)) ++ groupIds = append(groupIds, g.ID) ++ } ++ liveGroups := make([]db.Group, 0, len(groupIds)) ++ if len(groupIds) > 0 { ++ if err = tx.Where("id IN ?", groupIds).Find(&liveGroups).Error; err != nil { ++ return 0, err ++ } + } + sort.Slice(gids, func(i, j int) bool { + return gids[i] < gids[j] + }) +- if err = d.Model(&sys).Updates(map[string]interface{}{ ++ if err = tx.Model(&sys).Updates(map[string]interface{}{ + "running": true, + "running_config_id": mConfig.ID, + "running_config_version": mConfig.Version, +@@ -562,7 +588,10 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) { + }).Error; err != nil { + return 0, err + } +- if err = d.Model(&sys).Association("RunningGroups").Replace(groups); err != nil { ++ if err = tx.Model(&sys).Association("RunningGroups").Replace(liveGroups); err != nil { ++ return 0, err ++ } ++ if err = tx.Commit().Error; err != nil { + return 0, err + } + diff --git a/daed/patches/0010-daed-serialize-scheduled-subscription-updates.patch b/daed/patches/0010-daed-serialize-scheduled-subscription-updates.patch new file mode 100644 index 00000000..8f6159aa --- /dev/null +++ b/daed/patches/0010-daed-serialize-scheduled-subscription-updates.patch @@ -0,0 +1,68 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] daed: serialize scheduled subscription updates +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +订阅更新的入口读也纳入同一把锁,整轮更新串行,排到才开始计时。 + +多个订阅定时任务撞在同一分钟时会互相锁死。 +--- + .../service/subscription/mutation_utils.go | 29 +++++++++++++------ + 1 file changed, 20 insertions(+), 9 deletions(-) + +diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go +index c6bc3e1..e2aec32 100644 +--- a/graphql/service/subscription/mutation_utils.go ++++ b/graphql/service/subscription/mutation_utils.go +@@ -225,12 +225,8 @@ func AddUpdateScheduler(ctx context.Context, id uint) { + tag = *sub.Tag + } + logrus.Info("Subscription " + tag + " update task enabled, with exp " + sub.CronExp) +- _, err := s.Cron(sub.CronExp).Do(func() { +- ctx, cancel := context.WithTimeout(context.Background(), scheduledUpdateTimeout) +- defer cancel() +- if _, err := UpdateById(ctx, sub.ID); err != nil { +- logrus.Error(err) +- } ++ _, err := s.Cron(sub.CronExp).SingletonMode().Do(func() { ++ runScheduledUpdate(sub.ID) + }) + if err != nil { + logrus.Errorf("Failed to schedule subscription %d update: invalid cron expression '%s': %v", sub.ID, sub.CronExp, err) +@@ -364,9 +360,6 @@ func reconcileSubscriptionNodes(tx *gorm.DB, subId uint, links []string) error { + } + + func updateSubscriptionTx(ctx context.Context, m *db.Subscription, subId uint, links []string) (err error) { +- subscriptionUpdateMu.Lock() +- defer subscriptionUpdateMu.Unlock() +- + tx := db.BeginTx(ctx) + if tx.Error != nil { + return tx.Error +@@ -404,7 +397,25 @@ func updateSubscriptionTx(ctx context.Context, m *db.Subscription, subId uint, l + return tx.Commit().Error + } + ++func runScheduledUpdate(subId uint) { ++ subscriptionUpdateMu.Lock() ++ defer subscriptionUpdateMu.Unlock() ++ ++ ctx, cancel := context.WithTimeout(context.Background(), scheduledUpdateTimeout) ++ defer cancel() ++ if _, err := updateByIdLocked(ctx, subId); err != nil { ++ logrus.Error(err) ++ } ++} ++ + func UpdateById(ctx context.Context, subId uint) (sub *db.Subscription, err error) { ++ subscriptionUpdateMu.Lock() ++ defer subscriptionUpdateMu.Unlock() ++ ++ return updateByIdLocked(ctx, subId) ++} ++ ++func updateByIdLocked(ctx context.Context, subId uint) (sub *db.Subscription, err error) { + var m db.Subscription + if err = db.DB(ctx).Where(&db.Subscription{ID: subId}).First(&m).Error; err != nil { + return nil, err diff --git a/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch b/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch new file mode 100644 index 00000000..fe7d04ce --- /dev/null +++ b/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch @@ -0,0 +1,44 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] daed: apply subscription cron changes without restart +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +改 cron 后先提交事务再重建调度器。 + +原来在事务里就去重建,调度器另开连接读到的还是旧表达式,必须重启 daed 才生效。 +--- + graphql/service/subscription/mutation_utils.go | 12 +++++++++--- + 1 file changed, 9 insertions(+), 3 deletions(-) + +diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go +index e2aec32..4f6bcfc 100644 +--- a/graphql/service/subscription/mutation_utils.go ++++ b/graphql/service/subscription/mutation_utils.go +@@ -561,10 +561,12 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable + } + + tx := db.BeginTx(ctx) ++ if tx.Error != nil { ++ return nil, tx.Error ++ } ++ committed := false + defer func() { +- if err == nil { +- tx.Commit() +- } else { ++ if !committed { + tx.Rollback() + } + }() +@@ -583,6 +585,10 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable + }).Error; err != nil { + return nil, err + } ++ if err = tx.Commit().Error; err != nil { ++ return nil, err ++ } ++ committed = true + + // Update scheduler + RemoveUpdateScheduler(id) diff --git a/ddns-go/Makefile b/ddns-go/Makefile index ea647cb1..cef77b30 100644 --- a/ddns-go/Makefile +++ b/ddns-go/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ddns-go -PKG_VERSION:=6.17.4 -PKG_RELEASE:=21 +PKG_VERSION:=6.17.5 +PKG_RELEASE:=22 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/jeessy2/ddns-go/tar.gz/v$(PKG_VERSION)? diff --git a/filebrowser/Makefile b/filebrowser/Makefile index 3c641e5e..4eacb52b 100644 --- a/filebrowser/Makefile +++ b/filebrowser/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser -PKG_VERSION:=1.5.0-stable +PKG_VERSION:=1.5.1-stable PKG_RELEASE=1 ifeq ($(ARCH),aarch64) diff --git a/gecoosac/Makefile b/gecoosac/Makefile index ea6c119b..bbd3d933 100644 --- a/gecoosac/Makefile +++ b/gecoosac/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gecoosac PKG_VERSION:=2.2.20251015 -PKG_RELEASE:=16 +PKG_RELEASE:=17 PKG_MAINTAINER:=Roc Lai PKG_LICENSE:=AGPL-3.0-only @@ -44,6 +44,7 @@ define Package/$(PKG_NAME) TITLE:=gecoosac server (version $(PKG_VERSION)) URL:=http://www.cnrouter.com/ DEPENDS:=@(aarch64||arm||i386||mips||mipsel||x86_64) +openssl-util + REPLACES:=luci-app-gecoosac endef define Package/$(PKG_NAME)/conffiles diff --git a/gecoosac/files/etc/init.d/gecoosac b/gecoosac/files/etc/init.d/gecoosac index 35392876..718a2cfa 100644 --- a/gecoosac/files/etc/init.d/gecoosac +++ b/gecoosac/files/etc/init.d/gecoosac @@ -19,7 +19,18 @@ DEFAULT_LANG=zh CERT_TIMEOUT=60 init_conf() { - config_load "gecoosac" + local section_type + + unset CONFIG_config_TYPE + config_load "gecoosac" || { + logger -t gecoosac "unable to read Gecoos configuration" + return 1 + } + config_get section_type config TYPE + [ "$section_type" = "gecoosac" ] || { + logger -t gecoosac "missing Gecoos configuration section" + return 1 + } config_get "db_dir" "config" "db_dir" "$DEFAULT_DB_DIR" config_get "upload_dir" "config" "upload_dir" "$DEFAULT_UPLOAD_DIR" config_get "enabled" "config" "enabled" "0" @@ -76,14 +87,22 @@ normalize_path() { } is_safe_upload_dir() { - local path + local path physical path="$(normalize_path "$1")" || return 1 + physical="$(managed_dir_path upload "$path")" || return 1 case "$path" in /etc/gecoosac|/etc/gecoosac/*) return 1 ;; esac + case "$physical" in + /etc/gecoosac|/etc/gecoosac/*) return 1 ;; + esac case "$path" in + */gecoosac/upload) ;; + *) return 1 ;; + esac + case "$physical" in */gecoosac/upload) return 0 ;; esac @@ -145,7 +164,7 @@ is_secure_upload_dir() { } is_safe_db_dir() { - local path upload_root + local path upload_root physical physical_upload_root path="$(normalize_path "$1")" || return 1 upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1 @@ -155,12 +174,15 @@ is_safe_db_dir() { *) return 1 ;; esac is_path_in_dir "$path" "$upload_root" && return 1 + physical="$(managed_dir_path db "$path")" || return 1 + physical_upload_root="$(managed_dir_path upload "$upload_root")" || return 1 + is_path_in_dir "$physical" "$physical_upload_root" && return 1 return 0 } is_safe_pid_dir() { - local path upload_root + local path upload_root physical physical_upload_root path="$(normalize_path "$1")" || return 1 upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1 @@ -170,6 +192,9 @@ is_safe_pid_dir() { *) return 1 ;; esac is_path_in_dir "$path" "$upload_root" && return 1 + physical="$(managed_dir_path pid "$path")" || return 1 + physical_upload_root="$(managed_dir_path upload "$upload_root")" || return 1 + is_path_in_dir "$physical" "$physical_upload_root" && return 1 return 0 } @@ -314,6 +339,7 @@ cert_matches_key() { generate_default_cert() { local cert_host cert_ip cert_cn cert_san tmp_crt tmp_key + local backup_dir old_crt old_key restore_ok path if ! command -v openssl >/dev/null 2>&1; then logger -t gecoosac "openssl is required to generate the default HTTPS certificate" @@ -338,8 +364,11 @@ generate_default_cert() { tmp_crt="${DEFAULT_CRT_FILE}.tmp" tmp_key="${DEFAULT_KEY_FILE}.tmp" - rm -f "$tmp_crt" "$tmp_key" - run_with_timeout "$CERT_TIMEOUT" openssl req \ + if ! rm -f "$tmp_crt" "$tmp_key"; then + logger -t gecoosac "unable to prepare default HTTPS certificate files" + return 1 + fi + if ! run_with_timeout "$CERT_TIMEOUT" openssl req \ -x509 \ -nodes \ -newkey ec \ @@ -353,17 +382,79 @@ generate_default_cert() { -addext "basicConstraints=critical,CA:FALSE" \ -addext "keyUsage=digitalSignature" \ -addext "extendedKeyUsage=serverAuth" \ - -addext "subjectAltName=$cert_san" /dev/null 2>&1; then - return 1 +service_running_state() { + local data instances instance state + + data="$(ubus call service list '{"name":"gecoosac"}' 2>/dev/null)" || return 2 + json_load "$data" 2>/dev/null || return 2 + if ! json_select gecoosac 2>/dev/null; then + json_cleanup + return 0 fi + if ! json_select instances 2>/dev/null; then + json_cleanup + return 0 + fi + json_get_keys instances + for instance in $instances; do + if ! json_select "$instance" 2>/dev/null; then + json_cleanup + return 2 + fi + json_get_var state running + json_select .. + case "$state" in + 1|true) + json_cleanup + return 1 + ;; + 0|false) ;; + *) + json_cleanup + return 2 + ;; + esac + done + json_cleanup + return 0 +} + +wait_service_stopped() { + local i state i=0 - while service_running gecoosac; do - [ "$i" -ge 5 ] && return 1 - sleep 1 - i=$((i + 1)) + while :; do + service_running_state + state="$?" + case "$state" in + 0) return 0 ;; + 1) + [ "$i" -ge 5 ] && return 1 + sleep 1 + i=$((i + 1)) + ;; + *) return 1 ;; + esac done - return 0 } reload_service() { @@ -585,7 +725,7 @@ reload_service() { logger -t gecoosac "unable to stop the existing service for reload" return 1 fi - start + rc_procd start_prepared_service } service_triggers() { diff --git a/gecoosac/files/etc/uci-defaults/gecoosac b/gecoosac/files/etc/uci-defaults/gecoosac index 795cf0f6..48e33ac7 100644 --- a/gecoosac/files/etc/uci-defaults/gecoosac +++ b/gecoosac/files/etc/uci-defaults/gecoosac @@ -12,7 +12,7 @@ CONFIG_COMPAT=2 ensure_section() { uci -q get gecoosac.config >/dev/null && return 0 - uci -q set gecoosac.config=gecoosac + uci -q set gecoosac.config=gecoosac || return 1 changed=1 } @@ -21,7 +21,7 @@ set_default() { local value="$2" uci -q get "gecoosac.config.${option}" >/dev/null && return 0 - uci -q set "gecoosac.config.${option}=${value}" + uci -q set "gecoosac.config.${option}=${value}" || return 1 changed=1 } @@ -64,15 +64,48 @@ is_abs_path() { esac } +managed_dir_path() { + local role="$1" path anchor + + case "$role" in + upload|db|pid|file) ;; + *) return 1 ;; + esac + path="$(normalize_path "$2")" || return 1 + case "$path" in + /var/run|/var/run/*) + anchor="$(readlink -f /var/run 2>/dev/null)" || return 1 + [ "$anchor" = "/tmp/run" ] || return 1 + printf '%s%s\n' "$anchor" "${path#/var/run}" + ;; + /var|/var/*) + anchor="$(readlink -f /var 2>/dev/null)" || return 1 + case "$anchor" in + /var|/tmp) printf '%s%s\n' "$anchor" "${path#/var}" ;; + *) return 1 ;; + esac + ;; + *) printf '%s\n' "$path" ;; + esac +} + is_safe_upload_dir() { - local path + local path physical path="$(normalize_path "$1")" || return 1 + physical="$(managed_dir_path upload "$path")" || return 1 case "$path" in /etc/gecoosac|/etc/gecoosac/*) return 1 ;; esac + case "$physical" in + /etc/gecoosac|/etc/gecoosac/*) return 1 ;; + esac case "$path" in + */gecoosac/upload) ;; + *) return 1 ;; + esac + case "$physical" in */gecoosac/upload) return 0 ;; esac @@ -91,7 +124,7 @@ is_path_in_dir() { } is_safe_db_dir() { - local path upload_root + local path upload_root physical physical_upload_root path="$(normalize_path "$1")" || return 1 upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1 @@ -101,12 +134,15 @@ is_safe_db_dir() { *) return 1 ;; esac is_path_in_dir "$path" "$upload_root" && return 1 + physical="$(managed_dir_path db "$path")" || return 1 + physical_upload_root="$(managed_dir_path upload "$upload_root")" || return 1 + is_path_in_dir "$physical" "$physical_upload_root" && return 1 return 0 } is_safe_pid_dir() { - local path upload_root + local path upload_root physical physical_upload_root path="$(normalize_path "$1")" || return 1 upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1 @@ -116,6 +152,9 @@ is_safe_pid_dir() { *) return 1 ;; esac is_path_in_dir "$path" "$upload_root" && return 1 + physical="$(managed_dir_path pid "$path")" || return 1 + physical_upload_root="$(managed_dir_path upload "$upload_root")" || return 1 + is_path_in_dir "$physical" "$physical_upload_root" && return 1 return 0 } @@ -153,7 +192,7 @@ normalize_dir_option() { fi [ "$value" = "$normalized" ] && return 0 - uci -q set "gecoosac.config.${option}=${normalized}" + uci -q set "gecoosac.config.${option}=${normalized}" || return 1 changed=1 } @@ -238,26 +277,26 @@ migrate_config() { changed=1 } -ensure_section +ensure_section || exit 1 migrate_config || exit 1 normalize_upload_dir || exit 1 -normalize_dir_option db_dir "$DEFAULT_DB_DIR" is_safe_db_dir -normalize_dir_option piddir "$DEFAULT_PID_DIR" is_safe_pid_dir -set_default enabled 0 -set_default port 60650 -set_default isonlyoneprot 1 -set_default m_port 8080 -set_default https 0 -set_default crt_file "$DEFAULT_CRT_FILE" -set_default key_file "$DEFAULT_KEY_FILE" -set_default upload_dir "$DEFAULT_UPLOAD_DIR" -set_default db_dir "$DEFAULT_DB_DIR" -set_default piddir "$DEFAULT_PID_DIR" -set_default lang zh -set_default debug 0 +normalize_dir_option db_dir "$DEFAULT_DB_DIR" is_safe_db_dir || exit 1 +normalize_dir_option piddir "$DEFAULT_PID_DIR" is_safe_pid_dir || exit 1 +set_default enabled 0 || exit 1 +set_default port 60650 || exit 1 +set_default isonlyoneprot 1 || exit 1 +set_default m_port 8080 || exit 1 +set_default https 0 || exit 1 +set_default crt_file "$DEFAULT_CRT_FILE" || exit 1 +set_default key_file "$DEFAULT_KEY_FILE" || exit 1 +set_default upload_dir "$DEFAULT_UPLOAD_DIR" || exit 1 +set_default db_dir "$DEFAULT_DB_DIR" || exit 1 +set_default piddir "$DEFAULT_PID_DIR" || exit 1 +set_default lang zh || exit 1 +set_default debug 0 || exit 1 # Preserve the previous package behavior for upgraded configs that never had showtip. -set_default showtip 0 -set_default log 0 +set_default showtip 0 || exit 1 +set_default log 0 || exit 1 if [ "$changed" = "1" ]; then uci -q commit gecoosac || exit 1 diff --git a/luci-app-gecoosac/Makefile b/luci-app-gecoosac/Makefile index 24b3b6c4..1c0bd0ca 100644 --- a/luci-app-gecoosac/Makefile +++ b/luci-app-gecoosac/Makefile @@ -7,11 +7,11 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-gecoosac PKG_VERSION:=2.2 -PKG_RELEASE:=16 +PKG_RELEASE:=17 LUCI_TITLE:=LuCI Support for gecoosac LUCI_DEPENDS:=+luci-base +gecoosac -LUCI_EXTRA_DEPENDS:=gecoosac (>=2.2.20251015-r5) +LUCI_EXTRA_DEPENDS:=gecoosac (>=2.2.20251015-r4) LUCI_PKGARCH:=all PKG_LICENSE:=AGPL-3.0-only diff --git a/luci-app-gecoosac/htdocs/luci-static/resources/view/gecoosac.js b/luci-app-gecoosac/htdocs/luci-static/resources/view/gecoosac.js index b8f82e04..d6e7eaf5 100644 --- a/luci-app-gecoosac/htdocs/luci-static/resources/view/gecoosac.js +++ b/luci-app-gecoosac/htdocs/luci-static/resources/view/gecoosac.js @@ -30,6 +30,33 @@ const callClearUpload = rpc.declare({ expect: { '': {} } }); +const callPathPolicy = rpc.declare({ + object: 'luci.gecoosac', + method: 'path_policy', + expect: { '': {} }, + reject: true +}); + +const RPC_ERROR_MESSAGES = { + 'Unable to query service status': _('Unable to query service status'), + 'Invalid service status response': _('Invalid service status response'), + 'Expecting an absolute path': _('Expecting an absolute path'), + 'Only Gecoos upload directories can be cleared': _('Only Gecoos upload directories can be cleared'), + 'Upload directory or its parent is not root-owned and private': _('Upload directory or its parent is not root-owned and private'), + 'Unable to resolve upload directory': _('Unable to resolve upload directory'), + 'Unable to read Gecoos configuration': _('Unable to read Gecoos configuration'), + 'Gecoos configuration changed during cleanup': _('Gecoos configuration changed during cleanup'), + 'Unable to prepare upload directory cleanup': _('Unable to prepare upload directory cleanup'), + 'Unable to validate upload cleanup stage': _('Unable to validate upload cleanup stage'), + 'Upload cleanup stage contains a configured protected path': _('Upload cleanup stage contains a configured protected path'), + 'Unable to validate configured paths': _('Unable to validate configured paths'), + 'Upload directory contains a configured protected path': _('Upload directory contains a configured protected path'), + 'Unable to recreate upload directory': _('Unable to recreate upload directory'), + 'Unable to remove upload directory contents': _('Unable to remove upload directory contents'), + 'Unable to remove upload cleanup stage': _('Unable to remove upload cleanup stage'), + 'Unable to resolve managed path policy': _('Unable to resolve managed path policy') +}; + function validPort(value, defaultValue) { const port = Number(value || defaultValue); return Number.isInteger(port) && port >= 1 && port <= 65535 ? String(port) : defaultValue; @@ -106,10 +133,43 @@ function normalizePath(value) { return '/' + parts.join('/'); } -function validUploadDir(value) { +function managedPath(value, policy) { const path = normalizePath(value); - return path !== null && path.endsWith('/gecoosac/upload') && !pathInDir(path, CONFIG_BACKUP_DIR); + if (path === null) + return null; + + if (path === '/var/run' || path.indexOf('/var/run/') === 0) { + if (!policy || policy.ok !== true || policy.var_run_root !== '/tmp/run') + return null; + + return policy.var_run_root + path.substring('/var/run'.length); + } + + if (path === '/var' || path.indexOf('/var/') === 0) { + if (!policy || policy.ok !== true || (policy.var_root !== '/var' && policy.var_root !== '/tmp')) + return null; + + return policy.var_root + path.substring('/var'.length); + } + + return path; +} + +function usesManagedPath(value) { + const path = normalizePath(value); + + return path === '/var' || path === '/var/run' || + (path !== null && (path.indexOf('/var/') === 0 || path.indexOf('/var/run/') === 0)); +} + +function validUploadDir(value, policy) { + const path = normalizePath(value); + const physical = managedPath(value, policy); + + return path !== null && physical !== null && path.endsWith('/gecoosac/upload') && + physical.endsWith('/gecoosac/upload') && !pathInDir(path, CONFIG_BACKUP_DIR) && + !pathInDir(physical, CONFIG_BACKUP_DIR); } function validPathPrefix(value, prefixes) { @@ -132,12 +192,22 @@ function pathInDir(value, dir) { return path !== null && root !== null && root !== '/' && (path === root || path.indexOf(root + '/') === 0); } -function validDbDir(value, uploadDir) { - return validPathPrefix(value, DB_DIR_PREFIXES) && !pathInDir(value, uploadDir || DEFAULT_UPLOAD_DIR); +function validDbDir(value, uploadDir, policy) { + const upload = uploadDir || DEFAULT_UPLOAD_DIR; + const physical = managedPath(value, policy); + const physicalUpload = managedPath(upload, policy); + + return validPathPrefix(value, DB_DIR_PREFIXES) && physical !== null && physicalUpload !== null && + !pathInDir(value, upload) && !pathInDir(physical, physicalUpload); } -function validPidDir(value, uploadDir) { - return validPathPrefix(value, PID_DIR_PREFIXES) && !pathInDir(value, uploadDir || DEFAULT_UPLOAD_DIR); +function validPidDir(value, uploadDir, policy) { + const upload = uploadDir || DEFAULT_UPLOAD_DIR; + const physical = managedPath(value, policy); + const physicalUpload = managedPath(upload, policy); + + return validPathPrefix(value, PID_DIR_PREFIXES) && physical !== null && physicalUpload !== null && + !pathInDir(value, upload) && !pathInDir(physical, physicalUpload); } function serviceRunning(status) { @@ -188,7 +258,8 @@ function clientUrl() { function renderStatusContent(status) { if (status && status.error) - return E('p', { 'class': 'gecoosac-stopped' }, _('Service status unavailable') + ': ' + _(status.error)); + return E('p', { 'class': 'gecoosac-stopped' }, _('Service status unavailable') + ': ' + + (RPC_ERROR_MESSAGES[status.error] || _('Unable to query service status'))); const running = serviceRunning(status); const text = running @@ -225,7 +296,9 @@ function updateStatus(status) { } function clearUploadError(res) { - return res && res.error ? _(res.error) : _('Upload directory was not cleared'); + return res && res.error && RPC_ERROR_MESSAGES[res.error] + ? RPC_ERROR_MESSAGES[res.error] + : _('Upload directory was not cleared'); } return view.extend({ @@ -234,6 +307,9 @@ return view.extend({ uci.load('gecoosac'), callServiceStatus().catch(function() { return statusFailure(); + }), + callPathPolicy().catch(function() { + return { ok: false }; }) ]); }, @@ -242,6 +318,7 @@ return view.extend({ let m, s, o, uploadDirOption; let portOption, managementPortOption, singlePortOption; let httpsOption, certificateOption, keyOption; + const pathPolicy = data[2] && data[2].ok === true ? data[2] : null; m = new form.Map('gecoosac', _('Gecoos AC'), _('Only supports Gecoos AP firmware 7.6 and above.') + '
' + @@ -362,7 +439,10 @@ return view.extend({ o.datatype = 'directory'; o.rmempty = false; o.validate = function(section_id, value) { - return validUploadDir(value) + if (usesManagedPath(value) && !pathPolicy) + return _('Unable to validate /var paths on this system.'); + + return validUploadDir(value, pathPolicy) ? true : _('Upload directory must be an absolute path ending with /gecoosac/upload and must not be under /etc/gecoosac.'); }; @@ -375,11 +455,13 @@ return view.extend({ o.rmempty = false; o.validate = function(section_id, value) { const uploadDir = uploadDirOption.formvalue(section_id) || DEFAULT_UPLOAD_DIR; + if ((usesManagedPath(value) || usesManagedPath(uploadDir)) && !pathPolicy) + return _('Unable to validate /var paths on this system.'); if (!validPathPrefix(value, DB_DIR_PREFIXES)) return _('Database directory must be under /etc/gecoosac, /tmp/gecoosac, or /var/lib/gecoosac.'); - return validDbDir(value, uploadDir) + return validDbDir(value, uploadDir, pathPolicy) ? true : _('Database directory must not be the upload directory or inside it.'); }; @@ -392,11 +474,13 @@ return view.extend({ o.rmempty = false; o.validate = function(section_id, value) { const uploadDir = uploadDirOption.formvalue(section_id) || DEFAULT_UPLOAD_DIR; + if ((usesManagedPath(value) || usesManagedPath(uploadDir)) && !pathPolicy) + return _('Unable to validate /var paths on this system.'); if (!validPathPrefix(value, PID_DIR_PREFIXES)) return _('PID directory must be under /var/run or /tmp/gecoosac.'); - return validPidDir(value, uploadDir) + return validPidDir(value, uploadDir, pathPolicy) ? true : _('PID directory must not be the upload directory or inside it.'); }; @@ -433,6 +517,8 @@ return view.extend({ ui.addNotification(null, E('p', {}, _('Saved upload directory cleared'))); else ui.addNotification(null, E('p', {}, clearUploadError(arguments[0])), 'danger'); + }).catch(function() { + ui.addNotification(null, E('p', {}, _('Upload directory was not cleared')), 'danger'); }); }; diff --git a/luci-app-gecoosac/po/zh_Hans/gecoosac.po b/luci-app-gecoosac/po/zh_Hans/gecoosac.po index e557ad2e..9c7735e3 100644 --- a/luci-app-gecoosac/po/zh_Hans/gecoosac.po +++ b/luci-app-gecoosac/po/zh_Hans/gecoosac.po @@ -15,6 +15,9 @@ msgstr "默认证书文件会在 HTTPS 启动时生成;自定义路径必须 msgid "Gecoos AC" msgstr "集客AC控制器" +msgid "Grant access for luci-app-gecoosac" +msgstr "授予 luci-app-gecoosac 访问权限" + msgid "Only supports Gecoos AP firmware 7.6 and above." msgstr "仅支持集客 AP 7.6 及以上版本固件。" @@ -145,9 +148,6 @@ msgstr "已保存的上传目录已清理" msgid "Unable to remove upload directory contents" msgstr "无法删除上传目录内容" -msgid "Upload directory contains configured runtime paths" -msgstr "上传目录包含已配置的运行目录" - msgid "Port must be an integer between 1 and 65535." msgstr "端口必须是 1 到 65535 之间的整数。" @@ -172,11 +172,32 @@ msgstr "上传目录包含已配置的受保护路径" msgid "Unable to validate configured paths" msgstr "无法验证已配置的路径" +msgid "Unable to read Gecoos configuration" +msgstr "无法读取 Gecoos 配置" + +msgid "Gecoos configuration changed during cleanup" +msgstr "清理期间 Gecoos 配置发生变化" + msgid "Upload directory or its parent is not root-owned and private" msgstr "上传目录或其父目录不是 root 所有且为私有目录" msgid "Unable to prepare upload directory cleanup" msgstr "无法准备上传目录清理" +msgid "Unable to validate upload cleanup stage" +msgstr "无法验证上传目录清理暂存区" + +msgid "Upload cleanup stage contains a configured protected path" +msgstr "上传目录清理暂存区包含已配置的受保护路径" + msgid "Unable to recreate upload directory" msgstr "无法重新创建上传目录" + +msgid "Unable to remove upload cleanup stage" +msgstr "无法删除上传目录清理暂存区" + +msgid "Unable to resolve managed path policy" +msgstr "无法解析受管路径策略" + +msgid "Unable to validate /var paths on this system." +msgstr "无法验证此系统上的 /var 路径。" diff --git a/luci-app-gecoosac/po/zh_Hant/gecoosac.po b/luci-app-gecoosac/po/zh_Hant/gecoosac.po index a164cfcd..eb7729d4 100644 --- a/luci-app-gecoosac/po/zh_Hant/gecoosac.po +++ b/luci-app-gecoosac/po/zh_Hant/gecoosac.po @@ -15,6 +15,9 @@ msgstr "預設憑證檔案會在 HTTPS 啟動時產生;自訂路徑必須指 msgid "Gecoos AC" msgstr "集客 AC 控制器" +msgid "Grant access for luci-app-gecoosac" +msgstr "授予 luci-app-gecoosac 存取權限" + msgid "Only supports Gecoos AP firmware 7.6 and above." msgstr "僅支援集客 AP 7.6 及以上版本韌體。" @@ -145,9 +148,6 @@ msgstr "已儲存的上傳目錄已清除" msgid "Unable to remove upload directory contents" msgstr "無法移除上傳目錄內容" -msgid "Upload directory contains configured runtime paths" -msgstr "上傳目錄包含已設定的執行時路徑" - msgid "Port must be an integer between 1 and 65535." msgstr "連接埠必須是 1 到 65535 之間的整數。" @@ -172,11 +172,32 @@ msgstr "上傳目錄包含已設定的受保護路徑" msgid "Unable to validate configured paths" msgstr "無法驗證已設定的路徑" +msgid "Unable to read Gecoos configuration" +msgstr "無法讀取 Gecoos 設定" + +msgid "Gecoos configuration changed during cleanup" +msgstr "清理期間 Gecoos 設定發生變更" + msgid "Upload directory or its parent is not root-owned and private" msgstr "上傳目錄或其父目錄不是 root 所有且為私有目錄" msgid "Unable to prepare upload directory cleanup" msgstr "無法準備上傳目錄清理" +msgid "Unable to validate upload cleanup stage" +msgstr "無法驗證上傳目錄清理暫存區" + +msgid "Upload cleanup stage contains a configured protected path" +msgstr "上傳目錄清理暫存區包含已設定的受保護路徑" + msgid "Unable to recreate upload directory" msgstr "無法重新建立上傳目錄" + +msgid "Unable to remove upload cleanup stage" +msgstr "無法移除上傳目錄清理暫存區" + +msgid "Unable to resolve managed path policy" +msgstr "無法解析受管理路徑策略" + +msgid "Unable to validate /var paths on this system." +msgstr "無法驗證此系統上的 /var 路徑。" diff --git a/luci-app-gecoosac/root/etc/uci-defaults/luci-gecoosac b/luci-app-gecoosac/root/etc/uci-defaults/luci-gecoosac index 51393d06..7e8e9166 100644 --- a/luci-app-gecoosac/root/etc/uci-defaults/luci-gecoosac +++ b/luci-app-gecoosac/root/etc/uci-defaults/luci-gecoosac @@ -1,19 +1,21 @@ #!/bin/sh -[ ! -f "/usr/share/ucitrack/luci-app-gecoosac.json" ] && { - cat > /usr/share/ucitrack/luci-app-gecoosac.json << EEOF +[ ! -f "/usr/share/ucitrack/luci-app-add.json" ] && { + cat > /usr/share/ucitrack/luci-app-add.json << EEOF { - "config": "gecoosac", - "init": "gecoosac" + "config": "add", + "init": "add" } EEOF } -uci -q batch <<-EOF >/dev/null - delete ucitrack.@gecoosac[-1] - add ucitrack gecoosac - set ucitrack.@gecoosac[-1].init=gecoosac - commit ucitrack -EOF +while uci -q get 'ucitrack.@gecoosac[-1]' >/dev/null; do + uci -q delete 'ucitrack.@gecoosac[-1]' || exit 1 +done + +section="$(uci -q add ucitrack gecoosac)" || exit 1 +[ -n "$section" ] || exit 1 +uci -q set "ucitrack.${section}.init=gecoosac" || exit 1 +uci -q commit ucitrack || exit 1 rm -f /tmp/luci-indexcache rm -rf /tmp/luci-modulecache diff --git a/luci-app-gecoosac/root/usr/libexec/rpcd/luci.gecoosac b/luci-app-gecoosac/root/usr/libexec/rpcd/luci.gecoosac index bf50d332..4cebd662 100755 --- a/luci-app-gecoosac/root/usr/libexec/rpcd/luci.gecoosac +++ b/luci-app-gecoosac/root/usr/libexec/rpcd/luci.gecoosac @@ -1,8 +1,13 @@ #!/bin/sh . /usr/share/libubox/jshn.sh +. /lib/functions.sh +DEFAULT_DB_DIR=/etc/gecoosac DEFAULT_UPLOAD_DIR=/tmp/gecoosac/upload +DEFAULT_CRT_FILE=/etc/gecoosac/tls/gecoosac.crt +DEFAULT_KEY_FILE=/etc/gecoosac/tls/gecoosac.key +DEFAULT_PID_DIR=/var/run json_result() { local ok="$1" @@ -53,6 +58,50 @@ normalize_path() { printf '%s\n' "$normalized" } +managed_dir_path() { + local role="$1" path anchor + + case "$role" in + upload|db|pid|file) ;; + *) return 1 ;; + esac + path="$(normalize_path "$2")" || return 1 + case "$path" in + /var/run|/var/run/*) + anchor="$(readlink -f /var/run 2>/dev/null)" || return 1 + [ "$anchor" = "/tmp/run" ] || return 1 + printf '%s%s\n' "$anchor" "${path#/var/run}" + ;; + /var|/var/*) + anchor="$(readlink -f /var 2>/dev/null)" || return 1 + case "$anchor" in + /var|/tmp) printf '%s%s\n' "$anchor" "${path#/var}" ;; + *) return 1 ;; + esac + ;; + *) printf '%s\n' "$path" ;; + esac +} + +load_clear_config() { + local section_type + + unset CONFIG_config_TYPE \ + CONFIG_config_upload_dir \ + CONFIG_config_db_dir \ + CONFIG_config_piddir \ + CONFIG_config_crt_file \ + CONFIG_config_key_file + config_load gecoosac || return 1 + config_get section_type config TYPE + [ "$section_type" = "gecoosac" ] || return 1 + config_get clear_upload_dir config upload_dir "$DEFAULT_UPLOAD_DIR" + config_get clear_db_dir config db_dir "$DEFAULT_DB_DIR" + config_get clear_piddir config piddir "$DEFAULT_PID_DIR" + config_get clear_crt_file config crt_file "$DEFAULT_CRT_FILE" + config_get clear_key_file config key_file "$DEFAULT_KEY_FILE" +} + safe_upload_path() { local path @@ -124,21 +173,56 @@ is_secure_upload_dir() { } configured_path_in_upload() { - local option="$1" - local upload_path="$2" path real_path + local option="$1" checked_root="$2" + local live_logical="${3:-$2}" live_physical="${4:-$2}" + local role path physical real_path suffix mapped - path="$(uci -q get "gecoosac.config.${option}")" + case "$option" in + db_dir) path="$clear_db_dir"; role=db ;; + piddir) path="$clear_piddir"; role=pid ;; + crt_file) path="$clear_crt_file"; role="file" ;; + key_file) path="$clear_key_file"; role="file" ;; + *) return 2 ;; + esac [ -n "$path" ] || return 1 path="$(normalize_path "$path")" || return 2 - path_in_dir "$path" "$upload_path" && return 0 - [ -e "$path" ] || [ -L "$path" ] || return 1 + physical="$(managed_dir_path "$role" "$path")" || return 2 + checked_root="$(normalize_path "$checked_root")" || return 2 + live_logical="$(normalize_path "$live_logical")" || return 2 + live_physical="$(normalize_path "$live_physical")" || return 2 - real_path="$(readlink -f "$path" 2>/dev/null)" || return 2 - [ -n "$real_path" ] || return 2 - real_path="$(normalize_path "$real_path")" || return 2 + path_in_dir "$path" "$checked_root" && return 0 + path_in_dir "$physical" "$checked_root" && return 0 + real_path= + if [ -e "$path" ] || [ -L "$path" ]; then + real_path="$(readlink -f "$path" 2>/dev/null)" || return 2 + [ -n "$real_path" ] || return 2 + real_path="$(normalize_path "$real_path")" || return 2 + path_in_dir "$real_path" "$checked_root" && return 0 + fi - path_in_dir "$real_path" "$upload_path" + [ "$checked_root" != "$live_physical" ] || { + path_in_dir "$path" "$live_logical" && return 0 + path_in_dir "$physical" "$live_physical" && return 0 + if [ -n "$real_path" ] && path_in_dir "$real_path" "$live_physical"; then + return 0 + fi + return 1 + } + + suffix= + if path_in_dir "$path" "$live_logical"; then + suffix="${path#"$live_logical"}" + elif path_in_dir "$physical" "$live_physical"; then + suffix="${physical#"$live_physical"}" + elif [ -n "$real_path" ] && path_in_dir "$real_path" "$live_physical"; then + suffix="${real_path#"$live_physical"}" + else + return 1 + fi + mapped="${checked_root%/}${suffix}" + [ -e "$mapped" ] || [ -L "$mapped" ] } status_result() { @@ -184,6 +268,26 @@ service_status() { status_result 1 "$running" } +path_policy() { + local var_root var_run_root + + if ! var_root="$(managed_dir_path file /var)" || \ + ! var_run_root="$(managed_dir_path file /var/run)"; then + json_init + json_add_boolean ok 0 + json_add_string error "Unable to resolve managed path policy" + json_dump + json_cleanup + return + fi + json_init + json_add_boolean ok 1 + json_add_string var_root "$var_root" + json_add_string var_run_root "$var_run_root" + json_dump + json_cleanup +} + clear_upload_unlock() { flock -u 9 >/dev/null 2>&1 exec 9<&- @@ -218,11 +322,28 @@ ensure_live_upload_dir() { is_secure_upload_dir "$path" } +restore_staged_upload() { + local stage="$1" live="$2" + + if [ -e "$stage/upload" ] || [ -L "$stage/upload" ]; then + [ -d "$stage/upload" ] && [ ! -L "$stage/upload" ] || return 1 + if [ -e "$live" ] || [ -L "$live" ]; then + [ -d "$live" ] && [ ! -L "$live" ] && rmdir "$live" 2>/dev/null || return 1 + fi + if ! mv "$stage/upload" "$live" 2>/dev/null; then + [ -d "$live" ] && [ ! -L "$live" ] && \ + [ ! -e "$stage/upload" ] && [ ! -L "$stage/upload" ] || return 1 + fi + fi + is_secure_upload_dir "$live" || return 1 + rmdir "$stage" 2>/dev/null +} + validate_clear_protected_paths() { - local root="$1" option + local root="$1" live_logical="${2:-$1}" live_physical="${3:-$1}" option for option in db_dir piddir crt_file key_file; do - configured_path_in_upload "$option" "$root" + configured_path_in_upload "$option" "$root" "$live_logical" "$live_physical" case "$?" in 0) return 0 ;; 1) ;; @@ -233,56 +354,102 @@ validate_clear_protected_paths() { } clear_upload() { - local path real parent stage option candidate stages live_exists + local logical physical resolved parent stage candidate stages live_exists current + local clear_upload_dir clear_db_dir clear_piddir clear_crt_file clear_key_file + local initial_upload initial_db initial_pid initial_crt initial_key - path="$(uci -q get gecoosac.config.upload_dir)" - [ -n "$path" ] || path="$DEFAULT_UPLOAD_DIR" + load_clear_config || { + json_result 0 "Unable to read Gecoos configuration" + return + } + logical="$clear_upload_dir" + initial_upload="$clear_upload_dir" + initial_db="$clear_db_dir" + initial_pid="$clear_piddir" + initial_crt="$clear_crt_file" + initial_key="$clear_key_file" - case "$path" in + case "$logical" in /*) ;; *) json_result 0 "Expecting an absolute path"; return ;; esac - path="$(normalize_path "$path")" || { json_result 0 "Expecting an absolute path"; return; } - if ! safe_upload_path "$path"; then - json_result 0 "Only Gecoos upload directories can be cleared" "$path" + logical="$(normalize_path "$logical")" || { json_result 0 "Expecting an absolute path"; return; } + if ! safe_upload_path "$logical"; then + json_result 0 "Only Gecoos upload directories can be cleared" "$logical" return fi - - parent="${path%/*}" + physical="$(managed_dir_path upload "$logical")" || { + json_result 0 "Unable to resolve upload directory" "$logical" + return + } + if ! safe_upload_path "$physical"; then + json_result 0 "Only Gecoos upload directories can be cleared" "$logical" + return + fi + parent="${physical%/*}" if [ ! -e "$parent" ] && [ ! -L "$parent" ]; then - json_result 1 "" "$path" + json_result 1 "" "$logical" return fi if ! is_secure_upload_dir "$parent"; then - json_result 0 "Upload directory or its parent is not root-owned and private" "$path" + json_result 0 "Upload directory or its parent is not root-owned and private" "$logical" return fi - - if [ -e "$path" ] || [ -L "$path" ]; then - real="$(readlink -f "$path" 2>/dev/null)" - else - real="$path" + if [ -e "$logical" ] || [ -L "$logical" ]; then + resolved="$(readlink -f "$logical" 2>/dev/null)" + [ -n "$resolved" ] || { json_result 0 "Unable to resolve upload directory" "$logical"; return; } + resolved="$(normalize_path "$resolved")" || { json_result 0 "Expecting an absolute path"; return; } + [ "$resolved" = "$physical" ] || { json_result 0 "Only Gecoos upload directories can be cleared" "$logical"; return; } fi - [ -n "$real" ] || { json_result 0 "Unable to resolve upload directory" "$path"; return; } - real="$(normalize_path "$real")" || { json_result 0 "Expecting an absolute path"; return; } - [ "$real" = "$path" ] || { json_result 0 "Only Gecoos upload directories can be cleared" "$real"; return; } exec 9<"$parent" 2>/dev/null || { - json_result 0 "Unable to prepare upload directory cleanup" "$real" + json_result 0 "Unable to prepare upload directory cleanup" "$logical" return } if ! flock -n 9 >/dev/null 2>&1; then clear_upload_unlock - json_result 0 "Unable to prepare upload directory cleanup" "$real" + json_result 0 "Unable to prepare upload directory cleanup" "$logical" return fi - + load_clear_config || { + clear_upload_locked_result 0 "Unable to read Gecoos configuration" "$logical" + return + } + if [ "$clear_upload_dir" != "$initial_upload" ] || \ + [ "$clear_db_dir" != "$initial_db" ] || \ + [ "$clear_piddir" != "$initial_pid" ] || \ + [ "$clear_crt_file" != "$initial_crt" ] || \ + [ "$clear_key_file" != "$initial_key" ]; then + clear_upload_locked_result 0 "Gecoos configuration changed during cleanup" "$logical" + return + fi + current="$(managed_dir_path upload "$logical")" || { + clear_upload_locked_result 0 "Unable to resolve upload directory" "$logical" + return + } + [ "$current" = "$physical" ] || { + clear_upload_locked_result 0 "Unable to resolve upload directory" "$logical" + return + } + if [ -e "$logical" ] || [ -L "$logical" ]; then + resolved="$(readlink -f "$logical" 2>/dev/null)" || { + clear_upload_locked_result 0 "Unable to resolve upload directory" "$logical" + return + } + resolved="$(normalize_path "$resolved")" || { + clear_upload_locked_result 0 "Expecting an absolute path" "$logical" + return + } + [ "$resolved" = "$physical" ] || { + clear_upload_locked_result 0 "Only Gecoos upload directories can be cleared" "$logical" + return + } + fi if ! is_secure_upload_dir "$parent"; then - clear_upload_locked_result 0 "Upload directory or its parent is not root-owned and private" "$real" + clear_upload_locked_result 0 "Upload directory or its parent is not root-owned and private" "$logical" return fi - parent="${real%/*}" stages=0 for candidate in "$parent"/.gecoosac-clear.??????; do [ "$candidate" != "$parent/.gecoosac-clear.??????" ] || continue @@ -292,7 +459,7 @@ clear_upload() { return } if [ -e "$candidate/upload" ] || [ -L "$candidate/upload" ]; then - validate_clear_protected_paths "$candidate/upload" + validate_clear_protected_paths "$candidate/upload" "$logical" "$physical" case "$?" in 0) clear_upload_locked_result 0 "Upload cleanup stage contains a configured protected path" "$candidate"; return ;; 1) ;; @@ -302,36 +469,89 @@ clear_upload() { done live_exists=0 - [ -e "$real" ] || [ -L "$real" ] && live_exists=1 - if [ "$live_exists" = "1" ] && ! is_secure_upload_dir "$real"; then - clear_upload_locked_result 0 "Upload directory or its parent is not root-owned and private" "$real" + [ -e "$physical" ] || [ -L "$physical" ] && live_exists=1 + if [ "$live_exists" = "1" ] && ! is_secure_upload_dir "$physical"; then + clear_upload_locked_result 0 "Upload directory or its parent is not root-owned and private" "$logical" return fi if [ "$live_exists" = "0" ] && [ "$stages" = "0" ]; then - clear_upload_locked_result 1 "" "$real" + clear_upload_locked_result 1 "" "$logical" return fi if [ "$live_exists" = "1" ]; then - validate_clear_protected_paths "$real" + validate_clear_protected_paths "$physical" "$logical" "$physical" case "$?" in - 0) clear_upload_locked_result 0 "Upload directory contains a configured protected path" "$real"; return ;; + 0) clear_upload_locked_result 0 "Upload directory contains a configured protected path" "$logical"; return ;; 1) ;; - *) clear_upload_locked_result 0 "Unable to validate configured paths" "$real"; return ;; + *) clear_upload_locked_result 0 "Unable to validate configured paths" "$logical"; return ;; esac fi if [ "$live_exists" = "0" ]; then - ensure_live_upload_dir "$real" || { - clear_upload_locked_result 0 "Unable to recreate upload directory" "$real" + ensure_live_upload_dir "$physical" || { + clear_upload_locked_result 0 "Unable to recreate upload directory" "$logical" return } fi + if [ "$live_exists" = "1" ]; then + stage="$(mktemp -d "$parent/.gecoosac-clear.XXXXXX" 2>/dev/null)" || { + clear_upload_locked_result 0 "Unable to prepare upload directory cleanup" "$logical" + return + } + if ! chmod 0700 "$stage"; then + rmdir "$stage" 2>/dev/null + clear_upload_locked_result 0 "Unable to prepare upload directory cleanup" "$logical" + return + fi + if ! mv "$physical" "$stage/upload"; then + restore_staged_upload "$stage" "$physical" || \ + logger -t gecoosac "upload cleanup retained staged data at $stage" + clear_upload_locked_result 0 "Unable to prepare upload directory cleanup" "$logical" + return + fi + if ! ensure_live_upload_dir "$physical"; then + restore_staged_upload "$stage" "$physical" || \ + logger -t gecoosac "upload cleanup retained staged data at $stage" + clear_upload_locked_result 0 "Unable to recreate upload directory" "$logical" + return + fi + else + stage= + fi + + load_clear_config || { + clear_upload_locked_result 0 "Unable to read Gecoos configuration" "$logical" + return + } + if [ "$clear_upload_dir" != "$initial_upload" ] || \ + [ "$clear_db_dir" != "$initial_db" ] || \ + [ "$clear_piddir" != "$initial_pid" ] || \ + [ "$clear_crt_file" != "$initial_crt" ] || \ + [ "$clear_key_file" != "$initial_key" ]; then + clear_upload_locked_result 0 "Gecoos configuration changed during cleanup" "$logical" + return + fi + for candidate in "$parent"/.gecoosac-clear.??????; do + [ "$candidate" != "$parent/.gecoosac-clear.??????" ] || continue + is_safe_clear_stage "$candidate" || { + clear_upload_locked_result 0 "Unable to validate upload cleanup stage" "$candidate" + return + } + if [ -e "$candidate/upload" ] || [ -L "$candidate/upload" ]; then + validate_clear_protected_paths "$candidate/upload" "$logical" "$physical" + case "$?" in + 0) clear_upload_locked_result 0 "Upload cleanup stage contains a configured protected path" "$candidate"; return ;; + 1) ;; + *) clear_upload_locked_result 0 "Unable to validate configured paths" "$candidate"; return ;; + esac + fi + done for candidate in "$parent"/.gecoosac-clear.??????; do [ "$candidate" != "$parent/.gecoosac-clear.??????" ] || continue if [ -e "$candidate/upload" ] || [ -L "$candidate/upload" ]; then rm -rf "$candidate/upload" || { logger -t gecoosac "upload cleanup retained staged data at $candidate" - clear_upload_locked_result 0 "Unable to remove upload directory contents" "$real" + clear_upload_locked_result 0 "Unable to remove upload directory contents" "$logical" return } fi @@ -341,49 +561,17 @@ clear_upload() { } done - if [ "$live_exists" = "1" ]; then - stage="$(mktemp -d "$parent/.gecoosac-clear.XXXXXX" 2>/dev/null)" || { - clear_upload_locked_result 0 "Unable to prepare upload directory cleanup" "$real" - return - } - if ! chmod 0700 "$stage" || ! mv "$real" "$stage/upload"; then - rm -rf "$stage" - clear_upload_locked_result 0 "Unable to prepare upload directory cleanup" "$real" - return - fi - if ! ensure_live_upload_dir "$real"; then - if [ ! -e "$real" ] && [ ! -L "$real" ]; then - mv "$stage/upload" "$real" 2>/dev/null - fi - clear_upload_locked_result 0 "Unable to recreate upload directory" "$real" - return - fi - else - stage= - fi - - if [ "$live_exists" = "1" ]; then - if ! rm -rf "$stage/upload"; then - logger -t gecoosac "upload cleanup retained staged data at $stage" - clear_upload_locked_result 0 "Unable to remove upload directory contents" "$real" - return - fi - rmdir "$stage" || { - clear_upload_locked_result 0 "Unable to remove upload cleanup stage" "$stage" - return - } - fi - - clear_upload_locked_result 1 "" "$real" + clear_upload_locked_result 1 "" "$logical" } case "$1" in list) - printf '{ "clear_upload": {}, "status": {} }' + printf '{ "clear_upload": {}, "path_policy": {}, "status": {} }' ;; call) case "$2" in status) service_status ;; + path_policy) path_policy ;; clear_upload) clear_upload ;; *) json_result 0 "Unknown method" ;; esac diff --git a/luci-app-gecoosac/root/usr/share/rpcd/acl.d/luci-app-gecoosac.json b/luci-app-gecoosac/root/usr/share/rpcd/acl.d/luci-app-gecoosac.json index 8b74d30e..7bdd4327 100644 --- a/luci-app-gecoosac/root/usr/share/rpcd/acl.d/luci-app-gecoosac.json +++ b/luci-app-gecoosac/root/usr/share/rpcd/acl.d/luci-app-gecoosac.json @@ -3,7 +3,7 @@ "description": "Grant access for luci-app-gecoosac", "read": { "ubus": { - "luci.gecoosac": [ "status" ] + "luci.gecoosac": [ "path_policy", "status" ] }, "uci": [ "gecoosac" ] }, diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index e538f577..1f9d40ac 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.8.1 -PKG_RELEASE:=204 +PKG_RELEASE:=205 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/htdocs/luci-static/resources/view/passwall/func.js b/luci-app-passwall/htdocs/luci-static/resources/view/passwall/func.js new file mode 100644 index 00000000..e7923077 --- /dev/null +++ b/luci-app-passwall/htdocs/luci-static/resources/view/passwall/func.js @@ -0,0 +1,61 @@ +function arraysEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function decodeIfBase64(str) { + try { + let s = str.replace(/-/g, '+').replace(/_/g, '/'); + while (s.length % 4) s += '='; + const decoded = decodeURIComponent( + atob(s).split('').map(c => + '%' + c.charCodeAt(0).toString(16).padStart(2, '0') + ).join('') + ); + if (btoa(unescape(encodeURIComponent(decoded))).replace(/=+$/, '') === s.replace(/=+$/, '')) { + return decoded; + } + } catch (e) {} + return str; +} + +function waitForElement(selector, callback) { + const el = document.querySelector(selector); + if (el) return callback(el); + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + observer.disconnect(); + callback(el); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +} + +function get_current_url() { + return window.location.origin + window.location.pathname; +} + +function getOption(config, section, opt) { + let obj; + const id = `cbid.${config}.${section}.${opt}`; + obj = document.getElementsByName(id)[0] || document.getElementById(id); + if (obj) { + const combobox = document.getElementById('cbi.combobox.' + id); + if (combobox) { + obj.combobox = combobox; + } + const div = document.getElementById(id); + if (div && div.getElementsByTagName("li").length > 0) { + obj = div; + } + return obj; + } else { + return null; + } +} diff --git a/luci-app-passwall/luasrc/controller/passwall.lua b/luci-app-passwall/luasrc/controller/passwall.lua index bb2d5b7a..2b9cde82 100644 --- a/luci-app-passwall/luasrc/controller/passwall.lua +++ b/luci-app-passwall/luasrc/controller/passwall.lua @@ -53,14 +53,14 @@ function index() entry({"admin", "services", appname, "socks_config"}, cbi(appname .. "/client/socks_config")).leaf = true entry({"admin", "services", appname, "acl"}, cbi(appname .. "/client/acl"), _("Access control"), 98).leaf = true entry({"admin", "services", appname, "acl_config"}, cbi(appname .. "/client/acl_config")).leaf = true - entry({"admin", "services", appname, "log"}, form(appname .. "/client/log"), _("Runtime Logs"), 999).leaf = true + entry({"admin", "services", appname, "log"}, template(appname .. "/log/log"), _("Runtime Logs"), 999).leaf = true --[[ Server ]] entry({"admin", "services", appname, "server"}, cbi(appname .. "/server/index"), _("Server-Side"), 99).leaf = true entry({"admin", "services", appname, "server_user"}, cbi(appname .. "/server/user")).leaf = true --[[ API ]] - entry({"admin", "services", appname, "server_user_update"}, call("server_user_update")).leaf = true + entry({"admin", "services", appname, "server_update_config"}, call("server_update_config")).leaf = true entry({"admin", "services", appname, "server_user_status"}, call("server_user_status")).leaf = true entry({"admin", "services", appname, "server_user_log"}, call("server_user_log")).leaf = true entry({"admin", "services", appname, "server_get_log"}, call("server_get_log")).leaf = true @@ -81,8 +81,8 @@ function index() entry({"admin", "services", appname, "connect_status"}, call("connect_status")).leaf = true entry({"admin", "services", appname, "ping_node"}, call("ping_node")).leaf = true entry({"admin", "services", appname, "urltest_node"}, call("urltest_node")).leaf = true + entry({"admin", "services", appname, "update_config"}, call("update_config")).leaf = true entry({"admin", "services", appname, "add_node"}, call("add_node")).leaf = true - entry({"admin", "services", appname, "update_node"}, call("update_node")).leaf = true entry({"admin", "services", appname, "set_node"}, call("set_node")).leaf = true entry({"admin", "services", appname, "copy_node"}, call("copy_node")).leaf = true entry({"admin", "services", appname, "clear_all_nodes"}, call("clear_all_nodes")).leaf = true @@ -98,6 +98,10 @@ function index() entry({"admin", "services", appname, "subscribe_manual"}, call("subscribe_manual")).leaf = true entry({"admin", "services", appname, "subscribe_manual_all"}, call("subscribe_manual_all")).leaf = true entry({"admin", "services", appname, "flush_set"}, call("flush_set")).leaf = true + entry({"admin", "services", appname, "get_shunt_rules"}, call("get_shunt_rules")).leaf = true + entry({"admin", "services", appname, "add_shunt_rule"}, call("add_shunt_rule")).leaf = true + entry({"admin", "services", appname, "delete_select_shunt_rules"}, call("delete_select_shunt_rules")).leaf = true + entry({"admin", "services", appname, "save_shunt_rule_order"}, call("save_shunt_rule_order")).leaf = true --[[rule_list]] entry({"admin", "services", appname, "read_rulelist"}, call("read_rulelist")).leaf = true @@ -548,6 +552,23 @@ function urltest_node() http_write_json(e) end +function update_config() + local id = http.formvalue("id") -- Node id + local data = http.formvalue("data") -- json new Data + if id and data then + local data_t = jsonParse(data) or {} + if next(data_t) then + for k, v in pairs(data_t) do + uci:set(appname, id, k, v) + end + api.uci_save(uci, appname) + http_write_json_ok() + return + end + end + http_write_json_error() +end + function add_node() local redirect = http.formvalue("redirect") @@ -570,23 +591,6 @@ function add_node() end end -function update_node() - local id = http.formvalue("id") -- Node id - local data = http.formvalue("data") -- json new Data - if id and data then - local data_t = jsonParse(data) or {} - if next(data_t) then - for k, v in pairs(data_t) do - uci:set(appname, id, k, v) - end - api.uci_save(uci, appname) - http_write_json_ok() - return - end - end - http_write_json_error() -end - function set_node() local protocol = http.formvalue("protocol") local section = http.formvalue("section") @@ -857,7 +861,7 @@ function rollback_rules() http_write_json_ok() end -function server_user_update() +function server_update_config() local id = http.formvalue("id") -- Node id local data = http.formvalue("data") -- json new Data if id and data then @@ -1211,3 +1215,87 @@ function fetch_certsha256() local data = api.fetch_cert_sha256(address, port, sni, timeout, h3) http_write_json(data ~= "" and { code = 1, data = data } or { code = 0 }) end + +function get_shunt_rules() + local id = http.formvalue("id") + local result = {} + + if id then + result = uci:get_all(appname, id) + else + local default_items = {} + local other_items = {} + uci:foreach(appname, "shunt_rules", function(t) + if not t.group or t.group == "" then + default_items[#default_items + 1] = t + else + other_items[#other_items + 1] = t + end + end) + for i = 1, #default_items do result[#result + 1] = default_items[i] end + for i = 1, #other_items do result[#result + 1] = other_items[i] end + end + http_write_json(result) +end + +function add_shunt_rule() + local add_name = http.formvalue("add_name") + local redirect = http.formvalue("redirect") + + local uuid = add_name + if add_name then + local has = uci:get(appname, uuid) + if has then + http_write_json_error({ message = "This ID already exists." }) + return + end + else + uuid = api.gen_short_uuid() + end + uci:section(appname, "shunt_rules", uuid) + + local group = http.formvalue("group") + if group and group ~= "default" then + uci:set(appname, uuid, "group", group) + end + + if redirect == "1" then + api.uci_save(uci, appname) + http.redirect(api.url("shunt_rules", uuid)) + else + api.uci_save(uci, appname) + http_write_json_ok({uuid = uuid, redirect_url = api.url("shunt_rules", uuid)}) + end +end + +function delete_select_shunt_rules() + local ids = http.formvalue("ids") + local redirect = http.formvalue("redirect") + string.gsub(ids, '[^' .. "," .. ']+', function(w) + uci:foreach(appname, "nodes", function(s) + if s["protocol"] and s["protocol"] == "_shunt" then + uci:delete(appname, s[".name"], w) + end + end) + uci:delete(appname, w) + end) + if redirect == "1" then + api.uci_save(uci, appname) + http.redirect(api.url("rule")) + else + api.uci_save(uci, appname, true, true) + end +end + +function save_shunt_rule_order() + local ids = http.formvalue("ids") or "" + local new_order = {} + for id in ids:gmatch("([^,]+)") do + new_order[#new_order + 1] = id + end + for idx, name in ipairs(new_order) do + luci.sys.call(string.format("uci -q reorder %s.%s=%d", appname, name, idx - 1)) + end + api.sh_uci_commit(appname) + http_write_json({ status = "ok" }) +end diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua index a4a9d2f9..e881ac22 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua @@ -154,6 +154,7 @@ if (has_singbox or has_xray) and #nodes_table > 0 then if current_node.protocol == "_shunt" then local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall/client/include/shunt_options.lua") setfenv(shunt_lua, getfenv(1))(m, s, { + s_cfgid = s:cfgsections()[1], node_id = current_node_id, node = current_node, socks_list = socks_list, diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/include/shunt_options.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/include/shunt_options.lua index 7711808d..4867e92f 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/include/shunt_options.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/include/shunt_options.lua @@ -4,9 +4,17 @@ if not data.node_id or not data.node then return end +local s_cfgid = data.s_cfgid local current_node_id = data.node_id local node_list = data.node_list or api.get_node_list() +local groups = {} +m.uci:foreach(appname, "shunt_rules", function(s) + if s.group and s.group ~= "" then + groups[s.group] = true + end +end) + local function get_cfgvalue() return function(self, section) return m:get(current_node_id, self.option) @@ -92,15 +100,27 @@ o = add_option(Flag, "fakedns", 'FakeDNS' .. " " .. translate("Suitable scenarios for let the node servers get the target domain names.") .. "
" .. translate("Such as: DNS unlocking of streaming media, reducing DNS query latency, etc.")) +shunt_group = add_option(ListValue, "shunt_group", translate("Shunt Rule Group")) +shunt_group:value("", translate("default")) +for k, v in pairs(groups) do + shunt_group:value(k) +end + +local shunt_group_val = m:get(current_node_id, "shunt_group") or "" +shunt_group_val = shunt_group_val:lower() local shunt_rules = {} m.uci:foreach(appname, "shunt_rules", function(e) - e.id = e[".name"] - e.remarks = e.remarks or e[".name"] - e["_node_option"] = e[".name"] - e["_node_default"] = "" - e["_fakedns_option"] = e[".name"] .. "_fakedns" - e["_proxy_tag_option"] = e[".name"] .. "_proxy_tag" - table.insert(shunt_rules, e) + local group = e.group or "" + group = group:lower() + if group == shunt_group_val then + e.id = e[".name"] + e.remarks = e.remarks or e[".name"] + e["_node_option"] = e[".name"] + e["_node_default"] = "" + e["_fakedns_option"] = e[".name"] .. "_fakedns" + e["_proxy_tag_option"] = e[".name"] .. "_proxy_tag" + table.insert(shunt_rules, e) + end end) table.insert(shunt_rules, { id = ".default", @@ -185,6 +205,8 @@ end local footer = Template(appname .. "/include/shunt_options") footer.api = api +footer.config = m.config footer.id = current_node_id +footer.s_cfgid = s_cfgid or current_node_id footer.normal_list = api.jsonc.stringify(node_list.normal_list) m:append(footer) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/log.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/log.lua deleted file mode 100644 index ef8c9be0..00000000 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/log.lua +++ /dev/null @@ -1,8 +0,0 @@ -local api = require "luci.passwall.api" -local appname = "passwall" - -f = SimpleForm(appname) -f.reset = false -f.submit = false -f:append(Template(appname .. "/log/log")) -return f diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_list.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_list.lua index 8c16f598..fdbf0c29 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_list.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_list.lua @@ -30,9 +30,6 @@ o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud o:value("https://wifi.vivo.com.cn/generate_204", "VIVO (CN)") o.default = o.keylist[3] --- [[ Add the node via the link ]]-- -s:append(Template(appname .. "/node_list/link_add_node")) - m:append(Template(appname .. "/node_list/node_list")) return api.return_map(m) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/rule.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/rule.lua index 38c96631..adad962d 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/rule.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/rule.lua @@ -89,9 +89,8 @@ if has_xray or has_singbox then o.rmempty = false o.description = "" function o.write(self, section, value) local old = m:get(section, self.option) or "0" @@ -146,34 +145,8 @@ end s:append(Template(appname .. "/rule/rule_version")) -local cfgname = "shunt_rules" - if has_xray or has_singbox then - s = m:section(TypedSection, cfgname, "Sing-Box/Xray " .. translate("Shunt Rule"), "" .. translate("Please note attention to the priority, the higher the order, the higher the priority.") .. "") - s.template = "cbi/tblsection" - s.anonymous = false - s.addremove = true - s.sortable = true - s.extedit = api.url("shunt_rules", "%s") - function s.create(e, t) - TypedSection.create(e, t) - luci.http.redirect(e.extedit:format(t)) - end - function s.remove(e, t) - m.uci:foreach(appname, "nodes", function(s) - if s["protocol"] and s["protocol"] == "_shunt" then - m:del(s[".name"], t) - end - end) - TypedSection.remove(e, t) - end - - o = s:option(DummyValue, "remarks", translate("Remarks")) + m:append(Template(appname .. "/rule/shunt_rule_list")) end -local sortable = Template(appname .. "/cbi/sortable") -sortable.api = api -sortable.target_cfgname = cfgname -m:append(sortable) - return api.return_map(m) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/shunt_rules.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/shunt_rules.lua index 4b45eba4..8ae6a0e6 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/shunt_rules.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/shunt_rules.lua @@ -44,6 +44,19 @@ function clean_text(text) :gsub("[ \t]*\n[ \t]*", "\n") end +local remarks_lookup = {} +local groups = {} +m.uci:foreach(appname, "shunt_rules", function(s) + if s[".name"] ~= arg[1] then + if s.remarks then + remarks_lookup[s.remarks] = s[".name"] + end + if s.group and s.group ~= "" then + groups[s.group] = true + end + end +end) + s = m:section(NamedSection, arg[1], "shunt_rules", "") s.addremove = false s.dynamic = false @@ -56,9 +69,34 @@ remarks.validate = function(self, value, section) if value == "" then return nil, translate("Remark cannot be empty.") end + if remarks_lookup[value] then + return nil, translate("This remark already exists, please change a new remark.") + end return value end +o = s:option(Value, "group", translate("Shunt Rule Group")) +o.default = "" +o:value("", translate("default")) +for k, v in pairs(groups) do + o:value(k) +end +o.write = function(self, section, value) + value = api.trim(value) + local lower = value:lower() + + if lower == "" or lower == "default" then + return m:del(section, self.option) + end + + for _, v in ipairs(self.keylist or {}) do + if v:lower() == lower then + return m:set(section, self.option, v) + end + end + m:set(section, self.option, value) +end + protocol = s:option(MultiValue, "protocol", translate("Protocol")) protocol:value("http") protocol:value("tls") diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua index bab7888c..ebfed516 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua @@ -141,12 +141,12 @@ o.default = 30 o:depends("enable_autoswitch", true) o = s:option(Value, "autoswitch_connect_timeout", translate("Timeout seconds"), translate("Units:seconds")) -o.datatype = "min(1)" +o.datatype = "range(3,10)" o.default = 3 o:depends("enable_autoswitch", true) o = s:option(Value, "autoswitch_retry_num", translate("Timeout retry num")) -o.datatype = "min(1)" +o.datatype = "range(1,5)" o.default = 1 o:depends("enable_autoswitch", true) diff --git a/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/luci-app-passwall/luasrc/passwall/util_sing-box.lua index be04bf81..afb9ad3b 100644 --- a/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -1594,11 +1594,15 @@ function gen_config(var) [".name"] = "GFW_Mode_List", remarks = "GFW_Mode_List", domain_list = (domain_list ~= "") and domain_list or nil, - ip_list = (ip_list ~= "") and ip_list or nil + ip_list = (ip_list ~= "") and ip_list or nil, + group = node["shunt_group"] }) end end foreach_shunt_rule(function(e) + if node["shunt_group"] ~= e.group then + return + end local outboundTag = gen_shunt_node(e[".name"]) if outboundTag and e.remarks then if outboundTag == "default" then @@ -1993,17 +1997,6 @@ function gen_config(var) else default_dns_flag = "direct" end end - if default_dns_flag == "remote" then - if remote_dns_fake then - table.insert(dns.rules, { - query_type = { "A", "AAAA" }, - server = fakedns_tag, - disable_cache = true, - rewrite_ttl = 30, - strategy = remote_strategy - }) - end - end dns.final = default_dns_flag --按分流顺序DNS @@ -2058,6 +2051,28 @@ function gen_config(var) end end end + if default_dns_flag == "remote" then + if remote_dns_fake then + -- When default is not direct and enable fakedns, default DNS use FakeDNS. + local fakedns_dns_rule = { + query_type = { + "A", "AAAA" + }, + server = fakedns_tag, + disable_cache = true, + rewrite_ttl = 30, + strategy = remote_strategy, + } + table.insert(dns.rules, fakedns_dns_rule) + else + local remote_dns_rule = { + server = "remote", + disable_cache = true, + strategy = remote_strategy, + } + table.insert(dns.rules, remote_dns_rule) + end + end local dns_in_inbound = { type = "direct", tag = "dns-in", diff --git a/luci-app-passwall/luasrc/passwall/util_xray.lua b/luci-app-passwall/luasrc/passwall/util_xray.lua index bb93f10b..e384d8b0 100644 --- a/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -1405,11 +1405,15 @@ function gen_config(var) [".name"] = "GFW_Mode_List", remarks = "GFW_Mode_List", domain_list = (domain_list ~= "") and domain_list or nil, - ip_list = (ip_list ~= "") and ip_list or nil + ip_list = (ip_list ~= "") and ip_list or nil, + group = node["shunt_group"] }) end end foreach_shunt_rule(function(e) + if node["shunt_group"] ~= e.group then + return + end local outbound_tag = gen_shunt_node(e[".name"]) if outbound_tag and e.remarks then if outbound_tag == "default" then @@ -1729,7 +1733,7 @@ function gen_config(var) elseif remote_dns_query_strategy == "UseIPv6" then table.insert(fakedns, fakedns6) end - if remote_dns_fake and inner_fakedns ~= "1" then + if remote_dns_fake then table.insert(dns.servers, 1, _remote_fakedns) end end diff --git a/luci-app-passwall/luasrc/view/passwall/cbi/header.htm b/luci-app-passwall/luasrc/view/passwall/cbi/header.htm index 5d284d90..a2a716bb 100644 --- a/luci-app-passwall/luasrc/view/passwall/cbi/header.htm +++ b/luci-app-passwall/luasrc/view/passwall/cbi/header.htm @@ -1,2 +1,24 @@ + + + diff --git a/luci-app-passwall/luasrc/view/passwall/global/footer.htm b/luci-app-passwall/luasrc/view/passwall/global/footer.htm index 7c7b1b4d..5c7daa9f 100644 --- a/luci-app-passwall/luasrc/view/passwall/global/footer.htm +++ b/luci-app-passwall/luasrc/view/passwall/global/footer.htm @@ -256,7 +256,7 @@ local appname = api.appname let new_val = el.target.value const new_hasItem = shunt_list.find(element => element.id == new_val); if (new_hasItem) { - XHR.get('<%=api.url("update_node")%>', { + XHR.get('<%=api.url("update_config")%>', { id: "<%=self.global_cfgid%>", data: JSON.stringify({ tcp_node: new_val diff --git a/luci-app-passwall/luasrc/view/passwall/include/shunt_options.htm b/luci-app-passwall/luasrc/view/passwall/include/shunt_options.htm index 7f5e4439..5a5b3608 100644 --- a/luci-app-passwall/luasrc/view/passwall/include/shunt_options.htm +++ b/luci-app-passwall/luasrc/view/passwall/include/shunt_options.htm @@ -53,6 +53,22 @@ } } } + document.addEventListener("DOMContentLoaded", function () { + waitForElement('select[name*="<%=self.config%>"][name*="shunt_group"]', function(el) { + let o_val = el.value; + el.addEventListener("change", () => { + el.blur(); + if (o_val != el.value) { + let save = true; + if (save) { + update_config("<%=self.id%>", { + shunt_group: getOption("<%=self.config%>", "<%=self.s_cfgid%>", "shunt_group").value, + }, get_current_url()); + } + } + }); + }); + }); document.addEventListener("DOMContentLoaded", () => setTimeout(() => { refresh_depends(); const table_dom = document.getElementById("cbi-passwall-shunt_option_list"); diff --git a/luci-app-passwall/luasrc/view/passwall/log/log.htm b/luci-app-passwall/luasrc/view/passwall/log/log.htm index 33208086..83836942 100644 --- a/luci-app-passwall/luasrc/view/passwall/log/log.htm +++ b/luci-app-passwall/luasrc/view/passwall/log/log.htm @@ -1,6 +1,7 @@ <% local api = require "luci.passwall.api" -%> +<%+header%> - - - - - -
-
-

<%:Reassign Node Group%>

-
-
- -
-
- <%:default%> - -
- - -
-
-
-
- - -
-
-
- - - -
-
- - - - - - - - - -
-
- - diff --git a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm index 654ccd54..f005c294 100644 --- a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm +++ b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm @@ -166,8 +166,205 @@ table td, .table .td { } -<% if api.is_js_luci() then -%> + + <%- else %> - <%- end %> + + + +
+
+ + + + + + + + + +
+
+ + + + + +
+
+

<%:Reassign Node Group%>

+
+
+ +
+
+ <%:default%> + +
+ + +
+
+
+
+ + +
+
+
+
<%:You choose node is:%>
- - - + + +
diff --git a/luci-app-passwall/luasrc/view/passwall/rule/shunt_rule_list.htm b/luci-app-passwall/luasrc/view/passwall/rule/shunt_rule_list.htm new file mode 100644 index 00000000..66c7cae0 --- /dev/null +++ b/luci-app-passwall/luasrc/view/passwall/rule/shunt_rule_list.htm @@ -0,0 +1,465 @@ +<% +local api = require "luci.passwall.api" +local appname = api.appname +local uci = api.uci +-%> + + + + +<% if api.is_js_luci() then -%> + +<%- end %> + + + + + + + + + + + diff --git a/luci-app-passwall/luasrc/view/passwall/server/config_footer.htm b/luci-app-passwall/luasrc/view/passwall/server/config_footer.htm index 7b835ec2..e6e88d4a 100644 --- a/luci-app-passwall/luasrc/view/passwall/server/config_footer.htm +++ b/luci-app-passwall/luasrc/view/passwall/server/config_footer.htm @@ -3,6 +3,9 @@ local api = self.api -%> \ No newline at end of file diff --git a/luci-app-passwall2/luasrc/view/passwall2/cbi/header.htm b/luci-app-passwall2/luasrc/view/passwall2/cbi/header.htm index 5d284d90..a2a716bb 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/cbi/header.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/cbi/header.htm @@ -1,2 +1,24 @@ + + + diff --git a/luci-app-passwall2/luasrc/view/passwall2/cbi/optimize_cbi_ui.htm b/luci-app-passwall2/luasrc/view/passwall2/cbi/optimize_cbi_ui.htm deleted file mode 100644 index 3fccf13c..00000000 --- a/luci-app-passwall2/luasrc/view/passwall2/cbi/optimize_cbi_ui.htm +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm b/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm index e1ba5c58..a62966a0 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm @@ -55,8 +55,8 @@ local appname = api.appname return; } let to_url = '<%=api.url("node_config")%>/' + node_select_value; - if (node_select_value.indexOf("Socks_") === 0) { - to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length); + if (node_select_value.indexOf("socks_") === 0) { + to_url = '<%=api.url("socks_config")%>/' + node_select_value; } location.href = to_url; } @@ -207,7 +207,7 @@ local appname = api.appname let new_val = el.target.value const new_hasItem = shunt_list.find(element => element.id == new_val); if (new_hasItem) { - XHR.get('<%=api.url("update_node")%>', { + XHR.get('<%=api.url("update_config")%>', { id: "<%=self.global_cfgid%>", data: JSON.stringify({ node: new_val diff --git a/luci-app-passwall2/luasrc/view/passwall2/include/shunt_options.htm b/luci-app-passwall2/luasrc/view/passwall2/include/shunt_options.htm index 9dcc05c7..b547e78e 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/include/shunt_options.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/include/shunt_options.htm @@ -30,7 +30,7 @@ const selectNode = document.getElementById(dom_id + "_node"); val = selectNode.value; } - if (val == "" || val.startsWith("_") || val.startsWith("Socks_") || !normal_list.find(element => element.id == val)) { + if (val == "" || val.startsWith("_") || val.startsWith("socks_") || !normal_list.find(element => element.id == val)) { const hiddenSelect = document.getElementById(cbid); const panel = document.getElementById(cbid + ".panel"); const display = document.getElementById(cbid + ".display"); @@ -53,6 +53,22 @@ } } } + document.addEventListener("DOMContentLoaded", function () { + waitForElement('select[name*="<%=self.config%>"][name*="shunt_group"]', function(el) { + let o_val = el.value; + el.addEventListener("change", () => { + el.blur(); + if (o_val != el.value) { + let save = true; + if (save) { + update_config("<%=self.id%>", { + shunt_group: getOption("<%=self.config%>", "<%=self.s_cfgid%>", "shunt_group").value, + }, get_current_url()); + } + } + }); + }); + }); document.addEventListener("DOMContentLoaded", () => setTimeout(() => { refresh_depends(); const table_dom = document.getElementById("cbi-passwall2-shunt_option_list"); diff --git a/luci-app-passwall2/luasrc/view/passwall2/log/log.htm b/luci-app-passwall2/luasrc/view/passwall2/log/log.htm index 4e6714ed..bfc2078b 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/log/log.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/log/log.htm @@ -1,6 +1,7 @@ <% local api = require "luci.passwall2.api" -%> +<%+header%> - - - - - -
-
-

<%:Reassign Node Group%>

-
-
- -
-
- <%:default%> - -
- - -
-
-
-
- - -
-
-
- - - -
-
- - - - - - - - - -
-
- - diff --git a/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm b/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm index 0bac7e80..d9c91c7c 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm @@ -9,10 +9,11 @@ local node = api.uci_get_type("global", "node") if node then local node_type = api.uci_get_type_id(node, "type") local node_protocol = api.uci_get_type_id(node, "protocol") - if node_type == "Xray" and node_protocol == "_shunt" then + if (node_type == "Xray" or node_type == "sing-box") and node_protocol == "_shunt" then default_node_type = node_protocol + local node_shunt_group = api.uci_get_type_id(node, "shunt_group") uci:foreach(appname, "shunt_rules", function(e) - if e[".name"] and e.remarks then + if e[".name"] and e.remarks and e.group == node_shunt_group then shunt_rule_list[#shunt_rule_list + 1] = e end end) @@ -36,12 +37,13 @@ table td, .table .td { display: none; width: 30rem; position: fixed; - top:50%; - padding-top: 30px; + top:45%; z-index: 99; text-align: center; background: white; box-shadow: darkgrey 10px 10px 30px 5px; + text-align: center; + padding: 0.5em; } ._now_use_bg { @@ -183,8 +185,205 @@ table td, .table .td { } -<% if api.is_js_luci() then -%> + + <%- else %> - <%- end %> + + + +
+
+ + + + + + + + + +
+
-
-
-
- <%:You choose node is:%> + + +