#!/usr/bin/env sh # Standalone bootstrapper and installed lifecycle implementation. set -eu umask 077 # ROOT is useful for package builders and isolated lifecycle tests; normal installs use /. ROOT="${THREE_M_UI_ROOT:-}" BASE="$ROOT/usr/local/lib/3m-ui" ENTRY="$ROOT/usr/local/bin/3m-ui" CONFIG_DIR="$ROOT/etc/3m-ui" DATA_DIR="${THREE_M_UI_DATA_DIR:-}" if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}" LOG_DIR="$ROOT/var/log/3m-ui" CONFIG_FILE="$CONFIG_DIR/config.yaml" APP_BIN="$BASE/3m-ui-bin" MIHOMO_BIN="$BASE/mihomo" SERVICE_NAME=3m-ui REPO="${THREE_M_UI_REPO:-}" CHANNEL="${THREE_M_UI_CHANNEL:-}" REQUESTED_VERSION="" INSTALL_MIHOMO=1 OPERATION=install YES=0 SNAPSHOT="" TRANSACTION=0 STOPPED=0 WAS_RUNNING=0 WORK="" say(){ printf '%s\n' "$*"; } err(){ say "Error: $*" >&2; exit 1; } command_exists(){ command -v "$1" >/dev/null 2>&1; } usage(){ cat </dev/null; then return 0 fi export THREE_M_UI_UPDATE_ESCAPED=1 say 'Update is running inside 3m-ui.service cgroup; re-scheduling outside it so stop cannot kill the updater.' if command_exists systemd-run && [ -d /run/systemd/system ]; then unit="3m-ui-update-$(date +%s)-$$" # --no-block: return immediately; the oneshot unit continues after panel stops. if systemd-run --no-block --collect --unit="$unit" --description="3m-ui self-update" --setenv=THREE_M_UI_UPDATE_ESCAPED=1 --setenv=THREE_M_UI_UPDATE_SILENT="${THREE_M_UI_UPDATE_SILENT:-}" --setenv=THREE_M_UI_CHANNEL="${THREE_M_UI_CHANNEL:-}" --setenv=THREE_M_UI_REPO="${THREE_M_UI_REPO:-}" --setenv=THREE_M_UI_ROOT="${THREE_M_UI_ROOT:-}" --setenv=THREE_M_UI_DATA_DIR="${THREE_M_UI_DATA_DIR:-}" --setenv=THREE_M_UI_VERIFY_COSIGN="${THREE_M_UI_VERIFY_COSIGN:-}" --setenv=PATH="$PATH" "$0" "$@"; then say "Scheduled systemd unit: $unit" exit 0 fi say 'systemd-run failed; falling back to setsid.' >&2 fi if command_exists setsid; then # setsid alone does not leave the systemd cgroup, but helps non-systemd hosts. setsid -f env THREE_M_UI_UPDATE_ESCAPED=1 "$0" "$@" >/dev/null 2>&1 || setsid env THREE_M_UI_UPDATE_ESCAPED=1 "$0" "$@" >/dev/null 2>&1 & exit 0 fi say 'Warning: could not escape service cgroup; update may be killed when the panel stops.' >&2 } escape_panel_cgroup_for_update "$@" init_system(){ if [ -d "$ROOT/run/systemd/system" ] && command_exists systemctl; then echo systemd elif command_exists rc-service; then echo openrc else echo unsupported; fi } service_action(){ case "$INIT" in systemd) systemctl "$1" "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" "$1";; *) return 1;; esac } service_active(){ case "$INIT" in systemd) systemctl is-active --quiet "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" status >/dev/null 2>&1;; *) return 1;; esac } reload_service(){ [ "$INIT" != systemd ] || systemctl daemon-reload; } stop_existing(){ if [ -e "$UNIT" ] || service_active; then service_action stop; fi STOPPED=1 } arch(){ case "$(uname -m)" in x86_64|amd64) echo amd64;; aarch64|arm64) echo arm64;; armv7l|armv7*) echo armv7;; armv6l|armv6*) echo armv6;; i386|i486|i586|i686|x86) echo 386;; riscv64) echo riscv64;; loongarch64|loong64) echo loong64;; ppc64le) echo ppc64le;; s390x) echo s390x;; *) err "Unsupported architecture: $(uname -m)";; esac } # ── Go runtime memory tuning (no swap, no mmap tricks) ────────── # GOMEMLIMIT = soft cap; Go GC runs harder before hitting it. # GOGC = GC trigger ratio (lower = more frequent GC = less RSS). # MemoryMax = systemd hard cap (OOM kills panel, not SSH). # # No swap creation. No mmap hacks. Pure Go runtime tuning. # # Override: THREE_M_UI_GOMEMLIMIT=80MiB THREE_M_UI_GOGC=75 compute_mem_tuning() { TOTAL_KB=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 524288) TOTAL_MB=$((TOTAL_KB / 1024)) if [ -n "${THREE_M_UI_GOMEMLIMIT:-}" ]; then PANEL_GOMEMLIMIT="$THREE_M_UI_GOMEMLIMIT" elif [ "$TOTAL_MB" -le 128 ]; then PANEL_GOMEMLIMIT="48MiB" PANEL_GOGC="20" elif [ "$TOTAL_MB" -le 256 ]; then PANEL_GOMEMLIMIT="96MiB" PANEL_GOGC="50" elif [ "$TOTAL_MB" -le 512 ]; then PANEL_GOMEMLIMIT="192MiB" PANEL_GOGC="75" else PANEL_GOMEMLIMIT="off" PANEL_GOGC="100" fi [ -n "${THREE_M_UI_GOGC:-}" ] && PANEL_GOGC="$THREE_M_UI_GOGC" case "$PANEL_GOMEMLIMIT" in off) PANEL_MEMORYMAX="infinity" ;; *MiB) _r=$(echo "$PANEL_GOMEMLIMIT" | sed 's/MiB//') PANEL_MEMORYMAX="$((_r + _r / 2))M" ;; *) PANEL_MEMORYMAX="infinity" ;; esac # Export immediately so ALL subprocesses (download, extract, init, # healthcheck, service start) inherit the limits. Note this only reaches Go # processes: curl, tar and gzip ignore it, which is why the download workflow # below is shaped to avoid holding anything in memory in the first place. if [ "$PANEL_GOMEMLIMIT" != "off" ]; then export GOMEMLIMIT="$PANEL_GOMEMLIMIT" export GOGC="$PANEL_GOGC" fi # Core-side tuning is separate from the panel's own heap: it switches the # Mihomo geodata loader to the memory-conservative one, which does not lean # on resident GEO data the way the standard loader does. Never exported — # only written into the service unit, so it cannot leak into this script's # own Go subprocesses, which want the tighter tier above. if [ -n "${THREE_M_UI_CORE_LOW_MEMORY:-}" ]; then PANEL_CORE_LOW_MEMORY="$THREE_M_UI_CORE_LOW_MEMORY" elif [ "$TOTAL_MB" -le 512 ]; then PANEL_CORE_LOW_MEMORY=1 else PANEL_CORE_LOW_MEMORY=0 fi say "Memory tuning: GOMEMLIMIT=$PANEL_GOMEMLIMIT GOGC=$PANEL_GOGC MemoryMax=$PANEL_MEMORYMAX core low-memory=$PANEL_CORE_LOW_MEMORY (RAM: ${TOTAL_MB}MB)" } compute_mem_tuning install_deps(){ if command_exists curl && command_exists tar && command_exists gzip && { command_exists sha256sum || command_exists shasum || command_exists openssl; }; then return; fi if command_exists apk; then apk add --no-cache curl ca-certificates tar gzip coreutils elif command_exists apt-get; then apt-get update && apt-get install -y curl ca-certificates tar gzip coreutils elif command_exists dnf; then dnf install -y curl ca-certificates tar gzip coreutils elif command_exists yum; then yum install -y curl ca-certificates tar gzip coreutils elif command_exists pacman; then pacman -Sy --noconfirm curl ca-certificates tar gzip coreutils elif command_exists zypper; then zypper --non-interactive install curl ca-certificates tar gzip coreutils else err 'Install curl, CA certificates, tar, gzip and sha256sum before continuing.'; fi } download(){ curl -fLsS --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; } file_sha256(){ if command_exists sha256sum; then sha256sum "$1" | awk '{print $1}' elif command_exists shasum; then shasum -a 256 "$1" | awk '{print $1}' else openssl dgst -sha256 "$1" | awk '{print $NF}'; fi } # Same three implementations, reading the stream instead of a path so a file can # be hashed while it is still being written. file_sha256_stream(){ if command_exists sha256sum; then sha256sum | awk '{print $1}' elif command_exists shasum; then shasum -a 256 | awk '{print $1}' else openssl dgst -sha256 | awk '{print $NF}'; fi } # Scratch space for downloads, extraction and the update snapshot. # # /tmp is deliberately avoided when we can do better. On many small hosts /tmp is # a tmpfs, and this machine has no swap, so every byte written there is pinned in # RAM and cannot be reclaimed or written out. A single install would otherwise # hold the downloaded bundle, the extracted release *and* a copy of the data # directory in memory at the same time — well past the total RAM of the boxes # this installer targets. DATA_DIR is on real storage, so use it, and fall back # to mktemp only if that fails. create_workdir(){ candidate="$DATA_DIR/.tmp/3m-ui-work-$$" if mkdir -p "$candidate" 2>/dev/null && [ -w "$candidate" ]; then WORK="$candidate" return 0 fi WORK="$(mktemp -d)" || err 'Cannot create a working directory.' } # Size the asset advertises, in bytes; 0 when the server does not say. HEAD only — # no body is fetched, so this costs nothing on metered NAT links. remote_bytes(){ curl -fLsSI --retry 2 --retry-delay 1 --connect-timeout 10 --max-time 60 "$1" 2>/dev/null | awk 'tolower($1)=="content-length:"{ gsub(/[^0-9]/,"",$2); if ($2 != "") v=$2 } END{ print v+0 }' } # Refuse to begin when the scratch area cannot hold what the operation needs. # Discovering this halfway through is much worse: the old service is already # stopped by then. An unreadable df reading means unknown, not zero, so we do # not block installs on hosts df cannot report on. require_scratch(){ need_kb="$1" avail_kb="$(df -Pk "$WORK" 2>/dev/null | awk 'NR==2{print $4}')" case "$avail_kb" in ''|*[!0-9]*) return 0;; esac [ "$avail_kb" -ge "$need_kb" ] || err "Not enough scratch space in $WORK: need about ${need_kb}KB, only ${avail_kb}KB free. Choose a THREE_M_UI_DATA_DIR on a disk with room, or free disk space." } # Need room for: the compressed asset, everything it expands to, and the files # we then copy out of it. Tripling the download covers tar blowups comfortably. reserve_for_asset(){ bytes="$(remote_bytes "$1")" [ "$bytes" -gt 0 ] || return 0 require_scratch $(( bytes * 3 / 1024 + 1024 )) } # Single-pass download: the bytes are written to disk and hashed on the way # through, so nothing has to re-read the file afterwards. Verification still # completes before anything is extracted — a corrupt or truncated download only # ever shows up as a checksum mismatch. Prints the computed sum. download_hashed(){ # url dest st="$WORK/.download-status" rm -f "$st" sum="$({ curl -fLsS --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" || printf '%s' "$?" > "$st"; } | tee "$2" | file_sha256_stream)" if [ -s "$st" ]; then err "Download failed (curl exit $(cat "$st")): $1"; fi printf '%s\n' "$sum" } source_settings(){ [ -n "$REPO" ] || { [ ! -s "$BASE/REPOSITORY" ] || REPO="$(cat "$BASE/REPOSITORY")"; } REPO="${REPO:-kazeyukiro/3m-ui}" case "$REPO" in *[!a-zA-Z0-9._/-]*|/*|*/|*/*/*|*/../*|../*) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac case "$REPO" in */*) :;; *) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac [ -n "$CHANNEL" ] || { [ ! -s "$BASE/CHANNEL" ] || CHANNEL="$(cat "$BASE/CHANNEL")"; } CHANNEL="${CHANNEL:-stable}" [ "$REQUESTED_VERSION" != pre ] || CHANNEL=pre [ "$CHANNEL" != prerelease ] || CHANNEL=pre case "$CHANNEL" in stable|pre) :;; *) err 'THREE_M_UI_CHANNEL must be stable or pre.';; esac if [ -n "$REQUESTED_VERSION" ]; then TAG="$REQUESTED_VERSION" elif [ "$CHANNEL" = pre ]; then TAG=pre else # GitHub latest excludes prereleases. Never silently fall back to a development build. location="$(curl -fLsSI --retry 3 --connect-timeout 10 --max-time 60 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || err 'No stable release available; choose a version or explicitly select THREE_M_UI_CHANNEL=pre.' TAG="${location##*/}" case "$TAG" in v[0-9]*) :;; *) err 'Unable to resolve a stable release; choose an explicit version.';; esac fi case "$TAG" in *[!a-zA-Z0-9._+-]*) err 'Invalid release tag.';; esac RELEASE_URL="https://github.com/$REPO/releases/download/$TAG" } expected_sha256(){ awk -v a="$1" '{ n=$2; sub(/^\*/,"",n); sub(/^\.\//,"",n); if(n==a) {print $1; exit} }' "$WORK/SHA256SUMS" } verify_asset(){ verify_sum "$1" "$(file_sha256 "$2")" } # Compare a sum that was already computed — used by download_hashed, which # hashed the stream on its way to disk and has no reason to read it again. verify_sum(){ expected="$(expected_sha256 "$1")" [ -n "$expected" ] || err "$1 is missing from SHA256SUMS." actual="$2" [ -n "$actual" ] || err "$1 downloaded no verifiable content." [ "$actual" = "$expected" ] || err "Checksum mismatch for $1." say "Checksum OK: $1" } verify_provenance(){ [ "${THREE_M_UI_VERIFY_COSIGN:-0}" = 1 ] || return 0 command_exists cosign || err 'THREE_M_UI_VERIFY_COSIGN=1 requires cosign.' download "$RELEASE_URL/SHA256SUMS.pem" "$WORK/SHA256SUMS.pem" download "$RELEASE_URL/SHA256SUMS.sig" "$WORK/SHA256SUMS.sig" workflow=release.yml [ "$TAG" != pre ] || workflow=release-pre.yml for ref in "refs/tags/$TAG" refs/heads/main refs/heads/test; do if cosign verify-blob --certificate "$WORK/SHA256SUMS.pem" --signature "$WORK/SHA256SUMS.sig" \ --certificate-identity "https://github.com/$REPO/.github/workflows/$workflow@$ref" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com "$WORK/SHA256SUMS" >/dev/null 2>&1; then return; fi done err 'Release signature verification failed.' } prepare_release(){ mkdir -p "$WORK/release" download "$RELEASE_URL/SHA256SUMS" "$WORK/SHA256SUMS" verify_provenance cpu="$(arch)" if [ "$INSTALL_MIHOMO" = 1 ]; then case "$cpu" in amd64|arm64) :;; *) err "Complete bundles are available for amd64/arm64; use --no-mihomo for $cpu.";; esac asset="3m-ui-bundle-linux-$cpu.tar.gz" say "Downloading $REPO $TAG ($asset)..." reserve_for_asset "$RELEASE_URL/$asset" verify_sum "$asset" "$(download_hashed "$RELEASE_URL/$asset" "$WORK/bundle.tar.gz")" # Bundles contain only these flat, regular files. Reject unexpected names/links. tar -tzf "$WORK/bundle.tar.gz" > "$WORK/members" while IFS= read -r member; do case "${member#./}" in 3m-ui-bin|mihomo|MIHOMO_VERSION|VERSION|REPOSITORY|mihomo.env|LICENSE|MIHOMO_LICENSE|MIHOMO_SOURCE|install.sh|update.sh|uninstall.sh|3m-ui.sh|3m-ui) :;; *) err "Unexpected bundle member: $member";; esac done < "$WORK/members" tar -tvzf "$WORK/bundle.tar.gz" | awk 'substr($0,1,1)!="-" { bad=1 } END {exit bad}' || err 'Bundle contains non-regular files.' tar -xzf "$WORK/bundle.tar.gz" -C "$WORK/release" for member in 3m-ui-bin mihomo MIHOMO_VERSION VERSION REPOSITORY install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do [ -s "$WORK/release/$member" ] || err "Incomplete bundle: $member missing." done [ "$(cat "$WORK/release/REPOSITORY")" = "$REPO" ] || err 'Bundle repository does not match the selected release source.' else asset="3m-ui-linux-$cpu" reserve_for_asset "$RELEASE_URL/$asset" say "Downloading $REPO $TAG ($asset)..." verify_sum "$asset" "$(download_hashed "$RELEASE_URL/$asset" "$WORK/release/3m-ui-bin")" for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do download "$RELEASE_URL/$member" "$WORK/release/$member" verify_asset "$member" "$WORK/release/$member" done fi for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do sh -n "$WORK/release/$member" chmod 0755 "$WORK/release/$member" done chmod 0755 "$WORK/release/3m-ui-bin" "$WORK/release/3m-ui-bin" --version >/dev/null if [ "$INSTALL_MIHOMO" = 1 ]; then chmod 0755 "$WORK/release/mihomo"; "$WORK/release/mihomo" -v >/dev/null; fi } validate_storage(){ [ -f "$CONFIG_FILE" ] || return 0 # Use the new binary to inspect old settings read-only, before any downtime or # migration. Refuse to claim a complete backup of shared external directories. inspection_bin="$1" (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$inspection_bin" storage-paths) > "$WORK/storage-paths" while IFS= read -r path; do case "$path" in "$CONFIG_DIR"|"$CONFIG_DIR"/*|"$DATA_DIR"|"$DATA_DIR"/*) :;; *) err "Storage outside the managed configuration/data directories: $path. Move it into $CONFIG_DIR or $DATA_DIR before using automatic install/update/backup.";; esac case "$path" in "$DATA_DIR/backups"|"$DATA_DIR/backups"/*) err "Active storage cannot live in the backup directory: $path";; esac done < "$WORK/storage-paths" } # Snapshots cover configuration, encryption keys, SQLite/WAL, certificates, all # app data, managed binaries/helpers, the entrypoint and service definition. create_snapshot(){ mkdir -p "$WORK/snapshot" "$DATA_DIR/backups" for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do name="${spec%%:*}"; path="${spec#*:}" if [ -e "$path" ] || [ -L "$path" ]; then cp -a "$path" "$WORK/snapshot/$name"; fi done if [ -d "$DATA_DIR" ]; then mkdir -p "$WORK/snapshot/data" # Intermediate archive propagates tar errors rather than hiding a failing pipeline. tar -cf "$WORK/data.tar" --exclude='./backups' --exclude='./.tmp' -C "$DATA_DIR" . tar -xf "$WORK/data.tar" -C "$WORK/snapshot/data" # Nothing reads data.tar again, and keeping it would hold the data directory # twice over — unaffordable on the small-RAM machines this targets. rm -f "$WORK/data.tar" fi printf '%s\n' '3m-ui-snapshot-v1' > "$WORK/snapshot/FORMAT" printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/snapshot/PATHS" printf '%s\n' "$WAS_RUNNING" > "$WORK/snapshot/RUNNING" enabled=0 case "$INIT" in systemd) systemctl is-enabled --quiet "$SERVICE_NAME" && enabled=1 || true;; openrc) [ ! -e "$ROOT/etc/runlevels/default/$SERVICE_NAME" ] || enabled=1;; esac printf '%s\n' "$enabled" > "$WORK/snapshot/ENABLED" SNAPSHOT="$DATA_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)-$$.tar.gz" tar -czf "$SNAPSHOT.tmp" -C "$WORK/snapshot" . mv "$SNAPSHOT.tmp" "$SNAPSHOT" say "Snapshot: $SNAPSHOT" } restore_files(){ restore_dir="$1" # Undo enablement created by a failed first installation before removing its unit. if [ "$(cat "$restore_dir/ENABLED")" = 0 ]; then case "$INIT" in systemd) systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true;; openrc) rc-update del "$SERVICE_NAME" default >/dev/null 2>&1 || true;; esac fi # Keep backup archives available across rollback and manual restores. mkdir -p "$DATA_DIR" || return 1 for path in "$DATA_DIR"/* "$DATA_DIR"/.[!.]* "$DATA_DIR"/..?*; do [ -e "$path" ] || [ -L "$path" ] || continue [ "$path" = "$DATA_DIR/backups" ] && continue # Scratch work now lives under DATA_DIR instead of tmpfs, so a restore would # otherwise delete the directory it is currently reading its own rollback # out of. Nothing here is application data; leave it for the cleanup trap. [ -n "$WORK" ] && [ "$path" = "$WORK" ] && continue [ "$path" = "$DATA_DIR/.tmp" ] && continue rm -rf "$path" || return 1 done [ ! -d "$restore_dir/data" ] || cp -a "$restore_dir/data/." "$DATA_DIR/" || return 1 for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do name="${spec%%:*}"; path="${spec#*:}" rm -rf "$path" || return 1 if [ -e "$restore_dir/$name" ] || [ -L "$restore_dir/$name" ]; then mkdir -p "$(dirname "$path")" || return 1 cp -a "$restore_dir/$name" "$path" || return 1 fi done reload_service || return 1 if [ "$(cat "$restore_dir/ENABLED")" = 1 ]; then case "$INIT" in systemd) systemctl enable "$SERVICE_NAME" >/dev/null;; openrc) rc-update add "$SERVICE_NAME" default >/dev/null;; esac fi } cleanup(){ result=$? trap - EXIT HUP INT TERM if [ "$TRANSACTION" = 1 ]; then say 'Operation failed; restoring the previous installation and data.' >&2 service_action stop >/dev/null 2>&1 || true if [ -d "$WORK/snapshot" ] && restore_files "$WORK/snapshot"; then if [ "$WAS_RUNNING" = 1 ]; then service_action start || say 'Could not restart the restored service; inspect 3m-ui logs.' >&2; fi else say "Automatic restore failed. Recovery snapshot: $SNAPSHOT" >&2 fi [ "$result" != 0 ] || result=1 fi if [ "$TRANSACTION" = 0 ] && [ "$STOPPED" = 1 ] && [ "$WAS_RUNNING" = 1 ]; then service_action start || say 'Could not restart the previous service; inspect 3m-ui logs.' >&2 fi [ -z "$WORK" ] || { rm -rf "$WORK"; rmdir "$DATA_DIR/.tmp" 2>/dev/null || true; } exit "$result" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' HUP TERM # Rewrites the memory directives an installed unit already carries. # # write_service() deliberately keeps whatever unit an operator has tuned, which # means the values baked into it at install time would never be refreshed by an # update. Running it again has to converge instead: drop the four directives we # own, re-emit them with this run's numbers, and leave every other line — and # therefore every other operator override — exactly where it was. ensure_service_limits(){ [ -n "$UNIT" ] && [ -f "$UNIT" ] || return 0 if [ "$INIT" = systemd ]; then limits="Environment=GOMEMLIMIT=${PANEL_GOMEMLIMIT} Environment=GOGC=${PANEL_GOGC} Environment=THREE_M_UI_CORE_LOW_MEMORY=${PANEL_CORE_LOW_MEMORY} MemoryMax=${PANEL_MEMORYMAX}" sed '/^Environment=GOMEMLIMIT=/d; /^Environment=GOGC=/d; /^Environment=THREE_M_UI_CORE_LOW_MEMORY=/d; /^MemoryMax=/d' \ "$UNIT" > "$WORK/service.stripped" || return 0 # Insert into [Service]; appending at the end would land in [Install] and # silently apply these settings to the wrong section. awk -v limits="$limits" ' { lines[NR]=$0 } END { seen=0 for (i=1; i<=NR; i++) { print lines[i] if (lines[i] ~ /^\[Service\]/) { print limits; seen=1 } } if (!seen) print limits }' "$WORK/service.stripped" > "$WORK/service.new" install -m 0644 "$WORK/service.new" "$UNIT" systemctl daemon-reload else limits="export GOMEMLIMIT=\"${PANEL_GOMEMLIMIT}\" export GOGC=\"${PANEL_GOGC}\" export THREE_M_UI_CORE_LOW_MEMORY=\"${PANEL_CORE_LOW_MEMORY}\"" sed '/^export GOMEMLIMIT=/d; /^export GOGC=/d; /^export THREE_M_UI_CORE_LOW_MEMORY=/d' \ "$UNIT" > "$WORK/service.stripped" || return 0 # OpenRC runscripts evaluate top to bottom, so re-export before anything # could consult them. awk -v limits="$limits" ' { lines[NR]=$0 } END { seen=0 for (i=1; i<=NR; i++) { print lines[i] if (lines[i] ~ /^description=/) { print limits; seen=1 } } if (!seen) print limits }' "$WORK/service.stripped" > "$WORK/service.new" install -m 0755 "$WORK/service.new" "$UNIT" fi } write_service(){ # Keep operator overrides, capabilities and enablement on an existing install. # Older systemd syscall filters cannot run the current pure-Go SQLite build. if [ -f "$WORK/snapshot/service" ]; then if [ "$INIT" = systemd ]; then if grep -q '^SystemCallFilter=' "$UNIT"; then sed '/^SystemCallFilter=/d; /^PrivateDevices=/d; /^MemoryDenyWriteExecute=/d; /^RestrictNamespaces=/d' "$UNIT" > "$WORK/service" install -m 0644 "$WORK/service" "$UNIT" fi systemctl daemon-reload fi # Existing install: nothing else about the unit changes, but the memory # directives still have to track this host, not the day it was installed. ensure_service_limits return 0 fi mkdir -p "$(dirname "$UNIT")" if [ "$INIT" = systemd ]; then cat > "$UNIT" </dev/null else cat > "$UNIT" </dev/null fi ensure_service_limits } wait_healthy(){ attempt=0 while [ "$attempt" -lt 30 ]; do if service_active && (cd "$DATA_DIR" && GOMEMLIMIT="${PANEL_GOMEMLIMIT}" GOGC="${PANEL_GOGC}" THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" healthcheck) >/dev/null 2>&1; then return 0; fi attempt=$((attempt + 1)) sleep 1 done err 'Service health check failed after 30 attempts.' } manual_restore(){ [ -f "$SNAPSHOT" ] || err 'Usage: 3m-ui restore /absolute/path/to/snapshot.tar.gz [--yes]' mkdir -p "$WORK/restore" # Only archives produced by this installer are accepted; never source metadata. tar -tzf "$SNAPSHOT" > "$WORK/members" while IFS= read -r member; do case "$member" in /*|../*|*/../*|*/..) err 'Unsafe snapshot member.';; esac case "${member#./}" in ''|FORMAT|PATHS|RUNNING|ENABLED|base|base/*|config|config/*|data|data/*|entry|service) :;; *) err 'Unrecognized snapshot layout.';; esac done < "$WORK/members" tar -xzf "$SNAPSHOT" -C "$WORK/restore" [ "$(cat "$WORK/restore/FORMAT")" = '3m-ui-snapshot-v1' ] || err 'Unsupported snapshot format.' printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/expected-paths" cmp -s "$WORK/expected-paths" "$WORK/restore/PATHS" || err 'Snapshot paths differ from this installation; restore on the original host paths.' if [ "$YES" != 1 ]; then [ -t 0 ] || err 'Non-interactive restore requires --yes.' printf 'Replace the installation and data with this snapshot? [y/N] ' read -r answer case "$answer" in y|Y|yes|YES) :;; *) exit 0;; esac fi validate_storage "$APP_BIN" service_active && WAS_RUNNING=1 || true stop_existing # Keep a snapshot of the current installation so a failed restore is reversible. create_snapshot TRANSACTION=1 restore_files "$WORK/restore" service_action start wait_healthy TRANSACTION=0 STOPPED=0 say 'Snapshot restored and service is healthy.' } main(){ [ "$(id -u)" -eq 0 ] || err 'Please run as root.' [ "$(uname -s)" = Linux ] || err 'Native installation requires Linux.' # Avoid ambiguous paths in generated service definitions and snapshot metadata. for path in "$ROOT" "$BASE" "$CONFIG_DIR" "$DATA_DIR"; do case "$path" in *[[:space:]]*|*/../*|*/..|/|/etc|/usr|/var) err "Unsupported installation path: $path";; esac done case "$DATA_DIR" in /*) :;; *) err 'THREE_M_UI_DATA_DIR must be an absolute path.';; esac INIT="$(init_system)" [ "$INIT" != unsupported ] || err 'A systemd or OpenRC Linux host is required.' if [ "$INIT" = systemd ]; then UNIT="$ROOT/etc/systemd/system/$SERVICE_NAME.service"; else UNIT="$ROOT/etc/init.d/$SERVICE_NAME"; fi create_workdir case "$OPERATION" in restore) manual_restore; return;; backup) [ -x "$APP_BIN" ] || err '3m-ui is not installed.' validate_storage "$APP_BIN" service_active && WAS_RUNNING=1 || true stop_existing # Cleanup restarts the previous service if creating the snapshot fails. create_snapshot if [ "$WAS_RUNNING" = 1 ]; then service_action start; fi STOPPED=0 return;; update) [ -x "$APP_BIN" ] || err '3m-ui is not installed.';; esac install_deps source_settings prepare_release validate_storage "$WORK/release/3m-ui-bin" service_active && WAS_RUNNING=1 || true stop_existing create_snapshot TRANSACTION=1 mkdir -p "$BASE" "$(dirname "$ENTRY")" "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" # Replace every release-owned binary and lifecycle script (always, on install and update). # Scripts must stay in lockstep with the panel so menu/CLI behaviour matches the release notes. for member in 3m-ui-bin install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do [ -s "$WORK/release/$member" ] || err "Release payload missing: $member" install -m 0755 "$WORK/release/$member" "$BASE/$member" say "Installed $BASE/$member" done if [ "$INSTALL_MIHOMO" = 1 ]; then install -m 0755 "$WORK/release/mihomo" "$MIHOMO_BIN" cp "$WORK/release/MIHOMO_VERSION" "$BASE/MIHOMO_VERSION" for member in mihomo.env LICENSE MIHOMO_LICENSE MIHOMO_SOURCE; do if [ -f "$WORK/release/$member" ]; then install -m 0644 "$WORK/release/$member" "$BASE/$member"; fi done fi # Management entry must match the release script (never leave a stale /usr/local/bin/3m-ui). install -m 0755 "$WORK/release/3m-ui" "$ENTRY" # Second copy from BASE so ENTRY cannot drift if install is interrupted mid-write. install -m 0755 "$BASE/3m-ui" "$ENTRY" say "Installed management entry: $ENTRY" for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui 3m-ui-bin; do [ -x "$BASE/$member" ] || err "Not executable after install: $BASE/$member" done [ -x "$ENTRY" ] || err "Not executable after install: $ENTRY" printf '%s\n' "$TAG" > "$BASE/VERSION" printf '%s\n' static > "$BASE/BUILD_MODE" printf '%s\n' "$REPO" > "$BASE/REPOSITORY" printf '%s\n' "$CHANNEL" > "$BASE/CHANNEL" printf '%s\n' "$DATA_DIR" > "$BASE/DATA_DIRECTORY" # Existing YAML/DB settings are preserved by init, including external core paths. (cd "$DATA_DIR" && \ GOMEMLIMIT="${PANEL_GOMEMLIMIT}" GOGC="${PANEL_GOGC}" \ THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" \ THREE_M_UI_MIHOMO_BINARY="${THREE_M_UI_MIHOMO_BINARY:-$MIHOMO_BIN}" "$APP_BIN" init) write_service service_action start wait_healthy TRANSACTION=0 STOPPED=0 say "3m-ui $TAG is ready ($REPO, channel $CHANNEL)." say 'New installations print a one-time administrator password above. Use 3m-ui reset-admin if it is lost.' say 'If you ever lock yourself out of the web UI (wrong port / SSL / listen address), SSH in and run: 3m-ui reset-config --panel --yes' say "Configuration: $CONFIG_FILE" # The panel hides behind a random base path generated on first run, so print # the full URL here: a fresh install would otherwise end with the operator # hitting a 404 at their own bare domain. panel_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit } }') || panel_ip= [ -n "$panel_ip" ] || panel_ip='' panel_port=$(awk ' /^[^[:space:]#]/ { inblk = (/^server:/) } inblk && $1 == "port:" { sub(/^[^:]+:[[:space:]]*/, ""); print; exit } ' "$CONFIG_FILE" 2>/dev/null) || panel_port= [ -n "$panel_port" ] || panel_port=8080 panel_path=$(awk ' /^[^[:space:]#]/ { inblk = (/^server:/) } inblk && $1 == "web_path:" { sub(/^[^:]+:[[:space:]]*/, ""); print; exit } ' "$CONFIG_FILE" 2>/dev/null | tr -d '" ') || panel_path= panel_path=${panel_path%/} case "$panel_path" in '' | /) panel_path='/' ;; /*) panel_path="$panel_path/" ;; *) panel_path="/$panel_path/" ;; esac # Port 443 means the panel terminates TLS itself (80 redirects to it), so # print the URL you can actually open instead of an http:// one on :443. if [ "$panel_port" = 443 ]; then say "Panel URL: https://${panel_ip}${panel_path}" else say "Panel URL: http://${panel_ip}:${panel_port}${panel_path}" fi say "Management: 3m-ui Backup: $SNAPSHOT" } main "$@"