diff --git a/honk/Makefile b/honk/Makefile new file mode 100644 index 00000000..7f5b4872 --- /dev/null +++ b/honk/Makefile @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# Copyright (C) 2026 kenzok8 + +include $(TOPDIR)/rules.mk + +PKG_NAME:=honk +PKG_VERSION:=2026.10.10 +PKG_RELEASE:=1 + +# Pinned honk-core revision (Glassyiris/honk feat/native-api), cross-compiled by +# ci/build-core.sh. The per-architecture asset/hash are filled by that script; +# keep a committed fallback so the feed still evaluates without a staging run. +CORE_COMMIT:=eac5e0c5fba5078a7ff4517a3e3851e7fa0f4f8e + +-include $(CURDIR)/generated-stage.mk + +HONK_ASSET_X86_64?=honk-core-$(CORE_COMMIT)-x86_64.tar.gz +HONK_ASSET_AARCH64?=honk-core-$(CORE_COMMIT)-aarch64.tar.gz +HONK_ASSET_ARMV7?=honk-core-$(CORE_COMMIT)-armv7.tar.gz +HONK_HASH_X86_64?=SKIP +HONK_HASH_AARCH64?=SKIP +HONK_HASH_ARMV7?=SKIP + +# ARCH_PACKAGES is the package arch (e.g. arm_cortex-a7_neon-vfpv4); +# ARCH is the CPU arch (x86_64/aarch64/arm) and coincides for 64-bit. +ifeq ($(ARCH_PACKAGES),x86_64) + HONK_ASSET:=$(HONK_ASSET_X86_64) + HONK_HASH:=$(HONK_HASH_X86_64) +else ifeq ($(filter aarch64%,$(ARCH_PACKAGES)),$(ARCH_PACKAGES)) + HONK_ASSET:=$(HONK_ASSET_AARCH64) + HONK_HASH:=$(HONK_HASH_AARCH64) +else ifeq ($(filter arm_%,$(ARCH_PACKAGES)),$(ARCH_PACKAGES)) + HONK_ASSET:=$(HONK_ASSET_ARMV7) + HONK_HASH:=$(HONK_HASH_ARMV7) +endif + +PKG_LICENSE:=AGPL-3.0-only +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=kenzok8 + +PKG_SOURCE:=$(HONK_ASSET) +PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-honk/releases/download/staging/ +PKG_HASH:=$(HONK_HASH) + +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) + +include $(INCLUDE_DIR)/package.mk + +define Package/honk + SECTION:=net + CATEGORY:=Network + SUBMENU:=Web Servers/Proxies + TITLE:=Honk transparent proxy core + URL:=https://github.com/Glassyiris/honk + DEPENDS:=@(x86_64||aarch64||arm) +ca-bundle +ip-full +ubus +kmod-sched-core +kmod-sched-bpf +kmod-veth +kmod-nft-queue +jsonfilter +jshn +sqlite3-cli +libubox +usign +coreutils-stat +v2ray-geoip +v2ray-geosite +@KERNEL_DEBUG_INFO_BTF +endef + +define Package/honk/description + Honk is a transparent proxy core for OpenWrt (x86_64, aarch64, armv7). +endef + +define Package/honk/conffiles +/etc/config/honk +/etc/honk/config.dae +/etc/honk/system.dae +endef + +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR) + $(TAR) -xzf $(DL_DIR)/$(PKG_SOURCE) -C $(PKG_BUILD_DIR) --strip-components=1 +endef + +define Build/Compile +endef + +define Package/honk/install + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) $(PKG_BUILD_DIR)/honk-core $(1)/usr/bin/honk-core + + $(INSTALL_DIR) $(1)/usr/share/licenses/honk + $(INSTALL_DATA) $(PKG_BUILD_DIR)/LICENSE $(1)/usr/share/licenses/honk/LICENSE + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) $(CURDIR)/files/honk.config $(1)/etc/config/honk + + $(INSTALL_DIR) $(1)/etc/honk + $(INSTALL_CONF) $(CURDIR)/files/config.dae $(1)/etc/honk/config.dae + $(INSTALL_CONF) $(CURDIR)/files/system.dae $(1)/etc/honk/system.dae + chmod 0444 $(1)/etc/honk/system.dae + $(INSTALL_DIR) $(1)/etc/honk/state + chmod 0700 $(1)/etc/honk/state + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) $(CURDIR)/files/honk.init $(1)/etc/init.d/honk + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) $(CURDIR)/files/90-honk-boot $(1)/etc/uci-defaults/90-honk + + $(INSTALL_DIR) $(1)/usr/share/honk + $(INSTALL_DATA) $(CURDIR)/files/config.dae $(1)/usr/share/honk/default-config.dae + $(INSTALL_DATA) $(CURDIR)/generated-provenance.json $(1)/usr/share/honk/provenance.json + $(INSTALL_BIN) $(CURDIR)/files/control.sh $(1)/usr/share/honk/control.sh + $(INSTALL_BIN) $(CURDIR)/files/lock.sh $(1)/usr/share/honk/lock.sh + $(INSTALL_BIN) $(CURDIR)/files/lifecycle.sh $(1)/usr/share/honk/lifecycle.sh + $(INSTALL_BIN) $(CURDIR)/files/maintenance.sh $(1)/usr/share/honk/maintenance.sh + $(INSTALL_BIN) $(CURDIR)/files/job.sh $(1)/usr/share/honk/job.sh + $(INSTALL_BIN) $(CURDIR)/files/update.sh $(1)/usr/share/honk/update.sh + $(INSTALL_BIN) $(CURDIR)/files/redact-log.sh $(1)/usr/share/honk/redact-log.sh + $(INSTALL_BIN) $(CURDIR)/files/log-level-filter.sh $(1)/usr/share/honk/log-level-filter.sh + $(INSTALL_DATA) $(CURDIR)/files/log-lines.sh $(1)/usr/share/honk/log-lines.sh +endef + +$(eval $(call BuildPackage,honk)) diff --git a/honk/files/90-honk-boot b/honk/files/90-honk-boot new file mode 100644 index 00000000..288afa91 --- /dev/null +++ b/honk/files/90-honk-boot @@ -0,0 +1,14 @@ +#!/bin/sh + +. /lib/functions.sh + +config_load honk +config_get_bool boot_enabled main boot_enabled 0 + +if [ "$boot_enabled" -eq 1 ]; then + /etc/init.d/honk enable +else + /etc/init.d/honk disable +fi + +exit 0 diff --git a/honk/files/config.dae b/honk/files/config.dae new file mode 100644 index 00000000..71c28870 --- /dev/null +++ b/honk/files/config.dae @@ -0,0 +1,7 @@ +include { + system.dae +} + +routing { + fallback: direct +} diff --git a/honk/files/control.sh b/honk/files/control.sh new file mode 100755 index 00000000..14dfd727 --- /dev/null +++ b/honk/files/control.sh @@ -0,0 +1,811 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH + +HONK_CORE=/usr/bin/honk-core +HONK_MAINT_DIR=/etc/.honk-maintenance +HONK_MAINT_JOURNAL=$HONK_MAINT_DIR/journal +HONK_MAINT_CAP_FILE=$HONK_MAINT_DIR/capability + +. /lib/functions.sh +. /usr/share/honk/lock.sh +. /usr/share/honk/lifecycle.sh + +fail() { + echo "honk: $*" >&2 + return 1 +} + +load_settings() { + config_load honk + config_get_bool enabled main enabled 0 + config_get_bool boot_enabled main boot_enabled 0 + config_get_bool initialized main initialized 0 + config_get config_file main config_file /etc/honk/config.dae + config_get listen_port main listen_port 9527 + config_get lan_network main lan_network lan +} + +valid_network_name() { + case "$lan_network" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + *) return 0 ;; + esac +} + +module_loaded() { + [ -d "/sys/module/$1" ] || grep -q "^$1 " /proc/modules +} + +module_available() { + module_loaded "$1" && return 0 + module_path=$(printf '%s' "$1" | tr '_' '-') + module_dir=/lib/modules/$(uname -r) + [ -r "$module_dir/modules.builtin" ] && grep -Eq "/${1}\.ko$|/${module_path}\.ko$" "$module_dir/modules.builtin" && return 0 + find "$module_dir" -type f \( -name "$1.ko" -o -name "$1.ko.gz" -o -name "$1.ko.xz" -o -name "$1.ko.zst" \ + -o -name "$module_path.ko" -o -name "$module_path.ko.gz" -o -name "$module_path.ko.xz" -o -name "$module_path.ko.zst" \) -print 2>/dev/null | grep -q . +} + +system_config_readonly() { + permissions=$(ls -ldn /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$permissions" = '-r--r--r--' ] +} + +no_honk_resources() { + for link in dae0 dae0peer; do + ip link show "$link" >/dev/null 2>&1 && { fail "$link already exists"; return 1; } + done + ip netns list 2>/dev/null | awk '{print $1}' | grep -qx daens && { fail "daens network namespace already exists"; return 1; } + return 0 +} + +check_port() { + if command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${listen_port}$" && { fail "listen port is occupied"; return 1; } + elif command -v ss >/dev/null 2>&1; then + ss -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${listen_port}$" && { fail "listen port is occupied"; return 1; } + else + fail "netstat or ss is required for listener conflict checks" + return 1 + fi + return 0 +} + +check_network() { + valid_network_name || { fail "lan_network contains invalid characters"; return 1; } + command -v ubus >/dev/null 2>&1 || { fail "ubus unavailable for network validation"; return 1; } + ubus call "network.interface.$lan_network" status >/dev/null 2>&1 || { fail "configured LAN network does not exist in netifd"; return 1; } +} + +check_config() { + [ "$config_file" = /etc/honk/config.dae ] || { fail "config_file must be /etc/honk/config.dae"; return 1; } + [ -f /etc/honk/config.dae ] && [ ! -L /etc/honk/config.dae ] || { fail "main config must be a regular file"; return 1; } + [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] || { fail "system config must be a regular file"; return 1; } + system_config_readonly || { fail "system config must be read-only (0444)"; return 1; } + [ -r /usr/share/doona/index.html ] || { fail "Doona UI index.html unavailable"; return 1; } + case "$listen_port" in + ''|*[!0-9]*) fail "listen_port must be numeric"; return 1 ;; + esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || { fail "listen_port must be 1024-65535"; return 1; } +} + +check_kernel() { + kernel=$(uname -r | cut -d- -f1) + major=$(printf '%s' "$kernel" | cut -d. -f1) + minor=$(printf '%s' "$kernel" | cut -d. -f2) + [ "$major" -gt 6 ] || { [ "$major" -eq 6 ] && [ "$minor" -ge 12 ]; } || { fail "Linux 6.12 or newer required"; return 1; } + [ -r /sys/kernel/btf/vmlinux ] || { fail "kernel BTF unavailable"; return 1; } + [ -d /sys/fs/bpf ] && grep -q ' /sys/fs/bpf bpf ' /proc/mounts || { fail "bpffs is not mounted at /sys/fs/bpf"; return 1; } + command -v ip >/dev/null 2>&1 || { fail "ip command unavailable"; return 1; } + command -v modprobe >/dev/null 2>&1 || { fail "modprobe unavailable"; return 1; } + [ -r /sys/kernel/btf/vmlinux ] || { fail "kernel BTF unavailable"; return 1; } + for module in sch_ingress cls_bpf veth nft_queue nfnetlink_queue; do + module_available "$module" || { fail "kernel module $module unavailable"; return 1; } + done +} + +check_conflicts() { + for process in dae daed clashoo mihomo sing-box; do + pidof "$process" >/dev/null 2>&1 && { fail "conflicting process $process is running"; return 1; } + done + for service in dae daed clashoo mihomo sing-box; do + if [ -x "/etc/init.d/$service" ] && "/etc/init.d/$service" running >/dev/null 2>&1; then + fail "conflicting service $service is running" + return 1 + fi + done +} + +maintenance_value() { + awk -F= -v key="$1" '$1 == key { value = substr($0, length($1) + 2); count++ } END { if (count == 1) print value; else exit 1 }' "$2" +} + +proc_starttime() { + [ -r "/proc/$1/stat" ] || return 1 + awk '{ sub(/^.*\) /, ""); split($0, fields, /[[:space:]]+/); if (fields[20] ~ /^[0-9]+$/) print fields[20]; else exit 1 }' "/proc/$1/stat" 2>/dev/null +} + +protected_root_file() { + [ -f "$1" ] && [ ! -L "$1" ] || return 1 + protected_permissions=$(ls -ln "$1" 2>/dev/null | awk 'NR == 1 { print $1 ":" $3 ":" $4 }') + [ "$protected_permissions" = '-rw-------:0:0' ] +} + +consume_maintenance_capability() { + [ -n "${HONK_MAINT_CAP:-}" ] || return 1 + [ -d "$HONK_MAINT_DIR" ] && [ ! -L "$HONK_MAINT_DIR" ] || return 1 + protected_root_file "$HONK_MAINT_JOURNAL" && protected_root_file "$HONK_MAINT_CAP_FILE" || return 1 + awk -F= 'BEGIN { split("format nonce txn_id phase owner_pid owner_starttime", keys, " ") } NR > 6 || index($0, "=") == 0 || $1 != keys[NR] { exit 1 } END { if (NR != 6) exit 1 }' "$HONK_MAINT_CAP_FILE" || return 1 + consumed_cap=$HONK_MAINT_DIR/.capability-consumed.$$ + [ ! -e "$consumed_cap" ] || return 1 + mv "$HONK_MAINT_CAP_FILE" "$consumed_cap" 2>/dev/null || return 1 + # Chain with any existing EXIT trap instead of overwriting it. + _prev_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//") + trap 'rm -f "$consumed_cap"; [ -n "$_prev_exit_trap" ] && eval "$_prev_exit_trap"' 0 + cap_format=$(maintenance_value format "$consumed_cap") || return 1 + cap_nonce=$(maintenance_value nonce "$consumed_cap") || return 1 + cap_txn=$(maintenance_value txn_id "$consumed_cap") || return 1 + cap_phase=$(maintenance_value phase "$consumed_cap") || return 1 + cap_pid=$(maintenance_value owner_pid "$consumed_cap") || return 1 + cap_starttime=$(maintenance_value owner_starttime "$consumed_cap") || return 1 + journal_format=$(maintenance_value format "$HONK_MAINT_JOURNAL") || return 1 + journal_operation=$(maintenance_value operation "$HONK_MAINT_JOURNAL") || return 1 + journal_phase=$(maintenance_value phase "$HONK_MAINT_JOURNAL") || return 1 + journal_txn=$(maintenance_value txn_id "$HONK_MAINT_JOURNAL") || return 1 + case "$cap_nonce:$cap_txn:$cap_pid:$cap_starttime" in *[!0-9a-f:]*|::*|:|'' ) return 1 ;; esac + case "$cap_nonce" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#cap_nonce}" -eq 32 ] && [ "${#cap_txn}" -eq 32 ] || return 1 + [ "$cap_format" = 1 ] && [ "$journal_format" = 1 ] || return 1 + case "$cap_pid:$cap_starttime" in *[!0-9:]*) return 1 ;; esac + [ "$cap_pid" -gt 1 ] && [ "$cap_starttime" -gt 0 ] || return 1 + [ "$HONK_MAINT_CAP" = "$cap_nonce" ] && [ "$cap_txn" = "$journal_txn" ] || return 1 + case "$cap_phase:$journal_operation:$journal_phase" in + candidate_health_start:restore:new_installed|candidate_health_start:import_apply:new_installed|rollback_old_start:restore:rollback_old_start|rollback_old_start:reset:rollback_old_start|rollback_old_start:import_apply:rollback_old_start) ;; + *) return 1 ;; + esac + [ "$(cat "$HONK_LOCK_DIR/pid" 2>/dev/null)" = "$cap_pid" ] || return 1 + [ "$(proc_starttime "$cap_pid")" = "$cap_starttime" ] || return 1 + return 0 +} + +maintenance_start_guard() { + if [ -e "$HONK_MAINT_JOURNAL" ] || [ -L "$HONK_MAINT_JOURNAL" ]; then + consume_maintenance_capability || { fail "maintenance_recovery_required"; return 1; } + elif [ -n "${HONK_MAINT_CAP:-}" ]; then + fail "maintenance_capability_without_journal" + return 1 + fi + if lock_owner_invalid || honk_lock_is_stale; then + fail "maintenance_recovery_required: stale operation lock" + return 1 + fi +} + +lock_owner_invalid() { + [ -d "$HONK_LOCK_DIR" ] || return 1 + lock_owner=$(cat "$HONK_LOCK_DIR/pid" 2>/dev/null) + # pid file now stores "pid starttime"; only the leading PID matters here. + lock_owner=${lock_owner%% *} + case "$lock_owner" in ''|*[!0-9]*) return 0 ;; esac + return 1 +} + +preflight() { + load_settings + [ "$enabled" = 1 ] && [ "$initialized" = 1 ] || { fail "package must be explicitly enabled and initialized"; return 1; } + maintenance_start_guard && check_config && check_network && check_kernel && no_honk_resources && check_conflicts && check_port +} + +prepare_runtime() { + preflight || return 1 + for module in sch_ingress cls_bpf veth nft_queue nfnetlink_queue; do + module_loaded "$module" || modprobe "$module" || { fail "cannot load kernel module $module"; return 1; } + done + mkdir -p /etc/honk/state || { fail "cannot create persistent state directory"; return 1; } + chmod 0700 /etc/honk/state || return 1 +} + +valid_username() { + case "$1" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + esac + [ "$(printf '%s' "$1" | wc -c)" -le 64 ] +} + +valid_password() { + password_bytes=$(printf '%s' "$1" | wc -c) + [ "$password_bytes" -ge 8 ] && [ "$password_bytes" -le 512 ] +} + +read_credentials() { + json_file=$(mktemp /tmp/honk-credentials.XXXXXX) || return 1 + chmod 0600 "$json_file" || { rm -f "$json_file"; return 1; } + cat > "$json_file" || { rm -f "$json_file"; return 1; } + json_bytes=$(wc -c < "$json_file") + [ "$json_bytes" -le 2048 ] || { rm -f "$json_file"; return 1; } + grep -Fq '\u0000' "$json_file" && { rm -f "$json_file"; return 1; } + newline=' +' + username_raw=$(jsonfilter -i "$json_file" -e '@.username' 2>/dev/null; printf '__HNK_END__') + password_raw=$(jsonfilter -i "$json_file" -e '@.password' 2>/dev/null; printf '__HNK_END__') + username=${username_raw%__HNK_END__} + password=${password_raw%__HNK_END__} + username=${username%"$newline"} + password=${password%"$newline"} + rm -f "$json_file" + [ -n "$username" ] && [ -n "$password" ] || return 1 + valid_username "$username" && valid_password "$password" +} + +network_settings() { + . /lib/functions/network.sh + network_get_device lan_device "$lan_network" || return 1 + network_get_ipaddr lan_ip "$lan_network" || return 1 + case "$lan_device" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + esac + [ "$(printf '%s' "$lan_device" | wc -c)" -le 15 ] || return 1 + printf '%s\n' "$lan_ip" | awk -F. 'NF == 4 { for (i=1;i<=4;i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; exit 0 } { exit 1 }' || return 1 +} + +ensure_loopback_system() { + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "system config changed concurrently; refusing initialization"; return 1; } + current_listen=$(sed -n "s/^[[:space:]]*listen:[[:space:]]*'\([^']*\)'.*/\1/p" /etc/honk/system.dae | head -n 1) + [ "$current_listen" = "127.0.0.1:$listen_port" ] && return 0 + temp_system=$(mktemp /etc/honk/.system.dae.XXXXXX) || return 1 + sed "s/^[[:space:]]*listen:[[:space:]]*'[^']*'.*/ listen: '127.0.0.1:$listen_port'/" /etc/honk/system.dae > "$temp_system" || { rm -f "$temp_system"; return 1; } + chmod 0444 "$temp_system" || { rm -f "$temp_system"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_system"; fail "system config changed concurrently; refusing initialization"; return 1; } + mv -f "$temp_system" /etc/honk/system.dae || { rm -f "$temp_system"; return 1; } + system_written_hash=$(sha256sum /etc/honk/system.dae | awk '{print $1}') +} + +restore_system() { + [ -n "$system_written_hash" ] || return 0 + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "system config changed concurrently; refusing rollback"; return 1; } + [ "$current_hash" != "$original_system_hash" ] || { system_written_hash=""; return 0; } + if [ -z "$original_system_hash" ]; then + rm -f /etc/honk/system.dae || return 1 + system_written_hash="" + return 0 + fi + temp_restore=$(mktemp /etc/honk/.system.dae.restore.XXXXXX) || return 1 + cp -p "$old_system" "$temp_restore" || { rm -f "$temp_restore"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_restore"; fail "system config changed concurrently; refusing rollback"; return 1; } + mv -f "$temp_restore" /etc/honk/system.dae || { rm -f "$temp_restore"; return 1; } + system_written_hash="" +} + +cleanup_initialize() { + if mock_stop >/dev/null 2>&1; then + if [ -n "$system_written_hash" ] && ! restore_system; then + logger -t honk "system config rollback refused or failed; inspect /etc/honk/system.dae" + fi + rm -rf "$temp_dir" + honk_lock_release >/dev/null 2>&1 + else + logger -t honk "mock process cleanup failed; keeping operation lock and temporary files" + fi +} + +write_runtime_system() { + lan_device=$1 + lan_ip=$2 + system_port=${3:-$listen_port} + system_listen=${4:-$lan_ip} + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "system config changed concurrently; refusing runtime config write"; return 1; } + temp_system=$(mktemp /etc/honk/.system.dae.XXXXXX) || return 1 + { + printf "global {\n wan_interface: auto\n lan_interface: '%s'\n" "$lan_device" + printf " log_level: info\n dial_mode: domain\n allow_insecure: false\n" + printf " auto_config_kernel_parameter: true\n data_dir: '/etc/honk'\n" + printf " store_subscribe: false\n nfqueue_enable: true\n}\n\n" + printf "experimental {\n native_api {\n enabled: true\n listen: '%s:%s'\n" "$system_listen" "$system_port" + printf " password_auth: true\n allow_anonymous_loopback: false\n" + printf " config_write: true\n ui: '/usr/share/doona'\n }\n}\n" + } > "$temp_system" || { rm -f "$temp_system"; return 1; } + chmod 0444 "$temp_system" || { rm -f "$temp_system"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_system"; fail "system config changed concurrently; refusing runtime config write"; return 1; } + mv -f "$temp_system" /etc/honk/system.dae || { rm -f "$temp_system"; return 1; } + system_written_hash=$(sha256sum /etc/honk/system.dae | awk '{print $1}') +} + +managed_core_pid() { + honk_lifecycle_managed_pid +} + +configured_system_port() { + sed -n "s/^[[:space:]]*listen:[[:space:]]*'[^:]*:\([0-9][0-9]*\)'.*/\1/p" /etc/honk/system.dae 2>/dev/null | head -n 1 +} + +sync_boot_link() { + if [ "$1" = 1 ]; then + /etc/init.d/honk enable + else + /etc/init.d/honk disable + fi +} + +restore_old_runtime() { + [ "$old_running" = 1 ] || return 0 + runtime_enabled=$(uci -q get honk.main.enabled || echo 0) + if [ "$runtime_enabled" != 1 ]; then uci set honk.main.enabled=1 && uci commit honk || return 1; fi + honk_lifecycle_start 30 || { + [ "$runtime_enabled" = 1 ] || { uci set "honk.main.enabled=$runtime_enabled" && uci commit honk; } + return 1 + } + if [ "$runtime_enabled" != 1 ]; then uci set "honk.main.enabled=$runtime_enabled" && uci commit honk || return 1; fi +} + +settings_rollback() { + rollback_ok=1 + honk_lifecycle_stop 30 || return 1 + if [ -n "$system_written_hash" ] && ! restore_system; then rollback_ok=0; fi + if ! uci import honk < "$old_uci" || ! uci commit honk; then rollback_ok=0; fi + if ! sync_boot_link "$old_boot_enabled"; then rollback_ok=0; fi + restore_old_runtime || rollback_ok=0 + [ "$rollback_ok" = 1 ] +} + +settings() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + . /usr/share/libubox/jshn.sh || { echo '{"ok":false,"message":"jshn_unavailable"}'; return 1; } + # Bound stdin to 64KiB (DoS protection); read one extra byte to detect truncation. + settings_json=$(head -c 65537) + if [ "${#settings_json}" -gt 65536 ]; then + echo '{"ok":false,"message":"settings_too_large"}'; return 1 + fi + json_load "$settings_json" || { echo '{"ok":false,"message":"invalid_settings_json"}'; return 1; } + json_get_var new_network lan_network + json_get_type network_type lan_network + json_get_var new_port listen_port + json_get_type port_type listen_port + json_get_var new_boot_enabled boot_enabled + json_get_type boot_enabled_type boot_enabled + [ "$network_type" = string ] || { echo '{"ok":false,"message":"invalid_lan_network"}'; return 1; } + case "$port_type" in int|integer) ;; *) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + [ "$boot_enabled_type" = boolean ] || { echo '{"ok":false,"message":"invalid_boolean_settings"}'; return 1; } + case "$new_network" in ''|*[!A-Za-z0-9_.-]*) echo '{"ok":false,"message":"invalid_lan_network"}'; return 1 ;; esac + case "$new_port" in ''|*[!0-9]*) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + [ "$new_port" -ge 1024 ] && [ "$new_port" -le 65535 ] || { echo '{"ok":false,"message":"invalid_listen_port"}'; return 1; } + load_settings + new_enabled=$enabled + old_network=$lan_network + old_port=$listen_port + old_enabled=$enabled + old_boot_enabled=$boot_enabled + old_initialized=$initialized + [ "$initialized" = 1 ] || { [ "$new_enabled" = 0 ] && [ "$new_boot_enabled" = 0 ] || { echo '{"ok":false,"message":"initialize_before_enabling_service"}'; return 1; }; } + lan_network=$new_network + listen_port=$new_port + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + old_running=0 + if managed_core_pid >/dev/null 2>&1; then + old_running=1 + elif pidof honk-core >/dev/null 2>&1; then + echo '{"ok":false,"message":"unmanaged_core_running"}' + return 1 + fi + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$new_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + load_settings + [ "$lan_network" = "$old_network" ] && [ "$listen_port" = "$old_port" ] && [ "$enabled" = "$old_enabled" ] && [ "$boot_enabled" = "$old_boot_enabled" ] && [ "$initialized" = "$old_initialized" ] || { echo '{"ok":false,"message":"settings_changed_concurrently"}'; return 1; } + new_enabled=$enabled + managed_core_pid >/dev/null 2>&1 && recheck_running=1 || recheck_running=0 + [ "$recheck_running" = "$old_running" ] || { echo '{"ok":false,"message":"service_state_changed_concurrently"}'; return 1; } + old_running=$recheck_running + lan_network=$new_network + listen_port=$new_port + check_network && network_settings || { echo '{"ok":false,"message":"lan_network_changed_during_settings"}'; return 1; } + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$new_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$initialized" = 1 ] && ! check_config; then echo '{"ok":false,"message":"package_config_unavailable"}'; return 1; fi + umask 077 + settings_dir=$(mktemp -d /tmp/honk-settings.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + old_uci=$settings_dir/honk.uci + uci export honk > "$old_uci" || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"uci_snapshot_failed"}'; return 1; } + old_system=$settings_dir/system.dae + system_written_hash="" + if [ "$initialized" = 1 ]; then + [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_config_unavailable"}'; return 1; } + cp -p /etc/honk/system.dae "$old_system" || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_snapshot_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + system_written_hash=$original_system_hash + fi + if [ "$old_running" = 1 ]; then + honk_lifecycle_stop 30 || { + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"service_stop_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"service_stop_and_rollback_failed_snapshot_retained"}'; fi + return 1 + } + fi + if [ "$initialized" = 1 ] && ! write_runtime_system "$lan_device" "$lan_ip" "$new_port"; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_config_write_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"system_config_write_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + uci set "honk.main.lan_network=$new_network" && uci set "honk.main.listen_port=$new_port" && uci set "honk.main.enabled=$new_enabled" && uci set "honk.main.boot_enabled=$new_boot_enabled" && uci commit honk || { + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"settings_commit_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"settings_commit_and_rollback_failed_snapshot_retained"}'; fi + return 1 + } + if ! sync_boot_link "$new_boot_enabled"; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"boot_link_update_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"boot_link_update_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + if [ "$initialized" = 1 ]; then + service_ok=0 + if [ "$new_enabled" = 1 ]; then + honk_lifecycle_start 30 || service_ok=1 + else + honk_lifecycle_stop 30 || service_ok=1 + fi + if [ "$service_ok" != 0 ]; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"service_apply_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"service_apply_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + fi + system_written_hash="" + rm -rf "$settings_dir" + echo '{"ok":true,"message":"settings_saved"}' +} + +diagnose() { + load_settings + errors="" + managed_core_pid >/dev/null 2>&1 && running=1 || running=0 + check_config >/dev/null 2>&1 || errors="$errors invalid_package_config" + check_network >/dev/null 2>&1 || errors="$errors lan_network_unavailable" + network_settings >/dev/null 2>&1 || errors="$errors lan_ipv4_unavailable" + check_kernel >/dev/null 2>&1 || errors="$errors kernel_requirements_unmet" + if [ "$running" != 1 ]; then + no_honk_resources >/dev/null 2>&1 || errors="$errors honk_resources_occupied" + pidof honk-core >/dev/null 2>&1 && errors="$errors unmanaged_core_running" + check_port >/dev/null 2>&1 || errors="$errors listen_port_occupied" + fi + check_conflicts >/dev/null 2>&1 || errors="$errors proxy_conflict" + if [ -d "$HONK_LOCK_DIR" ]; then + if lock_owner_invalid || honk_lock_is_stale; then + errors="$errors stale_operation_lock maintenance_recovery_required" + else + errors="$errors operation_in_progress" + fi + fi + { [ -e "$HONK_MAINT_JOURNAL" ] || [ -L "$HONK_MAINT_JOURNAL" ]; } && errors="$errors maintenance_recovery_required" + if [ -n "$errors" ]; then + printf '%s\n' "$errors" | sed 's/^ *//' + return 1 + fi + return 0 +} + +main_has_system_include() { + awk ' + /^[[:space:]]*include[[:space:]]*\{/ { inside = 1 } + inside && /(^|[^[:alnum:]_.-])system\.dae([^[:alnum:]_.-]|$)/ { found = 1 } + inside && /\}/ { inside = 0 } + END { exit !found } + ' /etc/honk/config.dae +} + +repair() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + load_settings + old_network=$lan_network + old_port=$listen_port + old_enabled=$enabled + old_boot_enabled=$boot_enabled + old_initialized=$initialized + [ "$config_file" = /etc/honk/config.dae ] || { echo '{"ok":false,"message":"invalid_config_path"}'; return 1; } + [ -f /etc/honk/config.dae ] && [ ! -L /etc/honk/config.dae ] || { echo '{"ok":false,"message":"main_config_unavailable"}'; return 1; } + main_hash=$(sha256sum /etc/honk/config.dae | awk '{print $1}') + main_has_system_include || { echo '{"ok":false,"message":"required_system_include_missing_manual_repair"}'; return 1; } + valid_network_name || { echo '{"ok":false,"message":"invalid_lan_network"}'; return 1; } + case "$listen_port" in ''|*[!0-9]*) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || { echo '{"ok":false,"message":"invalid_listen_port"}'; return 1; } + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + old_running=0 + managed_core_pid >/dev/null 2>&1 && old_running=1 + if pidof honk-core >/dev/null 2>&1 && [ "$old_running" = 0 ]; then echo '{"ok":false,"message":"unmanaged_core_running"}'; return 1; fi + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$listen_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + load_settings + [ "$lan_network" = "$old_network" ] && [ "$listen_port" = "$old_port" ] && [ "$enabled" = "$old_enabled" ] && [ "$boot_enabled" = "$old_boot_enabled" ] && [ "$initialized" = "$old_initialized" ] || { echo '{"ok":false,"message":"settings_changed_concurrently"}'; return 1; } + managed_core_pid >/dev/null 2>&1 && recheck_running=1 || recheck_running=0 + [ "$recheck_running" = "$old_running" ] || { echo '{"ok":false,"message":"service_state_changed_concurrently"}'; return 1; } + [ "$(sha256sum /etc/honk/config.dae | awk '{print $1}')" = "$main_hash" ] || { echo '{"ok":false,"message":"main_config_changed_concurrently"}'; return 1; } + check_network && network_settings || { echo '{"ok":false,"message":"lan_network_changed_during_repair"}'; return 1; } + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$listen_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$initialized" = 1 ]; then system_host=$lan_ip; else system_host=127.0.0.1; fi + if [ -r /usr/share/doona/index.html ] && [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] && system_config_readonly && \ + grep -Fqx ' wan_interface: auto' /etc/honk/system.dae && grep -Fqx " lan_interface: '$lan_device'" /etc/honk/system.dae && \ + grep -Fqx " data_dir: '/etc/honk'" /etc/honk/system.dae && grep -Fqx ' nfqueue_enable: true' /etc/honk/system.dae && \ + grep -Fqx " listen: '$system_host:$listen_port'" /etc/honk/system.dae && \ + grep -Fqx " ui: '/usr/share/doona'" /etc/honk/system.dae && grep -Fqx ' password_auth: true' /etc/honk/system.dae && \ + grep -Fqx ' allow_anonymous_loopback: false' /etc/honk/system.dae && grep -Fqx ' config_write: true' /etc/honk/system.dae; then + echo '{"ok":true,"message":"system_config_healthy"}' + return 0 + fi + if [ -e /etc/honk/system.dae ] && { [ ! -f /etc/honk/system.dae ] || [ -L /etc/honk/system.dae ]; }; then + echo '{"ok":false,"message":"system_config_not_regular_file"}' + return 1 + fi + [ -r /usr/share/doona/index.html ] || { echo '{"ok":false,"message":"ui_assets_missing"}'; return 1; } + umask 077 + repair_dir=$(mktemp -d /tmp/honk-repair.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + old_system=$repair_dir/system.dae + original_system_hash="" + if [ -f /etc/honk/system.dae ]; then + cp -p /etc/honk/system.dae "$old_system" || { rm -rf "$repair_dir"; echo '{"ok":false,"message":"system_snapshot_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + fi + system_written_hash=$original_system_hash + if [ "$old_running" = 1 ]; then + if ! honk_lifecycle_stop 30; then + logger -t honk "repair stop/drain unconfirmed; no restart attempted; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"service_stop_unconfirmed_snapshot_retained"}' + return 1 + fi + fi + if [ "$initialized" = 1 ]; then system_listen=$lan_ip; else system_listen=127.0.0.1; fi + if ! write_runtime_system "$lan_device" "$lan_ip" "$listen_port" "$system_listen"; then + repair_rollback_ok=1 + [ -n "$system_written_hash" ] && ! restore_system && repair_rollback_ok=0 + restore_old_runtime || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then + rm -rf "$repair_dir" + echo '{"ok":false,"message":"system_config_repair_failed_rolled_back"}' + else + logger -t honk "repair rollback failed; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"system_config_repair_rollback_failed_snapshot_retained"}' + fi + return 1 + fi + if [ "$initialized" = 1 ] && { [ "$old_running" = 1 ] || [ "$enabled" = 1 ]; }; then + if ! honk_lifecycle_start 30; then + repair_rollback_ok=1 + honk_lifecycle_stop 30 || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then restore_system || repair_rollback_ok=0; fi + restore_old_runtime || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then + rm -rf "$repair_dir" + echo '{"ok":false,"message":"repair_restart_failed_rolled_back"}' + else + logger -t honk "repair restart rollback failed; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"repair_restart_and_rollback_failed_snapshot_retained"}' + fi + return 1 + fi + fi + system_written_hash="" + rm -rf "$repair_dir" + echo '{"ok":true,"message":"system_config_repaired"}' +} + +api_discovery() { + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 3 "$1/api" -o "$2" +} + +mock_start() { + mock_config=$1 + mock_log=$2 + "$HONK_CORE" --config "$mock_config" --mock-ebpf > "$mock_log" 2>&1 & + mock_pid=$! + mock_identity="" + attempt=0 + while [ "$attempt" -lt 20 ]; do + if ! kill -0 "$mock_pid" 2>/dev/null; then + return 1 + fi + if api_discovery "$3" "$4" 2>/dev/null; then + mock_identity=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) + case "$mock_identity" in + *"$mock_config"*--mock-ebpf*) + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] && return 0 + ;; + esac + return 1 + fi + sleep 1 + attempt=$((attempt + 1)) + done + return 1 +} + +mock_stop() { + [ -n "$mock_pid" ] || return 0 + [ -r "/proc/$mock_pid/cmdline" ] || { mock_pid=""; return 0; } + current=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) + case "$current" in + *"$mock_config"*--mock-ebpf*) + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + kill -TERM "$mock_pid" 2>/dev/null || return 1 + ;; + *) return 1 ;; + esac + attempt=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$attempt" -lt 10 ]; do sleep 1; attempt=$((attempt + 1)); done + if kill -0 "$mock_pid" 2>/dev/null; then return 1; fi + wait "$mock_pid" 2>/dev/null || true + mock_pid="" +} + +post_credentials() { + endpoint=$1 + response_file=$2 + json_init + json_add_string username "$username" + json_add_string password "$password" + json_dump | curl -sS --noproxy '*' --connect-timeout 1 --max-time 5 -o "$response_file" -w '%{http_code}' \ + -H 'Content-Type: application/json' --data-binary @- "$endpoint" +} + +initialize_preflight() { + load_settings + [ "$enabled" = 0 ] && [ "$initialized" = 0 ] || { echo '{"ok":false,"message":"disable_service_before_initialization"}'; return 1; } + [ "$config_file" = /etc/honk/config.dae ] || { echo '{"ok":false,"message":"invalid_config_path"}'; return 1; } + [ -r /usr/share/doona/index.html ] || { echo '{"ok":false,"message":"ui_assets_missing"}'; return 1; } + if [ "$1" = read_credentials ]; then + read_credentials || { echo '{"ok":false,"message":"invalid_credentials"}'; return 1; } + fi + [ -x "$HONK_CORE" ] || { echo '{"ok":false,"message":"core_missing"}'; return 1; } + pidof honk-core >/dev/null 2>&1 && { echo '{"ok":false,"message":"core_already_running"}'; return 1; } + no_honk_resources || { echo '{"ok":false,"message":"honk_resources_already_exist"}'; return 1; } + check_conflicts || { echo '{"ok":false,"message":"conflicting_proxy_running"}'; return 1; } + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + check_config || { echo '{"ok":false,"message":"invalid_package_config"}'; return 1; } + return 0 +} + +initialize() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + . /usr/share/libubox/jshn.sh || return 1 + initialize_preflight read_credentials || return 1 + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + initialize_preflight || return 1 + + umask 077 + temp_dir=$(mktemp -d /tmp/honk-init.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + mock_pid="" + trap cleanup_initialize 0 + trap 'exit 1' HUP INT TERM + old_system=$temp_dir/system.dae + cp -p /etc/honk/system.dae "$old_system" || { echo '{"ok":false,"message":"system_config_backup_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + system_written_hash=$original_system_hash + ensure_loopback_system || { echo '{"ok":false,"message":"loopback_listener_write_failed"}'; return 1; } + mock_config=$temp_dir/mock.dae + mock_log=$temp_dir/core.log + response_file=$temp_dir/response.json + cat > "$mock_config" </dev/null || true + loopback_url="http://127.0.0.1:$listen_port" + if ! mock_start "$mock_config" "$mock_log" "$loopback_url" "$response_file"; then + echo '{"ok":false,"message":"mock_core_start_failed"}' + return 1 + fi + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + case "$setup_required" in + true) + status=$(post_credentials "$loopback_url/api/v1/auth/setup" "$response_file") + [ "$status" = 201 ] || { echo '{"ok":false,"message":"administrator_setup_failed"}'; return 1; } + ;; + false) + status=$(post_credentials "$loopback_url/api/v1/auth/login" "$response_file") + [ "$status" = 200 ] || { echo '{"ok":false,"message":"administrator_exists_reuse_credentials_or_reset_state"}'; return 1; } + ;; + *) echo '{"ok":false,"message":"native_api_setup_state_unavailable"}'; return 1 ;; + esac + api_discovery "$loopback_url" "$response_file" || { echo '{"ok":false,"message":"setup_verification_failed"}'; return 1; } + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + [ "$setup_required" = false ] || { echo '{"ok":false,"message":"setup_verification_failed"}'; return 1; } + mock_stop || { echo '{"ok":false,"message":"mock_core_stop_failed"}'; return 1; } + if ! mock_start "$mock_config" "$mock_log" "$loopback_url" "$response_file"; then + echo '{"ok":false,"message":"mock_core_restart_failed"}' + return 1 + fi + api_discovery "$loopback_url" "$response_file" || { echo '{"ok":false,"message":"persistence_verification_failed"}'; return 1; } + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + [ "$setup_required" = false ] || { echo '{"ok":false,"message":"persistence_verification_failed"}'; return 1; } + status=$(post_credentials "$loopback_url/api/v1/auth/login" "$response_file") + [ "$status" = 200 ] || { echo '{"ok":false,"message":"persistence_login_failed"}'; return 1; } + mock_stop || { echo '{"ok":false,"message":"mock_core_stop_failed"}'; return 1; } + + write_runtime_system "$lan_device" "$lan_ip" || { restore_system; echo '{"ok":false,"message":"system_config_write_failed"}'; return 1; } + lan_url="http://$lan_ip:$listen_port" + if ! mock_start /etc/honk/config.dae "$mock_log" "$lan_url" "$response_file"; then + restore_system + echo '{"ok":false,"message":"generated_config_invalid_restored_loopback"}' + return 1 + fi + api_discovery "$lan_url" "$response_file" || { + mock_stop >/dev/null 2>&1 + restore_system + echo '{"ok":false,"message":"lan_api_verification_failed_restored_loopback"}' + return 1 + } + mock_stop || { + restore_system + echo '{"ok":false,"message":"mock_core_stop_failed_restored_loopback"}' + return 1 + } + uci set honk.main.initialized='1' && uci commit honk || { + restore_system + uci set honk.main.initialized='0' >/dev/null 2>&1 + uci commit honk >/dev/null 2>&1 + echo '{"ok":false,"message":"uci_commit_failed_restored_loopback"}' + return 1 + } + system_written_hash="" + password="" + username="" + echo '{"ok":true,"message":"administrator_initialized"}' +} + +case "$1" in + check) + preflight + ;; + diagnose) + diagnose + ;; + preflight) + prepare_runtime + ;; + initialize) + initialize + ;; + settings) + settings + ;; + repair) + repair + ;; + *) + echo "usage: $0 {check|diagnose|preflight|initialize|settings|repair}" >&2 + exit 2 + ;; +esac diff --git a/honk/files/honk.config b/honk/files/honk.config new file mode 100644 index 00000000..47cc5ce2 --- /dev/null +++ b/honk/files/honk.config @@ -0,0 +1,11 @@ +config honk 'main' + option enabled '0' + option boot_enabled '0' + option initialized '0' + option config_file '/etc/honk/config.dae' + option listen_port '9527' + option lan_network 'lan' + option geoip_url '' + option geosite_url '' + option geo_auto '0' + option geo_auto_freq 'daily' diff --git a/honk/files/honk.init b/honk/files/honk.init new file mode 100755 index 00000000..9eeacc1a --- /dev/null +++ b/honk/files/honk.init @@ -0,0 +1,55 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 +START=95 +STOP=10 + +. /lib/functions.sh + +HONK_CONTROL=/usr/share/honk/control.sh +HONK_CONFIG=/etc/honk/config.dae + +boot() { + local boot_enabled + config_load honk + config_get_bool boot_enabled main boot_enabled 0 + [ "$boot_enabled" -eq 1 ] || return 0 + uci set honk.main.enabled='1' || return 1 + uci commit honk || return 1 + start +} + +start_service() { + local enabled initialized config_file + config_load honk + config_get_bool enabled main enabled 0 + config_get_bool initialized main initialized 0 + [ "$enabled" -eq 1 ] && [ "$initialized" -eq 1 ] || { + logger -t honk "refusing to start before explicit initialization and enablement" + return 1 + } + config_get config_file main config_file "$HONK_CONFIG" + [ "$config_file" = "$HONK_CONFIG" ] || { + logger -t honk "refusing non-default config path" + return 1 + } + "$HONK_CONTROL" preflight || return 1 + unset HONK_MAINT_CAP + + procd_open_instance main + procd_set_param command /usr/bin/honk-core --config "$HONK_CONFIG" --disable-timestamp + procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param respawn 3600 5 5 + procd_set_param term_timeout 20 + procd_close_instance +} + +reload_service() { + # honk-core has no hot-reload API wired here; a reload is an explicit + # restart so existing connections are cycled. The UI only exposes + # restart, this exists so manual `/etc/init.d/honk reload` is defined. + logger -t honk "reload requested, restarting (no hot-reload)" + restart +} diff --git a/honk/files/job.sh b/honk/files/job.sh new file mode 100644 index 00000000..b0574bf2 --- /dev/null +++ b/honk/files/job.sh @@ -0,0 +1,122 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 +. /usr/share/libubox/jshn.sh + +JOBS=/tmp/honk-jobs +MAINT=/usr/share/honk/maintenance.sh + +write_status() { + status_state=$1 + status_phase=$2 + status_ok=$3 + status_message=$4 + status_tmp=$job_dir/status.new + { + json_init + json_add_string state "$status_state" + json_add_string phase "$status_phase" + json_add_boolean ok "$status_ok" + json_add_string message "$status_message" + json_dump + } > "$status_tmp" || return 1 + chmod 0600 "$status_tmp" || return 1 + mv -f "$status_tmp" "$job_dir/status" || return 1 +} + +job_id=$1 +operation=$2 +case "$job_id" in + *[!0-9a-f]*|'') exit 2 ;; +esac +[ "${#job_id}" -eq 32 ] || exit 2 +case "$operation" in backup|restore|reset|update_check|update_apply) ;; *) exit 2 ;; esac + +job_dir=$JOBS/$job_id +[ -d "$JOBS" ] && [ ! -L "$JOBS" ] && [ -d "$job_dir" ] && [ ! -L "$job_dir" ] || exit 2 +[ "$(stat -c '%u:%a' "$JOBS" 2>/dev/null)" = 0:700 ] && [ "$(stat -c '%u:%a' "$job_dir" 2>/dev/null)" = 0:700 ] || exit 2 +[ "$(id -u)" = 0 ] || exit 2 +write_status running preparing true preparing || exit 1 +: > "$job_dir/output" || exit 1 +chmod 0600 "$job_dir/output" || exit 1 +if [ "$operation" = update_apply ]; then + /usr/share/honk/update.sh apply > "$job_dir/output" 2>&1 & +elif [ "$operation" = update_check ]; then + /usr/share/honk/update.sh check > "$job_dir/output" 2>&1 & +else + "$MAINT" "$operation" > "$job_dir/output" 2>&1 & +fi +maint_pid=$! +logger -t honk-maintenance "$operation started" 2>/dev/null || : +# Total watchdog: never poll forever if maintenance.sh hangs. +_poll_deadline=$(($(date +%s) + 1800)) +while kill -0 "$maint_pid" 2>/dev/null; do + if [ "$(date +%s)" -ge "$_poll_deadline" ]; then + logger -t honk-maintenance "$operation timed out after 1800s, killing $maint_pid" 2>/dev/null || : + kill -9 "$maint_pid" 2>/dev/null || : + break + fi + journal_phase=$(sed -n 's/^phase=//p' /etc/.honk-maintenance/journal 2>/dev/null | sed -n '1p') + if [ "$operation" = update_apply ]; then + update_phase=$(cat /etc/.honk-update/phase 2>/dev/null) + case "$update_phase" in + downloading) status_phase=downloading ;; + verifying) status_phase=validating ;; + stopping) status_phase=stopping ;; + installing) status_phase=applying ;; + starting) status_phase=starting ;; + *) status_phase=preparing ;; + esac + elif [ "$operation" = update_check ]; then + status_phase=checking_feed + else case "$journal_phase" in + prepared) status_phase=preparing ;; + stopped) status_phase=stopping ;; + old_moved|new_installed) status_phase=applying ;; + uci_applied) status_phase=validating ;; + service_started) status_phase=starting ;; + rollback_old_start) status_phase=rolling_back ;; + committed) status_phase=committing ;; + *) status_phase=preparing ;; + esac; fi + write_status running "$status_phase" true "$status_phase" || : + sleep 1 +done +wait "$maint_pid" +operation_rc=$? +json_load "$(cat "$job_dir/output" 2>/dev/null)" 2>/dev/null || { + if [ -e /etc/.honk-maintenance/journal ] || [ -L /etc/.honk-maintenance/journal ] || [ -d /run/honk.lock ]; then + write_status rollback_required recovery false maintenance_recovery_required + else + write_status failed complete false operation_failed + fi + logger -t honk-maintenance "$operation failed (invalid result)" 2>/dev/null || : + exit 1 +} +json_get_var operation_ok ok +json_get_var operation_message message +if [ "$operation_rc" -eq 0 ] && [ "$operation_ok" = 1 ]; then + if [ "$operation" = update_check ]; then + json_add_string state succeeded + json_add_string phase complete + json_add_string job_id "$job_id" + json_dump > "$job_dir/status.new" && chmod 0600 "$job_dir/status.new" && mv -f "$job_dir/status.new" "$job_dir/status" || exit 1 + logger -t honk-maintenance "$operation completed" 2>/dev/null || : + exit 0 + fi + write_status succeeded complete true "$operation_message" + logger -t honk-maintenance "$operation completed" 2>/dev/null || : + exit 0 +fi +case "$operation_message" in + *rollback_failed*|*recovery_required*|*snapshot_retained*) + write_status rollback_required recovery false "$operation_message" + logger -t honk-maintenance "$operation requires recovery" 2>/dev/null || : + exit 1 + ;; +esac +write_status failed complete false "${operation_message:-operation_failed}" +logger -t honk-maintenance "$operation failed" 2>/dev/null || : +exit 1 diff --git a/honk/files/lifecycle.sh b/honk/files/lifecycle.sh new file mode 100644 index 00000000..d1490ca4 --- /dev/null +++ b/honk/files/lifecycle.sh @@ -0,0 +1,202 @@ +#!/bin/sh + +HONK_LIFECYCLE_CORE=/usr/bin/honk-core + +honk_lifecycle_procd_pid() { + ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.pid' 2>/dev/null +} + +honk_lifecycle_procd_running() { + ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.running' 2>/dev/null +} + +honk_lifecycle_managed_pid() { + hlp_pid=$(honk_lifecycle_procd_pid) + [ -n "$hlp_pid" ] && [ -r "/proc/$hlp_pid/cmdline" ] || return 1 + hlp_cmd=$(tr '\000' ' ' < "/proc/$hlp_pid/cmdline" 2>/dev/null) || return 1 + case "$hlp_cmd" in + */usr/bin/honk-core\ --config\ /etc/honk/config.dae\ --disable-timestamp*) ;; + *) return 1 ;; + esac + case "$hlp_cmd" in *--mock-ebpf*) return 1 ;; esac + [ "$(readlink -f "/proc/$hlp_pid/exe" 2>/dev/null)" = "$HONK_LIFECYCLE_CORE" ] || return 1 + printf '%s\n' "$hlp_pid" +} + +honk_lifecycle_starttime() { + [ -r "/proc/$1/stat" ] || return 1 + awk '{ sub(/^.*\) /, ""); split($0, fields, /[[:space:]]+/); if (fields[20] ~ /^[0-9]+$/) print fields[20]; else exit 1 }' "/proc/$1/stat" 2>/dev/null +} + +honk_lifecycle_listen() { + sed -n "s/^[[:space:]]*listen:[[:space:]]*'\([^']*\)'.*/\1/p" /etc/honk/system.dae 2>/dev/null | head -n 1 +} + +honk_lifecycle_parse_listen() { + HONK_LIFECYCLE_LISTEN=$(honk_lifecycle_listen) + case "$HONK_LIFECYCLE_LISTEN" in *:*) ;; *) return 1 ;; esac + HONK_LIFECYCLE_PORT=${HONK_LIFECYCLE_LISTEN##*:} + case "$HONK_LIFECYCLE_LISTEN" in ''|*[!A-Za-z0-9:._-]*) return 1 ;; esac + case "$HONK_LIFECYCLE_PORT" in ''|*[!0-9]*) return 1 ;; esac + [ "$HONK_LIFECYCLE_PORT" -ge 1024 ] && [ "$HONK_LIFECYCLE_PORT" -le 65535 ] +} + +honk_lifecycle_port_open() { + hlp_port=$1 + case "$hlp_port" in ''|*[!0-9]*) return 1 ;; esac + awk -v port="$hlp_port" 'BEGIN { printf "%04X\n", port }' | { + IFS= read -r hlp_hex || exit 1 + for hlp_table in /proc/net/tcp /proc/net/tcp6; do + [ -r "$hlp_table" ] || continue + awk -v port="$hlp_hex" 'NR > 1 && toupper($2) ~ (":" port "$") && $4 == "0A" { found = 1 } END { exit !found }' "$hlp_table" && exit 0 + done + exit 1 + } +} + +honk_lifecycle_listener_owned() { + hlp_pid=$1 + hlp_port=$2 + hlp_hex=$(awk -v port="$hlp_port" 'BEGIN { printf "%04X", port }') || return 1 + for hlp_table in /proc/net/tcp /proc/net/tcp6; do + [ -r "$hlp_table" ] || continue + for hlp_inode in $(awk -v port="$hlp_hex" 'NR > 1 && toupper($2) ~ (":" port "$") && $4 == "0A" { print $10 }' "$hlp_table"); do + for hlp_fd in /proc/"$hlp_pid"/fd/*; do + [ "$(readlink "$hlp_fd" 2>/dev/null)" = "socket:[$hlp_inode]" ] && return 0 + done + done + done + return 1 +} + +honk_lifecycle_api_owned() { + hlp_pid=$1 + honk_lifecycle_parse_listen || return 1 + honk_lifecycle_listener_owned "$hlp_pid" "$HONK_LIFECYCLE_PORT" && \ + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://$HONK_LIFECYCLE_LISTEN/api" -o /dev/null 2>/dev/null +} + +honk_lifecycle_resources_clear() { + command -v ip >/dev/null 2>&1 || return 1 + for hlp_link in dae0 dae0peer; do + ip link show "$hlp_link" >/dev/null 2>&1 && return 1 + done + ! ip netns list 2>/dev/null | awk '{print $1}' | grep -qx daens +} + +honk_lifecycle_drain() { + hlp_timeout=${1:-30} + while [ "$hlp_timeout" -gt 0 ]; do + hlp_wait=0 + if [ -n "${HONK_LIFECYCLE_STOP_PID:-}" ] && \ + [ "$(honk_lifecycle_starttime "$HONK_LIFECYCLE_STOP_PID" 2>/dev/null)" = "$HONK_LIFECYCLE_STOP_STARTTIME" ]; then + hlp_wait=1 + fi + hlp_procd=$(honk_lifecycle_procd_pid) + [ -z "$hlp_procd" ] || hlp_wait=1 + hlp_running=$(honk_lifecycle_procd_running) + case "$hlp_running" in true|1) hlp_wait=1 ;; esac + hlp_all=$(pidof honk-core 2>/dev/null) + if [ -n "$hlp_all" ]; then + for hlp_pid in $hlp_all; do + if [ "$hlp_pid" != "${HONK_LIFECYCLE_STOP_PID:-}" ] || \ + [ "$(honk_lifecycle_starttime "$hlp_pid" 2>/dev/null)" != "${HONK_LIFECYCLE_STOP_STARTTIME:-}" ]; then + echo "honk lifecycle: unmanaged honk-core process remains" >&2 + return 1 + fi + done + hlp_wait=1 + fi + honk_lifecycle_resources_clear || hlp_wait=1 + honk_lifecycle_parse_listen || { echo "honk lifecycle: invalid configured listener" >&2; return 1; } + hlp_port=$HONK_LIFECYCLE_PORT + if honk_lifecycle_port_open "$hlp_port"; then hlp_wait=1; fi + if [ "$hlp_wait" = 0 ]; then + return 0 + fi + sleep 1 + hlp_timeout=$((hlp_timeout - 1)) + done + echo "honk lifecycle: old process or network resources did not drain" >&2 + return 1 +} + +honk_lifecycle_stop() { + hlp_timeout=${1:-30} + HONK_LIFECYCLE_STOP_PID=$(honk_lifecycle_managed_pid 2>/dev/null) + if [ -n "$HONK_LIFECYCLE_STOP_PID" ]; then + HONK_LIFECYCLE_STOP_STARTTIME=$(honk_lifecycle_starttime "$HONK_LIFECYCLE_STOP_PID") || return 1 + else + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing to stop unmanaged honk-core" >&2; return 1; } + [ -z "$(honk_lifecycle_procd_pid)" ] || { echo "honk lifecycle: procd instance is not the expected core" >&2; return 1; } + case "$(honk_lifecycle_procd_running)" in true|1) echo "honk lifecycle: procd instance is still running" >&2; return 1 ;; esac + HONK_LIFECYCLE_STOP_STARTTIME= + fi + if [ -n "$HONK_LIFECYCLE_STOP_PID" ]; then /etc/init.d/honk stop >/dev/null 2>&1 || :; fi + honk_lifecycle_drain "$hlp_timeout" +} + +honk_lifecycle_start() { + hlp_timeout=${1:-30} + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing start while a core process exists" >&2; return 1; } + [ -z "$(honk_lifecycle_procd_pid)" ] || { echo "honk lifecycle: procd instance has not stopped" >&2; return 1; } + case "$(honk_lifecycle_procd_running)" in true|1) echo "honk lifecycle: procd instance is still running" >&2; return 1 ;; esac + honk_lifecycle_resources_clear || { echo "honk lifecycle: network resources have not drained" >&2; return 1; } + honk_lifecycle_parse_listen || { echo "honk lifecycle: invalid configured listener" >&2; return 1; } + hlp_listen=$HONK_LIFECYCLE_LISTEN + hlp_port=$HONK_LIFECYCLE_PORT + if honk_lifecycle_port_open "$hlp_port"; then echo "honk lifecycle: listener port remains occupied" >&2; return 1; fi + if [ -n "${HONK_MAINT_CAP:-}" ]; then export HONK_MAINT_CAP; fi + /etc/init.d/honk start >/dev/null 2>&1 + hlp_start_status=$? + unset HONK_MAINT_CAP + [ "$hlp_start_status" = 0 ] || { echo "honk lifecycle: init start failed" >&2; return 1; } + hlp_stable=0 + hlp_pid= + hlp_starttime= + while [ "$hlp_timeout" -gt 0 ]; do + hlp_new_pid=$(honk_lifecycle_managed_pid 2>/dev/null) + if [ -n "$hlp_new_pid" ]; then + hlp_new_starttime=$(honk_lifecycle_starttime "$hlp_new_pid") || hlp_new_starttime= + if [ -n "$hlp_new_starttime" ] && \ + { [ -z "${HONK_LIFECYCLE_STOP_PID:-}" ] || [ "$hlp_new_pid:$hlp_new_starttime" != "$HONK_LIFECYCLE_STOP_PID:$HONK_LIFECYCLE_STOP_STARTTIME" ]; } && \ + honk_lifecycle_listener_owned "$hlp_new_pid" "$hlp_port" && \ + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://$hlp_listen/api" -o /dev/null 2>/dev/null; then + if [ "$hlp_pid:$hlp_starttime" = "$hlp_new_pid:$hlp_new_starttime" ]; then + hlp_stable=$((hlp_stable + 1)) + else + hlp_pid=$hlp_new_pid + hlp_starttime=$hlp_new_starttime + hlp_stable=1 + fi + if [ "$hlp_stable" -ge 3 ]; then + HONK_LIFECYCLE_STOP_PID=$hlp_new_pid + HONK_LIFECYCLE_STOP_STARTTIME=$hlp_new_starttime + return 0 + fi + else + hlp_stable=0 + hlp_pid= + hlp_starttime= + fi + else + hlp_stable=0 + hlp_pid= + hlp_starttime= + fi + sleep 1 + hlp_timeout=$((hlp_timeout - 1)) + done + echo "honk lifecycle: new managed core did not become stable and own its API listener" >&2 + return 1 +} + +honk_lifecycle_restart() { + if [ -n "$(honk_lifecycle_managed_pid 2>/dev/null)" ]; then + HONK_LIFECYCLE_WAS_RUNNING=1 + else + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing unmanaged honk-core" >&2; return 1; } + HONK_LIFECYCLE_WAS_RUNNING=0 + fi + honk_lifecycle_stop "${1:-30}" && honk_lifecycle_start "${1:-30}" +} diff --git a/honk/files/lock.sh b/honk/files/lock.sh new file mode 100644 index 00000000..cc77d70f --- /dev/null +++ b/honk/files/lock.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +HONK_LOCK_DIR=/run/honk.lock + +# PID alone is not enough to identify a process (PID reuse race). We store +# "pid starttime" where starttime is field 22 of /proc/pid/stat. +_honk_proc_starttime() { + cut -d' ' -f22 "/proc/$1/stat" 2>/dev/null +} + +honk_lock_acquire() { + mkdir "$HONK_LOCK_DIR" 2>/dev/null || return 1 + chmod 0700 "$HONK_LOCK_DIR" || { rmdir "$HONK_LOCK_DIR"; return 1; } + printf '%s %s\n' "$$" "$(_honk_proc_starttime $$)" > "$HONK_LOCK_DIR/pid" || { rm -f "$HONK_LOCK_DIR/pid"; rmdir "$HONK_LOCK_DIR"; return 1; } +} + +honk_lock_is_stale() { + [ -r "$HONK_LOCK_DIR/pid" ] || return 1 + read -r owner owner_start < "$HONK_LOCK_DIR/pid" || return 1 + case "$owner" in ''|*[!0-9]*) return 1 ;; esac + # Dead if the PID is gone, or if it was reused by a newer process. + ! kill -0 "$owner" 2>/dev/null && return 0 + [ "$(_honk_proc_starttime "$owner")" = "$owner_start" ] || return 0 + return 1 +} + +honk_lock_release() { + [ -r "$HONK_LOCK_DIR/pid" ] || return 1 + read -r owner owner_start < "$HONK_LOCK_DIR/pid" || return 1 + [ "$owner" = "$$" ] && [ "$owner_start" = "$(_honk_proc_starttime $$)" ] || return 1 + rm -f "$HONK_LOCK_DIR/pid" && rmdir "$HONK_LOCK_DIR" +} diff --git a/honk/files/log-level-filter.sh b/honk/files/log-level-filter.sh new file mode 100644 index 00000000..c4ebcdab --- /dev/null +++ b/honk/files/log-level-filter.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only + +requested=$1 +case "$requested" in all|debug|info|warn|error) ;; *) exit 2 ;; esac + +awk -v requested="$requested" ' +function rank(level) { + if (level == "DEBUG" || level == "TRACE") return 0 + if (level == "INFO" || level == "NOTICE") return 1 + if (level == "WARN" || level == "WARNING") return 2 + if (level == "ERROR" || level == "FATAL" || level == "CRIT" || level == "CRITICAL" || level == "ALERT" || level == "EMERG") return 3 + return -1 +} +function from_syslog(header, token, parts, count, level) { + count = split(header, parts, /[[:space:]]+/) + for (i = 1; i <= count; i++) { + token = parts[i] + if (token ~ /^[[:alnum:]_-]+\.(debug|info|notice|warn|warning|err|error|crit|critical|alert|emerg)$/) { + sub(/^.*\./, "", token) + if (token == "err") token = "error" + return rank(toupper(token)) + } + } + return -1 +} +{ + if (requested == "all" || requested == "debug") { print; next } + line = $0 + header = line + message = "" + separator = index(line, "]: ") + if (separator > 0) { + header = substr(line, 1, separator + 1) + message = substr(line, separator + 3) + } else { + separator = index(line, ": ") + if (separator > 0) { + header = substr(line, 1, separator - 1) + message = substr(line, separator + 2) + } + } + sub(/^[0-9][0-9][0-9][0-9]-[0-9T:.+Z-]+[[:space:]]+/, "", message) + if (match(message, /^(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|FATAL|CRIT|CRITICAL|ALERT|EMERG)([[:space:]:]|$)/)) { + level = message + sub(/[[:space:]:].*$/, "", level) + severity = rank(level) + } else if (match(line, /^<[0-7]>/)) { + priority = substr(line, 2, 1) + 0 + severity = priority <= 3 ? 3 : priority == 4 ? 2 : priority <= 6 ? 1 : 0 + } else { + severity = from_syslog(header) + } + threshold = requested == "info" ? 1 : requested == "warn" ? 2 : 3 + if (severity >= threshold) print +}' diff --git a/honk/files/log-lines.sh b/honk/files/log-lines.sh new file mode 100644 index 00000000..5f773973 --- /dev/null +++ b/honk/files/log-lines.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only + +honk_add_log_lines() { + [ -s "$1" ] || return 0 + while IFS= read -r line; do + json_add_string "" "$line" + done < "$1" +} diff --git a/honk/files/maintenance.sh b/honk/files/maintenance.sh new file mode 100755 index 00000000..8b48bbb5 --- /dev/null +++ b/honk/files/maintenance.sh @@ -0,0 +1,1124 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 + +HONK_ROOT=/etc/honk +HONK_CONFIG=$HONK_ROOT/config.dae +HONK_SYSTEM=$HONK_ROOT/system.dae +HONK_STATE=$HONK_ROOT/state/honk.db +HONK_CORE=/usr/bin/honk-core +IMPORT_PREVIEW_ROOT=/tmp/honk-v2-import +BACKUP=/tmp/honk-backup.tar.gz +RESTORE=/tmp/honk-maintenance/restore.tar.gz +LOCK_DIR=/run/honk.lock +TXN_DIR=/etc/.honk-maintenance +JOURNAL=$TXN_DIR/journal +MAX_COMPRESSED=33554432 +MAX_EXPANDED=134217728 +MAX_FILE=67108864 +MAX_FILES=512 +CAPABILITY=$TXN_DIR/capability + +if [ "${HONK_MAINT_LIBRARY:-0}" != 1 ]; then + . /usr/share/honk/lock.sh || exit 1 + . /usr/share/honk/lifecycle.sh || exit 1 + . /usr/share/libubox/jshn.sh || exit 1 +fi + +json_result() { + json_ok=0 + [ "$1" = true ] && json_ok=1 + json_init + json_add_boolean ok "$json_ok" + [ -n "$2" ] && json_add_string path "$2" + json_add_string message "$3" + json_dump +} + +error() { + json_result false "" "$1" + return 1 +} + +managed_core_pid() { + managed_pid=$(ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.pid' 2>/dev/null) + case "$managed_pid" in ''|*[!0-9]*) return 1 ;; esac + [ -r "/proc/$managed_pid/cmdline" ] || return 1 + managed_cmd=$(tr '\000' ' ' < "/proc/$managed_pid/cmdline" 2>/dev/null) || return 1 + case "$managed_cmd" in + */usr/bin/honk-core\ --config\ /etc/honk/config.dae\ --disable-timestamp*) ;; + *) return 1 ;; + esac + case "$managed_cmd" in *--mock-ebpf*) return 1 ;; esac + [ "$(readlink -f "/proc/$managed_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + printf '%s\n' "$managed_pid" +} + +foreign_core_running() { + managed_pid=$(managed_core_pid 2>/dev/null || true) + for core_pid in $(pidof honk-core 2>/dev/null); do + [ "$core_pid" = "$managed_pid" ] || return 0 + done + return 1 +} + +proc_starttime() { + stat_line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 + stat_tail=${stat_line##*) } + set -- $stat_tail + [ "$#" -ge 20 ] || return 1 + stat_index=1 + for stat_field do + if [ "$stat_index" = 20 ]; then + case "$stat_field" in ''|*[!0-9]*) return 1 ;; esac + printf '%s\n' "$stat_field" + return 0 + fi + stat_index=$((stat_index + 1)) + done + return 1 +} + +new_nonce() { + nonce_value=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-') || return 1 + case "$nonce_value" in [0-9a-f][0-9a-f]*) [ "${#nonce_value}" = 32 ] || return 1 ;; *) return 1 ;; esac + printf '%s\n' "$nonce_value" +} + +issue_capability() { + cap_phase=$1 + [ "$(id -u 2>/dev/null)" = 0 ] || return 1 + case "$cap_phase:$journal_operation:$journal_phase" in + candidate_health_start:restore:new_installed|rollback_old_start:restore:rollback_old_start|rollback_old_start:reset:rollback_old_start) ;; + *) return 1 ;; + esac + cap_nonce=$(new_nonce) || return 1 + cap_owner=$$ + cap_starttime=$(proc_starttime "$cap_owner") || return 1 + cap_tmp=$TXN_DIR/capability.new.$cap_owner + { + printf 'format=1\nnonce=%s\ntxn_id=%s\nphase=%s\nowner_pid=%s\nowner_starttime=%s\n' \ + "$cap_nonce" "$txn_id" "$cap_phase" "$cap_owner" "$cap_starttime" + } > "$cap_tmp" || return 1 + chmod 0600 "$cap_tmp" || { rm -f "$cap_tmp"; return 1; } + mv -f "$cap_tmp" "$CAPABILITY" || { rm -f "$cap_tmp"; return 1; } + export HONK_MAINT_CAP=$cap_nonce + honk_lifecycle_start 30 + cap_start_rc=$? + unset HONK_MAINT_CAP + return "$cap_start_rc" +} + +stop_managed() { + old_pid=$(managed_core_pid 2>/dev/null || true) + if foreign_core_running; then return 2; fi + if [ -n "$old_pid" ]; then + honk_lifecycle_stop 30 || return 1 + managed_core_pid >/dev/null 2>&1 && return 1 + fi + return 0 +} + +restore_running() { + if [ "$old_running" != 1 ]; then service_stopped=0; return 0; fi + foreign_core_running && return 1 + honk_lifecycle_start 30 || return 1 + service_stopped=0 +} + +capture_boot_links() { + boot_start_path=/etc/rc.d/S95honk + boot_stop_path=/etc/rc.d/K10honk + old_start_link=- + old_stop_link=- + if [ -L "$boot_start_path" ]; then old_start_link=$(readlink "$boot_start_path") || return 1 + elif [ -e "$boot_start_path" ]; then return 1; fi + if [ -L "$boot_stop_path" ]; then old_stop_link=$(readlink "$boot_stop_path") || return 1 + elif [ -e "$boot_stop_path" ]; then return 1; fi + case "$old_start_link:$old_stop_link" in + -:-|../init.d/honk:-|-:../init.d/honk|../init.d/honk:../init.d/honk) ;; + *) return 1 ;; + esac +} + +restore_boot_links() { + for boot_link in /etc/rc.d/S95honk /etc/rc.d/K10honk; do + [ ! -e "$boot_link" ] || [ -L "$boot_link" ] || return 1 + rm -f "$boot_link" || return 1 + done + if [ "$old_start_link" != - ]; then ln -s "$old_start_link" /etc/rc.d/S95honk || return 1; fi + if [ "$old_stop_link" != - ]; then ln -s "$old_stop_link" /etc/rc.d/K10honk || return 1; fi +} + +disable_boot_links() { + for boot_link in /etc/rc.d/S95honk /etc/rc.d/K10honk; do + [ ! -e "$boot_link" ] || [ -L "$boot_link" ] || return 1 + rm -f "$boot_link" || return 1 + done +} + +journal_write() { + journal_next=$TXN_DIR/journal.new + { + printf 'format=1\noperation=%s\nphase=%s\ntxn_id=%s\nold_running=%s\nold_enabled=%s\nold_boot=%s\nold_initialized=%s\nold_start_link=%s\nold_stop_link=%s\n' \ + "$journal_operation" "$1" "$txn_id" "$old_running" "$old_enabled" "$old_boot" "$old_initialized" "$old_start_link" "$old_stop_link" + } > "$journal_next" || return 1 + chmod 0600 "$journal_next" || return 1 + sync + mv -f "$journal_next" "$JOURNAL" || return 1 + sync + journal_phase=$1 +} + +journal_create() { + journal_operation=$1 + txn_id=$(new_nonce) || return 1 + [ ! -e "$TXN_DIR" ] && [ ! -L "$TXN_DIR" ] || return 1 + mkdir "$TXN_DIR" || return 1 + chmod 0700 "$TXN_DIR" || return 1 + cp "$temp_dir/old-uci" "$TXN_DIR/old-uci" || return 1 + chmod 0600 "$TXN_DIR/old-uci" || return 1 + old_uci_snapshot=$TXN_DIR/old-uci + journal_active=1 + journal_write prepared +} + +journal_value() { + sed -n "s/^$1=//p" "$JOURNAL" 2>/dev/null +} + +recover_transaction() { + [ -e "$TXN_DIR" ] || { journal_active=0; return 0; } + [ -d "$TXN_DIR" ] && [ ! -L "$TXN_DIR" ] && [ -f "$JOURNAL" ] && [ ! -L "$JOURNAL" ] || return 1 + journal_operation=$(journal_value operation) + journal_phase=$(journal_value phase) + journal_format=$(journal_value format) + txn_id=$(journal_value txn_id) + old_running=$(journal_value old_running) + old_enabled=$(journal_value old_enabled) + old_boot=$(journal_value old_boot) + old_initialized=$(journal_value old_initialized) + old_start_link=$(journal_value old_start_link) + old_stop_link=$(journal_value old_stop_link) + [ "$journal_format" = 1 ] || return 1 + case "$txn_id" in [0-9a-f][0-9a-f]*) [ "${#txn_id}" = 32 ] || return 1 ;; *) return 1 ;; esac + case "$journal_operation:$journal_phase" in backup:prepared|backup:stopped|backup:committed|restore:prepared|restore:stopped|restore:old_moved|restore:new_installed|restore:uci_applied|restore:service_started|restore:rollback_old_start|restore:committed|reset:prepared|reset:stopped|reset:old_moved|reset:new_installed|reset:uci_applied|reset:rollback_old_start|reset:committed) ;; + *) return 1 ;; + esac + case "$old_running:$old_enabled:$old_boot:$old_initialized" in + 0:0:0:0|0:0:0:1|0:0:1:0|0:0:1:1|0:1:0:0|0:1:0:1|0:1:1:0|0:1:1:1|1:0:0:0|1:0:0:1|1:0:1:0|1:0:1:1|1:1:0:0|1:1:0:1|1:1:1:0|1:1:1:1) ;; + *) return 1 ;; + esac + case "$old_start_link:$old_stop_link" in -:-|../init.d/honk:-|-:../init.d/honk|../init.d/honk:../init.d/honk) ;; *) return 1 ;; esac + old_uci_snapshot=$TXN_DIR/old-uci + [ -f "$old_uci_snapshot" ] && [ ! -L "$old_uci_snapshot" ] || return 1 + if [ "$journal_phase" = committed ]; then + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + swap_started=0 + if [ "$journal_operation" = backup ] && [ "$old_running" = 1 ]; then + honk_lifecycle_start 30 || return 1 + fi + service_stopped=0 + return 0 + fi + foreign_core_running && return 1 + if [ "$journal_operation" = restore ] || [ "$journal_operation" = reset ]; then + if managed_core_pid >/dev/null 2>&1; then + honk_lifecycle_stop 30 || return 1 + fi + if [ -d "$TXN_DIR/old-tree" ] && [ ! -L "$TXN_DIR/old-tree" ]; then + if [ -e "$HONK_ROOT" ]; then + [ ! -e "$TXN_DIR/failed-tree" ] || return 1 + mv "$HONK_ROOT" "$TXN_DIR/failed-tree" || return 1 + fi + mv "$TXN_DIR/old-tree" "$HONK_ROOT" || return 1 + else + [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ] || return 1 + fi + fi + if ! uci import honk < "$old_uci_snapshot" || ! uci commit honk; then return 1; fi + restore_boot_links || return 1 + if [ "$old_running" = 1 ]; then + if ! managed_core_pid >/dev/null 2>&1; then + if [ "$journal_operation" = restore ] || [ "$journal_operation" = reset ]; then + journal_write rollback_old_start || return 1 + issue_capability rollback_old_start || return 1 + else + # A backup never changes the live tree; clear its journal before normal startup. + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + honk_lifecycle_start 30 || return 1 + fi + fi + else + if managed_core_pid >/dev/null 2>&1; then honk_lifecycle_stop 30 || return 1; fi + fi + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + service_stopped=0 + swap_started=0 +} + +backup_abort() { + backup_message=$1 + if [ -n "$mock_pid" ] && ! stop_mock; then + error "${backup_message}_mock_stop_failed_runtime_not_restarted" + return 1 + fi + if [ "$journal_active" = 1 ]; then + recover_transaction || backup_message="${backup_message}_runtime_restore_failed" + elif ! restore_running; then backup_message="${backup_message}_runtime_restore_failed"; fi + error "$backup_message" +} + +restore_abort() { + restore_message=$1 + if [ -n "$mock_pid" ] && ! stop_mock; then + error "${restore_message}_mock_stop_failed" + return 1 + fi + if [ "$journal_active" = 1 ]; then + recover_transaction || restore_message="${restore_message}_rollback_failed" + elif [ "$service_stopped" = 1 ] && ! restore_running; then + restore_message="${restore_message}_runtime_restore_failed" + fi + error "$restore_message" +} + +stop_mock() { + [ -n "$mock_pid" ] || return 0 + if [ ! -r "/proc/$mock_pid/cmdline" ]; then + stop_status=0 + wait "$mock_pid" 2>/dev/null || stop_status=$? + mock_pid= + [ "$stop_status" = 0 ] || return 1 + return 0 + fi + mock_identity=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) || return 1 + case "$mock_identity" in *"$mock_config"*--mock-ebpf*) ;; *) return 1 ;; esac + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + kill -TERM "$mock_pid" 2>/dev/null || return 1 + stop_forced=0 + stop_tries=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$stop_tries" -lt 10 ]; do sleep 1; stop_tries=$((stop_tries + 1)); done + if kill -0 "$mock_pid" 2>/dev/null; then + kill -KILL "$mock_pid" 2>/dev/null || return 1 + stop_forced=1 + stop_tries=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$stop_tries" -lt 5 ]; do sleep 1; stop_tries=$((stop_tries + 1)); done + fi + kill -0 "$mock_pid" 2>/dev/null && return 1 + stop_status=0 + wait "$mock_pid" 2>/dev/null || stop_status=$? + mock_pid= + [ "$stop_forced" = 0 ] && [ "$stop_status" = 0 ] +} + +cleanup() { + cleanup_status=$? + cleanup_safe=1 + if [ -n "$mock_pid" ] && ! stop_mock; then cleanup_safe=0; fi + if [ "$cleanup_safe" = 1 ]; then + [ ! -e "$CAPABILITY" ] || rm -f "$CAPABILITY" + if [ "$journal_active" = 1 ]; then + recover_transaction || { cleanup_safe=0; echo "honk maintenance: transaction recovery failed; preserve $TXN_DIR and $temp_dir" >&2; } + elif [ "$service_stopped" = 1 ]; then + restore_running || { cleanup_safe=0; echo "honk maintenance: service restart failed; preserving $temp_dir and lock" >&2; } + fi + fi + if [ "$cleanup_safe" = 1 ]; then + if [ -n "$temp_dir" ] && [ -d "$temp_dir" ]; then rm -rf "$temp_dir"; fi + if [ "$lock_owned" = 1 ]; then honk_lock_release >/dev/null 2>&1; fi + else + echo "honk maintenance: cleanup/rollback not confirmed; keeping lock, temporary data at $temp_dir, and any /etc/.honk.restore.* snapshot" >&2 + fi + return "$cleanup_status" +} + +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +lock_owned=0 +temp_dir= +mock_pid= +mock_config= +old_running=0 +service_stopped=0 +swap_started=0 +old_tree_path= +old_uci_snapshot= +journal_active=0 +journal_operation= +journal_phase= +old_start_link=- +old_stop_link=- +lock_error=operation_in_progress + +acquire_lock() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then lock_error=update_recovery_required; return 1; fi + if [ -d "$LOCK_DIR" ]; then + if [ -e "$TXN_DIR" ]; then lock_error=recovery_required; else lock_error=operation_in_progress_or_stale_lock; fi + return 1 + fi + honk_lock_acquire || { lock_error=operation_in_progress; return 1; } + lock_owned=1 + if [ -e "$TXN_DIR" ]; then + journal_active=1 + recover_transaction || { lock_error=interrupted_transaction_recovery_failed; return 2; } + fi + lock_error= +} + +recover_action() { + if [ -d "$LOCK_DIR" ]; then + error recovery_required_clear_stale_lock_only_after_confirming_no_honk_core + return 1 + fi + if ! honk_lock_acquire; then error operation_in_progress; return 1; fi + lock_owned=1 + if [ -e "$TXN_DIR" ]; then journal_active=1; fi + recover_transaction || { error interrupted_transaction_recovery_failed; return 1; } + json_result true "" transaction_recovered +} + +check_root_file() { + [ ! -L "$1" ] && [ -f "$1" ] || return 1 + [ "$(id -u 2>/dev/null)" = 0 ] || return 1 + [ "$(ls -ln "$1" 2>/dev/null | awk 'NR == 1 { print $3 }')" = 0 ] || return 1 + chmod 0600 "$1" 2>/dev/null || return 1 + [ ! -L "$1" ] && [ -f "$1" ] || return 1 + file_mode=$(ls -ldn "$1" 2>/dev/null | awk 'NR == 1 { print $1 }') + [ "$file_mode" = '-rw-------' ] +} + +safe_relpath() { + case "$1" in ''|/*|*..*|*[^A-Za-z0-9._/-]*|*//*|*/./*|./*|*/.) return 1 ;; esac + return 0 +} + +size_of() { + wc -c < "$1" | tr -d ' ' +} + +sha_of() { + sha256sum "$1" 2>/dev/null | awk '{print $1}' +} + +core_commit() { + jsonfilter -i /usr/share/honk/provenance.json -e '@.core.commit' 2>/dev/null +} + +load_uci_values() { + uci_bin=${1:-uci} + uci_config=${2:-} + # Run uci with optional -c/-t flags without eval: keep args in "$@" style + # via a helper so later callers can't inject through string concatenation. + if [ -n "$uci_config" ]; then + uci_run() { "$uci_bin" -c "$uci_config" -t "$temp_dir/uci-tmp" "$@"; } + else + uci_run() { "$uci_bin" "$@"; } + fi + uci_show=$(uci_run -q show honk 2>/dev/null) || return 1 + printf '%s\n' "$uci_show" | awk -F= ' + /^honk\.main=/ { main++ ; next } + /^honk\.main\.(enabled|boot_enabled|initialized|config_file|listen_port|lan_network)=/ { next } + /^honk\.main\./ { bad=1; next } + /^honk\.[A-Za-z0-9_.-]+=/ { bad=1; next } + END { exit (bad || main != 1) } + ' || return 1 + get_uci() { uci_run -q get "honk.main.$1" 2>/dev/null; } + u_enabled=$(get_uci enabled) || return 1 + u_boot=$(get_uci boot_enabled) || return 1 + u_initialized=$(get_uci initialized) || return 1 + u_config=$(get_uci config_file) || return 1 + u_port=$(get_uci listen_port) || return 1 + u_network=$(get_uci lan_network) || return 1 + case "$u_enabled:$u_boot:$u_initialized" in 0:0:0|0:0:1|0:1:0|0:1:1|1:0:0|1:0:1|1:1:0|1:1:1) ;; *) return 1 ;; esac + [ "$u_config" = /etc/honk/config.dae ] || return 1 + case "$u_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$u_port" -ge 1024 ] && [ "$u_port" -le 65535 ] || return 1 + case "$u_network" in ''|*[!A-Za-z0-9_.-]*) return 1 ;; esac +} + +check_sqlite() { + check_db=$1 + [ ! -L "$check_db" ] && [ -f "$check_db" ] || return 1 + command -v sqlite3 >/dev/null 2>&1 || return 1 + check_result=$(sqlite3 "$check_db" 'PRAGMA quick_check;' 2>/dev/null) || return 1 + [ "$check_result" = ok ] +} + +checkpoint_sqlite() { + [ -e "$HONK_STATE" ] || return 0 + check_sqlite "$HONK_STATE" || return 1 + checkpoint_result=$(sqlite3 "$HONK_STATE" 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null) || return 1 + case "$checkpoint_result" in + 0\|[0-9]*\|[0-9]*) ;; + *) return 1 ;; + esac + checkpoint_busy=${checkpoint_result%%|*} + checkpoint_rest=${checkpoint_result#*|} + checkpoint_log=${checkpoint_rest%%|*} + checkpoint_done=${checkpoint_rest#*|} + case "$checkpoint_log:$checkpoint_done" in *[!0-9:]*|:*|*:) return 1 ;; esac + [ "$checkpoint_busy" = 0 ] && [ "$checkpoint_log" = "$checkpoint_done" ] || return 1 + for sidecar in "$HONK_STATE-wal" "$HONK_STATE-shm"; do + [ ! -L "$sidecar" ] || return 1 + if [ -e "$sidecar" ]; then + [ -f "$sidecar" ] || return 1 + [ "$(size_of "$sidecar")" -eq 0 ] || [ "$sidecar" = "$HONK_STATE-shm" ] || return 1 + rm -f "$sidecar" || return 1 + fi + done + [ ! -e "$HONK_STATE-wal" ] && [ ! -e "$HONK_STATE-shm" ] +} + +is_excluded() { + case "$1" in + */logs/*|logs/*|*/log/*|log/*|*.log|*.tmp|*.pid|*.lock|*/.lock|*/honk.db-wal|*/honk.db-shm|*/honk.db-journal) + return 0 ;; + *) return 1 ;; + esac +} + +collect_tree() { + collect_root=$1 + collect_out=$2 + [ -d "$collect_root" ] && [ ! -L "$collect_root" ] || return 1 + if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then return 1; fi + if find "$collect_root" ! -type f ! -type d -print 2>/dev/null | grep -q .; then return 1; fi + : > "$collect_out" || return 1 + ( + cd "$collect_root" || exit 1 + find . -type f -print + ) > "$collect_out.raw" || return 1 + : > "$collect_out" + collect_count=0 + collect_total=0 + while IFS= read -r collect_path; do + collect_path=${collect_path#./} + is_excluded "$collect_path" && continue + safe_relpath "$collect_path" || exit 1 + [ -f "$collect_root/$collect_path" ] && [ ! -L "$collect_root/$collect_path" ] || return 1 + collect_size=$(size_of "$collect_root/$collect_path") || return 1 + [ "$collect_size" -le "$MAX_FILE" ] || return 1 + collect_count=$((collect_count + 1)) + collect_total=$((collect_total + collect_size)) + [ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || return 1 + printf '%s\n' "$collect_path" >> "$collect_out" || exit 1 + done < "$collect_out.raw" || return 1 + [ -f "$collect_root/config.dae" ] && [ -f "$collect_root/system.dae" ] || return 1 + return 0 +} + +validate_config_tree() { + config_root=$1 + expected_device=$2 + expected_ip=$3 + expected_port=$4 + expected_initialized=$5 + main=$config_root/config.dae + system=$config_root/system.dae + [ -f "$main" ] && [ ! -L "$main" ] && [ -f "$system" ] && [ ! -L "$system" ] || return 1 + validate_system_schema "$system" "$expected_device" "$expected_ip" "$expected_port" "$expected_initialized" || return 2 + # `validate_candidate` asks honk-core's own parser to resolve every include + # and captures the complete source tree inside its private shadow. Keep this + # shell check limited to filesystem entry types and managed system fields. + find "$config_root" -type l -print 2>/dev/null | grep -q . && return 2 + find "$config_root" ! -type f ! -type d -print 2>/dev/null | grep -q . && return 2 + return 0 +} + +validate_system_schema() { + trusted_system=$1 + expected_device=$2 + expected_ip=$3 + expected_port=$4 + expected_initialized=$5 + lan_iface=$(sed -n "s/^ lan_interface: '\\([^']*\\)'$/\\1/p" "$trusted_system") + listen_addr=$(sed -n "s/^ listen: '\\([^']*\\)'$/\\1/p" "$trusted_system") + case "$lan_iface" in ''|*[!A-Za-z0-9_.-]*) return 1 ;; esac + case "$listen_addr" in ''|*[!0-9.:]*) return 1 ;; esac + case "$listen_addr" in *.*.*.*:*) ;; *) return 1 ;; esac + listen_host=${listen_addr%:*} + listen_port=${listen_addr##*:} + case "$listen_host" in ''|*[!0-9.]*) return 1 ;; esac + case "$listen_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || return 1 + [ "$lan_iface" = "$expected_device" ] && [ "$listen_port" = "$expected_port" ] || return 1 + [ "$listen_host" = "$expected_ip" ] || [ "$listen_host" = 127.0.0.1 ] || return 1 + cat > "$temp_dir/system.expected" </dev/null || return 1 + tar -tvzf "$archive" > "$member_list" 2>/dev/null || return 1 + awk ' + { + if (NF < 2 || substr($1,1,1) != "-") exit 1 + name=$NF + if (name !~ /^[A-Za-z0-9_.\/-]+$/ || name ~ /^\// || name ~ /(^|\/)\.\.(\/|$)/ || name ~ /(^|\/)\.($|\/)/ || name ~ /\/\//) exit 1 + seen[name]++ + if (seen[name] > 1) exit 1 + count++ + if (count > 513) exit 1 + print name + } + END { if (!count) exit 1 } + ' "$member_list" > "$member_list.names" || return 1 + grep -qx 'manifest.tsv' "$member_list.names" || return 1 +} + +extract_archive() { + archive=$1 + stage=$2 + member_listing=$temp_dir/members.verbose + check_tar_members "$archive" "$member_listing" || return 1 + mkdir -p "$stage" || return 1 + : > "$stage/manifest.tsv" || return 1 + ulimit -f 262144 2>/dev/null || return 1 + tar -xOzf "$archive" manifest.tsv > "$stage/manifest.tsv" 2>/dev/null || return 1 + [ "$(size_of "$stage/manifest.tsv")" -le 1048576 ] || return 1 + awk -F '\t' ' + NR==1 { if (NF!=2 || $1!="format" || $2!="1") exit 1; next } + NR==2 { if (NF!=2 || $1!="core_commit" || length($2)!=40 || $2 !~ /^[0-9a-f]+$/) exit 1; next } + NR==3 { if (NF!=2 || $1!="state" || ($2!="present" && $2!="absent")) exit 1; next } + $1=="file" { + if (NF!=4 || $2 !~ /^[A-Za-z0-9_.\/-]+$/ || $2 ~ /^\// || $2 ~ /(^|\/)\.\.(\/|$)/ || $2 ~ /(^|\/)\.($|\/)/ || $2 ~ /\/\//) exit 1 + if ($3 !~ /^[0-9]+$/ || $3 > 67108864 || $4 !~ /^[0-9a-f]{64}$/) exit 1 + if (seen[$2]++) exit 1 + total += $3; count++; next + } + { exit 1 } + END { if (NR<4 || count<3 || count>512 || total>133169152) exit 1 } + ' "$stage/manifest.tsv" || return 1 + manifest_commit=$(sed -n '2s/^core_commit\t//p' "$stage/manifest.tsv") + [ -n "$manifest_commit" ] && [ "$manifest_commit" = "$(core_commit)" ] || return 2 + archive_state=$(sed -n '3s/^state\t//p' "$stage/manifest.tsv") + while IFS="$(printf '\t')" read -r kind relpath expected_size expected_sha; do + [ "$kind" = file ] || continue + safe_relpath "$relpath" || return 1 + is_excluded "$relpath" && return 1 + grep -qx "$relpath" "$member_listing.names" || return 1 + case "$relpath" in uci/honk|etc/honk/*) ;; *) return 1 ;; esac + mkdir -p "$stage/$(dirname "$relpath")" || return 1 + : > "$stage/$relpath" || return 1 + tar -xOzf "$archive" "$relpath" > "$stage/$relpath" 2>/dev/null || return 1 + [ "$(size_of "$stage/$relpath")" -eq "$expected_size" ] || return 1 + [ "$(sha_of "$stage/$relpath")" = "$expected_sha" ] || return 1 + done < "$stage/manifest.tsv" + # Every tar member must be declared exactly once in the manifest. + awk -F '\t' '$1=="file" {print $2}' "$stage/manifest.tsv" | sort > "$member_listing.expected" + grep -v '^manifest.tsv$' "$member_listing.names" | sort > "$member_listing.actual" + cmp -s "$member_listing.expected" "$member_listing.actual" || return 1 + [ -f "$stage/etc/honk/config.dae" ] && [ -f "$stage/etc/honk/system.dae" ] && [ -f "$stage/uci/honk" ] || return 1 + if [ "$archive_state" = present ]; then + [ -f "$stage/etc/honk/state/honk.db" ] || return 1 + else + [ ! -e "$stage/etc/honk/state/honk.db" ] || return 1 + fi +} + +parse_uci_file() { + uci_dir=$1 + mkdir -p "$temp_dir/uci-tmp" || return 1 + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" -q export honk >/dev/null 2>&1 || return 1 + load_uci_values uci "$uci_dir" || return 1 + [ "$u_initialized" = 1 ] || [ ! -f "$restore_stage/etc/honk/state/honk.db" ] || return 1 +} + +archive_sqlite_check() { + db=$1 + [ -f "$db" ] || return 0 + check_sqlite "$db" || return 1 +} + +unused_loopback_port() { + command -v netstat >/dev/null 2>&1 || command -v ss >/dev/null 2>&1 || return 1 + for port_candidate in 39127 39129 39131 39133 39135 39137 39139 39141; do + if command -v netstat >/dev/null 2>&1; then + port_listeners=$(netstat -lnt 2>/dev/null) + else + port_listeners=$(ss -lnt 2>/dev/null) + fi + if ! printf '%s\n' "$port_listeners" | awk -v p="$port_candidate" '$4 ~ (":" p "$") {found=1} END {exit !found}'; then + printf '%s\n' "$port_candidate" + return 0 + fi + done + return 1 +} + +rewrite_shadow_system() { + shadow_system=$1 + shadow_data=$2 + shadow_port=$3 + cat > "$shadow_system.new" < "$live_system" </dev/null) || return 1 + case "$mock_identity" in *"$mock_config"*--mock-ebpf*--offline-validation*) ;; *) return 1 ;; esac + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] +} + +validate_candidate() { + candidate=$1 + uci_dir=$2 + state=$3 + config_tree=$candidate/etc/honk + mkdir -p "$temp_dir/uci-tmp" || return 1 + load_uci_values uci "$uci_dir" || return 1 + . /lib/functions/network.sh || return 1 + candidate_device= + candidate_ip= + network_get_device candidate_device "$u_network" || return 1 + network_get_ipaddr candidate_ip "$u_network" || return 1 + validate_config_tree "$config_tree" "$candidate_device" "$candidate_ip" "$u_port" "$u_initialized" || return 2 + if [ "$state" = present ] || { [ "$state" = preview ] && { [ -e "$config_tree/state/honk.db" ] || [ -L "$config_tree/state/honk.db" ]; }; }; then + archive_sqlite_check "$config_tree/state/honk.db" || return 1 + fi + mkdir -p "$temp_dir/shadow/etc" "$temp_dir/shadow-data" || return 1 + chmod 0700 "$temp_dir/shadow" "$temp_dir/shadow/etc" "$temp_dir/shadow-data" || return 1 + cp -pR "$config_tree" "$temp_dir/shadow/etc/honk" || return 1 + find "$temp_dir/shadow" -type d -exec chmod 0700 {} + || return 1 + find "$temp_dir/shadow/etc/honk" -type f -exec chmod 0600 {} + || return 1 + find "$temp_dir/shadow/etc/honk" -type l -print 2>/dev/null | grep -q . && return 2 + find "$temp_dir/shadow/etc/honk" ! -type f ! -type d -print 2>/dev/null | grep -q . && return 2 + shadow_port=$(unused_loopback_port) || return 1 + rewrite_shadow_system "$temp_dir/shadow/etc/honk/system.dae" "$temp_dir/shadow-data" "$shadow_port" || return 2 + chmod 0600 "$temp_dir/shadow/etc/honk"/*.dae 2>/dev/null || true + if [ "$state" = present ] || { [ "$state" = preview ] && [ -f "$config_tree/state/honk.db" ]; }; then + mkdir -p "$temp_dir/shadow-data/state" || return 1 + cp "$config_tree/state/honk.db" "$temp_dir/shadow-data/state/honk.db" || return 1 + chmod 0600 "$temp_dir/shadow-data/state/honk.db" || return 1 + fi + mock_config=$temp_dir/shadow/etc/honk/config.dae + mock_log=$temp_dir/mock.log + response_file=$temp_dir/discovery.json + "$HONK_CORE" --config "$mock_config" --mock-ebpf --offline-validation > "$mock_log" 2>&1 & + mock_pid=$! + mock_ready=0 + attempt=0 + while [ "$attempt" -lt 15 ]; do + if ! kill -0 "$mock_pid" 2>/dev/null; then return 1; fi + if curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://127.0.0.1:$shadow_port/api" -o "$response_file" 2>/dev/null; then + mock_identity_ok || return 1 + mock_ready=1 + break + fi + sleep 1 + attempt=$((attempt + 1)) + done + [ "$mock_ready" = 1 ] || return 1 + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + case "$setup_required" in true) restore_initialized=0 ;; false) restore_initialized=1 ;; *) return 1 ;; esac + [ "$state" != absent ] || [ "$restore_initialized" = 0 ] || return 1 + mock_identity_ok || return 1 + stop_mock || return 1 + return 0 +} + +make_manifest() { + root=$1 + state=$2 + manifest=$3 + commit=$(core_commit) + case "$commit" in [0-9a-f][0-9a-f]*) [ "${#commit}" -eq 40 ] || return 1 ;; *) return 1 ;; esac + { + printf 'format\t1\ncore_commit\t%s\nstate\t%s\n' "$commit" "$state" + while IFS= read -r relpath; do + [ -n "$relpath" ] || continue + file=$root/$relpath + file_size=$(size_of "$file") || return 1 + file_sha=$(sha_of "$file") || return 1 + [ "$file_size" -le "$MAX_FILE" ] || return 1 + printf 'file\t%s\t%s\t%s\n' "$relpath" "$file_size" "$file_sha" + done < "$temp_dir/files.list" + } > "$manifest" || return 1 + [ "$(size_of "$manifest")" -le 1048576 ] +} + +backup() { + if [ -L "$BACKUP" ] || { [ -e "$BACKUP" ] && [ ! -f "$BACKUP" ]; }; then error unsafe_backup_path; return 1; fi + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + load_uci_values || { error uci_validation_failed; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error uci_export_failed; return 1; } + if [ "$old_running" = 1 ]; then + capture_boot_links || { error boot_link_state_unavailable; return 1; } + journal_create backup || { error transaction_journal_create_failed; return 1; } + service_stopped=1 + stop_managed || { backup_abort service_stop_failed; return 1; } + journal_write stopped || { backup_abort transaction_journal_write_failed; return 1; } + fi + uci export honk > "$temp_dir/uci.honk" 2>/dev/null || { backup_abort uci_export_failed; return 1; } + if [ -f "$HONK_STATE" ]; then + checkpoint_sqlite || { backup_abort sqlite_checkpoint_failed; return 1; } + state=present + [ "$u_initialized" = 1 ] || { backup_abort state_without_initialized_profile; return 1; } + else + state=absent + [ "$u_initialized" = 0 ] || { backup_abort initialized_state_missing; return 1; } + fi + mkdir -p "$temp_dir/archive/etc" "$temp_dir/archive/uci" || { backup_abort temporary_storage_unavailable; return 1; } + collect_tree "$HONK_ROOT" "$temp_dir/files.list" || { backup_abort unsupported_honk_tree_entry; return 1; } + while IFS= read -r relpath; do + [ -n "$relpath" ] || continue + dest=$temp_dir/archive/etc/honk/$relpath + mkdir -p "$(dirname "$dest")" || { backup_abort snapshot_failed; return 1; } + cp "$HONK_ROOT/$relpath" "$dest" || { backup_abort snapshot_failed; return 1; } + chmod 0600 "$dest" || { backup_abort snapshot_failed; return 1; } + done < "$temp_dir/files.list" + cp "$temp_dir/uci.honk" "$temp_dir/archive/uci/honk" || { backup_abort uci_snapshot_failed; return 1; } + chmod 0600 "$temp_dir/archive/uci/honk" || { backup_abort uci_snapshot_failed; return 1; } + if [ "$state" = present ]; then + check_sqlite "$temp_dir/archive/etc/honk/state/honk.db" || { backup_abort sqlite_check_failed; return 1; } + fi + validate_candidate "$temp_dir/archive" "$temp_dir/archive/uci" "$state" + validate_rc=$? + if [ "$validate_rc" -ne 0 ]; then + if ! restore_running; then + [ "$validate_rc" = 2 ] && error unsupported_candidate_config_runtime_restore_failed || error candidate_validation_failed_runtime_restore_failed + else + [ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed + fi + return 1 + fi + [ "$u_initialized" = "$restore_initialized" ] || { backup_abort initialized_state_mismatch; return 1; } + ( + cd "$temp_dir/archive" || exit 1 + find etc uci -type f -print | sort > "$temp_dir/files.list" + ) + make_manifest "$temp_dir/archive" "$state" "$temp_dir/archive/manifest.tsv" || { backup_abort manifest_create_failed; return 1; } + ( + cd "$temp_dir/archive" || exit 1 + { printf '%s\n' manifest.tsv; cat "$temp_dir/files.list"; } > "$temp_dir/tar.list" + ulimit -f 65536 2>/dev/null || exit 1 + tar -czf "$temp_dir/backup.tar.gz" -T "$temp_dir/tar.list" + ) || { backup_abort archive_create_failed; return 1; } + [ "$(size_of "$temp_dir/backup.tar.gz")" -le "$MAX_COMPRESSED" ] || { backup_abort archive_too_large; return 1; } + check_tar_members "$temp_dir/backup.tar.gz" "$temp_dir/output-members" || { backup_abort archive_self_validation_failed; return 1; } + if [ -L "$BACKUP" ]; then backup_abort unsafe_backup_path; return 1; fi + mv -f "$temp_dir/backup.tar.gz" "$BACKUP" || { backup_abort backup_write_failed; return 1; } + chmod 0600 "$BACKUP" || { backup_abort backup_permissions_failed; return 1; } + if [ "$journal_active" = 1 ]; then + journal_write committed || { backup_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error backup_created_journal_cleanup_failed; return 1; } + journal_active=0 + fi + if ! restore_running; then backup_abort backup_created_service_restart_failed; return 1; fi + json_result true "$BACKUP" backup_created +} + +restore() { + check_root_file "$RESTORE" || { error unsafe_restore_path; return 1; } + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + archive_hash=$(sha_of "$RESTORE") + [ -n "$archive_hash" ] || { error archive_read_failed; return 1; } + restore_stage=$temp_dir/extracted + extract_archive "$RESTORE" "$restore_stage" + extract_rc=$? + [ "$extract_rc" = 0 ] || { [ "$extract_rc" = 2 ] && error incompatible_core_version || error invalid_archive; return 1; } + uci_dir=$restore_stage/uci + parse_uci_file "$uci_dir" || { error invalid_uci_config; return 1; } + archive_state=$(sed -n '3s/^state\t//p' "$restore_stage/manifest.tsv") + if [ "$archive_state" = present ]; then + archive_sqlite_check "$restore_stage/etc/honk/state/honk.db" || { error invalid_sqlite_state; return 1; } + fi + load_uci_values || { error current_uci_invalid; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error current_uci_snapshot_failed; return 1; } + capture_boot_links || { error boot_link_state_unavailable; return 1; } + if [ "$archive_state" = absent ] && { [ "$old_enabled" = 1 ] || [ "$old_boot" = 1 ] || [ "$old_running" = 1 ] || [ "$old_start_link" != - ]; }; then + error empty_archive_would_clear_active_state + return 1 + fi + validate_candidate "$restore_stage" "$uci_dir" "$archive_state" + validate_rc=$? + if [ "$validate_rc" -ne 0 ]; then + [ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed + return 1 + fi + [ "$old_running" != 1 ] || [ "$restore_initialized" = 1 ] || { error running_service_requires_initialized_restore; return 1; } + [ "$(sha_of "$RESTORE")" = "$archive_hash" ] || { error restore_archive_changed; return 1; } + write_live_system "$restore_stage/etc/honk/system.dae" "$candidate_device" "$candidate_ip" "$u_port" "$restore_initialized" || { error system_config_render_failed; return 1; } + chmod 0444 "$restore_stage/etc/honk/system.dae" || { error system_config_render_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.enabled=$old_enabled" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.boot_enabled=$old_boot" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.initialized=$restore_initialized" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set honk.main.config_file=/etc/honk/config.dae || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" commit honk || { error restore_profile_stage_failed; return 1; } + load_uci_values uci "$uci_dir" || { error restore_profile_stage_failed; return 1; } + [ "$u_enabled:$u_boot:$u_initialized" = "$old_enabled:$old_boot:$restore_initialized" ] || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" export honk > "$temp_dir/new-uci" 2>/dev/null || { error restore_profile_stage_failed; return 1; } + if [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ]; then :; else error current_tree_unavailable; return 1; fi + journal_create restore || { error transaction_journal_create_failed; return 1; } + mkdir "$TXN_DIR/new-tree" || { restore_abort restore_stage_failed; return 1; } + cp -pR "$restore_stage/etc/honk/." "$TXN_DIR/new-tree/" || { restore_abort restore_stage_failed; return 1; } + find "$TXN_DIR/new-tree" -type d -exec chmod 0700 {} \; 2>/dev/null + find "$TXN_DIR/new-tree" -type f -exec chmod 0600 {} \; 2>/dev/null + chmod 0444 "$TXN_DIR/new-tree/system.dae" || { restore_abort restore_stage_failed; return 1; } + if [ "$old_running" = 1 ]; then + service_stopped=1 + stop_managed || { restore_abort service_stop_failed; return 1; } + fi + journal_write stopped || { restore_abort transaction_journal_write_failed; return 1; } + if foreign_core_running; then restore_abort unmanaged_core_running; return 1; fi + old_tree_path=$TXN_DIR/old-tree + new_tree_path=$TXN_DIR/new-tree + if ! mv "$HONK_ROOT" "$old_tree_path"; then restore_abort tree_swap_failed; return 1; fi + swap_started=1 + journal_write old_moved || { restore_abort transaction_journal_write_failed; return 1; } + if ! mv "$new_tree_path" "$HONK_ROOT"; then restore_abort tree_swap_failed; return 1; fi + journal_write new_installed || { restore_abort transaction_journal_write_failed; return 1; } + if ! uci import honk < "$temp_dir/new-uci" || ! uci commit honk; then + apply_failed=1 + else + apply_failed=0 + fi + if [ "$apply_failed" = 0 ]; then + load_uci_values || apply_failed=1 + [ "$apply_failed" = 1 ] || [ "$u_enabled:$u_boot:$u_initialized" = "$old_enabled:$old_boot:$restore_initialized" ] || apply_failed=1 + if [ "$apply_failed" = 0 ] && [ "$old_running" = 1 ]; then + issue_capability candidate_health_start || apply_failed=1 + fi + fi + if [ "$apply_failed" = 1 ]; then + restore_abort restore_failed + return 1 + fi + journal_write service_started || { restore_abort transaction_journal_write_failed; return 1; } + journal_write committed || { restore_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error restore_succeeded_snapshot_cleanup_pending; return 1; } + journal_active=0 + swap_started=0 + service_stopped=0 + json_result true "" restored +} + +reset_data() { + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + load_uci_values || { error current_uci_invalid; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error current_uci_snapshot_failed; return 1; } + capture_boot_links || { error boot_link_state_unavailable; return 1; } + [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ] || { error current_tree_unavailable; return 1; } + reset_stage=$temp_dir/reset-stage + reset_uci=$temp_dir/reset-uci + mkdir -p "$reset_stage/etc/honk" "$reset_uci" || { error temporary_storage_unavailable; return 1; } + [ -f /usr/share/honk/default-config.dae ] && [ ! -L /usr/share/honk/default-config.dae ] || { error default_config_unavailable; return 1; } + cp /usr/share/honk/default-config.dae "$reset_stage/etc/honk/config.dae" || { error reset_stage_failed; return 1; } + chmod 0600 "$reset_stage/etc/honk/config.dae" || { error reset_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" import honk < "$temp_dir/old-uci" || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.enabled=0 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.boot_enabled=0 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.listen_port=9527 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.lan_network=lan || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" commit honk || { error reset_profile_stage_failed; return 1; } + load_uci_values uci "$reset_uci" || { error reset_profile_stage_failed; return 1; } + . /lib/functions/network.sh || { error lan_network_unavailable; return 1; } + reset_device= + reset_ip= + network_get_device reset_device "$u_network" || { error lan_network_unavailable; return 1; } + network_get_ipaddr reset_ip "$u_network" || { error lan_network_unavailable; return 1; } + write_live_system "$reset_stage/etc/honk/system.dae" "$reset_device" "$reset_ip" "$u_port" "$u_initialized" || { error system_config_render_failed; return 1; } + chmod 0444 "$reset_stage/etc/honk/system.dae" || { error system_config_render_failed; return 1; } + reset_candidate=$temp_dir/reset-candidate + mkdir -p "$reset_candidate/etc" || { error reset_stage_failed; return 1; } + cp -pR "$reset_stage/etc/honk" "$reset_candidate/etc/honk" || { error reset_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" export honk > "$temp_dir/new-uci" 2>/dev/null || { error reset_profile_stage_failed; return 1; } + validate_candidate "$reset_candidate" "$reset_uci" absent + validate_rc=$? + [ "$validate_rc" = 0 ] || { [ "$validate_rc" = 2 ] && error default_config_unsupported || error default_config_validation_failed; return 1; } + journal_create reset || { error transaction_journal_create_failed; return 1; } + if [ "$old_running" = 1 ]; then + service_stopped=1 + stop_managed || { restore_abort service_stop_failed; return 1; } + fi + journal_write stopped || { restore_abort transaction_journal_write_failed; return 1; } + foreign_core_running && { restore_abort unmanaged_core_running; return 1; } + checkpoint_sqlite || { restore_abort sqlite_checkpoint_failed; return 1; } + rm -rf "$reset_stage/etc/honk" || { restore_abort reset_stage_failed; return 1; } + stage_reset_tree "$HONK_ROOT" "$reset_stage/etc/honk" "$reset_candidate/etc/honk/system.dae" /usr/share/honk/default-config.dae || { restore_abort reset_stage_failed; return 1; } + "$HONK_CORE" --config "$reset_stage/etc/honk/config.dae" --data-dir "$reset_stage/etc/honk" admin reset-business --revision-root /etc/honk >/dev/null 2>&1 || { restore_abort state_reset_failed; return 1; } + reset_archive_state=absent + if [ -f "$reset_stage/etc/honk/state/honk.db" ]; then reset_archive_state=present; fi + if [ "$reset_archive_state" = present ]; then archive_sqlite_check "$reset_stage/etc/honk/state/honk.db" || { restore_abort state_reset_validation_failed; return 1; }; fi + rm -rf "$reset_candidate" || { restore_abort reset_stage_failed; return 1; } + mkdir -p "$reset_candidate/etc" || { restore_abort reset_stage_failed; return 1; } + cp -pR "$reset_stage/etc/honk" "$reset_candidate/etc/honk" || { restore_abort reset_stage_failed; return 1; } + mkdir "$TXN_DIR/new-tree" || { restore_abort reset_stage_failed; return 1; } + cp -pR "$reset_candidate/etc/honk/." "$TXN_DIR/new-tree/" || { restore_abort reset_stage_failed; return 1; } + find "$TXN_DIR/new-tree" -type d -exec chmod 0700 {} \; 2>/dev/null + find "$TXN_DIR/new-tree" -type f -exec chmod 0600 {} \; 2>/dev/null + chmod 0444 "$TXN_DIR/new-tree/system.dae" || { restore_abort reset_stage_failed; return 1; } + foreign_core_running && { restore_abort unmanaged_core_running; return 1; } + old_tree_path=$TXN_DIR/old-tree + new_tree_path=$TXN_DIR/new-tree + mv "$HONK_ROOT" "$old_tree_path" || { restore_abort tree_swap_failed; return 1; } + swap_started=1 + journal_write old_moved || { restore_abort transaction_journal_write_failed; return 1; } + mv "$new_tree_path" "$HONK_ROOT" || { restore_abort tree_swap_failed; return 1; } + journal_write new_installed || { restore_abort transaction_journal_write_failed; return 1; } + uci import honk < "$temp_dir/new-uci" && uci commit honk || { restore_abort reset_failed; return 1; } + load_uci_values || { restore_abort reset_failed; return 1; } + [ "$u_enabled:$u_boot:$u_initialized:$u_network:$u_port" = "0:0:$old_initialized:lan:9527" ] || { restore_abort reset_failed; return 1; } + disable_boot_links || { restore_abort boot_link_update_failed; return 1; } + journal_write uci_applied || { restore_abort transaction_journal_write_failed; return 1; } + journal_write committed || { restore_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error reset_succeeded_snapshot_cleanup_pending; return 1; } + journal_active=0 + swap_started=0 + service_stopped=0 + json_result true "" data_reset +} + +stage_reset_tree() { + stage_source=$1 + stage_target=$2 + stage_system=$3 + stage_default=$4 + [ -d "$stage_source" ] && [ ! -L "$stage_source" ] || return 1 + [ -f "$stage_system" ] && [ ! -L "$stage_system" ] || return 1 + [ -f "$stage_default" ] && [ ! -L "$stage_default" ] || return 1 + mkdir -p "$stage_target/state" || return 1 + cp "$stage_default" "$stage_target/config.dae" || return 1 + cp "$stage_system" "$stage_target/system.dae" || return 1 + chmod 0600 "$stage_target/config.dae" || return 1 + chmod 0444 "$stage_target/system.dae" || return 1 + chmod 0700 "$stage_target/state" || return 1 + if [ -e "$stage_source/state/honk.db" ] || [ -L "$stage_source/state/honk.db" ]; then + [ -f "$stage_source/state/honk.db" ] && [ ! -L "$stage_source/state/honk.db" ] || return 1 + cp -p "$stage_source/state/honk.db" "$stage_target/state/honk.db" || return 1 + chmod 0600 "$stage_target/state/honk.db" || return 1 + fi +} + +if [ "${HONK_MAINT_LIBRARY:-0}" = 1 ]; then + trap - 0 HUP INT TERM + return 0 2>/dev/null || exit 0 +fi + +case "$1" in + backup) backup ;; + restore) restore ;; + reset) reset_data ;; + recover) recover_action ;; + *) error invalid_action ;; +esac diff --git a/honk/files/redact-log.sh b/honk/files/redact-log.sh new file mode 100644 index 00000000..0319b8eb --- /dev/null +++ b/honk/files/redact-log.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +exec sed -E \ + -e 's#([[:alnum:]+.-]+://)[^[:space:]"<>]+#\1[redacted]#g' \ + -e 's/([Bb]earer[[:space:]]+)[^[:space:],;]+/\1[redacted]/g' \ + -e 's/([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn])[^:=]*[=:][[:space:]]*.*/\1=[redacted]/' diff --git a/honk/files/system.dae b/honk/files/system.dae new file mode 100644 index 00000000..bb2251f0 --- /dev/null +++ b/honk/files/system.dae @@ -0,0 +1,22 @@ +global { + wan_interface: auto + lan_interface: br-lan + log_level: info + dial_mode: domain + allow_insecure: false + auto_config_kernel_parameter: true + data_dir: '/etc/honk' + store_subscribe: false + nfqueue_enable: true +} + +experimental { + native_api { + enabled: true + listen: '127.0.0.1:9527' + password_auth: true + allow_anonymous_loopback: false + config_write: true + ui: '/usr/share/doona' + } +} diff --git a/honk/files/update.sh b/honk/files/update.sh new file mode 100644 index 00000000..cab15e4f --- /dev/null +++ b/honk/files/update.sh @@ -0,0 +1,292 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 +. /usr/share/libubox/jshn.sh + +UPDATE_ROOT=/tmp/honk-update +TRUST_ROOT=/etc/honk/update-trust +FEED_FILE=$TRUST_ROOT/feed.url +MANIFEST_KEY=$TRUST_ROOT/manifest.pub +MANIFEST=$UPDATE_ROOT/manifest.json +SIGNATURE=$UPDATE_ROOT/manifest.json.sig +RECORDS=$UPDATE_ROOT/packages.tsv +UPDATE_LOCK_OWNED=0 +CHECK_DIR= + +update_lock_acquire() { + . /usr/share/honk/lock.sh || return 1 + honk_lock_acquire || return 1 + UPDATE_LOCK_OWNED=1 +} + +update_lock_release() { + [ "$UPDATE_LOCK_OWNED" = 1 ] || return 0 + honk_lock_release >/dev/null 2>&1 || return 1 + UPDATE_LOCK_OWNED=0 +} + +discard_check_dir() { + case "$CHECK_DIR" in + "$UPDATE_ROOT"/.check.*) + [ -d "$CHECK_DIR" ] && [ ! -L "$CHECK_DIR" ] && rm -rf "$CHECK_DIR" + ;; + esac + CHECK_DIR= +} + +cleanup_update() { + discard_check_dir + update_lock_release || : +} + +emit_update() { + json_init + json_add_boolean ok "$1" + json_add_boolean available "$2" + json_add_boolean apply_enabled "$3" + json_add_string message "$4" + json_add_string reason "$5" + [ -z "$6" ] || json_add_string version "$6" + json_dump +} + +safe_file() { + [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c '%u:%a' "$1" 2>/dev/null)" = "0:$2" ] +} + +prepare_update_root() { + if [ -e "$UPDATE_ROOT" ] || [ -L "$UPDATE_ROOT" ]; then + [ -d "$UPDATE_ROOT" ] && [ ! -L "$UPDATE_ROOT" ] && [ "$(stat -c '%u:%a' "$UPDATE_ROOT" 2>/dev/null)" = 0:700 ] || return 1 + else + mkdir "$UPDATE_ROOT" && chmod 0700 "$UPDATE_ROOT" || return 1 + fi +} + +read_trust() { + [ -d "$TRUST_ROOT" ] && [ ! -L "$TRUST_ROOT" ] && [ "$(stat -c '%u:%a' "$TRUST_ROOT" 2>/dev/null)" = 0:700 ] || return 1 + safe_file "$FEED_FILE" 600 && safe_file "$MANIFEST_KEY" 600 || return 1 + [ "$(wc -c < "$FEED_FILE" | tr -d ' ')" -le 512 ] || return 1 + feed_url=$(cat "$FEED_FILE") || return 1 + [ -n "$feed_url" ] || return 1 + case "$feed_url" in *'?'*|*'#'*|*'@'*|*' '*|*'\'*|*'"'*|*"'"*) return 1 ;; esac + echo "$feed_url" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/%+-]*)?$' || return 1 + return 0 +} + +manifest_field() { + jsonfilter -i "$MANIFEST" -e "$1" 2>/dev/null +} + +installed_version() { + awk -v wanted="$1" ' + $0 == "P:" wanted { found=1; next } + found && /^V:/ { print substr($0, 3); exit } + found && /^P:/ { exit } + ' /lib/apk/db/installed 2>/dev/null +} + +validate_manifest() { + validate_url=$1 + validate_json=$(cat "$MANIFEST") || return 1 + json_load "$validate_json" 2>/dev/null || return 1 + json_get_type validate_packages_type packages + [ "$validate_packages_type" = array ] || return 1 + json_select packages || return 1 + json_get_keys validate_package_keys + [ "$(printf '%s\n' "$validate_package_keys" | wc -w | tr -d ' ')" = 2 ] || return 1 + json_select .. || return 1 + [ "$(manifest_field '@.schema')" = 1 ] || return 1 + [ "$(manifest_field '@.product')" = openwrt-honk ] || return 1 + # Target must match this device: map uname -m to manifest arch names. + case "$(uname -m)" in + x86_64) _local_arch=x86_64 ;; + aarch64) _local_arch=aarch64 ;; + armv7l) _local_arch=armv7 ;; + *) return 1 ;; + esac + [ "$(manifest_field '@.target.architecture')" = "$_local_arch" ] || return 1 + # OpenWrt version must match: 24.10 (opkg/ipk) or 25.12 (apk). + _local_owrt=$(sed -n 's/^DISTRIB_RELEASE="\([0-9]*\.[0-9]*\).*/\1/p' /etc/openwrt_release 2>/dev/null | head -n 1) + case "$_local_owrt" in 24.10|25.12) ;; *) return 1 ;; esac + [ "$(manifest_field '@.target.openwrt')" = "$_local_owrt" ] || return 1 + case "$_local_owrt" in + 24.10) [ "$(manifest_field '@.target.package_format')" = ipk ] || return 1 ;; + 25.12) [ "$(manifest_field '@.target.package_format')" = apk ] || return 1 ;; + esac + [ "$(manifest_field '@.target.kernel_min')" = 6.12 ] || return 1 + [ "$(manifest_field '@.target.requires_btf')" = true ] || return 1 + [ "$(manifest_field '@.min_updater_api')" = 1 ] || return 1 + [ "$(manifest_field '@.state_schema')" = 2 ] || return 1 + # (arch and OpenWrt version already validated against local system above) + [ -r /sys/kernel/btf/vmlinux ] || return 1 + kernel_version=$(uname -r | sed 's/-.*//') + kernel_major=${kernel_version%%.*} + kernel_minor=${kernel_version#*.} + kernel_minor=${kernel_minor%%.*} + case "$kernel_major:$kernel_minor" in *[!0-9:]*|:*) return 1 ;; esac + [ "$kernel_major" -gt 6 ] || { [ "$kernel_major" = 6 ] && [ "$kernel_minor" -ge 12 ]; } || return 1 + validate_commit=$(manifest_field '@.core.commit') + case "$validate_commit" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_commit}" -eq 40 ] || return 1 + validate_commit=$(manifest_field '@.doona.commit') + case "$validate_commit" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_commit}" -eq 40 ] || return 1 + for validate_sha in \ + "$(manifest_field '@.core.artifact_sha256')" \ + "$(manifest_field '@.doona.patch_sha256')" \ + "$(manifest_field '@.doona.artifact_sha256')" \ + "$(manifest_field '@.sdk_sha256')"; do + case "$validate_sha" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_sha}" -eq 64 ] || return 1 + done + validate_version=$(manifest_field '@.packages[0].version') + case "$validate_version" in ''|*[!A-Za-z0-9.+~-]*) return 1 ;; esac + : > "$RECORDS" || return 1 + validate_seen=' ' + validate_available=0 + for validate_index in 0 1; do + validate_name=$(manifest_field "@.packages[$validate_index].name") + validate_version=$(manifest_field "@.packages[$validate_index].version") + validate_arch=$(manifest_field "@.packages[$validate_index].arch") + validate_filename=$(manifest_field "@.packages[$validate_index].filename") + validate_size=$(manifest_field "@.packages[$validate_index].size") + validate_sha=$(manifest_field "@.packages[$validate_index].sha256") + validate_package_url=$(manifest_field "@.packages[$validate_index].url") + case "$validate_name" in honk|luci-app-honk) ;; *) return 1 ;; esac + case "$validate_seen" in *" $validate_name "*) return 1 ;; esac + validate_seen="$validate_seen$validate_name " + case "$validate_version" in ''|*[!A-Za-z0-9.+~-]*) return 1 ;; esac + case "$validate_arch" in x86_64|aarch64|armv7|noarch) ;; *) return 1 ;; esac + case "$validate_name:$validate_arch" in honk:x86_64|honk:aarch64|honk:armv7|luci-app-honk:x86_64|luci-app-honk:aarch64|luci-app-honk:armv7|luci-app-honk:noarch) ;; *) return 1 ;; esac + [ "$validate_filename" = "$validate_name-$validate_version.apk" ] || [ "$validate_filename" = "${validate_name}_${validate_version}_${validate_arch}.ipk" ] || return 1 + case "$validate_filename" in *'/'*|*'..'*|*[!A-Za-z0-9.+_-]*) return 1 ;; esac + case "$validate_size" in ''|*[!0-9]*) return 1 ;; esac + [ "$validate_size" -gt 0 ] && [ "$validate_size" -le 134217728 ] || return 1 + case "$validate_sha" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_sha}" -eq 64 ] || return 1 + [ "$validate_package_url" = "${validate_url%/}/packages/$validate_filename" ] || return 1 + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$validate_name" "$validate_version" "$validate_arch" "$validate_filename" "$validate_size" "$validate_sha" "$validate_package_url" >> "$RECORDS" || return 1 + [ "$(installed_version "$validate_name")" = "$validate_version" ] || validate_available=1 + done + [ "$(wc -l < "$RECORDS" | tr -d ' ')" = 2 ] || return 1 + for validate_required in honk luci-app-honk; do + case "$validate_seen" in *" $validate_required "*) ;; *) return 1 ;; esac + done + update_version=$(awk -F '\t' '$1 == "honk" { print $2; exit }' "$RECORDS") + return 0 +} + +verify_signed_manifest() { + read_trust || return 2 + prepare_update_root || return 3 + CHECK_DIR=$(mktemp -d "$UPDATE_ROOT/.check.XXXXXX") || return 3 + check_dir=$CHECK_DIR + chmod 0700 "$check_dir" || { discard_check_dir; return 3; } + check_manifest=$check_dir/manifest.json + check_signature=$check_dir/manifest.json.sig + if ! curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 4 --max-time 12 --max-filesize 65536 \ + "${feed_url%/}/manifest.json" -o "$check_manifest" 2>/dev/null || + ! curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 4 --max-time 8 --max-filesize 4096 \ + "${feed_url%/}/manifest.json.sig" -o "$check_signature" 2>/dev/null || + ! usign -V -m "$check_manifest" -p "$MANIFEST_KEY" -x "$check_signature" >/dev/null 2>&1; then + discard_check_dir + return 4 + fi + MANIFEST=$check_manifest + RECORDS=$check_dir/packages.tsv + if ! validate_manifest "$feed_url"; then + discard_check_dir + return 5 + fi + cp "$check_manifest" "$UPDATE_ROOT/manifest.json.new" && cp "$check_signature" "$UPDATE_ROOT/manifest.json.sig.new" || { discard_check_dir; return 3; } + cp "$RECORDS" "$UPDATE_ROOT/packages.tsv.new" || { discard_check_dir; return 3; } + printf '%s\n' "$(date +%s)" > "$UPDATE_ROOT/checked-at.new" || { discard_check_dir; return 3; } + chmod 0600 "$UPDATE_ROOT/manifest.json.new" "$UPDATE_ROOT/manifest.json.sig.new" "$UPDATE_ROOT/packages.tsv.new" "$UPDATE_ROOT/checked-at.new" || { discard_check_dir; return 3; } + mv -f "$UPDATE_ROOT/manifest.json.new" "$UPDATE_ROOT/manifest.json" && + mv -f "$UPDATE_ROOT/manifest.json.sig.new" "$UPDATE_ROOT/manifest.json.sig" && + mv -f "$UPDATE_ROOT/packages.tsv.new" "$UPDATE_ROOT/packages.tsv" && + mv -f "$UPDATE_ROOT/checked-at.new" "$UPDATE_ROOT/checked-at" || { discard_check_dir; return 3; } + discard_check_dir + return 0 +} + +update_check() { + read_trust || { emit_update true false false no_release trusted_feed_unavailable; return 0; } + update_lock_acquire || { emit_update false false false update_check_failed operation_in_progress; return 1; } + verify_signed_manifest + check_rc=$? + update_lock_release + case "$check_rc" in + 2) emit_update true false false no_release trusted_feed_unavailable; return 0 ;; + 4) emit_update false false false update_check_failed signed_manifest_unavailable; return 1 ;; + 3) emit_update false false false update_check_failed update_storage_unavailable; return 1 ;; + 5) emit_update false false false update_check_failed manifest_incompatible; return 1 ;; + 0) ;; + *) emit_update false false false update_check_failed manifest_invalid; return 1 ;; + esac + if [ "$validate_available" = 1 ]; then + emit_update true true false update_available rollback_tuple_unavailable "$update_version" + else + emit_update true false false no_update already_current "$update_version" + fi +} + +update_gate() { + read_trust || { emit_update true false false no_release trusted_feed_unavailable; return 0; } + update_lock_acquire || { emit_update false false false update_check_failed operation_in_progress; return 1; } + gate_ok=1 + gate_available=0 + gate_reason= + prepare_update_root || { gate_ok=0; gate_reason=update_storage_unavailable; } + if [ "$gate_ok" = 1 ]; then + for gate_file in "$UPDATE_ROOT/manifest.json" "$UPDATE_ROOT/manifest.json.sig" "$UPDATE_ROOT/packages.tsv" "$UPDATE_ROOT/checked-at"; do + safe_file "$gate_file" 600 || { gate_reason=signed_manifest_missing; break; } + done + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + checked_at=$(cat "$UPDATE_ROOT/checked-at") + case "$checked_at" in ''|*[!0-9]*) gate_reason=signed_manifest_expired ;; esac + if [ -z "$gate_reason" ] && [ $(( $(date +%s) - checked_at )) -gt 600 ]; then gate_reason=signed_manifest_expired; fi + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + usign -V -m "$UPDATE_ROOT/manifest.json" -p "$MANIFEST_KEY" -x "$UPDATE_ROOT/manifest.json.sig" >/dev/null 2>&1 || { gate_ok=0; gate_reason=manifest_signature_invalid; } + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + MANIFEST=$UPDATE_ROOT/manifest.json + RECORDS=$UPDATE_ROOT/packages.tsv + validate_manifest "$feed_url" || { gate_ok=0; gate_reason=manifest_incompatible; } + fi + gate_available=${validate_available:-0} + gate_version=${update_version:-} + update_lock_release + if [ "$gate_ok" != 1 ]; then emit_update false false false update_check_failed "$gate_reason"; return 1; fi + if [ -n "$gate_reason" ]; then emit_update true false false no_release "$gate_reason"; return 0; fi + if [ "$gate_available" = 1 ]; then + emit_update true true false update_available rollback_tuple_unavailable "$gate_version" + else + emit_update true false false no_update already_current "$gate_version" + fi +} + +update_apply() { + update_gate >/dev/null || { emit_update false false false update_apply_failed trusted_manifest_invalid; return 1; } + [ "$validate_available" = 1 ] || { emit_update false false false update_apply_failed no_update; return 1; } + # No previous signed package tuple or exercised downgrade path is available yet. + # Keep the backend gate closed even when the update manifest itself is compatible. + emit_update false false false update_apply_failed rollback_tuple_unavailable + return 1 +} + +trap cleanup_update EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +case "$1" in + check) update_check ;; + gate) update_gate ;; + apply) update_apply ;; + *) emit_update false false false update_check_failed invalid_action; exit 2 ;; +esac diff --git a/luci-app-chatgpt-web/.github/workflows/main.yml b/luci-app-chatgpt-web/.github/workflows/main.yml deleted file mode 100644 index 3de4bc6d..00000000 --- a/luci-app-chatgpt-web/.github/workflows/main.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Build - -on: - push: - tags: - - v* - -jobs: - build: - name: Build ${{ matrix.arch }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - arch: - - - aarch64_generic - - i386_pentium-mmx - - loongarch64_generic - - mips_mips32 - - mipsel_24kc - - mipsel_mips32 - - x86_64 - - steps: - - uses: actions/checkout@main - with: - fetch-depth: 0 - - name: Building packages - uses: sbwml/openwrt-gh-action-sdk@go1.24 - env: - ARCH: ${{ matrix.arch }}-openwrt-24.10 - FEEDNAME: packages_ci - PACKAGES: luci-app-chatgpt-web - NO_REFRESH_CHECK: true - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.arch }} - path: bin/packages/${{ matrix.arch }}/packages_ci/*.ipk - - - name: Upload packages - uses: ncipollo/release-action@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - allowUpdates: true - replacesArtifacts: true - artifacts: "bin/packages/${{ matrix.arch }}/packages_ci/*.ipk" diff --git a/luci-app-chatgpt-web/Makefile b/luci-app-chatgpt-web/Makefile deleted file mode 100644 index b5cae97b..00000000 --- a/luci-app-chatgpt-web/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# -# Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web -# -# This is free software, licensed under the Apache License, Version 2.0 . -# -include $(TOPDIR)/rules.mk - -NAME:=chatgpt-web -PKG_NAME:=luci-app-$(NAME) -PKG_VERSION:=1.2.1 -PKG_RELEASE:=16 - -LUCI_TITLE:=LuCI Support for chatgpt-web Client -LUCI_PKGARCH:=all - -define Package/$(PKG_NAME)/conffiles -/etc/config/chatgpt-web -endef -include $(TOPDIR)/feeds/luci/luci.mk - -# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-chatgpt-web/README.md b/luci-app-chatgpt-web/README.md deleted file mode 100644 index ab5de559..00000000 --- a/luci-app-chatgpt-web/README.md +++ /dev/null @@ -1,179 +0,0 @@ -## 访问数:![hello](https://views.whatilearened.today/views/github/sirpdboy/deplives.svg)[![](https://img.shields.io/badge/TG群-点击加入-FFFFFF.svg)](https://t.me/joinchat/AAAAAEpRF88NfOK5vBXGBQ) - - - -

