Files
風雪 c6cf289228 fix(scripts): make SSH menu config access indentation-agnostic (#101)
* fix(scripts): make SSH menu config access indentation-agnostic

Running the panel built from source exposed two defects that static review
could not see, because both depend on what the Go binary actually writes to
config.yaml.

The Go binary rewrites config.yaml through a YAML marshaller. `init` emits
two-space indentation, but any later rewrite — notably `reset-config`, the SSH
rescue path — emits four, and re-sorts the keys. Every shell helper matched on
a hardcoded two-space prefix, so the menu and the binary disagreed about the
file's shape.

1. Duplicate keys after an SSH rescue

After `reset-config`, `yaml_get '  port:'` matched nothing, so the menu showed
an empty current port. `yaml_set` then took its insert branch and appended a
two-space `port:` line alongside the marshaller's four-space one:

    server:
        mode: release
        port: 8080
        sub_port: 0
      port: 9090          <- inserted, duplicate

The menu then reported "Configured port: 9090" and restarted the service. The
failure mode matters more than the diff: SSH rescue is what an operator reaches
for when the panel is unreachable, which is exactly when the menu has to work.

2. `3m-ui config port <port>` left the panel unable to boot

That command hardcoded a two-space indent, producing mixed indentation inside
one block:

    server:
        mode: release
      port: 19090
        public_url: ""

The binary then refuses to start at all:

    failed to decode config YAML: yaml: line 12: did not find expected key

This command is documented in the README, so following the documentation
bricks the panel.

Both are fixed the same way: key lookup is now indentation-agnostic (matched
on $1 rather than a fixed prefix, which also removes any ambiguity between
`port:` and `sub_port:`), replacement preserves the indentation of the line it
rewrites, and insertion adopts the block's existing indent. Callers pass
"port:" instead of "  port:".

Also fixed: menu item 17 (Healthcheck) produced no output at all, because the
binary's healthcheck exits 0 silently on success. It now reports pass/fail
based on the exit status.

Verified against a panel compiled from source and running on loopback:
rescue -> change port -> restart yields a single `port:` key with consistent
indentation, config stays 0600, the panel serves HTTP 200, and the healthcheck
item reports OK. The regression script (scripts regression, 16 cases) covers
both defects by reproducing them against the old implementation first.

* fix(scripts): make SSH menu config access indentation-agnostic

Running the panel built from source exposed two defects that static review
could not see, because both depend on what the Go binary actually writes to
config.yaml.

The Go binary rewrites config.yaml through a YAML marshaller. `init` emits
two-space indentation, but any later rewrite — notably `reset-config`, the SSH
rescue path — emits four, and re-sorts the keys. Every shell helper matched on
a hardcoded two-space prefix, so the menu and the binary disagreed about the
file's shape.

1. Duplicate keys after an SSH rescue

After `reset-config`, `yaml_get '  port:'` matched nothing, so the menu showed
an empty current port. `yaml_set` then took its insert branch and appended a
two-space `port:` line alongside the marshaller's four-space one:

    server:
        mode: release
        port: 8080
        sub_port: 0
      port: 9090          <- inserted, duplicate

The menu then reported "Configured port: 9090" and restarted the service. The
failure mode matters more than the diff: SSH rescue is what an operator reaches
for when the panel is unreachable, which is exactly when the menu has to work.

2. `3m-ui config port <port>` left the panel unable to boot

That command hardcoded a two-space indent, producing mixed indentation inside
one block:

    server:
        mode: release
      port: 19090
        public_url: ""

The binary then refuses to start at all:

    failed to decode config YAML: yaml: line 12: did not find expected key

This command is documented in the README, so following the documentation
bricks the panel.

Both are fixed the same way: key lookup is now indentation-agnostic (matched
on $1 rather than a fixed prefix, which also removes any ambiguity between
`port:` and `sub_port:`), replacement preserves the indentation of the line it
rewrites, and insertion adopts the block's existing indent. Callers pass
"port:" instead of "  port:".

Also fixed: menu item 17 (Healthcheck) produced no output at all, because the
binary's healthcheck exits 0 silently on success. It now reports pass/fail
based on the exit status.

Verified against a panel compiled from source and running on loopback:
rescue -> change port -> restart yields a single `port:` key with consistent
indentation, config stays 0600, the panel serves HTTP 200, and the healthcheck
item reports OK. The regression script (scripts regression, 16 cases) covers
both defects by reproducing them against the old implementation first.

* test(scripts): add regression coverage for the SSH menu config helpers

The existing lifecycle tests exercise install.sh end to end but never touch
the menu's config read/write path, which is where both defects in the previous
commit lived. This adds 16 cases.

Each case runs the pre-fix implementation and the fixed one against the same
input and asserts both directions: the old one must reproduce the defect, the
new one must not exhibit it. A case whose "before" assertion passes is scored
FAIL — otherwise the suite could go green without ever having exercised the
bug.

Covers: block-scoped writes, 0600 preservation, `config port` with a missing
argument, the self-referencing entrypoint loop, insertion placement, missing
`server:` block, indentation-agnostic lookup, and indentation consistency.

The suite is self-contained: it needs no installed panel, writes only to a
temp dir, and the one case that touches /usr/local/bin/3m-ui skips itself if
that path already exists.
2026-10-09 00:39:10 +08:00

281 lines
9.8 KiB
Bash
Executable File

#!/bin/sh
# 3m-ui unified management logic
# The command entrypoint is /usr/local/bin/3m-ui.
# The actual Go application is /usr/local/lib/3m-ui/3m-ui-bin.
set -eu
APP_NAME="3m-ui"
ROOT="${THREE_M_UI_ROOT:-}"
BASE="$ROOT/usr/local/lib/3m-ui"
APP_BIN="$BASE/3m-ui-bin"
VERSION_FILE="$BASE/VERSION"
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}"
CONFIG_DIR="$ROOT/etc/3m-ui"
CONFIG_FILE="$CONFIG_DIR/config.yaml"
SERVICE="3m-ui"
say() { printf '%s\n' "$*"; }
err() { say "Error: $*" >&2; exit 1; }
need_root() {
[ "$(id -u)" = 0 ] || err "Please run as root."
}
init_system() {
if command -v systemctl >/dev/null 2>&1 && [ -d "$ROOT/run/systemd/system" ]; then
printf '%s' systemd
elif command -v rc-service >/dev/null 2>&1; then
printf '%s' openrc
else
printf '%s' unsupported
fi
}
service_action() {
action="$1"
case "$(init_system)" in
systemd) systemctl "$action" "$SERVICE" ;;
openrc) rc-service "$SERVICE" "$action" ;;
*) err "No supported init system found." ;;
esac
}
status() {
case "$(init_system)" in
systemd) systemctl --no-pager --full status "$SERVICE" || true ;;
openrc) rc-service "$SERVICE" status || true ;;
*) say "init: unsupported" ;;
esac
say ""
if [ -x "$APP_BIN" ]; then
say "Application: $APP_BIN"
if ! "$APP_BIN" --version 2>/dev/null; then
if [ -s "$VERSION_FILE" ]; then
say "Version: $(cat "$VERSION_FILE")"
else
say "Version: unavailable (installed binary does not expose --version)"
fi
fi
else
say "Application binary not found: $APP_BIN"
fi
}
logs() {
case "$(init_system)" in
systemd) journalctl -u "$SERVICE" -n 100 --no-pager ;;
openrc) tail -n 100 "$DATA_DIR/logs/3m-ui.log" 2>/dev/null || tail -n 100 "/var/log/3m-ui/3m-ui.log" 2>/dev/null || say "No log file found." ;;
*) say "No supported log backend found." ;;
esac
}
version() {
[ -x "$APP_BIN" ] || err "3m-ui application is not installed."
if "$APP_BIN" --version 2>/dev/null; then
return 0
fi
if [ -s "$VERSION_FILE" ]; then
say "3m-ui $(cat "$VERSION_FILE")"
return 0
fi
err "Unable to determine installed version."
}
uninstall() {
"$BASE/uninstall.sh" "$@"
}
config() {
action="${1:-show}"
case "$action" in
show)
if [ -f "$CONFIG_FILE" ]; then
say "Config file: $CONFIG_FILE"
say "---"
cat "$CONFIG_FILE"
else
err "Config file not found: $CONFIG_FILE"
fi
;;
port)
# ${2:-} — under `set -u` a bare "$2" aborts with "parameter not set"
# before the usage message below can ever be printed.
new_port="${2:-}"
case "$new_port" in
''|*[!0-9]*) err "Usage: 3m-ui config port <1-65535>" ;;
esac
[ "$new_port" -ge 1 ] 2>/dev/null && [ "$new_port" -le 65535 ] 2>/dev/null || err "Port must be 1-65535."
[ -f "$CONFIG_FILE" ] || err "Config file not found: $CONFIG_FILE"
# Read current port, scoped to the server: block and indentation-agnostic
old_port=$(awk '
/^[^[:space:]#]/ { inblk = (/^server:/) }
inblk && $1 == "port:" { sub(/^[^:]+:[[:space:]]*/, ""); print; exit }
' "$CONFIG_FILE" 2>/dev/null)
old_port="${old_port:-8080}"
if [ "$old_port" = "$new_port" ]; then
say "Port is already $new_port — no change needed."
return 0
fi
# Rewrite only the server: block's port, preserving its indentation.
# sed -i "s/^ port: .*/ port: N/"
# rewrites EVERY indented "port:" line, silently clobbering unrelated
# listener ports that happen to share the same indentation.
#
# A hardcoded two-space indent is just as fatal: the Go binary rewrites
# config.yaml through a YAML marshaller that uses four spaces, so
# emitting " port:" next to " mode:" yields mixed indentation and
# the panel then refuses to boot with
# "failed to decode config YAML: did not find expected key".
tmp=$(mktemp "${CONFIG_FILE}.tmp.XXXXXX") || return 1
awk -v p="$new_port" '
/^[^[:space:]#]/ { inblk = (/^server:/) }
inblk && $1 == "port:" && !done {
match($0, /^[[:space:]]*/)
print substr($0, 1, RLENGTH) "port: " p
done = 1
next
}
{ print }
END { if (!done) exit 3 }
' "$CONFIG_FILE" > "$tmp" || {
rm -f "$tmp"
err "Could not locate the server port in $CONFIG_FILE (no change made)."
}
# Write back through the original inode so owner and mode (0600 — the
# config holds JWT and credential secrets) are preserved.
cat "$tmp" > "$CONFIG_FILE" || { rm -f "$tmp"; return 1; }
rm -f "$tmp"
say "Port changed: $old_port → $new_port"
say "Restarting 3m-ui to apply..."
service_action restart
sleep 1
if service_is_active; then
say "✓ 3m-ui restarted. Configured port: $new_port (a separate HTTPS listener follows the panel TLS settings)."
else
say "✗ 3m-ui failed to start. Check logs: 3m-ui logs"
fi
;;
*)
err "Unknown config action: $action. Use 'show' or 'port <number>'."
;;
esac
}
service_is_active() {
case "$(init_system)" in
systemd) systemctl is-active --quiet "$SERVICE" ;;
openrc) rc-service "$SERVICE" status >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
reset_config() {
# --help / -h should NOT stop/restart the panel — it's a read-only help
# invocation. Detect it and pass through directly.
for a in "$@"; do
case "$a" in
--help|-h)
(cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" "$APP_BIN" reset-config "$@")
return $?
;;
esac
done
# reset-config modifies config.yaml + DB rows the running panel may have
# cached. The Go binary refuses to run reset-config while the panel is up
# (it might race writes), so stop the service first, run reset, then restart.
say "Stopping 3m-ui service (reset-config requires the panel to be stopped)..."
service_action stop >/dev/null 2>&1 || true
# Pass through all flags (--panel / --access / --public / --all / --yes / -y)
# to the Go binary's reset-config subcommand.
(cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" "$APP_BIN" reset-config "$@")
rc=$?
say ""
say "Restarting 3m-ui service..."
service_action start || true
if [ $rc -ne 0 ]; then
err "reset-config failed (exit $rc). Check the message above; the panel was restarted with the old config."
fi
}
usage() {
cat <<EOF
Usage: 3m-ui <command>
Commands:
status Show service and application status
start Start 3m-ui
restart Restart 3m-ui
stop Stop 3m-ui
logs Show recent service logs
version Show installed 3m-ui version
config Show or edit config (see below)
uninstall Remove 3m-ui but preserve data/config (use --purge to delete)
backup Stop service, snapshot data/config/binaries, resume service
restore Restore a complete snapshot: restore /path/snapshot.tar.gz
reset-admin Generate and display a new random administrator password
reset-config Reset panel config via SSH rescue (see below)
healthcheck Probe the actual configured HTTP/HTTPS panel
help Show this help
Config sub-commands:
3m-ui config show Display current config.yaml
3m-ui config port <1-65535> Change panel port and restart
Reset-config sub-commands (SSH rescue when web UI is unreachable):
3m-ui reset-config Reset panel listen/SSL (default scope)
3m-ui reset-config --panel Clear server.listen + disable SSL
3m-ui reset-config --access Clear listener public_host/SNI/ALPN
3m-ui reset-config --public Clear server.public_url
3m-ui reset-config --all Apply all three scopes
3m-ui reset-config --panel --yes Skip y/N prompt (scripted rescue)
3m-ui reset-config --help Show full reset-config help
Run '3m-ui' without arguments to open the interactive management menu.
EOF
}
main() {
need_root
cmd=${1:-}
case "$cmd" in
status) status ;;
start) service_action start ;;
restart) service_action restart ;;
stop) service_action stop ;;
logs) logs ;;
version) version ;;
config) shift; config "$@" ;;
uninstall) shift; uninstall "$@" ;;
backup) shift; "$BASE/install.sh" --backup "$@" ;;
restore) shift; "$BASE/install.sh" --restore "$@" ;;
init|reset-admin|healthcheck) (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" "$APP_BIN" "$cmd") ;;
reset-config) shift; reset_config "$@" ;;
help|-h|--help) usage ;;
'')
entry=/usr/local/bin/3m-ui
resolved=$(readlink -f "$entry" 2>/dev/null || true)
self=$(readlink -f "$0" 2>/dev/null || true)
if [ -x "$entry" ] && [ "$resolved" = "$APP_BIN" ]; then
err "Invalid installation: management entrypoint points to the application binary. Re-run the latest installer to migrate it."
fi
# Direct self-reference: exec would restart this exact script with no
# args, land here again, and fork forever.
if [ -n "$resolved" ] && [ "$resolved" = "$self" ]; then
err "Invalid installation: $entry resolves to this script ($self). Re-run the latest installer to repair the entrypoint."
fi
# Indirect self-reference (A -> B -> A ...): the marker survives exec.
if [ -n "${THREE_M_UI_MENU_REEXEC:-}" ]; then
err "Detected a re-exec loop through $entry. Re-run the latest installer to repair the entrypoint."
fi
THREE_M_UI_MENU_REEXEC=1 exec "$entry"
;;
*) err "Unknown command: $cmd" ;;
esac
}
main "$@"