From 4a4dfac1a2b792bfbd2a243a4ef3c998c7d0dc48 Mon Sep 17 00:00:00 2001 From: action Date: Mon, 7 Sep 2026 01:57:13 +0800 Subject: [PATCH] update 2026-09-07 01:57:13 --- dae/Makefile | 3 +- dae/files/dae-guard | 39 +++ dae/files/dae.init | 8 +- daed/Makefile | 2 +- daed/files/daed-guard | 67 +++- daed/files/daed.init | 4 +- lean/automount/Makefile | 2 +- lean/automount/files/15-automount | 3 + luci-app-daede/Makefile | 5 +- .../resources/view/daede/backend.js | 62 +++- .../resources/view/daede/converter.js | 310 +++++++++++++++--- .../resources/view/daede/styles.js | 1 + .../resources/view/daede/widgets.js | 117 +++++-- luci-app-daede/po/templates/daede.pot | 6 + luci-app-daede/po/zh-cn/daede.po | 6 + luci-app-daede/po/zh_Hans/daede.po | 6 + .../usr/share/luci-app-daede/config-backup.sh | 18 + .../usr/share/luci-app-daede/snapshot-file.sh | 81 +++++ .../usr/share/rpcd/acl.d/luci-app-daede.json | 9 +- luci-app-daede/root/www/cgi-bin/daede-sub | 10 +- luci-app-syncthing/Makefile | 22 ++ luci-app-syncthing/README.md | 2 + .../luasrc/controller/syncthing.lua | 17 + .../luasrc/model/cbi/syncthing.lua | 48 +++ .../view/syncthing/syncthing_status.htm | 27 ++ .../root/etc/uci-defaults/luci-syncthing | 12 + .../share/rpcd/acl.d/luci-app-syncthing.json | 11 + 27 files changed, 796 insertions(+), 102 deletions(-) create mode 100644 dae/files/dae-guard create mode 100644 luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh create mode 100644 luci-app-syncthing/Makefile create mode 100644 luci-app-syncthing/README.md create mode 100644 luci-app-syncthing/luasrc/controller/syncthing.lua create mode 100644 luci-app-syncthing/luasrc/model/cbi/syncthing.lua create mode 100644 luci-app-syncthing/luasrc/view/syncthing/syncthing_status.htm create mode 100644 luci-app-syncthing/root/etc/uci-defaults/luci-syncthing create mode 100644 luci-app-syncthing/root/usr/share/rpcd/acl.d/luci-app-syncthing.json diff --git a/dae/Makefile b/dae/Makefile index 85103773..ba0701b6 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae PKG_VERSION:=2026.08.28 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE:=dae-src-2026.08.28-d6aca8a10b35.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src @@ -127,6 +127,7 @@ define Package/dae/install $(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR)) $(INSTALL_DIR) $(1)/usr/bin $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/dae $(1)/usr/bin/dae + $(INSTALL_BIN) $(CURDIR)/files/dae-guard $(1)/usr/bin/dae-guard $(INSTALL_DIR) $(1)/etc/dae $(INSTALL_CONF) $(PKG_BUILD_DIR)/example.dae $(1)/etc/dae/example.dae diff --git a/dae/files/dae-guard b/dae/files/dae-guard new file mode 100644 index 00000000..6d6f2cd7 --- /dev/null +++ b/dae/files/dae-guard @@ -0,0 +1,39 @@ +#!/bin/sh + +STATE="/var/run/daede-dae.ready" +LOG="/var/log/dae/dae.log" +READY="Total startup time:" +PID="$$" +watcher_pid= + +mark_state() { + local value="$1" tmp="${STATE}.${PID}" + printf '%s %s\n' "$PID" "$value" > "$tmp" && mv -f "$tmp" "$STATE" +} + +watch_ready() { + local offset size + offset="$(wc -c < "$LOG" 2>/dev/null || echo 0)" + mark_state starting + ( + while kill -0 "$PID" 2>/dev/null; do + size="$(wc -c < "$LOG" 2>/dev/null || echo 0)" + [ "$size" -lt "$offset" ] && offset=0 + if [ -r "$LOG" ] && tail -c "+$((offset + 1))" "$LOG" 2>/dev/null | grep -Fq "$READY"; then + # Check before and after the atomic publish. If dae exits during this + # window, remove the ready marker rather than leaving a false positive. + [ "$(cat "$STATE" 2>/dev/null)" = "$PID starting" ] || exit 1 + kill -0 "$PID" 2>/dev/null || exit 1 + mark_state ready || exit 1 + kill -0 "$PID" 2>/dev/null || { rm -f "$STATE" "$STATE".*; exit 1; } + [ "$(cat "$STATE" 2>/dev/null)" = "$PID ready" ] || exit 1 + exit 0 + fi + sleep 1 + done + ) >/dev/null 2>&1 & + watcher_pid=$! +} + +watch_ready +exec /usr/bin/dae "$@" diff --git a/dae/files/dae.init b/dae/files/dae.init index 138d41cb..73fbe0dd 100644 --- a/dae/files/dae.init +++ b/dae/files/dae.init @@ -7,7 +7,7 @@ START=99 extra_command "hot_reload" "Hot-reload service" CONF="dae" -PROG="/usr/bin/dae" +PROG="/usr/bin/dae-guard" LOG_DIR="/var/log/dae" start_service() { @@ -20,12 +20,11 @@ start_service() { local config_file config_get config_file "config" "config_file" "/etc/dae/config.dae" - "$PROG" validate -c "$config_file" || return 1 + /usr/bin/dae validate -c "$config_file" || return 1 local log_maxbackups log_maxsize config_get log_maxbackups "config" "log_maxbackups" "1" config_get log_maxsize "config" "log_maxsize" "1" - procd_open_instance "$CONF" procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)" procd_set_param command "$PROG" run @@ -58,6 +57,7 @@ stop_service() { fi # clean orphaned dae0 ip link del dae0 2>/dev/null || true + rm -f /var/run/daede-dae.ready /var/run/daede-dae.ready.* } service_triggers() { @@ -65,5 +65,5 @@ service_triggers() { } hot_reload() { - /etc/init.d/dae running && "$PROG" reload "$(cat /var/run/dae.pid)" + /etc/init.d/dae running && /usr/bin/dae reload "$(cat /var/run/dae.pid)" } diff --git a/daed/Makefile b/daed/Makefile index 267a3146..d7ce034f 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed PKG_VERSION:=2026.08.28 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE:=daed-src-2026.08.28-73f01b995c34.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src diff --git a/daed/files/daed-guard b/daed/files/daed-guard index cc6e5cf5..34076ff9 100644 --- a/daed/files/daed-guard +++ b/daed/files/daed-guard @@ -2,16 +2,68 @@ . /usr/share/daed/cleanup.sh +STATE="/var/run/daede-daed.ready" +LOG="/var/log/daed/daed.log" +READY="Reload: Finished" +PID="$$" +watcher_pid= + +mark_state() { + local value="$1" tmp="${STATE}.${PID}" + printf '%s %s\n' "$PID" "$value" > "$tmp" && mv -f "$tmp" "$STATE" +} + +watch_ready() { + local offset size + offset="$(wc -c < "$LOG" 2>/dev/null || echo 0)" + ( + while kill -0 "$PID" 2>/dev/null; do + size="$(wc -c < "$LOG" 2>/dev/null || echo 0)" + [ "$size" -lt "$offset" ] && offset=0 + if [ -r "$LOG" ] && tail -c "+$((offset + 1))" "$LOG" 2>/dev/null | grep -Fq "$READY"; then + [ "$(cat "$STATE" 2>/dev/null)" = "$PID starting" ] && mark_state ready + exit 0 + fi + sleep 1 + done + ) >/dev/null 2>&1 & + watcher_pid=$! +} + +stop_watcher() { + [ -n "$watcher_pid" ] || return 0 + kill "$watcher_pid" 2>/dev/null + wait "$watcher_pid" 2>/dev/null + watcher_pid= +} + +mark_state starting + if ! daed_cleanup_runtime; then echo "daed: stale network state could not be removed" >&2 exit 1 fi +watch_ready + # Keep daed as a child so a panic or unexpected exit is followed by an # immediate teardown of all kernel/runtime state before procd can respawn us. child_pid= +term_requested=0 cleanup_child() { - [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null + term_requested=1 + [ -n "$child_pid" ] || return 0 + kill "$child_pid" 2>/dev/null + # Do not let an interrupted wait leave a daed child behind. Four one-second + # polls give a graceful TERM about four seconds before the bounded KILL. + local attempt=0 + while kill -0 "$child_pid" 2>/dev/null && [ "$attempt" -lt 4 ]; do + sleep 1 + attempt=$((attempt + 1)) + done + if kill -0 "$child_pid" 2>/dev/null; then + kill -9 "$child_pid" 2>/dev/null + fi } trap cleanup_child TERM INT @@ -19,8 +71,21 @@ trap cleanup_child TERM INT child_pid=$! wait "$child_pid" status=$? +# A signal can interrupt ash's wait even though the trap has already asked the +# child to exit. Confirm the process is gone and reap it a second time before +# publishing stopping or running cleanup. +if [ "$term_requested" = 1 ]; then + cleanup_child + wait "$child_pid" 2>/dev/null + reap_status=$? + [ "$status" -eq 0 ] && status="$reap_status" +fi child_pid= trap - TERM INT +# The watcher may have observed the marker at the same time as the child +# exited. Reap it before publishing stopping so it cannot overwrite that state. +stop_watcher +mark_state stopping if ! daed_cleanup_runtime; then echo "daed: runtime cleanup after exit failed" >&2 diff --git a/daed/files/daed.init b/daed/files/daed.init index 887158d8..c91702be 100644 --- a/daed/files/daed.init +++ b/daed/files/daed.init @@ -21,7 +21,6 @@ start_service() { config_get listen_addr "config" "listen_addr" "0.0.0.0:2023" config_get log_maxbackups "config" "log_maxbackups" "1" config_get log_maxsize "config" "log_maxsize" "5" - procd_open_instance "$CONF" procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)" procd_set_param command "$PROG" run @@ -45,6 +44,9 @@ start_service() { stop_service() { rm -f "$LOG" daed_cleanup_runtime + local status=$? + rm -f /var/run/daede-daed.ready /var/run/daede-daed.ready.* + return "$status" } restart() { diff --git a/lean/automount/Makefile b/lean/automount/Makefile index bdb334de..faa13a53 100644 --- a/lean/automount/Makefile +++ b/lean/automount/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=automount PKG_VERSION:=1 -PKG_RELEASE:=40 +PKG_RELEASE:=41 PKG_ARCH:=all include $(INCLUDE_DIR)/package.mk diff --git a/lean/automount/files/15-automount b/lean/automount/files/15-automount index bb222e81..f4d3f5cf 100755 --- a/lean/automount/files/15-automount +++ b/lean/automount/files/15-automount @@ -6,6 +6,9 @@ blkdev=`dirname $DEVPATH` basename=`basename $blkdev` device=`basename $DEVPATH` + +grep -qs "^/dev/$device " /proc/mounts && exit 0 + skip=`block info | grep -vE '(f2fs|kernel|squashfs)' | sed 's/\(.*\): .*/\1/' | grep -q $device ; echo $?` path=$DEVPATH diff --git a/luci-app-daede/Makefile b/luci-app-daede/Makefile index c6faad62..3b39a983 100644 --- a/luci-app-daede/Makefile +++ b/luci-app-daede/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-daede -PKG_VERSION:=1.14.7 -PKG_RELEASE:=25 +PKG_VERSION:=1.15 +PKG_RELEASE:=1 PKG_MAINTAINER:=kenzok8 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) @@ -78,6 +78,7 @@ define Package/$(PKG_NAME)/install $(INSTALL_BIN) ./root/usr/share/luci-app-daede/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-backup.sh $(1)/usr/share/luci-app-daede/config-backup.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/snapshot-file.sh $(1)/usr/share/luci-app-daede/snapshot-file.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-defaults.sh $(1)/usr/share/luci-app-daede/config-defaults.sh $(INSTALL_DIR) $(1)/usr/share/luci-app-daede/defaults $(INSTALL_DATA) $(CURDIR)/../dae/files/dae.config $(1)/usr/share/luci-app-daede/defaults/dae diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js index f141f745..fd298086 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js @@ -23,7 +23,9 @@ const BACKENDS = { pkg: 'daed', hasWebUI: true, useNetns: true, - defaultListen: '0.0.0.0:2023' + defaultListen: '0.0.0.0:2023', + readyState: '/var/run/daede-daed.ready', + readyPattern: /Reload: Finished/ }, dae: { name: 'dae', @@ -34,7 +36,9 @@ const BACKENDS = { pkg: 'dae', config: '/etc/dae/config.dae', hasWebUI: false, - useNetns: false + useNetns: false, + readyState: '/var/run/daede-dae.ready', + readyPattern: /Total startup time:/ } }; @@ -64,6 +68,56 @@ function serviceStatus(name) { }); } +function serviceLogOffset(be) { + if (!be || !be.log) + return Promise.resolve(0); + + return L.resolveDefault(fs.read_direct(be.log, 'text'), '').then(function(content) { + return String(content || '').length; + }); +} + +function serviceReady(be, afterOffset, pid) { + if (!be || !be.log || !be.readyPattern) + return Promise.resolve(false); + + const expectedPid = Number(pid) || 0; + const notFound = function(error) { + const code = String(error && (error.code || error.errno || error.name) || '').toUpperCase(); + const message = String(error && (error.message || error) || '').toUpperCase(); + return code === 'ENOENT' || code === '-2' || code === 'NOTFOUNDERROR' || + /ENOENT|NO SUCH FILE|RESOURCE NOT FOUND/.test(message); + }; + const readState = be.readyState + ? fs.stat(be.readyState).then(function(stat) { + // A successful stat makes the state file authoritative. A subsequent + // read error must fail closed instead of falling back to an old log line. + if (!stat) + return { present: true, error: true }; + return fs.read_direct(be.readyState, 'text').then(function(value) { + return { present: true, value: String(value || '').trim() }; + }, function() { + return { present: true, error: true }; + }); + }, function(error) { + return notFound(error) ? { present: false } : { present: true, error: true }; + }) + : Promise.resolve({ present: true, error: true }); + + return readState.then(function(result) { + if (result.present) + return !result.error && expectedPid > 0 && result.value === expectedPid + ' ready'; + + // Compatibility fallback for a luci-app upgrade before the matching dae or + // daed package. It is allowed only when stat explicitly reported ENOENT. + return L.resolveDefault(fs.read_direct(be.log, 'text'), '').then(function(content) { + const text = String(content || ''); + const offset = Number(afterOffset) || 0; + return be.readyPattern.test(text.slice(text.length < offset ? 0 : offset)); + }); + }); +} + function detectInstalledBackends() { return Promise.all([ exists(BACKENDS.dae.initd), @@ -136,5 +190,7 @@ return baseclass.extend({ detectRunning: detectRunning, detectBackend: detectBackend, setActiveBackend: setActiveBackend, - serviceStatus: serviceStatus + serviceStatus: serviceStatus, + serviceLogOffset: serviceLogOffset, + serviceReady: serviceReady }); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js index ed59b5c9..455460ca 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js @@ -13,6 +13,7 @@ const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh'; const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh'; +const SNAPSHOT_HELPER = '/usr/share/luci-app-daede/snapshot-file.sh'; const SUB_STAGE = '/tmp/daede-sub.txt'; const FETCH_CHUNK_BYTES = 16384; @@ -651,11 +652,19 @@ return view.extend({ const name = airportName.value.trim(); const groupName = airportSync.backendGroupName(name, 'daed', airportId); const endpoint = daedEndpoint(); - // daed can't read file:// — it HTTP-fetches a subscription link. Stage - // the converted links (base64) and let daed pull them from the - // loopback-only CGI, so the import lands as ONE subscription. - const subToken = airportSync.backendId(airportId) + Date.now().toString(36); - const stageFile = '/tmp/daede-daedsub-' + subToken; + // daed can't read file:// — it HTTP-fetches a subscription link. Persist + // the converted snapshot and let daed pull it from the loopback-only CGI, + // so later daed refreshes and configuration backups keep working. + // Keep the CGI token opaque, bounded, and unique across imports started in + // the same millisecond. The final sanitization also protects the filename + // and the CGI's allow-list if backendId ever changes. + const tokenTime = Date.now().toString(36); + const tokenRandom = Math.random().toString(36).slice(2, 12); + const tokenPrefix = airportSync.backendId(airportId).replace(/[^A-Za-z0-9]/g, '') + .slice(0, Math.max(0, 64 - tokenTime.length - tokenRandom.length)); + const subToken = (tokenPrefix + tokenTime + tokenRandom).slice(0, 64); + const snapshotFile = '/etc/daed/daede-sub-' + subToken; + const stageFile = '/etc/daed/.daede-sub-stage-' + subToken; const subUrl = 'http://127.0.0.1/cgi-bin/daede-sub?t=' + subToken; const b64 = btoa(items.map(function(item) { return item.link; }).join('\n') + '\n'); @@ -664,15 +673,156 @@ return view.extend({ let createdGroupId = ''; const createdNodeIds = []; let groupReady = false; + let snapshotCommitted = false; + let snapshotFinalToken = ''; + let reuseExistingSnapshot = false; const oldSubId = existingAirport ? existingAirport.subscription_id : ''; const oldNodeIds = existingAirport ? existingAirport.node_ids : []; - const dropStage = function() { return fs.exec('/bin/rm', [ '-f', stageFile ]).catch(function() {}); }; + const dropSnapshot = function(path) { + if (!path) + return Promise.resolve(); + return fs.remove(path).catch(function(error) { + const message = String(error && (error.message || error) || ''); + if (/not found|no such file|enoent/i.test(message)) + return; + throw error; + }); + }; + const appendWarning = function(error, warning) { + const result = error instanceof Error ? error : new Error(String(error && (error.message || error) || error)); + result.message = result.message + '; ' + warning; + return result; + }; + const snapshotAction = function(args) { + return fs.exec(SNAPSHOT_HELPER, args).then(function(res) { + if (!res || res.code !== 0) + throw new Error((res && (res.stderr || res.stdout)) || _('Snapshot operation failed')); + return res; + }); + }; + const cleanupStage = function() { + return snapshotAction([ 'discard', subToken ]); + }; + const querySubscriptions = function() { + if (!token) + return Promise.reject(new Error(_('daed subscription cleanup could not be verified'))); + return graphQL(endpoint, 'query SubscriptionLinks{subscriptions{id link}}', {}, token).then(function(value) { + if (!value || !Array.isArray(value.subscriptions)) + throw new Error(_('daed returned an invalid subscription list')); + return value.subscriptions; + }); + }; + const dropSnapshotIfUnreferenced = function() { + if (snapshotFinalToken !== subToken) + return Promise.resolve(); + return querySubscriptions().then(function(subscriptions) { + if (subscriptions.some(function(sub) { return String(sub.link || '') === subUrl; })) + throw new Error(_('new daed subscription snapshot is still referenced; it was kept')); + return dropSnapshot(snapshotFile); + }); + }; + const finishWithSnapshotCleanup = function(result) { + return dropSnapshotIfUnreferenced().then(function() { + return result; + }).catch(function(error) { + const warning = String(error && (error.message || error) || error); + result.warning = result.warning + ? result.warning + '; ' + warning + : warning; + return result; + }); + }; + const updateExistingSubscription = function() { + const old = (((before || {}).subscriptions) || []).find(function(sub) { return sub.id === oldSubId; }); + if (!old) + return Promise.reject(new Error(_('Managed subscription no longer exists in daed'))); + + const oldLink = String(old.link || ''); + const localToken = oldLink.match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); + const refresh = function() { + return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token); + }; + const currentLink = function() { + return querySubscriptions().then(function(subscriptions) { + const current = subscriptions.find(function(sub) { return sub.id === oldSubId; }); + return current ? String(current.link || '') : ''; + }); + }; + const restoreOldLink = function(originalError) { + return graphQL(endpoint, 'mutation RestoreLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { + id: oldSubId, + link: oldLink + }, token).then(refresh).then(currentLink).then(function(link) { + if (link !== oldLink) { + snapshotCommitted = true; + throw appendWarning(originalError, _('rollback was not confirmed; both subscription snapshots were kept')); + } + throw originalError; + }, function(rollbackError) { + snapshotCommitted = true; + throw appendWarning(originalError, _('rollback could not be confirmed; both subscription snapshots were kept: %s').format(String(rollbackError && (rollbackError.message || rollbackError) || rollbackError))); + }); + }; + const finish = function() { + const tag = old.tag !== groupName + ? graphQL(endpoint, 'mutation TagSub($id:ID!,$tag:String!){tagSubscription(id:$id,tag:$tag)}', { + id: oldSubId, + tag: groupName + }, token).catch(function(error) { + return String(error && (error.message || error) || error); + }) + : Promise.resolve(''); + return tag.then(function(tagWarning) { + writeAirportRecord(existingAirport, { + id: airportId, + backend: 'daed', + name: name, + sourceHash: state.sourceHash, + groupId: existingAirport.group_id, + subscriptionId: oldSubId, + nodeIds: [] + }); + return applyUciChanges().then(function() { + items.forEach(function(item) { item.duplicate = true; item.selected = false; }); + return { + added: items.length, + duplicates: 0, + failed: 0, + warning: tagWarning + }; + }); + }); + }; + + if (localToken && reuseExistingSnapshot) { + return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token) + .then(currentLink).then(function(link) { + if (link !== oldLink) + throw new Error(_('daed did not confirm the existing subscription link after refresh')); + }).catch(function(error) { + throw appendWarning(error, _('daed subscription refresh failed; the new snapshot remains at the existing link and existing nodes were kept')); + }).then(finish); + } + + return graphQL(endpoint, 'mutation SetLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { + id: oldSubId, + link: subUrl + }, token).then(refresh).then(function() { + return currentLink().then(function(link) { + if (link !== subUrl) + throw new Error(_('daed did not confirm the new subscription link')); + }); + }).catch(restoreOldLink).then(function() { + snapshotCommitted = true; + return finish(); + }); + }; const loadState = function(forceLogin) { return requestDaedToken(endpoint, forceLogin).then(function(auth) { token = auth.token; usedCachedToken = auth.cached; - return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token); + return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}} subscriptions{id link tag}}', {}, token); }); }; const importDirectNodes = function() { @@ -797,7 +947,7 @@ return view.extend({ }); }; - return fs.write(stageFile, b64).then(function() { + return fs.write(stageFile, b64, 384).then(function() { return loadState(false).catch(function(error) { if (!usedCachedToken || !daedSession.isAccessDenied(error)) throw error; @@ -806,42 +956,63 @@ return view.extend({ }); }).then(function(dataValue) { before = dataValue; - // drop this airport's previous subscription first: the new one - // reuses the same tag (group name) and daed enforces unique tags. - if (!oldSubId) - return null; - return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {}); - }).then(function() { - // import the whole converted batch as one subscription - return graphQL(endpoint, - 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}', - { a: { link: subUrl, tag: groupName } }, token).then(function(result) { - const sub = result.importSubscription && result.importSubscription.sub; - const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || []; - const usable = rows.length - ? rows.some(function(r) { return !r.error || r.error === 'node already exists'; }) - : !!(sub && sub.id); - if (!sub || !sub.id || !usable) { - const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; '); - if (sub && sub.id) - newSubId = sub.id; - throw new Error(details || _('No usable nodes were imported')); - } - return result; - }).catch(function(error) { - if (daedSession.isAccessDenied(error)) - throw error; - const cleanupSub = newSubId - ? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function() {}) - : Promise.resolve(); - newSubId = ''; - return cleanupSub.then(importDirectNodes).then(function(result) { - return { directResult: result }; - }); + const old = (before.subscriptions || []).find(function(sub) { return sub.id === oldSubId; }); + const localToken = old && String(old.link || '').match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); + const oldLinkReferences = localToken + ? (before.subscriptions || []).filter(function(sub) { return String(sub.link || '') === String(old.link || ''); }) + : []; + reuseExistingSnapshot = !!(localToken && oldLinkReferences.length === 1 && String(oldLinkReferences[0].id) === String(oldSubId)); + const publish = reuseExistingSnapshot + ? snapshotAction([ 'replace', localToken[1], subToken ]).then(function() { + snapshotFinalToken = localToken[1]; + snapshotCommitted = true; + }) + : snapshotAction([ 'publish', subToken ]).then(function() { + snapshotFinalToken = subToken; }); + return publish.then(function() { + if (oldSubId) + return updateExistingSubscription().then(function(result) { return { existingResult: result }; }); + // import the whole converted batch as one subscription + return graphQL(endpoint, + 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}', + { a: { link: subUrl, tag: groupName } }, token).then(function(result) { + const sub = result.importSubscription && result.importSubscription.sub; + const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || []; + const usable = rows.length + ? rows.some(function(r) { return !r.error || r.error === 'node already exists'; }) + : !!(sub && sub.id); + if (!sub || !sub.id || !usable) { + const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; '); + if (sub && sub.id) + newSubId = sub.id; + throw new Error(details || _('No usable nodes were imported')); + } + return result; + }).catch(function(error) { + if (daedSession.isAccessDenied(error)) + throw error; + const cleanupSub = newSubId + ? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { + snapshotCommitted = true; + throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); + }) + : Promise.resolve(); + newSubId = ''; + return cleanupSub.then(function() { + if (oldSubId) + throw error; + return importDirectNodes(); + }).then(function(result) { + return { directResult: result }; + }); + }); + }); }).then(function(result) { + if (result.existingResult) + return result.existingResult; if (result.directResult) - return result.directResult; + return finishWithSnapshotCleanup(result.directResult); const sub = result.importSubscription && result.importSubscription.sub; newSubId = sub.id; @@ -861,6 +1032,7 @@ return view.extend({ return ensureGroup.then(function(groupId) { return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token).then(function() { groupReady = true; + snapshotCommitted = true; const cleanup = []; // Migrate converter-managed airport groups to proxy, but never // disturb proxy's existing nodes or other subscriptions. @@ -880,7 +1052,12 @@ return view.extend({ }); return applyUciChanges().then(function() { items.forEach(function(item) { item.duplicate = true; item.selected = false; }); - return { added: items.length, duplicates: 0, failed: failed }; + return { + added: items.length, + duplicates: 0, + failed: failed, + warning: '' + }; }); }); }); @@ -892,15 +1069,47 @@ return view.extend({ throw error; const cleanup = []; if (newSubId) - cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token)); + cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { + snapshotCommitted = true; + throw cleanupError; + })); if (createdNodeIds.length) cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token)); if (createdGroupId) cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token)); - return Promise.all(cleanup).catch(function() {}).then(function() { throw error; }); - }).finally(function() { - token = ''; - return dropStage(); + return Promise.all(cleanup).then(function() { + throw error; + }, function(cleanupError) { + snapshotCommitted = true; + throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); + }); + }).then(function(result) { + return cleanupStage().then(function() { + token = ''; + return result; + }, function(cleanupError) { + token = ''; + result.warning = result.warning + ? result.warning + '; ' + String(cleanupError && (cleanupError.message || cleanupError) || cleanupError) + : String(cleanupError && (cleanupError.message || cleanupError) || cleanupError); + return result; + }); + }, function(error) { + let cleanupWarning = ''; + const removeSnapshot = snapshotCommitted + ? Promise.resolve() + : dropSnapshotIfUnreferenced().catch(function(cleanupError) { + cleanupWarning = _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)); + }); + return removeSnapshot.then(function() { + return cleanupStage().catch(function(stageError) { + const warning = _('snapshot stage cleanup failed: %s').format(String(stageError && (stageError.message || stageError) || stageError)); + cleanupWarning = cleanupWarning ? cleanupWarning + '; ' + warning : warning; + }); + }).then(function() { + token = ''; + throw cleanupWarning ? appendWarning(error, cleanupWarning) : error; + }); }); }; @@ -924,8 +1133,11 @@ return view.extend({ setImportStatus(_('Importing node group…')); const action = state.target === 'dae' ? importDae(items) : importDaed(items); action.then(function(result) { - setImportStatus(_('Node group imported: added %d, reused %d, failed %d') - .format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok'); + let message = _('Node group imported: added %d, reused %d, failed %d') + .format(result.added, result.duplicates, result.failed); + if (result.warning) + message += ' ' + _('Completed with warning: %s').format(result.warning); + setImportStatus(message, result.failed || result.warning ? 'err' : 'ok'); renderResults(); }).catch(function(e) { setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err'); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js index 2525f899..d9ae9276 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js @@ -16,6 +16,7 @@ const CSS = [ '.dd-status-row .dd-grow{flex:1 1 auto}', '.dd-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 10px;border-radius:999px;font-size:10.5px;font-weight:700;letter-spacing:.3px;border:1px solid transparent;line-height:1.3}', '.dd-badge-run{color:#3da66a;border-color:rgba(61,166,106,.5)}', + '.dd-badge-wait{color:#d6a23d;border-color:rgba(214,162,61,.55)}', '.dd-badge-stop{color:#d96d6d;border-color:rgba(217,109,109,.55)}', '.dd-badge-dot{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block}', '.dd-meta{font-size:11.5px;opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}', diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js index 0686bb6c..1464174d 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js @@ -23,8 +23,7 @@ function execInit(be, action) { } function restartService(be) { - return execChecked(be.initd, ['restart']) - .then(function() { return waitForService(be, true, 80); }); + return execChecked(be.initd, ['restart']); } function rejectIfOtherRunning(be, running) { @@ -60,14 +59,26 @@ function toggleService(be, turnOn) { .then(function() { return execChecked(be.initd, [action]); }); } -function waitForService(be, turnOn, attempts) { +function waitForService(be, turnOn, attempts, readyAfter) { return backend.serviceStatus(be.name).then(function(state) { - if (!!state.running === turnOn) + if (!turnOn && !state.running) + return state; + if (turnOn && state.running) + return backend.serviceReady(be, readyAfter, state.pid).then(function(ready) { + if (ready) { + state.ready = true; + return state; + } + return null; + }); + return null; + }).then(function(state) { + if (state) return state; if (attempts <= 0) throw new Error(turnOn ? _('Service did not start in time.') : _('Service did not stop in time.')); return new Promise(function(resolve) { setTimeout(resolve, 250); }) - .then(function() { return waitForService(be, turnOn, attempts - 1); }); + .then(function() { return waitForService(be, turnOn, attempts - 1, readyAfter); }); }); } @@ -156,6 +167,21 @@ function renderStatusCard(ctx, listenAddr) { let busy = false; let lastError = ''; let refreshGeneration = 0; + const boundaryKey = 'daede.ready-boundary.' + window.location.host + '.' + be.name; + let readyBoundary = null; + try { + const savedBoundary = window.localStorage.getItem(boundaryKey); + if (savedBoundary !== null && /^\d+$/.test(savedBoundary)) + readyBoundary = Number(savedBoundary); + } catch (e) {} + const rememberBoundary = function(offset) { + readyBoundary = Number(offset) || 0; + try { window.localStorage.setItem(boundaryKey, String(readyBoundary)); } catch (e) {} + }; + const clearBoundary = function() { + readyBoundary = null; + try { window.localStorage.removeItem(boundaryKey); } catch (e) {} + }; const body = E('div', { 'id': 'dd-status-body' }, E('em', {}, _('Collecting data…'))); const card = E('div', { 'class': 'dd-card dd-status-card' }, [ E('h4', { 'class': 'dd-card-title' }, _('Service Status')), @@ -165,7 +191,10 @@ function renderStatusCard(ctx, listenAddr) { const render = function(state) { while (body.firstChild) body.removeChild(body.firstChild); - const badge = state.running + const starting = !!state.starting; + const badge = starting + ? E('span', { 'class': 'dd-badge dd-badge-wait' }, [ E('span', { 'class': 'dd-badge-dot' }), _('Starting…') ]) + : state.running ? E('span', { 'class': 'dd-badge dd-badge-run' }, [ E('span', { 'class': 'dd-badge-dot' }), _('RUNNING') ]) : E('span', { 'class': 'dd-badge dd-badge-stop' }, [ E('span', { 'class': 'dd-badge-dot' }), _('STOPPED') ]); @@ -177,7 +206,12 @@ function renderStatusCard(ctx, listenAddr) { meta.push(E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, 'PID'), state.pid ])); const swErr = E('span', { 'class': 'dd-meta dd-err', 'style': lastError ? '' : 'display:none' }, lastError); - const sw = E('button', { 'class': 'dd-switch' + (state.running ? ' is-on' : ''), 'type': 'button', 'aria-label': _('Toggle service') }, [ + const sw = E('button', { + 'class': 'dd-switch' + (state.running ? ' is-on' : ''), + 'type': 'button', + 'aria-label': _('Toggle service'), + 'disabled': busy ? '' : null + }, [ E('span', { 'class': 'dd-switch-knob' }) ]); sw.addEventListener('click', function(ev) { @@ -185,21 +219,25 @@ function renderStatusCard(ctx, listenAddr) { if (busy) return; refreshGeneration++; busy = true; - sw.disabled = true; lastError = ''; - swErr.style.display = 'none'; const turnOn = !state.running; - /* instant optimistic feedback — the start/stop chain (esp. dae's eBPF - load) takes a few seconds; flip the switch and show a pending label - right away instead of looking frozen */ - sw.classList.toggle('is-on', turnOn); - const lbl = sw.parentNode && sw.parentNode.querySelector('.dd-switch-label'); - if (lbl) lbl.textContent = '…'; - toggleService(be, turnOn) - .then(function() { return waitForService(be, turnOn, 40); }) - .then(function() { - busy = false; - return refresh(true); + /* Keep process liveness separate from data-plane readiness. The process + appears in procd before dae has attached its rules, so show the real + transition until the backend's final startup marker is logged. */ + render({ running: turnOn, pid: 0, starting: turnOn }); + if (!turnOn) + clearBoundary(); + const boundary = turnOn ? backend.serviceLogOffset(be) : Promise.resolve(0); + boundary.then(function(offset) { + if (turnOn) + rememberBoundary(offset); + return toggleService(be, turnOn) + .then(function() { return waitForService(be, turnOn, turnOn ? 240 : 40, offset); }); + }) + .then(function() { + busy = false; + clearBoundary(); + return refresh(true); }) .catch(function(e) { busy = false; @@ -214,7 +252,7 @@ function renderStatusCard(ctx, listenAddr) { E('span', { 'class': 'dd-grow' }), swErr, E('span', { 'class': 'dd-switch-wrap' }, [ - E('span', { 'class': 'dd-switch-label' }, state.running ? 'ON' : 'OFF'), + E('span', { 'class': 'dd-switch-label' }, starting ? _('Starting…') : (state.running ? 'ON' : 'OFF')), sw ]) ]); @@ -223,7 +261,7 @@ function renderStatusCard(ctx, listenAddr) { body.appendChild(E('div', { 'class': 'dd-status-meta' }, meta)); const actions = []; - if (be.hasWebUI && state.running) { + if (be.hasWebUI && state.running && !starting) { const port = (listenAddr || be.defaultListen).split(':').slice(-1)[0]; actions.push(E('a', { 'class': 'cbi-button cbi-button-action', @@ -232,19 +270,23 @@ function renderStatusCard(ctx, listenAddr) { 'rel': 'noreferrer noopener' }, _('Open WebUI'))); } - if (state.running) { + if (state.running && !starting) { const restart = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Restart')); restart.addEventListener('click', function(ev) { ev.preventDefault(); if (busy) return; busy = true; - restart.disabled = true; lastError = ''; - swErr.style.display = 'none'; - restartService(be) - .then(function() { - busy = false; - return refresh(true); + render({ running: true, pid: 0, starting: true }); + backend.serviceLogOffset(be).then(function(offset) { + rememberBoundary(offset); + return restartService(be) + .then(function() { return waitForService(be, true, 240, offset); }); + }) + .then(function() { + busy = false; + clearBoundary(); + return refresh(true); }) .catch(function(e) { busy = false; @@ -254,7 +296,7 @@ function renderStatusCard(ctx, listenAddr) { }); actions.push(restart); } - if (state.running && be.name === 'dae') { + if (state.running && !starting && be.name === 'dae') { const hot = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Hot Reload')); hot.addEventListener('click', function(ev) { ev.preventDefault(); @@ -263,7 +305,7 @@ function renderStatusCard(ctx, listenAddr) { }); actions.push(hot); } - if (state.running && be.name === 'dae') { + if (state.running && !starting && be.name === 'dae') { const ckBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Test YouTube')); const ckRes = E('span', { 'class': 'dd-meta', style: 'margin-left:8px;display:none' }, ''); ckBtn.addEventListener('click', function(ev) { @@ -308,7 +350,18 @@ function renderStatusCard(ctx, listenAddr) { const generation = refreshGeneration; return backend.serviceStatus(be.name).then(function(state) { if (generation !== refreshGeneration || (busy && !force)) return; - render(state); + if (!state.running) { + clearBoundary(); + render(state); + return; + } + return backend.serviceReady(be, readyBoundary, state.pid).then(function(ready) { + if (generation !== refreshGeneration || (busy && !force)) return; + state.starting = !ready; + if (ready) + clearBoundary(); + render(state); + }); }); }; diff --git a/luci-app-daede/po/templates/daede.pot b/luci-app-daede/po/templates/daede.pot index 4dca0858..84705320 100644 --- a/luci-app-daede/po/templates/daede.pot +++ b/luci-app-daede/po/templates/daede.pot @@ -812,6 +812,12 @@ msgstr "" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "" +msgid "Completed with warning: %s" +msgstr "" + +msgid "Managed subscription no longer exists in daed" +msgstr "" + msgid "Node group import failed: %s" msgstr "" diff --git a/luci-app-daede/po/zh-cn/daede.po b/luci-app-daede/po/zh-cn/daede.po index 67cb0230..1a95c417 100644 --- a/luci-app-daede/po/zh-cn/daede.po +++ b/luci-app-daede/po/zh-cn/daede.po @@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" +msgid "Completed with warning: %s" +msgstr "已完成,但有警告:%s" + +msgid "Managed subscription no longer exists in daed" +msgstr "daed 中已不存在该托管订阅" + msgid "Node group import failed: %s" msgstr "节点组导入失败:%s" diff --git a/luci-app-daede/po/zh_Hans/daede.po b/luci-app-daede/po/zh_Hans/daede.po index 67cb0230..1a95c417 100644 --- a/luci-app-daede/po/zh_Hans/daede.po +++ b/luci-app-daede/po/zh_Hans/daede.po @@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" +msgid "Completed with warning: %s" +msgstr "已完成,但有警告:%s" + +msgid "Managed subscription no longer exists in daed" +msgstr "daed 中已不存在该托管订阅" + msgid "Node group import failed: %s" msgstr "节点组导入失败:%s" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh b/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh index c76838ad..1f93b56b 100755 --- a/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh +++ b/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh @@ -31,6 +31,12 @@ paths() { case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac printf 'etc/dae/subscriptions/%s\n' "$name" done + for sub in "$1"/etc/daed/daede-sub-*; do + [ -e "$sub" ] || [ -L "$sub" ] || continue + name="${sub##*/daede-sub-}" + case "$name" in ''|*[!A-Za-z0-9]*) fail 'unexpected daed subscription filename' ;; esac + printf 'etc/daed/daede-sub-%s\n' "$name" + done } check_paths() { for dir in /etc/config /etc/dae /etc/daed /etc/dae/subscriptions; do @@ -42,6 +48,14 @@ check_paths() { [ ! -e "/$p" ] || [ -f "/$p" ] || fail "not a regular file: /$p" done < "$WORK/paths" } +cleanup_stages() { + for stage in /etc/daed/.daede-sub-stage-*; do + [ -e "$stage" ] || [ -L "$stage" ] || continue + [ ! -L "$stage" ] || fail "refusing symlink: $stage" + [ -f "$stage" ] || fail "not a regular file: $stage" + rm -f "$stage" || fail "failed to remove stale snapshot stage: $stage" + done +} snapshot() { mkdir -p "$WORK/before" while IFS= read -r p; do @@ -139,6 +153,9 @@ validate_archive() { etc/dae/subscriptions/*.sub) name="${p#etc/dae/subscriptions/}" case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'invalid subscription path' ;; esac ;; + etc/daed/daede-sub-*) + name="${p#etc/daed/daede-sub-}" + case "$name" in ''|*[!A-Za-z0-9]*) fail 'invalid daed subscription path' ;; esac ;; *) fail 'unexpected archive entry' ;; esac done < "$WORK/entries" @@ -175,6 +192,7 @@ run_action() { fi fi stop_backends + [ "$ACTION" = reset ] && cleanup_stages snapshot if [ "$ACTION" = export ]; then paths "$WORK/before" > "$WORK/candidates" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh b/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh new file mode 100644 index 00000000..69097768 --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Publish converter snapshots without exposing a partially written final file. + +set -eu + +DIR=/etc/daed +PREFIX=daede-sub +STAGE_PREFIX=.daede-sub-stage + +fail() { + echo "$*" >&2 + exit 1 +} + +valid_token() { + [ "$#" -eq 1 ] || return 1 + case "$1" in + ''|*[!A-Za-z0-9]*) return 1 ;; + esac + [ "${#1}" -le 64 ] +} + +stage_path() { printf '%s/%s-%s\n' "$DIR" "$STAGE_PREFIX" "$1"; } +final_path() { printf '%s/%s-%s\n' "$DIR" "$PREFIX" "$1"; } + +regular_file() { + [ -f "$1" ] && [ ! -L "$1" ] +} + +safe_dir() { + [ -d "$DIR" ] && [ ! -L "$DIR" ] || fail "invalid snapshot directory" +} + +publish() { + [ "$#" -eq 1 ] || fail 'publish expects one token' + valid_token "$1" || fail 'invalid snapshot token' + safe_dir + stage=$(stage_path "$1") + final=$(final_path "$1") + regular_file "$stage" || fail 'snapshot stage is not a regular file' + [ ! -e "$final" ] && [ ! -L "$final" ] || fail 'snapshot already exists' + chmod 600 "$stage" || fail 'cannot secure snapshot stage' + mv -f "$stage" "$final" || fail 'cannot publish snapshot' +} + +replace() { + [ "$#" -eq 2 ] || fail 'replace expects existing and stage tokens' + valid_token "$1" || fail 'invalid existing snapshot token' + valid_token "$2" || fail 'invalid stage snapshot token' + safe_dir + [ "$1" != "$2" ] || fail 'existing and stage tokens must differ' + stage=$(stage_path "$2") + final=$(final_path "$1") + regular_file "$stage" || fail 'snapshot stage is not a regular file' + if [ -e "$final" ] || [ -L "$final" ]; then + regular_file "$final" || fail 'existing snapshot is not a regular file' + fi + chmod 600 "$stage" || fail 'cannot secure snapshot stage' + mv -f "$stage" "$final" || fail 'cannot replace snapshot' +} + +discard() { + [ "$#" -eq 1 ] || fail 'discard expects one token' + valid_token "$1" || fail 'invalid snapshot token' + safe_dir + stage=$(stage_path "$1") + if [ -L "$stage" ]; then + fail 'refusing symlink snapshot stage' + fi + if [ -e "$stage" ]; then + [ -f "$stage" ] || fail 'snapshot stage is not a regular file' + rm -f "$stage" || fail 'cannot discard snapshot stage' + fi +} + +case "${1:-}" in + publish) shift; publish "$@" ;; + replace) shift; replace "$@" ;; + discard) shift; discard "$@" ;; + *) fail 'unknown snapshot action' ;; +esac diff --git a/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json index 7ad9aa86..ca7070f1 100644 --- a/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json +++ b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json @@ -10,6 +10,8 @@ "/etc/dae/example.dae": [ "read" ], "/var/log/dae/dae.log": [ "read" ], "/var/log/daed/daed.log": [ "read" ], + "/var/run/daede-dae.ready": [ "read" ], + "/var/run/daede-daed.ready": [ "read" ], "/usr/share/v2ray/geoip.dat": [ "read" ], "/usr/share/v2ray/geosite.dat": [ "read" ], "/sys/kernel/btf/vmlinux": [ "read" ], @@ -43,9 +45,12 @@ "/tmp/dae-validate.dae": [ "write" ], "/tmp/daede-import.b64": [ "write" ], "/tmp/daede-sub.txt": [ "write" ], - "/tmp/daede-daedsub-*": [ "write" ], - "/bin/rm -f /tmp/daede-daedsub-*": [ "exec" ], + "/etc/daed/.daede-sub-stage-*": [ "write" ], + "/etc/daed/daede-sub-*": [ "write" ], "/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh publish *": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh replace * *": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh discard *": [ "exec" ], "/usr/share/luci-app-daede/gen-dae-config.sh write-sub *": [ "exec" ], "/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ], "/etc/config/daede": [ "write" ], diff --git a/luci-app-daede/root/www/cgi-bin/daede-sub b/luci-app-daede/root/www/cgi-bin/daede-sub index 081acb31..f368d87b 100644 --- a/luci-app-daede/root/www/cgi-bin/daede-sub +++ b/luci-app-daede/root/www/cgi-bin/daede-sub @@ -2,13 +2,13 @@ # daede-sub - serve a converter's staged node list to the local daed only. # # daed (dae-wing) can only build a subscription by HTTP-fetching a link, and it -# does not read file:// like dae core. So the converter stages the base64 share -# links in /tmp/daede-daedsub- and points daed's importSubscription at +# does not read file:// like dae core. So the converter stores the base64 share +# links in /etc/daed/daede-sub- and points daed's importSubscription at # http://127.0.0.1/cgi-bin/daede-sub?t=. This CGI is the read side. # # Locked to loopback (REMOTE_ADDR), so even though uhttpd listens on the LAN no -# remote client can pull the links. The frontend deletes the staged file after -# the import (success or failure), so the exposure is one local fetch. +# remote client can pull the links. The snapshot remains available for daed's +# own refresh action and is included in daede configuration backups. deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbidden\n'; exit 0; } @@ -18,7 +18,7 @@ deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbid t=$(printf '%s' "$QUERY_STRING" | sed -n 's/^.*\bt=\([A-Za-z0-9]\{1,64\}\).*$/\1/p') [ -n "$t" ] || deny -f="/tmp/daede-daedsub-$t" +f="/etc/daed/daede-sub-$t" [ -f "$f" ] || deny printf 'Content-Type: text/plain\r\n\r\n' diff --git a/luci-app-syncthing/Makefile b/luci-app-syncthing/Makefile new file mode 100644 index 00000000..2219fcb2 --- /dev/null +++ b/luci-app-syncthing/Makefile @@ -0,0 +1,22 @@ +# Copyright (C) 2020 Gyj1109 +# 适配 OpenWrt 25.12 修改版 + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-syncthing +PKG_VERSION:=1.0 +PKG_RELEASE:=3 + +LUCI_TITLE:=LuCI support for Syncthing +LUCI_PKGARCH:=all +# 适配 25.12:确保安装了 syncthing 后,LuCI 界面能正确拉起 +LUCI_DEPENDS:=+syncthing + +include $(TOPDIR)/feeds/luci/luci.mk + +# 明确定义配置文件的归属,这对 25.12 的 apk 管理器非常重要 +define Package/$(PKG_NAME)/conffiles +/etc/config/syncthing +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/luci-app-syncthing/README.md b/luci-app-syncthing/README.md new file mode 100644 index 00000000..b07298c1 --- /dev/null +++ b/luci-app-syncthing/README.md @@ -0,0 +1,2 @@ +# luci-app-syncthing +在Potat0000源码基础上进行了汉化优化,官方openwrt23.05.3编译后可使用 diff --git a/luci-app-syncthing/luasrc/controller/syncthing.lua b/luci-app-syncthing/luasrc/controller/syncthing.lua new file mode 100644 index 00000000..ee2ca7ef --- /dev/null +++ b/luci-app-syncthing/luasrc/controller/syncthing.lua @@ -0,0 +1,17 @@ +module("luci.controller.syncthing", package.seeall) + +function index() + if not nixio.fs.access("/etc/config/syncthing") then + return + end + + entry({"admin", "services", "syncthing"}, cbi("syncthing"), _("文件同步"), 10).dependent = true + entry({"admin", "services", "syncthing", "status"}, call("act_status")).leaf = true +end + +function act_status() + local e = {} + e.running = luci.sys.call("pgrep syncthing >/dev/null") == 0 + luci.http.prepare_content("application/json") + luci.http.write_json(e) +end diff --git a/luci-app-syncthing/luasrc/model/cbi/syncthing.lua b/luci-app-syncthing/luasrc/model/cbi/syncthing.lua new file mode 100644 index 00000000..324ea054 --- /dev/null +++ b/luci-app-syncthing/luasrc/model/cbi/syncthing.lua @@ -0,0 +1,48 @@ +require("nixio.fs") + +m = Map("syncthing", translate("Syncthing同步工具")) + +m:section(SimpleSection).template = "syncthing/syncthing_status" + +s = m:section(TypedSection, "syncthing") + +s.anonymous = true + +o = s:option(Flag, "enabled", translate("启用")) +o.default = 0 +o.rmempty = false + +gui_address = s:option(Value, "gui_address", translate("GUI访问地址")) +gui_address.description = translate("使用0.0.0.0以监控所有访问。") +gui_address.default = "http://0.0.0.0:8384" +gui_address.placeholder = "http://0.0.0.0:8384" +gui_address.rmempty = false + +home = s:option(Value, "home", translate("配置文件目录")) +home.description = translate("只有保存在/etc/syncthing中的配置会自动备份!") +home.default = "/etc/syncthing" +home.placeholder = "/etc/syncthing" +home.rmempty = false + +user = s:option(ListValue, "user", translate("用户")) +user.description = translate("默认是syncthing,但这可能会导致权限被拒绝。Syncthing官方不建议以root身份运行。") +user:value("", translate("syncthing")) +for u in luci.util.execi("cat /etc/passwd | cut -d ':' -f1") do + user:value(u) +end + +macprocs = s:option(Value, "macprocs", translate("线程限制")) +macprocs.description = translate("0表示匹配CPU数量(默认),>0表示显式指定并发数。") +macprocs.default = "0" +macprocs.placeholder = "0" +macprocs.datatype = "range(0,32)" +macprocs.rmempty = false + +nice = s:option(Value, "nice", translate("优先级")) +nice.description = translate("显式指定优先级值。0是最高,19是最低。(暂时不允许设置负值)") +nice.default = "19" +nice.placeholder = "19" +nice.datatype = "range(0,19)" +nice.rmempty = false + +return m diff --git a/luci-app-syncthing/luasrc/view/syncthing/syncthing_status.htm b/luci-app-syncthing/luasrc/view/syncthing/syncthing_status.htm new file mode 100644 index 00000000..566cc020 --- /dev/null +++ b/luci-app-syncthing/luasrc/view/syncthing/syncthing_status.htm @@ -0,0 +1,27 @@ + + +
+