-
luci-app-chatgpt-web
-

- -

-OpenWrt -Chrome -Safari -Firefox - GitHub release -Downloads -

- - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明1.jpg) - -请 **认真阅读完毕** 本页面,本页面包含注意事项和如何使用。 - -## 功能说明 - -luci-app-chatgpt Pure ChatGPT by OPENWRT based on OpenAI API - -luci-app-chatgpt 在OPENWRT上实现ChatGPT项目,基于OpenAI API - - - -- [luci-app-chatgpt](#功能说明) - - [特性](#特性) - - [编译方法](#编译方法) - - [使用方法](#使用方法) - - [自定义选项](#自定义选项)。 - - [说明](#说明) - - [界面](#界面) - - [捐助](#捐助) - - - -## 特性 - -- 无须第三依赖,直接使用官方API接口。 -- 支持复制/更新/刷新会话,语音输入,朗读等功能,以及众多[自定义选项](#自定义选项)。 -- 支持搜索会话,导入导出会话和设置,自定义头像,自定义系统角色等。 - -参考项目: -[markdown-it](https://github.com/markdown-it/markdown-it), -[highlight.js](https://github.com/highlightjs/highlight.js), -[github-markdown-css](https://github.com/sindresorhus/github-markdown-css), -[chatgpt-html](https://github.com/slippersheepig/chatgpt-html), -[markdown-it-copy](https://github.com/ReAlign/markdown-it-copy), -[markdown-it-texmath](https://github.com/goessner/markdown-it-texmath), -[awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) - - -## 编译方法 - -- 将luci-app-chatgpt添加至 LEDE/OpenWRT 源码的方法。 - -### 下载源码方法: - - ```Brach - - # 下载源码 - - git clone https://github.com/sirpdboy/chatgpt-web.git package/luci-app-chatgpt - make menuconfig - - ``` -### 配置菜单 - - ```Brach - make menuconfig - # 找到 LuCI -> Applications, 选择 luci-app-chatgpt, 保存后退出。 - ``` - -### 编译 - - ```Brach - # 编译固件 - make package/luci-app-chatgpt/compile V=s - ``` - -## 使用方法 - - - 打开luci-app-chatgpt基本设置,填写API密钥、GPT模型、填写OpenAI接口、选择用户图像等。 - - - 可选GPT模型,默认gpt-3.5,当前使用gpt-4模型需通过openai的表单申请。 - - - OpenAI接口当本地可正常访问`api.openai.com`,填写`https://api.openai.com/` - - - 不可正常访问`api.openai.com`,填写其反代地址,注意:反代接口响应需添加跨域Header `Access-Control-Allow-Origin` - - - chatgpt-web页面就可以正常使用了,如果需要设置更多请看 自定义设置设置语音和系统角色等。 - -## 自定义选项 - -- 左边栏支持,清除对话,搜索会话,新建/重命名/删除(会话/文件夹)导出/导入/重置会话和设置数据,显示本地存储。 - -- 可选系统角色,默认不开启,有四个预设角色,后期会加入更多角色。 - -- 可选角色性格,默认灵活创新,对应接口文档的top_p参数。 - -- 可选回答质量,默认平衡,对应接口文档的temperature参数。 - -- 修改打字机速度,默认较快,值越大速度越快。 - -- 允许连续对话,默认开启,对话中包含上下文信息,会导致api费用增加。 - -- 允许长回复,默认关闭,**开启后可能导致api费用增加,并丢失大部分上下文,对于一些要发送`继续`才完整的回复,不用发`继续`了。** - -- 选择语音,默认Bing语音,支持Azure语音和系统语音,可分开设置提问语音和回答语音。 - -- 音量,默认最大。 - -- 语速,默认正常。 - -- 音调,默认正常。 - -- 允许连续朗读,默认开启,连续郎读到所有对话结束。 - -- 允许自动朗读,默认关闭,自动朗读新的回答。**(iOS需打开设置-自动播放视频预览,Mac上Safari需打开此网站的设置-允许全部自动播放)** - -- 支持语音输入,默认识别为普通话,可长按语音按钮修改识别选项。**语音识别必需条件:使用chrome内核系浏览器 + https网页或本地网页。** 如点击语音按钮没反应,可能是未授予麦克风权限或者没安装麦克风设备。 - - -## 说明 - --源码来源:https://github.com/sirpdboy/chatgpt-web --你可以随意使用其中的源码,但请注明出处。 - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明2.jpg) - -## 界面 - -![screenshots](./doc/chatgpt1.png) - -![screenshots](./doc/chatgpt2.png) - -![screenshots](./doc/chatgpt3.png) - - -## 使用与授权相关说明 - -- 本人开源的所有源码,任何引用需注明本处出处,如需修改二次发布必告之本人,未经许可不得做于任何商用用途。 - -# My other project - -- 路由安全看门狗 :https://github.com/sirpdboy/luci-app-watchdog -- 网络速度测试 :https://github.com/sirpdboy/luci-app-netspeedtest -- 计划任务插件(原定时设置) : https://github.com/sirpdboy/luci-app-taskplan -- 关机功能插件 : https://github.com/sirpdboy/luci-app-poweroffdevice -- opentopd主题 : https://github.com/sirpdboy/luci-theme-opentopd -- kucat酷猫主题: https://github.com/sirpdboy/luci-theme-kucat -- kucat酷猫主题设置工具: https://github.com/sirpdboy/luci-app-kucat-config -- NFT版上网时间控制插件: https://github.com/sirpdboy/luci-app-timecontrol -- 家长控制: https://github.com/sirpdboy/luci-theme-parentcontrol -- 定时限速: https://github.com/sirpdboy/luci-app-eqosplus -- 系统高级设置 : https://github.com/sirpdboy/luci-app-advanced -- ddns-go动态域名: https://github.com/sirpdboy/luci-app-ddns-go -- 进阶设置(系统高级设置+主题设置kucat/agron/opentopd): https://github.com/sirpdboy/luci-app-advancedplus -- 网络设置向导: https://github.com/sirpdboy/luci-app-netwizard -- 一键分区扩容: https://github.com/sirpdboy/luci-app-partexp -- lukcy大吉: https://github.com/sirpdboy/luci-app-lukcy - -## 捐助 - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明3.jpg) - -| 图飞了😂 | 图飞了😂 | -| :-----------------: | :-------------: | -|![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/支付宝.png) | ![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/微信.png) | - - - 图飞了😂 - - diff --git a/luci-app-chatgpt-web/doc/chatgpt1.png b/luci-app-chatgpt-web/doc/chatgpt1.png deleted file mode 100644 index f715b006..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt1.png and /dev/null differ diff --git a/luci-app-chatgpt-web/doc/chatgpt2.png b/luci-app-chatgpt-web/doc/chatgpt2.png deleted file mode 100644 index 7de824a2..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt2.png and /dev/null differ diff --git a/luci-app-chatgpt-web/doc/chatgpt3.png b/luci-app-chatgpt-web/doc/chatgpt3.png deleted file mode 100644 index 9d280941..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt3.png and /dev/null differ diff --git a/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua deleted file mode 100644 index c100132c..00000000 --- a/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua +++ /dev/null @@ -1,17 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web --- Licensed to the public under the Apache License 2.0. - -module("luci.controller.chatgpt-web", package.seeall) - -function index() - if not nixio.fs.access("/etc/config/chatgpt-web") then - return - end - - local e = entry({"admin", "services", "chatgpt-web"}, alias("admin", "services", "chatgpt-web", "setting"),_("Chatgpt Web"), 20) - e.dependent=false - e.acl_depends = { "luci-app-chatgpt-web" } - entry({"admin", "services", "chatgpt-web", "setting"}, cbi("chatgpt-web"), _("Base Setting"), 10).leaf=true - entry({"admin", "services", "chatgpt-web", "chatgpt-web"}, cbi("chatgpt",{hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Chatgpt Web"), 30).leaf=true -end - diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua deleted file mode 100644 index 38f5e7da..00000000 --- a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua +++ /dev/null @@ -1,36 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/https://github.com/sirpdboy/luci-app-chatgpt-web - -local m, s ,o - -m = Map("chatgpt-web") -m.title = translate("Chatgpt Web") -m.description = translate("ChatGPT-Web is the version used on OPENWRT of OPENAI")..translate("
For specific usage, see:")..translate("GitHub @sirpdboy/chatgpt-web\ ") - -s = m:section(TypedSection, "basic", translate("Base Setting")) -s.addremove = false -s.anonymous = true - - -o = s:option(Value,"userpic",translate("Character images")) -o:value("girl.jpg",translate("girl")) -o:value("girl2.jpg",translate("girl2")) -o:value("girl3.jpg",translate("girl3")) -o:value("boy.jpg",translate("boy")) -o:value("boy2.jpg",translate("boy2")) -o:value("boy3.jpg",translate("boy3")) -o:value("dog.jpg",translate("dog")) -o:value("cartoon.jpg",translate("cartoon")) -o:value("cartoonboy.jpg",translate("cartoonboy")) -o:value("cartoonboy2.jpg",translate("cartoonboy2")) -o:value("cartoongirl.jpg",translate("cartoongirl")) -o:value("cartoongirl2.jpg",translate("cartoongirl2")) -o.rmempty = true -o.default="girl.jpg" - - -o=s:option(TextValue, "systemrole" ,translate("Default Role Settings")) -o.rows = 5 -o.rmempty=false - - -return m diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua deleted file mode 100644 index 162de6d0..00000000 --- a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web -require("luci.util") -local m, s - -m = Map("chatgpt-web",translate("")) - -m:section(SimpleSection).template = "chatgpt-web" - -return m diff --git a/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm b/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm deleted file mode 100644 index 078e5ac7..00000000 --- a/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm +++ /dev/null @@ -1,6845 +0,0 @@ -<%# - Copyright 2023-2025 sirpdboy Wich https://github.com/sirpdboy/luci-app-chatgpt-web - Licensed to the public under the Apache License 2.0. --%> -<% -local fs = require "nixio.fs" -local dsp = require "luci.dispatcher" -local uci = luci.model.uci.cursor() -local apikeys = uci:get_first('chatgpt-web', 'basic', 'apikey') -local userpic = "/luci-static/chatgpt-web/user/" .. uci:get_first('chatgpt-web', 'basic', 'userpic') -local sysrole = uci:get_first('chatgpt-web', 'basic', 'systemrole') - --%> - -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
ChatGPT
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
- - 4o - - -
-
-
- - -
- - -
-
-
-
- -
-
-
-
- - -
- - -
-
-
-
-
-
- - - -
-
-
-
-
- - - -
-
- - - -
-
-
-
-
-
ChatGPT
-
DeepSeek
-
Azure
-
Gemini
-
Claude
-
-
- -
-
-
-
ChatGPT
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
- - - - -
- - - -
-
-
-
- - - - - - - - - - - - - - - - -
\ No newline at end of file diff --git a/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po b/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po deleted file mode 100644 index 91654dbd..00000000 --- a/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po +++ /dev/null @@ -1,71 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8" - -msgid "chatgpt-web" -msgstr "ChatGPT-Web" - -msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" -msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" - -msgid "Base Setting" -msgstr "基本设置" - -msgid "GPT Model" -msgstr "GPT模型" - -msgid "Character images" -msgstr "人物图像" - -msgid "boy" -msgstr "男生" - -msgid "boy2" -msgstr "男生2" - -msgid "boy3" -msgstr "男生3" - -msgid "girl" -msgstr "女生" - -msgid "girl2" -msgstr "女生2" - -msgid "girl3" -msgstr "女生3" - -msgid "dog" -msgstr "狗" - -msgid "cartoon" -msgstr "卡通图像" - -msgid "cartoonboy" -msgstr "卡通男孩" - -msgid "cartoonboy2" -msgstr "卡通小男孩" - -msgid "cartoongirl" -msgstr "卡通女生" - -msgid "cartoongirl2" -msgstr "卡通女生2" - -msgid "OpenAI interface" -msgstr "OpenAI接口" - -msgid "API Key" -msgstr "API密钥" - -msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" -msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" - -msgid "Click to open" -msgstr "点击前往" - -msgid "Default Role Settings" -msgstr "默认角色设置" - -msgid "
For specific usage, see:" -msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po b/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po deleted file mode 100644 index 91654dbd..00000000 --- a/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po +++ /dev/null @@ -1,71 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8" - -msgid "chatgpt-web" -msgstr "ChatGPT-Web" - -msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" -msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" - -msgid "Base Setting" -msgstr "基本设置" - -msgid "GPT Model" -msgstr "GPT模型" - -msgid "Character images" -msgstr "人物图像" - -msgid "boy" -msgstr "男生" - -msgid "boy2" -msgstr "男生2" - -msgid "boy3" -msgstr "男生3" - -msgid "girl" -msgstr "女生" - -msgid "girl2" -msgstr "女生2" - -msgid "girl3" -msgstr "女生3" - -msgid "dog" -msgstr "狗" - -msgid "cartoon" -msgstr "卡通图像" - -msgid "cartoonboy" -msgstr "卡通男孩" - -msgid "cartoonboy2" -msgstr "卡通小男孩" - -msgid "cartoongirl" -msgstr "卡通女生" - -msgid "cartoongirl2" -msgstr "卡通女生2" - -msgid "OpenAI interface" -msgstr "OpenAI接口" - -msgid "API Key" -msgstr "API密钥" - -msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" -msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" - -msgid "Click to open" -msgstr "点击前往" - -msgid "Default Role Settings" -msgstr "默认角色设置" - -msgid "
For specific usage, see:" -msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/root/etc/config/chatgpt-web b/luci-app-chatgpt-web/root/etc/config/chatgpt-web deleted file mode 100644 index a7a1636a..00000000 --- a/luci-app-chatgpt-web/root/etc/config/chatgpt-web +++ /dev/null @@ -1,8 +0,0 @@ - -config basic - option apiHost 'https://api.openai.com/' - option modelver 'gpt-3.5-turbo' - option userpic 'girl.jpg' - option apikey '' - option systemrole '你是一个乐于助人的专业助手,尽量简明扼要地回答' - diff --git a/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json b/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json deleted file mode 100644 index c8c06f50..00000000 --- a/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "luci-app-chatgpt-web": { - "description": "Grant UCI access for luci-app-chatgpt-web", - "read": { - "uci": [ "chatgpt-web" ] - }, - "write": { - "uci": [ "chatgpt-web" ] - } - } -} diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css deleted file mode 100644 index 275239a7..00000000 --- a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css +++ /dev/null @@ -1,10 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! - Theme: GitHub - Description: Light theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Outdated base version: https://github.com/primer/github-syntax-light - Current colors taken from GitHub's CSS -*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css deleted file mode 100644 index 7a24e376..00000000 --- a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css +++ /dev/null @@ -1,2166 +0,0 @@ - - .chat_window { - display: flex; - width: 100%; - max-width: 1920px; - height: 888px; - border-radius: 8px; - background-color: rgba(255,255,255,0.5); - overflow: auto; - } - - .chat_window>.overlay { - width: 100%; - height: 100%; - position: absolute; - left: 0; - top: 0; - background-color: rgba(0, 0, 0, .3); - z-index: 90; - opacity: 0; - visibility: hidden; - cursor: pointer; - transition: all 120ms ease-in-out; - } - - .chat_window .mainContent .cbi-input-invalid { - color: var(--title-color); - border-bottom-color: var(--title-color); - } - - - @media screen and (min-width: 800px) { - - .mainContent { - width: calc(100% - 250px); - } - - .chat_window>.nav { - position: relative; - margin-left: -250px; - transition: margin-left 250ms; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - } - - .show-nav .nav { - margin-left: 0; - } - - #sysDialog { - max-width: 600px; - } - - .sysContent { - display: flex; - } - - .sysSwitch { - flex-shrink: 0; - width: 160px; - } - - .sysSwitch>div { - padding-left: 6px; - } - - .sysDetail { - margin-left: 12px; - margin-top: 5px; - } - } - - @media screen and (max-width: 800px) { - - .mainContent { - width: 100%; - } - - .chat_window>.nav { - position: absolute !important; - left: -250px; - transition: left 250ms; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - } - - .show-nav .nav { - left: 0; - box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; - } - - .show-nav .overlay { - visibility: visible; - opacity: 1; - } - - #sysDialog { - max-width: 400px; - } - - .sysSwitch { - display: flex; - } - - .sysSwitch>div { - width: 50%; - justify-content: center; - } - - .sysDetail { - margin-top: 8px; - } - } - .full_window { - max-width: none; - max-height: none; - } - - .chat_window>.nav { - width: 250px; - background-color: rgba(var(--primary-rgbbody),1); - color: var(--body-color); - top: 0; - z-index: 99; - flex-shrink: 0; - display: flex; - flex-direction: column; - margin-left: -250px; - transition: left 120ms ease-in-out; - } - - .mainContent { - height: 100%; - position: relative; - width: 100%; - display: flex; - flex-direction: column; - flex: 1; - } - - .show-nav .nav { - margin-left: 0; - } - .show-nav .chatnav { - margin-left: 0; - box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; - } - - .show-nav .overlay { - opacity: 1; - visibility: visible; - } - .top_menu { - width: 100%; - background-color: rgba(255,255,255,0.5); - height: 50px; - padding: 5px 0; - } - - .top_menu .toggler { - margin-left: 10px; - width: 40px; - height: 40px; - float: left; - padding: 5px 7px; - border-radius: 4px; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - } - - .top_menu .toggler:hover { - background: #e0e0e0; - } - - .top_menu .toggler .button { - width: 26px; - height: 4px; - border-radius: 4px; - position: absolute; - pointer-events: none; - } - - .top_menu .toggler .button.close { - margin-top: 3px; - background-color: #000; - } - - .top_menu .toggler .button.minimize { - margin-top: 13px; - background-color: #000; - } - - .top_menu .toggler .button.maximize { - margin-top: 23px; - background-color: #000; - } - - .top_menu .title { - font-size: 20px; - height: 40px; - line-height: 40px; - position: relative; - pointer-events: none; - } - - #selector { - position: absolute; - top: 50%; - left: 50%; - padding: 0 4px 0 10px; - cursor: pointer; - font-size: 20px; - transform: translate(-50%, -50%); - pointer-events: auto; - border-radius: 4px; - -webkit-user-select: none; - user-select: none; - display: flex; - align-items: center; - } - - #selector:hover { - color: var(--title-color); - } - - #modelVer { - margin-left: 6px; - margin-right: 4px; - height: 40px; - line-height: 38px; - } - - .messages { - position: relative; - flex: 1; - overflow-x: hidden; - overflow-y: auto; - font-size: 16px; - width: 100%; - text-align: center; - } - - .messages::-webkit-scrollbar, - - #chatlog .markdown-body>pre::-webkit-scrollbar, - #chatlog .markdown-body>pre>code::-webkit-scrollbar, - #chatlog .responseBody pre>code::-webkit-scrollbar, - #setDialog::-webkit-scrollbar, - .allList::-webkit-scrollbar, - .sysDetail::-webkit-scrollbar, - #apiSelect::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - .messages::-webkit-scrollbar-track, - - #chatlog .markdown-body>pre::-webkit-scrollbar-track, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-track, - #chatlog .responseBody pre>code::-webkit-scrollbar-track, - #setDialog::-webkit-scrollbar-track, - .allList::-webkit-scrollbar-track, - .sysDetail::-webkit-scrollbar-track, - #apiSelect::-webkit-scrollbar-track { - background-clip: padding-box; - background: transparent; - border: none; - } - - .messages::-webkit-scrollbar-corner, - - #chatlog .markdown-body>pre::-webkit-scrollbar-corner, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-corner, - #chatlog .responseBody pre>code::-webkit-scrollbar-corner, - #setDialog::-webkit-scrollbar-corner, - .allList::-webkit-scrollbar-corner, - .sysDetail::-webkit-scrollbar-corner, - #apiSelect::-webkit-scrollbar-corner { - background-color: transparent; - } - - .messages::-webkit-scrollbar-thumb, - - #chatlog .markdown-body>pre::-webkit-scrollbar-thumb, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb, - #chatlog .responseBody pre>code::-webkit-scrollbar-thumb, - #setDialog::-webkit-scrollbar-thumb, - .allList::-webkit-scrollbar-thumb, - .sysDetail::-webkit-scrollbar-thumb, - #apiSelect::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.1); - background-clip: padding-box; - border: solid transparent; - border-radius: 10px; - } - - .messages::-webkit-scrollbar-thumb:hover, - - #chatlog .markdown-body>pre::-webkit-scrollbar-thumb:hover, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb:hover, - #chatlog .responseBody pre>code::-webkit-scrollbar-thumb:hover, - #setDialog::-webkit-scrollbar-thumb:hover, - .allList::-webkit-scrollbar-thumb:hover, - .sysDetail::-webkit-scrollbar-thumb:hover, - #apiSelect::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - #chatlog { - word-wrap: break-word; - text-align: start; - } - - #chatlog>div { - padding: 18px 40px 18px 54px; - } - .chatAvatar { - position: absolute; - width: 30px; - height: 30px; - border-radius: 2px; - } - - .request>.chatAvatar { - top: 18px; - left: 13px; - margin-top: 15px; - margin-bottom: 15px; - } - .response>.chatAvatar { - background: #10a37f; - top: 18px; - left: 13px; - margin-top: 15px; - margin-bottom: 15px; - } - - .gptAvatar>svg { - margin-top: 3px; - margin-left: 3px; - } - - .chatAvatar>img { - display: block; - width: 100%; - height: 100%; - border-radius: 2px; - } - - .modelAvatar>svg, - .chatAvatar>svg { - display: block; - margin-top: 4px; - margin-left: 4px; - } - - #chatlog .request { - position: relative; - background-color: var(--inputborder-color); - display: flex; - justify-content: center; - } - - - .response .responseBody { - display: flex; - justify-content: flex-end; - margin: 18px 45px 18px 20px; - font-size: 15px; - - } - .requestBody { - white-space: pre-wrap; - margin: 18px; - flex: 1; - } - - #chatlog .response { - background-color: var(--inputborder-color); - position: relative; - } - - .response .markdown-body { - margin: 18px 65px 18px 0px; - flex: 1; - font-size: 15px; - background-color: rgba(255,255,255,0)!important; - - } - - #chatlog .markdown-body a { - padding: 0 1px 0 2px; - } - - #chatlog .markdown-body pre { - overflow-x: auto; - padding: 10px; - position: relative; - white-space: pre-wrap; - background-color: var(--inputborder-color)!important; - - } - - #chatlog .markdown-body .mermaid { - margin-bottom: 8px; - padding: 0 10px 0 10px; - position: relative; - border-radius: 6px; - } - - .mermaid>svg { - cursor: pointer; - max-height: 600px; - display: block; - } - - #chatlog .markdown-body pre>code { - overflow-x: auto; - white-space: pre-wrap; - display: block; - } - - #chatlog .markdown-body ul { - list-style-type: disc; - } - - .m-mdic-copy-wrapper { - position: absolute; - top: 5px; - right: 16px; - -webkit-user-select: none; - user-select: none; - } - - .m-mdic-copy-wrapper span.u-mdic-copy-code_lang { - position: absolute; - top: 3px; - right: calc(100% + 4px); - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #bbb; - } - - .m-mdic-copy-wrapper div.u-mdic-copy-notify { - position: absolute; - top: 0; - right: 0; - padding: 3px 6px; - border: 0; - border-radius: 3px; - background: none; - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #555; - outline: none; - right: 100%; - padding-right: 4px; - } - - .m-mdic-copy-wrapper button.u-mdic-copy-btn { - position: relative; - top: 0; - right: 0; - padding: 3px 6px; - border: 0; - border-radius: 3px; - background: none; - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #bbb; - outline: none; - cursor: pointer; - transition: color 0.2s; - } - - .m-mdic-copy-wrapper span.u-mdic-copy-code_lang::before, - .m-mdic-copy-wrapper div.u-mdic-copy-notify::before, - .m-mdic-copy-wrapper button.u-mdic-copy-btn::before { - content: attr(text); - } - - .m-mdic-copy-wrapper button.u-mdic-copy-btn:hover { - color: #555; - } - - #stopChat { - display: none; - margin: 0 auto; - margin-top: 3px; - width: 80px; - height: 32px; - text-align: center; - line-height: 32px; - color: white; - background: #f8b26a; - cursor: pointer; - border-radius: 3px; - position: sticky; - bottom: 2px; - justify-content: center; - align-items: center; - } - - #stopChat>svg { - margin-right: 8px; - } - - #stopChat:hover { - background: #f0aa60; - } - - .bottom_wrapper { - position: relative; - width: 100%; - padding: 10px 10px; - max-width: 91%; - margin: 0 auto; - } - - .bottom_wrapper .message_input_wrapper { - width: calc(100% - 134px); - position: relative; - text-align: left; - } - - .bottom_wrapper .message_input_wrapper .message_input_text { - border-radius: 4px; - border: none; - outline: none; - resize: none; - background-color: var(--inputborder-color); - height: 47px; - font-size: 16px; - max-height: 200px; - padding: 13px 0 13px 13px; - width: 100%!important; - display: block; - transition: background-color 250ms; - } - - .bottom_wrapper .message_input_wrapper .message_input_text:focus { - background-color: var(--inputborder-color); - } - - .bottom_wrapper .message_input_wrapper .message_input_text::-webkit-scrollbar { - display: none; - width: 0; - height: 0; - } - - #sendbutton { - width: 70px; - height: 47px; - font-size: 18px; - font-weight: bold; - color: #fff; - background-color: #7ac516; - padding: 0.5rem 0.5rem; - transition: all 0.2s linear; - position: absolute; - right: 65px; - bottom: 10px; - cursor: not-allowed; - } - - .activeSendBtn { - background-color: #99c959 !important; - cursor: pointer !important; - } - - .activeSendBtn:hover { - background-color: #90c050 !important; - } - - .clearConv { - position: absolute; - right: 10px; - bottom: 10px; - width: 47px; - padding: 0.5rem 0.5rem; - height: 47px; - } - - .clearConv>svg { - margin: 0 auto; - top: 50%; - left: 50%; - } - - .clearConv:hover { - background-color: var(--body-hover-bgcolor); - } - - .clearConv svg:first-child { - display: none; - } - - .clearConv svg:nth-child(2) { - display: block; - } - - .closeConv { - background: #e0e0e0;; - } - - .closeConv:hover { - background: #e8e8e8; - } - - .closeConv svg:first-child { - display: block; - } - - .closeConv svg:nth-child(2) { - display: none; - } - - .loaded>span { - display: inline-block; - } - - .loaded>svg { - display: none; - } - - .loading { - background: rgba(0,0,0,0) !important; - } - - .loading>span { - display: none; - } - - .loading>svg { - display: block; - } - - .switch-slide { - display: inline-block; - vertical-align: middle; - } - - .switch-slide-label { - display: block; - width: 38px; - height: 18px; - background: #909090; - border-radius: 30px; - cursor: pointer; - position: relative; - -webkit-transition: 0.3s ease; - transition: 0.3s ease; - } - - .switch-slide-label:after { - content: ""; - display: block; - width: 16px; - height: 16px; - border-radius: 100%; - background: #fff; - box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - position: absolute; - left: 1px; - top: 1px; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-transition: all 250ms; - transition: all 250ms; - } - - .switch-slide input:checked+label { - background: #99c959; - -webkit-transition: all 250ms; - transition: all 250ms; - } - - .switch-slide input:checked+label:after { - left: 21px; - } - - .settings { - padding: 0px 7px; - margin-top: -40px; - float: right; - border-radius: 4px; - cursor: pointer; - top: 5px; - } - - - .setBtn { - margin-left: 2px; - cursor: pointer; - padding: 5px; - border: none; - border-radius: 4px; - } - - .setBtn>svg { - display: block; - color: #909090; - } - - .setBtn:hover { - background: #e0e0e0;; - } - - #setting { - right: 15px; - } - - #toggleFull { - right: 56px; - } - - #toggleLight *, - #toggleFull * { - pointer-events: none; - } - - .showSetting { - background-color: var(--inputborder-color); - } - - .showModels { - background-color: var(--inputborder-color); - } - - .showModels>svg:last-child { - transform: scaleY(-1); - } - - #modelDialog { - position: absolute; - z-index: 2; - background: rgba(var(--primary-rgbbody), 1); - width: 240px; - left: 50%; - transform: translateX(-50%); - top: 46px; - overflow-y: auto; - max-height: calc(100% - 55px); - -webkit-user-select: none; - user-select: none; - border-radius: 4px; - padding: 8px 5px 8px 5px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - .modelSingle { - height: 40px; - border-radius: 3px; - cursor: pointer; - line-height: 40px; - display: flex; - align-items: center; - position: relative; - } - - .modelSingle * { - pointer-events: none; - } - - .modelSingle:hover { - background-color: var(--body-hover-bgcolor); - } - - .activeModel::before { - content: ""; - position: absolute; - right: 15px; - top: 7px; - width: 8px; - height: 16px; - border-style: solid; - border-width: 0 4px 4px 0; - transform: rotate(45deg); - } - - .modelAvatar { - margin-left: 6px; - margin-right: 10px; - width: 30px; - flex-shrink: 0; - border-radius: 2px; - height: 30px; - } - - #setDialog { - position: absolute; - z-index: 2; - background-color: rgba(var(--primary-rgbbody),1); - width: 320px; - right: 6px; - top: 46px; - overflow-y: auto; - max-height: calc(100% - 55px); - -webkit-user-select: none; - user-select: none; - border-radius: 5px; - padding: 8px 12px 8px 12px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - #setDialog input { - width: 100%; - } - - #setDialog .inlineTitle { - display: inline-block; - width: 80px; - line-height: 16px; - vertical-align: middle; - } - - #convOption, - #speechOption, - #speechDetail, - #recOption { - margin-bottom: 6px; - } - - #convOption>div, - #speechOption>div, - #speechDetail>div, - #recOption div { - margin-top: 10px; - } - - #voiceRecSetting select, #speechDetail select { - color: var(--title-color); - - } - .inputTextClass { - outline: none; - border-radius: 2px; - margin-top: 3px; - height: 32px; - font-size: 15px; - padding-left: 6px; - border: none; - } - - .areaTextClass { - width: 100%; - height: 150px; - white-space: normal; - resize: none; - padding: 6px; - } - - input[type="range"] { - -webkit-appearance: none; - appearance: none; - display: block; - margin: 4px 0 3px 0; - height: 8px; - background: #909090; - border-radius: 5px; - background-image: linear-gradient(#99c959, #99c959); - background-size: 100% 100%; - background-repeat: no-repeat; - } - - input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - height: 15px; - width: 15px; - border-radius: 50%; - background: #99c959; - cursor: ew-resize; - } - - input[type=range]::-webkit-slider-runnable-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; - } - - .justSetLine { - display: flex; - justify-content: space-between; - align-items: center; - } - - .justSetBtn { - height: 32px; - border-radius: 3px; - line-height: 32px; - background-color: var(--inputborder-color); - text-align: center; - padding: 0px 8px; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - } - - .justSetBtn:hover { - background-color: #d0d0d0; - } - - .justSetBtn>svg { - margin-right: 3px; - } - - .readyTestVoice>div:not(:first-child) { - display: none; - } - - .pauseTestVoice>div:nth-child(1), - .pauseTestVoice>div:nth-child(3) { - display: none; - } - - .resumeTestVoice>div:nth-child(1), - .resumeTestVoice>div:nth-child(2) { - display: none; - } - - .presetSelect>div { - display: inline-block; - } - - .presetSelect select { - outline: none; - border-radius: 3px; - width: 120px; - border-color: rgba(0, 0, 0, .3); - } - - .selectDef { - display: flex; - justify-content: space-between; - font-size: 13px; - color: #909090; - } - - #preSetSpeech { - width: 100%; - outline: none; - height: 30px; - line-height: 30px; - font-size: 14px; - margin-top: 5px; - border-radius: 3px; - border-color: rgba(0, 0, 0, .3); - } - - .mdOption { - position: absolute; - right: 0px; - top: 0; - width: 50px; - height: 100%; - pointer-events: none; - } - - .mdOption>div { - pointer-events: auto; - cursor: pointer; - } - - .mdOption svg * { - pointer-events: none; - } - - .refreshReq svg:not(:first-child) { - display: none; - } - - .halfRefReq svg:not(:nth-child(2)) { - display: none; - } - - .optionItems { - position: absolute; - bottom: -5px; - display: flex; - justify-content: space-between; - visibility: visible; - z-index: 1; - color: #808080; - } - - .optionItems:hover { - visibility: visible; - } - - .request:hover .optionItems, - .request:hover .voiceCls, - .response:hover .optionItems, - .response:hover .voiceCls { - visibility: visible; - } - - .optionItem { - border-radius: 10px; - height: 24px; - width: 32px; - border: 1px solid #e0e0e0;; - background-color: #f7f7f8; ; - display: flex !important; - justify-content: center; - align-items: center; - } - - .optionItem * { - pointer-events: none; - } - - .optionItem:hover { - background: #e0e0e0;; - } - - .voiceCls { - position: relative; - height: 100%; - visibility: visible; - display: flex; - align-items: center; - } - - .voiceCls>svg { - color: #c0c0c0; - display: block; - margin-left: 5px; - position: relative; - pointer-events: none; - } - - .voiceCls:hover>svg { - color: #808080; - } - - .showVoiceCls, - .showVoiceCls .markdown-body { - background: #e0e0e0 !important; - } - - .showVoiceCls .voiceCls { - visibility: visible !important; - } - - .showEditReq { - position: sticky !important; - top: 0; - bottom: 0; - z-index: 1; - } - - .showEditReq, - .showEditReq .markdown-body { - background: #e0e0e0 !important; - } - - .readyVoice svg:not(:first-child) { - display: none; - } - - .pauseVoice svg:not(:nth-child(2)) { - display: none; - } - - .resumeVoice svg:not(:nth-child(3)) { - display: none; - } - - #voiceTypes>span { - border-radius: 3px; - margin-left: 4px; - cursor: pointer; - color: var(--title-color); - padding: 1px 5px; - } - - #voiceTypes>span:hover { - background-color: var(--body-hover-bgcolor); - } - - .selVoiceType { - background-color: var(--inputborder-color); - } - - .navHeader { - width: 100%; - padding: 5px 10px; - display: flex; - justify-content: space-between; - } - - #newChat { - text-align: center; - width: 80%; - height: 40px; - border-radius: 3px; - background: var(--inputborder-color); - display: flex; - align-items: center; - justify-content: center; - -webkit-user-select: none; - user-select: none; - cursor: pointer; - flex: 1; - } - - .navHeader>div:hover { - background: var(--body-hover-bgcolor)!important; - } - - #newChat>svg { - margin-right: 2px; - } - - #newFolder { - height: 40px; - width: 40px; - margin-left: 10px; - border-radius: 3px; - background: var(--inputborder-color); - cursor: pointer; - position: relative; - -webkit-user-select: none; - user-select: none; - } - - #newFolder>svg { - display: block; - margin: 8px auto; - } - - .extraChat { - padding: 2px 10px 6px 10px; - position: relative; - } - - #searchChat { - width: 100%; - height: 36px; - padding-left: 10px; - padding-right: 16px; - font-size: 16px; - outline: none; - border: none; - background: var(--inputborder-color); - border-radius: 3px; - } - - #searchChat:focus { - background: var(--inputborder-color); - } - - #searchChat:placeholder-shown+#clearSearch { - display: none; - } - - #clearSearch { - position: absolute; - right: 12px; - top: 8px; - cursor: pointer; - } - - #clearSearch:hover { - color: var(--title-color); - } - - #clearSearch>svg { - display: block; - } - - .seledSearch { - background: var(--inputborder-color)!important; - } - - #matchCaseSearch { - position: absolute; - right: 12px; - top: 8px; - cursor: pointer; - border-radius: 3px; - color: var(--inputborder-color); - } - - #matchCaseSearch:hover { - background: var(--body-hover-bgcolor); - } - - #matchCaseSearch>svg { - display: block; - } - - .navFooter { - padding-bottom: 8px; - background: var(--inputborder-color); - } - - .navFooter .divider { - width: 100%; - border-top: 1px solid var(--inputborder-color); - margin: 4px 0; - } - - .navFunc { - padding-top: 5px; - display: flex; - justify-content: space-evenly; - } - - .navFunc svg { - display: block; - } - - .navFunc>div, - .navFunc>label { - border-radius: 20px; - text-align: center; - padding: 8px 8px; - font-size: 14px; - cursor: pointer; - } - - .navFunc>div:hover, - .navFunc>label:hover { - background: var(--body-hover-bgcolor); - } - - .navFooter .links { - text-align: center; - -webkit-user-select: none; - user-select: none; - } - - .navFooter .links a { - text-decoration: none; - } - - - .allList { - width: 100%; - flex: 1; - overflow-y: auto; - } - - #chatList { - min-height: 50px; - } - - .dragingLi { - filter: brightness(90%); - } - - .dragingChat { - background: #e8e8e8; - } - - .expandFolder>.headLi>svg { - transform: rotate(90deg); - } - - .expandFolder>.chatsInFolder { - display: block; - } - - .chatsInFolder { - display: none; - margin-left: 22px; - padding-left: 2px; - border-left: 1px solid #909090; ; - } - - .headLi, - .chatLi { - cursor: pointer; - width: 100%; - height: 50px; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; - } - - .headLi *, - .chatLi * { - pointer-events: none; - } - - .headLi>svg, - .chatLi>svg { - margin-left: 10px; - pointer-events: none; - } - - .folderOption svg, - .chatOption svg { - pointer-events: auto; - } - - .headLi svg *, - .chatLi svg * { - pointer-events: none; - } - - .headLi .folderInfo { - position: absolute; - left: 40px; - height: 40px; - max-width: calc(100% - 115px); - } - - .chatLi .chatInfo { - position: absolute; - left: 45px; - height: 40px; - max-width: calc(100% - 100px); - } - - .folderInfo *, - .chatInfo * { - -webkit-user-select: none; - user-select: none; - } - - .chatInfo span { - background: var(--inputborder-color)!important; - } - - .headLi .folderName, - .chatLi .chatName { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - line-height: 20px; - height: 20px; - } - - .headLi .folderNum, - .chatLi .chatPre { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - font-size: 12px; - line-height: 20px; - height: 20px; - } - - .headLi .folderOption, - .chatLi .chatOption { - visibility: hidden; - display: flex; - margin-right: 5px; - } - - .folderLi .chatLi>svg { - margin-left: 5px; - } - - .folderLi .chatLi .chatInfo { - left: 35px; - max-width: calc(100% - 85px); - } - - .folderLi .chatLi #activeChatEdit { - left: 32px; - width: calc(100% - 60px) - } - - .folderLi:hover { - background: var(--body-hover-bgcolor)!important; - } - - .chatLi:hover { - background: var(--body-hover-bgcolor)!important; - } - - .headLi:hover .folderOption, - .chatLi:hover .chatOption { - visibility: visible !important; - } - - .activeFolder, - .activeChatLi { - background: var(--inputborder-color)!important; - } - - .activeChatLi .chatOption { - visibility: visible !important; - } - - .folderOption>svg:hover, - .chatOption>svg:hover { - color: var(--title-color); - } - - #activeChatEdit { - position: absolute; - left: 42px; - font-size: 16px; - border-radius: 2px; - color: var(--title-color); - background-color: rgba(var(--primary-rgbbody),1); - outline: none; - border: none; - pointer-events: auto; - height: 24px; - line-height: 24px; - width: calc(100% - 75px); - padding: 20px 3px; - z-index: 1; - } - - #loadMask { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 100; - background-color: #edeff2; - } - - #loadMask>div { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - text-align: center; - } - - @keyframes loading { - - 0%, - 100% { - transform: scale(0); - } - - 50% { - transform: scale(1); - } - } - - .loadingCSSIcon { - position: relative; - display: flex; - align-items: center; - justify-content: space-evenly; - } - - .loadingCSSIcon div { - width: 15%; - height: 0; - padding-bottom: 15%; - border-radius: 50%; - animation: loading 1s cubic-bezier(0.3, 0, 0.7, 1) infinite; - } - - .loadingCSSIcon div:nth-child(1) { - background: #e15b64; - animation-delay: -0.4s - } - - .loadingCSSIcon div:nth-child(2) { - background: #f8b26a; - animation-delay: -0.2s - } - - .loadingCSSIcon div:nth-child(3) { - background: #99c959; - animation-delay: 0s; - } - - #loadMask>div>:first-child { - font-size: 40px; - color: #909090; ; - } - - #loadMask>div>:last-child { - width: 140px; - height: 70px; - margin: 0 auto; - } - - #voiceRec { - position: absolute; - right: 0; - top: 0; - width: 47px; - height: 100%; - } - - .message_if_voice { - padding-right: 47px !important; - } - - #voiceRecIcon { - width: 100%; - height: 100%; - text-align: center; - cursor: pointer; - position: relative; - } - - #voiceRecIcon:hover>svg { - color: #808080; - } - - #voiceRecIcon>svg { - width: 38px; - height: 38px; - color: #b0b0b0; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - #voiceRecIcon>svg .animVoice { - display: none; - } - - .voiceRecing>svg { - color: #99c959 !important; - } - - .voiceRecing .animVoice { - display: inline !important; - transform-origin: 0 64%; - animation-duration: 1.5s; - animation-name: scaleVoice; - animation-timing-function: ease; - animation-iteration-count: infinite; - } - - .voiceLong .animVoice { - display: inline !important; - transform-origin: 0 64%; - animation-duration: 0.3s; - animation-name: longVoice; - animation-timing-function: ease-in-out; - animation-iteration-count: 1; - } - - @keyframes longVoice { - 0% { - transform: scaleY(0); - } - - 100% { - transform: scaleY(1); - } - } - - @keyframes scaleVoice { - 0% { - transform: scaleY(0.28); - } - - 20% { - transform: scaleY(0.60); - } - - 28% { - transform: scaleY(0.28); - } - - 36% { - transform: scaleY(0.45); - } - - 44% { - transform: scaleY(0.28); - } - - 52% { - transform: scaleY(0.45); - } - - 62% { - transform: scaleY(0.80); - } - - 72% { - transform: scaleY(0.80); - } - - 90% { - transform: scaleY(0.28); - } - - 100% { - transform: scaleY(0.28); - } - } - - #voiceRecSetting { - display: none; - position: absolute; - top: -70px; - left: -26px; - z-index: 1; - padding: 4px 4px; - -webkit-user-select: none; - user-select: none; - border-radius: 3px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - #voiceRecSetting select { - width: 102px; - outline: none; - height: 28px; - border-radius: 3px; - border-color: rgba(0, 0, 0, .3); - } - - .presetModelCls label { - margin-right: 8px; - } - - .presetModelCls select { - height: 30px; - line-height: 30px; - margin-top: 2px; - font-size: 15px; - } - - .modelSwitch, - .setSwitch { - display: flex; - } - - .modelSwitch { - margin-bottom: 10px; - -webkit-user-select: none; - user-select: none; - } - - .modelSwitch>div, - .setSwitch>div { - border-radius: 3px; - width: calc(100% / 3); - padding: 8px; - line-height: 1; - text-align: center; - cursor: pointer; - } - - .modelSwitch>div:hover, - .setSwitch>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .activeSwitch { - background-color: var(--inputborder-color); - } - - .checkLoad { - height: 32px; - border-radius: 3px; - line-height: 32px; - background: #d0d0d0; - text-align: center; - display: flex; - justify-content: center; - cursor: pointer; - } - - .checkLoad:hover { - background: #c0c0c0; - } - - .voiceChecking { - background-color: #c0c0c0 !important; - } - - .voiceChecking>svg { - display: inline !important; - } - - .checkLoad>svg { - display: none; - margin-right: 8px; - height: 32px; - width: 64px; - } - - #preSetSystem { - height: 30px; - line-height: 30px; - } - - #sysMask { - display: none; - position: fixed; - z-index: 200; - top: 0; - left: 0; - bottom: 0; - right: 0; - cursor: pointer; - justify-content: center; - align-items: center; - background: rgba(0, 0, 0, .4); - -webkit-tap-highlight-color: transparent; - } - - #sysDialog { - position: relative; - background: rgba(var(--primary-rgbbody),1); - cursor: auto; - max-height: 100%; - width: 100%; - display: flex; - flex-direction: column; - border-radius: 4px; - padding: 20px; - } - - .sysTitle { - font-size: 20px; - font-weight: bold; - margin-bottom: 8px; - -webkit-user-select: none; - user-select: none; - } - - .sysSwitch, - .sysSwitch>div * { - pointer-events: none; - } - - .sysSwitch>div { - border-radius: 3px; - height: 32px; - line-height: 32px; - text-align: center; - cursor: pointer; - pointer-events: auto; - font-weight: bold; - display: flex; - align-items: center; - -webkit-user-select: none; - user-select: none; - } - - .sysSwitch>div>svg { - margin-right: 4px; - } - - .sysSwitch>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .sysDetail { - overflow-y: auto; - flex: 1; - margin-top: 4px; - } - - #closeSet { - position: absolute; - right: 0px; - top: 0px; - cursor: pointer; - padding: 10px 14px; - } - - #closeSet:hover { - color: #000; - } - - .setContent { - margin-top: 8px; - } - - .setNotNormalFlow { - position: relative; - } - - .setTitle { - margin-bottom: 6px; - font-weight: bold; - -webkit-user-select: none; - user-select: none; - } - - .setDetail { - margin: 0 10px; - -webkit-user-select: none; - user-select: none; - } - - .autoSelect>label, - .autoSelect>input { - cursor: pointer; - } - - .dataDetail { - display: flex; - } - - .dataDetail svg { - margin-right: 4px; - } - - .dataDetail>div, - .dataDetail>label { - border-radius: 3px; - text-align: center; - padding: 6px 8px; - margin-right: 12px; - font-size: 15px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - } - - .dataDetail>div:hover, - .dataDetail>label:hover { - background-color: var(--body-hover-bgcolor); - } - - .keyOptionDetail { - display: flex; - margin-top: 10px; - } - - #resetHotKey { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - font-size: 15px; - border-radius: 3px; - padding: 6px 8px; - margin-left: 90px; - } - - #resetHotKey>svg { - margin-right: 4px; - } - - #resetHotKey:hover { - background-color:var(--body-hover-bgcolor); - } - - .hotKeyDetail>div { - position: relative; - height: 30px; - margin-bottom: 2px; - } - - .hotKeyDetail label { - line-height: 30px; - } - - .hotKeyDetail select { - position: absolute; - left: 110px; - outline: none; - border-radius: 3px; - width: 120px; - border-color: rgba(0, 0, 0, .3); - height: 30px; - font-size: 15px; - } - - .avatarDetail { - display: flex; - margin-top: 2px; - } - - .avatarDetail img { - border-radius: 2px; - width: 32px; - height: 32px; - flex-shrink: 0; - margin-right: 10px; - margin-top: 2px; - } - - .inputDetail input { - outline: none; - border-radius: 3px; - padding-left: 8px; - font-size: 15px; - width: 100%; - height: 34px; - border: 1px solid rgba(0, 0, 0, .3); - } - - .themeDetail { - display: flex; - width: 180px; - justify-content: space-between; - pointer-events: none; - } - - .themeDetail svg { - display: block; - } - - .themeDetail>div { - pointer-events: auto; - border-radius: 20px; - text-align: center; - padding: 8px 8px; - font-size: 14px; - cursor: pointer; - } - - .themeDetail>div * { - pointer-events: none; - } - - .themeDetail>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .darkTheme>div:first-child { - background-color: var(--inputborder-color); - } - - .lightTheme>div:nth-child(2) { - background-color: var(--inputborder-color); - } - - .autoTheme>div:nth-child(3) { - background-color: var(--inputborder-color); - } - - .langDetail { - width: 110px; - } - - .enLang>div:first-child { - background-color: var(--inputborder-color); - } - - .zhLang>div:nth-child(2) { - background-color: var(--inputborder-color); - } - - #customAutoSet input { - width: 100px; - height: 30px; - line-height: 30px; - font-size: 15px; - outline: none; - border: 1px solid rgba(0, 0, 0, .3); - text-align: center; - border-radius: 3px; - background: #fff; - color: #24292f; - } - - #customAutoSet label { - margin-right: 8px; - } - - .progressBar { - position: relative; - width: 100%; - height: 12px; - border-radius: 6px; - background: #e0e0e0; - overflow: hidden; - } - - .nowProgress { - position: absolute; - left: 0; - top: 0; - height: 12px; - min-width: 1px; - border-radius: 6px; - background: #99c959; - } - - .progressDetail { - display: flex; - justify-content: space-between; - font-size: 15px; - } - - .cursorCls { - background-color: var(--inputborder-color); - width: 4px; - animation: 1s cursor-blinker infinite step-start; - } - - @keyframes cursor-blinker { - 0% { - opacity: 0; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0; - } - } - - .apiSelsContainer { - position: relative; - z-index: 1; - } - - #apiSelect { - position: absolute; - top: 64px; - padding: 4px 0; - width: calc(100% - 20px); - left: 10px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - max-height: 180px; - overflow-y: auto; - -webkit-user-select: none; - user-select: none; - } - - #apiSelect>div { - pointer-events: auto; - cursor: pointer; - font-size: 15px; - padding: 6px 0 6px 8px; - height: 36px; - display: flex; - justify-content: space-between; - align-items: center; - } - - #apiSelect>div:hover { - background-color:var(--body-hover-bgcolor); - } - - #apiSelect>div>span { - height: 100%; - line-height: 23px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - #apiSelect>div * { - pointer-events: none; - } - - .delApiOption:hover { - background-color: var(--body-hover-bgcolor); - } - - .delApiOption { - width: 36px; - height: 36px; - flex-shrink: 0; - pointer-events: auto !important; - } - - .delApiOption>svg { - margin: 6px; - display: block; - } - - @keyframes textInOut { - from { - opacity: 0; - } - - to { - opacity: 1; - } - } - - .response[data-loading="true"]>.responseBody>*:not(.cursorCls) { - animation: textInOut 0.48s ease-in-out forwards; - } - - .cotBtn { - width: 120px; - height: 32px; - font-size: 14px; - user-select: none; - background-color: var(--inputborder-color); - position: relative; - padding-left: 14px; - line-height: 32px; - border-radius: 8px; - margin-bottom: 12px; - cursor: pointer; - } - - .cotBtn:hover { - background-color: var(--body-hover-bgcolor); - } - - .cotBtn.endCot { - width: 142px; - } - - .cotBtn::after { - content: ''; - display: inline-block; - position: absolute; - right: 15px; - top: 14px; - width: 6px; - height: 6px; - transform: rotate(-45deg); - } - - .cotBtn.down::after { - top: 11px; - transform: rotate(135deg); - } - - .cotContent { - background-color: var(--inputborder-color); - padding-left: 12px; - font-size: 14px; - } - - .cotContent>:last-child { - margin-bottom: 12px !important; - } - - .hide { - display: none; - } - - - - - - - diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png deleted file mode 100644 index f29b31f9..00000000 Binary files a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png and /dev/null differ diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json deleted file mode 100644 index 1694b462..00000000 --- a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "/cgi-bin/luci/", - "name": "LuCI", - "short_name": "LuCI", - "start_url": "/cgi-bin/luci/", - "scope": "/cgi-bin/luci/", - "display": "standalone", - "background_color": "#f6f8fa", - "theme_color": "#f6f8fa", - "icons": [ - { - "src": "/luci-static/footstrap/app-icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable" - } - ] -} diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js deleted file mode 100644 index 87c126c6..00000000 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js +++ /dev/null @@ -1,842 +0,0 @@ -'use strict'; -'require baseclass'; -'require ui'; -'require dom'; -'require fs-prefs as prefs'; -'require fs-axes as axes'; -'require fs-assets as assets'; -'require fs-version as ver'; - -/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js - * holds the axes and fs-version.js the version string; this file is the form they are shown in. - * - * Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary - * route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page - * existed the same form was stapled onto a tab on System -> System instead, watching for the stock - * form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a - * stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a - * sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI - * remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on - * either stand — a mount stapled onto another app's own render is a timing dependency on that - * render, by construction, and the most likely account of a user report that could never be - * reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one - * axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating - * popover that has to trap Tab and stay inside a 320px column, and keeping both containers would - * render every axis twice. - * - * The version line makes no request and must not grow one: which version is INSTALLED is what this - * page answers, and which is available is the package manager's question. */ - -/* ---- colour: reading what the page is actually painted ---- - * - * This lived in fs-widgets.js, which the menu and the search palette also require — so the whole - * colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe, - * canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was - * fs-widgets' only colour export and this file its only consumer. */ - -/* ---- colour: reading what the page is actually painted ---- - * - * Two questions no stored value answers: what colour a role is right now (the palette's own while - * the axis is off — there is deliberately no copy of the palette in JS), and what contrast the - * user's colour lands at. Both are about the computed cascade, so both are asked of the browser. - * - * `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property - * computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back - * unevaluated. Setting the expression as a real `color` and reading it back makes the browser - * resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of. - * One hidden probe is reused; an element per query would thrash layout on every slider drag. */ -let _probe = null; -function probeColor(expr) { - if (!_probe) { - /* Off-screen rather than display:none, so the reading does not depend on a display:none - * element computing `color` in every engine. It has no text and no size, so it paints - * nothing. - * - * Every declaration is !important: this is an unmarked element in a document - * shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks - * a layer and a plain inline style alike. A probe that loses its own colour reports the - * app's, which then becomes the admin's saved axis on the next confirm. */ - /* the id is this probe's escape from fs-router.js's strayBodyNode(): it is - * appended once and never removed, so without an fs-* id it would read as body litter and - * force every navigation away from this page into a full load. */ - _probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' }); - _probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;' - + 'width:0!important;height:0!important;overflow:hidden!important;' - + 'pointer-events:none!important;'; - document.body.appendChild(_probe); - } - /* cleared first: an expression the engine rejects leaves the previous colour standing, which - * would report a stale answer as a fresh one */ - _probe.style.setProperty('color', ''); - _probe.style.setProperty('color', expr, 'important'); - return getComputedStyle(_probe).color; -} - -/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps - * the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the - * wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read", - * in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine - * convert instead (the export tier is checked the same way). Canvas 2D is universal on every - * engine 24.10 ships against, so there is no second path. */ -let _cx = null; -function rasterCtx() { - if (!_cx) { - const cv = document.createElement('canvas'); - cv.width = cv.height = 1; - _cx = cv.getContext('2d', { willReadFrequently: true }); - } - return _cx; -} -function parseColor(s) { - const cx = rasterCtx(); - /* fillStyle keeps the last value it could parse, so a colour this engine rejects would - * report the previous one as a fresh reading — the trap probeColor() clears for */ - cx.fillStyle = '#000'; - cx.fillStyle = String(s || ''); - cx.clearRect(0, 0, 1, 1); - cx.fillRect(0, 0, 1, 1); - const d = cx.getImageData(0, 0, 1, 1).data; - /* translucent: composite over nothing is meaningless for a readout */ - return d[3] === 255 ? [ d[0], d[1], d[2] ] : null; -} - -/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states - * what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css - * derives the ink over a fill, which is a different question). */ -function luminance(rgb) { - const c = rgb.map((v) => { - const x = v / 255; - return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4); - }); - return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]); -} -function contrastRatio(fgExpr, bgExpr) { - const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr)); - if (!fg || !bg) return null; - const a = luminance(fg), b = luminance(bg); - return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); -} - -/* #rrggbb, because accepts nothing else. An unparseable colour becomes black - * rather than throwing: the text field beside the swatch is the authoritative one. */ -function toHex(s) { - const rgb = parseColor(s) || [ 0, 0, 0 ]; - return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''); -} - -/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour. - * Reports through onPick as a hex string, or 0 for "back to the palette", either of which the - * caller hands straight to fs-prefs.js's colorAxis. - * - * There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma, - * so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet - * still rotates the palette by one, so a saved value goes on working. - * - * `opts.probe` is the live token the effective colour is read back from, so the field shows the - * palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is - * the pair whose ratio is reported under the row. */ -function colorControl(onPick, label, opts) { - const o = opts; - - /* type=color leaves the picker to the browser: accessible without reimplementing a colour - * wheel, and native on a phone. The text field beside it takes a pasted hex and is the - * fallback where the browser draws no picker. */ - const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' }); - const field = E('input', { - 'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off', - 'inputmode': 'text', 'maxlength': '7', 'aria-label': label || '' - }); - const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]); - const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null; - - /* Repaint everything that mirrors the axis. Called after every edit, and through the returned - * refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's - * own colour is while this axis stays off. */ - function reflect(v) { - const live = probeColor(o.probe); - const hex = (typeof v === 'string') ? v : toHex(live); - swatch.value = hex; - /* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on - * every keystroke made the input impossible to type into */ - if (document.activeElement !== field) field.value = hex; - /* the button back to the palette doubles as the axis state readout: enabled means the axis - * holds a colour of its own, disabled means the field shows the palette's */ - clear.disabled = !v; - if (!ratio) return; - const r = contrastRatio(o.contrast.fg, o.contrast.bg); - if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; } - /* The readout states what the ratio means; the number itself stays in the title. - * Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a - * hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint - * border is a legitimate choice. - * - * Class names are written out whole: dead CSS is found by matching fs-* tokens in the - * source, and a concatenated name is invisible to that match. */ - const where = o.contrast.label; - const grade = (o.contrast.kind === 'shape') - ? ((r >= 3) - ? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) } - : { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) }) - : (r >= 4.5) - ? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) } - : (r >= 3) - ? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) } - : { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) }; - ratio.className = 'fs-color-contrast ' + grade.cls; - ratio.textContent = grade.text; - ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap') - .format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5'); - } - - const pick = (v) => { onPick(v); reflect(v); }; - - swatch.addEventListener('input', () => pick(swatch.value.toLowerCase())); - /* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page - * under the cursor. An unparseable value snaps back to what the axis holds, so the field - * cannot claim a colour the page is not painted in. */ - const commit = () => { - const v = field.value.trim().toLowerCase(); - if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); - /* re-read rather than a cached copy: the page can change what the axis holds behind this - * control (a preset, Reset to default), which a snapshot taken at build time would miss */ - else reflect(o.read()); - }; - field.addEventListener('blur', commit); - field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); - clear.addEventListener('click', () => pick(0)); - - const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [ - E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ]) - ].concat(ratio ? [ ratio ] : [])); - /* the caller decides when this runs: probeColor() needs the document, and this control is not - * in it yet */ - wrap.fsRefresh = () => reflect(o.read()); - return wrap; -} - -/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the - * page's own render(). - * - * Everything applies immediately and there is nothing to save: every axis is this browser's, in - * localStorage, and the page repaints under the control as it moves. Only "Save to router" writes - * anything, pushing the current look to the ROUTER for other browsers. That distinction is the - * model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own — - * view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually - * drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to - * apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured, - * live, before that null). */ -function build() { - /* every saved axis re-checks the Save button after applying, so it greys the moment this - * browser matches the saved default and un-greys when it diverges. Wrapped around the appliers - * because the controls call them directly and have no other seam back here. */ - const bump = (fn) => (v) => { fn(v); refreshSave(); }; - - /* Every colour control mirrors something it does not own: the palette's colour while its own - * axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves - * all of it under controls nobody touched, so they refresh together. */ - const colourCtls = []; - const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh()); - /* wrap an applier so the colour readouts follow it: mode and palette change what every axis is - * measured against */ - const repaint = (fn) => (v) => { fn(v); refreshColours(); }; - - /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + - * `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and - * theme/60-inputs.css already lay them out — so this page inherits every future fix to the form - * layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2, - * where an app may win on specificity), which is right for a page inside #view. - * - * `make` is handed the same label string the caption renders, because every control needs it a - * second time as its aria-label; stating it twice is how the visible caption and what a screen - * reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks - * the rows CSS has to single out. */ - const group = (label, make, opts) => { - const o = opts || {}; - return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [ - E('label', { 'class': 'cbi-value-title' }, [ label ]), - E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || [])) - ]); - }; - - /* the three literals every colour row repeats; a string literal survives minification intact */ - const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap'); - - /* ---- the controls are LuCI's own ---- - * - * Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other - * tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css, - * `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports - * — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further - * back. - * - * Listen for `widget-change` from UIElement, not for an event on the inner element: that is the - * seam the widget publishes, and reaching past it ties this to how the widget happens to be - * built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes - * the page under the drag; the appliers are idempotent, so wiring both costs nothing. */ - const selectCtl = (current, choices, apply, label) => { - const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) }); - const node = w.render(); - node.setAttribute('aria-label', label); - node.addEventListener('widget-change', () => apply(w.getValue())); - return node; - }; - - const sliderCtl = (current, min, max, apply, label, opts) => { - const o = opts || {}; - const w = new ui.RangeSlider(String(current), { - min: min, max: max, step: o.step || 1 - }); - const node = w.render(); - node.setAttribute('aria-label', label); - const push = () => apply(parseInt(w.getValue(), 10)); - /* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column - * the Appearance page is drawn in, so applying mid-drag slides the handle out from under - * the pointer, the pointer catches up, and one small drag runs the value to the maximum — - * reported from a router, and not a thing any other slider here can do: rounding and the - * tint sliders repaint around a control that stays where it is. On release the page still - * follows, and a keyboard user is unaffected either way: an arrow key on a range input - * fires `change` as well as `input`, so the value applies at once. */ - if (o.live !== false) node.addEventListener('widget-update', push); - node.addEventListener('widget-change', push); - return node; - }; - - /* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is - * the live token the control reads the effective colour back from, `contrast` the pair it - * reports */ - const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { - const ctl = colorControl(bump(axis.apply), lbl, { - probe: probe, - read: axis.current, - contrast: contrast, - cls: opts.cls - }); - colourCtls.push(ctl); - return ctl; - }, opts); - - /* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves - * one merged catalogue — load_catalog() loads every *..lmo and a lookup returns the first - * archive holding the hash — so a bare msgid is a global name any luci-app may take, and - * readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router - * because another catalogue translates "Top" as "maximum". Contexting cannot be - * selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are - * deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme - * has no catalogue for. */ - - /* ---- section 1: the shell ---- */ - const shell = [ - group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), { - sidebar: _('Sidebar', 'footstrap'), - top: _('Top', 'footstrap') - }, bump(prefs.applyLayout), label)), - - group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), { - auto: _('Auto', 'footstrap'), - light: _('Light', 'footstrap'), - dark: _('Dark', 'footstrap') - }, bump(repaint(prefs.applyMode)), label)), - - group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), { - footstrap: 'Footstrap', - hicontrast: 'Hi-Contrast', - /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — - * so it is a proper noun and stays untranslated, like the two above it */ - bootstrap: 'Bootstrap', - /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ - '2020': 'OpenWrt 2020', - /* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is — - * a proper noun like the three above it, not the English common noun "forum" */ - forum: 'Forum' - }, bump(repaint(axes.palette.apply)), label)), - - group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { - compact: _('Compact', 'footstrap'), - normal: _('Normal', 'footstrap'), - large: _('Large', 'footstrap') - }, bump(prefs.applyDensity), label)), - - /* the content column's own cap, separate from Density (which moves type and - * air, not the column's ceiling). The slider STARTS at the 1280px the theme has always - * shipped and only ever widens — there is no reason to offer a column narrower than the - * one every page was designed against, and keeping 1280 as the left end also keeps - * --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach - * by construction rather than by a rule someone has to remember. */ - group(_('Content width', 'footstrap'), - (label) => sliderCtl(axes.contentWidth.current(), 1280, 3840, - bump(axes.contentWidth.apply), label, { step: 40, live: false })), - - group(_('Rounding', 'footstrap'), - (label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)), - - /* The top layout has no accordion, so this switch is meaningless there: always built, - * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an - * `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze - * the control to the layout the page loaded in while CSS morphs the chrome live. */ - group(_('Submenus', 'footstrap'), (label) => selectCtl( - prefs.currentAutoCollapse() ? 'on' : 'off', { - off: _('Keep open', 'footstrap'), - on: _('Auto-collapse', 'footstrap') - }, bump(prefs.applyAutoCollapse), label), - { cls: 'fs-ap-submenus' }) - ]; - - /* ---- section 2: colours ---- */ - const colours = [ - /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody - * would look for the router-identity cue under it */ - colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', { - /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette - * token this axis must not move, so the ratio is reported instead of corrected */ - fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') - }, { cls: 'fs-ap-tint' }), - - /* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour - * asked for, with no chroma of ours to scale. CSS hides it in the other two states. - * - * Not called "Density": that is the select above, and this string is both the caption and - * the aria-label, so a screen reader would announce two rows under one name. */ - group(_('Tint strength', 'footstrap'), - (label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, { - step: 5 - }), { cls: 'fs-ap-tint fs-ap-tintstr' }), - - /* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas. - * Measured as text on a card, the use that fails first: as a fill it carries derived ink, - * as a link or status label it carries only itself. It is also what answers #20 ("sometimes - * you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here - * are not coming back. */ - /* Four status roles, one shape: the role's own colour read against a card. Written out - * eight times between here and the surfaces below, they cost their repeated literals in - * full — a string is not mangled — so the rows are data and the row is stated once. */ - ...[ - [ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ], - [ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ], - [ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ], - [ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ] - ].map(([ label, axis, ink ]) => - colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) - ]; - - /* ---- the surfaces: the sheet the UI is drawn on ---- - * Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on - * each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the - * palette's, and moving it would recolour the very thing being measured against. - * - * The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label - * — and below that it is decoration, which a hairline is entitled to be, so the readout states - * the number and leaves the call to the admin. */ - const surfaces = [ - /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text - * for the three that carry body text and the hairline itself for the border. */ - ...[ - [ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ], - [ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], - [ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], - [ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] - ].map(([ label, axis, probe, fg, bg, where, kind ]) => - colourGroup(label, axis, probe, { fg, bg, label: where, kind })) - ]; - - /* ---- section 3: the wallpaper and the rows each value brings ---- - * - * Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an - * uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of - * its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column - * inside the first, and those controls started 216px right of every other one on the page, - * measured on the router. Flat rows hidden as a group is what the stock pages do with a - * dependent field. - * - * The select is the per-browser switch deciding whether to paint an image, so it is what keeps - * the Save button honest; Choose/Remove only swap the picture and never touch the axis. The - * native file inputs stay hidden — the styled buttons trigger them. */ - const wallpaper = (() => { - const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); - /* display:none, not `hidden`: a bare `hidden=""` still renders the native - * "Choose File / No file chosen" control */ - const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' }); - const chooseLabel = _('Choose image', 'footstrap'); - const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]); - const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); - - const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); - const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' }); - const patChooseLabel = _('Choose SVG', 'footstrap'); - const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]); - const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); - - /* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS - * and snapshotAxes(), so it moves this browser toward or away from the router default and - * must be bump()-ed like every other saved axis, or the Save button misreports its own - * status. Separate from the Tint strength above. */ - const dimLabel = _('Dim', 'footstrap'); - const scaleLabel = _('Scale', 'footstrap'); - const strengthLabel = _('Strength', 'footstrap'); - const inkLabel = _('Colours', 'footstrap'); - - /* The rows the pattern brings. Scale and Strength are live: the appliers write a custom - * property, so the tile resizes and fades under the drag. Colours decides whether the - * file's own palette is kept or replaced by the theme's — a mask uses the alpha only, - * which is right for line art and wrong for artwork that carries its own colours. */ - const patRows = [ - group(_('Pattern', 'footstrap'), - () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), - { extra: [ patInput, patPreview, patErr ] }), - group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600, - bump(axes.patternSize.apply), lbl, { step: 20 })), - group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100, - bump(axes.patternStrength.apply), lbl, { step: 5 })), - group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), { - theme: _('Theme', 'footstrap'), - original: _('As in file', 'footstrap') - }, bump(axes.patternInk.apply), lbl)) - ]; - /* …and the rows the FILE photo brings. */ - const fileRows = [ - group(_('File', 'footstrap'), - () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), - { extra: [ fileInput, preview, err ] }), - group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100, - bump(axes.photoDim.apply), lbl, { step: 5 })) - ]; - - function reflect(tok) { - if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; } - else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } - } - function reflectPattern(tok) { - if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; } - else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } - } - /* `hidden` on the row, which 80-appearance.css restates at a specificity beating - * `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not - * removed: each row holds a live control, so rebuilding on every switch would freeze it to - * the state it was constructed in. */ - function togglePanel(v) { - patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); - fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); - } - reflect(axes.loginBg.current()); - reflectPattern(axes.pattern.current()); - togglePanel(axes.wallpaper.current()); - - const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); }; - - /* Both uploads present the same three controls and the same four states — pick, upload, - * report, remove — so the wiring is stated once. What differs is `after`: the pattern also - * has to move the Wallpaper dropdown, because the upload switched this browser onto the - * tile and the page would otherwise paint it while the control still read Off. - * - * The file input is cleared on every change so re-picking the SAME file fires `change` - * again, and the button carries its own busy state: the label is restored in `finally`, or - * a failed upload leaves "Uploading…" standing for the life of the form. */ - const wireUploader = (u) => { - const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; }; - u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); }); - u.input.addEventListener('change', () => { - const f = u.input.files && u.input.files[0]; - u.input.value = ''; - if (!f) return; - u.err.hidden = true; u.choose.disabled = true; - u.choose.textContent = _('Uploading…', 'footstrap'); - u.upload(f) - .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); }) - .catch(fail) - .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; }); - }); - u.remove.addEventListener('click', () => { - u.err.hidden = true; u.remove.disabled = true; - u.drop().then(() => u.reflect('')).catch(fail) - .finally(() => { u.remove.disabled = false; }); - }); - }; - - wireUploader({ - choose: patChoose, remove: patRemove, input: patInput, err: patErr, - label: patChooseLabel, reflect: reflectPattern, - upload: assets.uploadPattern, drop: assets.removePattern, - /* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is - * then called directly, because a programmatic setValue emits no `widget-change`. */ - after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); } - }); - - wireUploader({ - choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, - label: chooseLabel, reflect: reflect, - upload: assets.uploadLoginBg, drop: assets.removeLoginBg - }); - - let seg; - const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { - seg = selectCtl(axes.wallpaper.current(), { - off: _('Off', 'footstrap'), - pattern: _('Pattern', 'footstrap'), - file: _('File', 'footstrap') - }, setWallpaper, label); - return seg; - }); - - return [ wallRow ].concat(patRows, fileRows); - })(); - - /* ---- section 4: the router default and the version ---- - * - * Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over - * the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the - * saved default only shows on a fresh browser. The two Reset buttons below are the escape - * hatches, and they do not land in the same place. */ - /* the button/status label said three times below, inside this one build() call — hoisting the - * RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere - * (measured: 22 B x3 -> 27 B, 39 B saved) */ - const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); - /* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */ - const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); - const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]); - /* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to - * router" clears them and lets every axis fall back to whatever the router holds, while "Reset - * to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme - * ships" on a router that has a look of its own. Neither touches /etc/config/footstrap. - * - * ONE word per state, and "default" is not one of them: it used to name the router's look in - * "Save as default" and the theme's in "Reset to default", while the router's look also - * answered to "saved" — three names for two states, asked about on the forum (topic 251930, - * post 92). The row now reads as one save and two resets, over `router` and `built-in`. */ - const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); - /* the stock destructive class, so the button discarding every local tweak is the red one - * (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state - * rather than discarding. */ - const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]); - /* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired - * session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set - * in the session and commit then no-ops without writing the file, returning success (measured - * on the router). The package owns that file and the read side falls back to built-in - * defaults. */ - const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - - /* The Save button is the status: matching disables it, diverging enables it. Called after every - * axis change (via bump). - * - * Unless the browser refuses storage, where the comparison would always be true — nothing was - * written, so every current*() reads the router default back however far the page has been - * dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to - * the router is the one thing that still works. */ - function refreshSave() { - if (prefs.storageBroken()) { - saveBtn.disabled = false; - saveBtn.textContent = SAVE_TO_ROUTER; - saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap'); - saveErr.hidden = false; - return; - } - const saved = axes.matchesSavedDefault(); - saveBtn.disabled = saved; - saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER; - } - saveBtn.addEventListener('click', () => { - saveBtn.disabled = true; - saveErr.hidden = true; - axes.saveAsDefault() - .then(() => { saveErr.hidden = true; }) - /* on failure refreshSave re-enables the button so the user can retry; the usual cause - * is a stale session, which a reload fixes. The raw rpc error stays in a title - * tooltip. */ - .catch((e) => { - saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap'); - saveErr.title = String((e && e.message) || e); - saveErr.hidden = false; - }) - .finally(refreshSave); - }); - /* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is - * banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a - * click meant for its neighbour. Each reload lands back on this same page — an ordinary route, - * unlike the tab this once needed a sessionStorage flag to return to. */ - const armed = new Map(); - function disarm(btn, label) { - armed.delete(btn); - btn.textContent = label; - btn.classList.remove('fs-ap-armed'); - } - function twoClick(btn, label, run) { - btn.addEventListener('click', () => { - if (!armed.has(btn)) { - [ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other))); - armed.set(btn, label); - btn.textContent = _('Confirm reset', 'footstrap'); - btn.classList.add('fs-ap-armed'); - return; - } - disarm(btn, label); - run(); - location.reload(); - }); - } - twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); - twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin); - refreshSave(); /* correct label and enabled state before the first paint */ - - const versionLink = E('a', { - 'class': 'fs-ap-version', - 'href': ver.REPO_URL, - 'target': '_blank', - /* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it - * that way */ - 'rel': 'noreferrer' - }, [ ver.label() ]); - - /* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves, - * because that is where the question is asked: with all three named "save"/"reset" and two of - * them resets, the row does not say which state each lands in (forum topic 251930, post 90 — - * "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather - * than a paragraph: three parts want a list, and each is read on its own. - * - * Text nodes separated by
, NOT a child per line: `.cbi-value-field *` in - * theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description` - * itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured, - * all three lines came out monospace. Text nodes inherit the sans face from the container, and - * one container also means one `?` glyph with every line aligned under it. */ - const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [ - _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'), - E('br'), - _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'), - E('br'), - _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') - ]); - - const defaults = [ - /* the one row whose control is a pair of buttons, each named by its own text, so `make` - * ignores the caption rather than re-using it as an aria-label */ - group(_('Saved look', 'footstrap'), - () => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]), - { extra: [ buttonHelp, saveErr ] }) - ]; - - - /* ---- the catalogue this router has not got ---- - * - * The theme's translations are their own packages since 0.14.4, the way every `luci-app-*` - * ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right - * one: apk learns it from `install-if` against `luci-i18n-base-` (owfeed.yml), opkg has no - * conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply - * loses the catalogue that used to ride inside the theme. `install.sh` covers its - * own path; this covers the one nobody ran a script on. - * - * ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not - * grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking - * for a string the catalogue certainly carries is the whole test. - * - * `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word - * that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in - * the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose - * Russian catalogue was installed and working. - * - * The language comes from the document, not from `L.env`, which carries no language field at - * all — the dispatcher stamps `` and that is what the page knows. - * - * Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */ - const lang = (document.documentElement.getAttribute('lang') || '').trim(); - const untranslated = lang && lang !== 'en' && lang !== 'auto' && - _('Layout', 'footstrap') === 'Layout'; - const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [ - E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]), - E('code', {}, [ 'luci-i18n-footstrap-' + lang ]) - ]) : ''; - - defaults.push(E('div', { 'class': 'fs-ap-footer' }, [ - E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]), - missing - ])); - - /* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put - * their rows straight into the pane. These are grouping headings within one pane, styled by - * pages/80-appearance.css. */ - const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [ - E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ]) - ].concat(rows)); - - /* ---- the folded groups ---- - * Nine colour fields and an uploader are the widest rows on the page and most admins never - * touch them, so each group is a disclosure, closed by default. - * - * A disclosure and not a switch: a switch answers "is this feature on", and turning it off - * would either revert nine colours or change nothing at all. Opening or closing a fold applies, - * un-applies and disables nothing. - * - * W3C APG disclosure pattern, as the menu's sections use: a