+ <%:正在收集数据...%> +

+
diff --git a/luci-app-syncthing/root/etc/uci-defaults/luci-syncthing b/luci-app-syncthing/root/etc/uci-defaults/luci-syncthing new file mode 100644 index 00000000..3adb7997 --- /dev/null +++ b/luci-app-syncthing/root/etc/uci-defaults/luci-syncthing @@ -0,0 +1,12 @@ +#!/bin/sh +touch /etc/config/syncthing + +uci -q batch <<-EOF >/dev/null + delete ucitrack.@syncthing[-1] + add ucitrack syncthing + set ucitrack.@syncthing[-1].exec='/etc/init.d/syncthing stop && /etc/init.d/syncthing start' + commit ucitrack +EOF +# remove LuCI cache +rm -f /tmp/luci* +exit 0 diff --git a/luci-app-syncthing/root/usr/share/rpcd/acl.d/luci-app-syncthing.json b/luci-app-syncthing/root/usr/share/rpcd/acl.d/luci-app-syncthing.json new file mode 100644 index 00000000..9fc9cc54 --- /dev/null +++ b/luci-app-syncthing/root/usr/share/rpcd/acl.d/luci-app-syncthing.json @@ -0,0 +1,11 @@ +{ + "luci-app-syncthing": { + "description": "Grant UCI access for luci-app-syncthing", + "read": { + "uci": [ "syncthing" ] + }, + "write": { + "uci": [ "syncthing" ] + } + } +}