diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d5537f2..f99e54fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,6 +263,9 @@ jobs: - name: Test installer transactions and release channels run: dash deploy/test-install-upgrade.sh + - name: Test installer with BusyBox ash and awk + run: docker run --rm -v "$PWD:/work:ro" -w /work busybox:1.37 sh deploy/test-install-upgrade.sh + - name: Test changelog extraction run: dash scripts/test-extract-changelog.sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 8cdd5287..c5517884 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl ### 2. 接入 Agent -以管理员登录 → **设置** → 生成一个一次性 **enrollment code**(单次使用,约 10 分钟后过期)。然后在每个节点上: +以管理员登录,进入 **服务器(Servers)→ 添加服务器(Add Server)**,复制生成的安装命令。其中的 Server 绑定注册凭据仅可使用一次,约 10 分钟后过期。然后在每个节点上运行: ```bash curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo sh -s -- agent --method binary \ @@ -85,7 +85,7 @@ password = "" # 留空自动生成 ```toml # /etc/serverbee/agent.toml server_url = "http://your-server:9527" -enrollment_code = "" # 来自设置页的一次性 code,仅用于首次注册 +enrollment_code = "" # 来自 Servers → Add Server 的一次性 code,仅用于首次注册 [collector] interval = 3 # 上报间隔(秒) @@ -130,7 +130,7 @@ make test # 前端测试 make cargo-clippy # Rust 代码检查 ``` -> `make dev-full` 启动带 HMR 的 Vite(`http://localhost:5173`),并代理 `/api/*` 到 `:9527` 的 Rust 服务端。在 **设置** 页生成一次性 enrollment code 即可接入开发用 Agent。 +> `make dev-full` 启动带 HMR 的 Vite(`http://localhost:5173`),并代理 `/api/*` 到 `:9527` 的 Rust 服务端。在 **Servers → Add Server** 生成一次性 enrollment code,即可接入开发用 Agent。 **技术栈:** Rust(Axum 0.8 · sea-orm · SQLite WAL)· React 19(Vite 7 · TanStack Router/Query · Bklit · shadcn/ui · Tailwind CSS v4)· Rust Agent(sysinfo · tokio-tungstenite)。 diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index da10d2ab..56c851b1 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -241,6 +241,8 @@ async fn main() -> anyhow::Result<()> { ); } Err(register::RegisterError::PermanentAuth(msg)) => { + // Stable installer acceptance contract. Keep this prefix in sync + // with deploy/install.sh::wait_for_agent_install. eprintln!( "Permanent registration failure: {msg}\n\ The enrollment code is invalid, expired, or already used. \ diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index bd55f967..d16ce453 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -147,6 +147,8 @@ impl Reporter { // intentionally ignored: capabilities are agent-owned // and already loaded into `capabilities` above. The // agent enforces purely on its local policy. + // Stable installer acceptance contract. Keep this prefix in sync + // with deploy/install.sh::wait_for_agent_install. tracing::info!( "Welcome from server {server_id}, interval={report_interval}s" ); diff --git a/deploy/install.sh b/deploy/install.sh index f73c5769..9f89153f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -21,6 +21,7 @@ esac # ─── Constants ──────────────────────────────────────────────────────────────── REPO="ZingerLittleBee/ServerBee" +INSTALLER_VERSION="1.0.0-beta.1" # Everything ServerBee installs lives under a single base directory for # unified management. The PATH-visible management CLI is the only exception. BASE_DIR="/opt/serverbee" @@ -32,6 +33,7 @@ DEFAULT_DOCKER_DIR="${BASE_DIR}" SNAP_DOCKER_DIR="/var/snap/docker/common/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" LANG_CACHE_FILE="${CONFIG_DIR}/.install-lang" +DOMAIN_CACHE_FILE="${CONFIG_DIR}/.install-domain" CLI_PATH="/usr/local/bin/serverbee" # Legacy FHS-split layout (pre-/opt). Kept only for one-time auto-migration. LEGACY_BIN_DIR="/usr/local/bin" @@ -53,8 +55,10 @@ LANG_CODE="${SERVERBEE_LANG:-}" YES=false PURGE=false SKIP_DNS_CHECK=false +NO_WAIT=false CONFIG_KEY="" CONFIG_VALUE="" +POSITIONAL_COUNT=0 MISSING_DEPS="" MANAGED_COMPONENTS="" UNMANAGED_COMPONENTS="" @@ -69,6 +73,11 @@ UPGRADE_HEALTH_ATTEMPTS=30 UPGRADE_STABILITY_CHECKS=10 DOCKER_UPGRADE_HEALTH_ATTEMPTS=90 DOCKER_UPGRADE_STABILITY_CHECKS=10 +INSTALL_SERVER_HEALTH_ATTEMPTS=30 +INSTALL_AGENT_ATTEMPTS=30 +AGENT_LOG_START_LINE=0 +AGENT_LOG_SINCE=0 +AGENT_INSTALL_PROOF="" # ─── Agent capability toggles ──────────────────────────────────────────────── # Keys MUST match the CapabilityKey strings in crates/common/src/constants.rs. @@ -155,6 +164,170 @@ sed_inplace() { sed "$_si_expr" "$_si_file" > "$_si_tmp" && mv "$_si_tmp" "$_si_file" } +validate_single_line_value() { + # Configuration formats below cannot represent control characters safely. + # Keep validation separate from rendering so rejected values never modify + # the destination file. + case "$1" in + *' +'*) return 1 ;; + esac + ! LC_ALL=C printf '%s' "$1" | grep '[[:cntrl:]]' >/dev/null 2>&1 +} + +atomic_replace_preserving_mode() { + # $1 = destination, $2 = generated replacement. Both temporary files live + # next to the destination so the final rename is atomic on one filesystem. + local destination generated staged + destination="$1" + generated="$2" + staged=$(mktemp "${destination}.tmp.XXXXXX") || return 1 + if ! cp -p "$destination" "$staged" \ + || ! cat "$generated" > "$staged" \ + || ! mv -f "$staged" "$destination"; then + rm -f "$staged" + return 1 + fi +} + +render_double_quoted_value() { + # TOML basic strings and YAML double-quoted scalars share the escaping we + # need for printable installer values: backslash and double quote. + SB_INSTALL_EDIT_VALUE="$1" + export SB_INSTALL_EDIT_VALUE + awk 'BEGIN { + value = ENVIRON["SB_INSTALL_EDIT_VALUE"] + for (i = 1; i <= length(value); i++) { + c = substr(value, i, 1) + if (c == "\\" || c == "\"") out = out "\\" + out = out c + } + printf "%s", out + }' + unset SB_INSTALL_EDIT_VALUE +} + +render_shell_double_quoted_value() { + SB_INSTALL_EDIT_VALUE="$1" + export SB_INSTALL_EDIT_VALUE + awk 'BEGIN { + value = ENVIRON["SB_INSTALL_EDIT_VALUE"] + for (i = 1; i <= length(value); i++) { + c = substr(value, i, 1) + if (c == "\\" || c == "\"" || c == "$" || c == "`") out = out "\\" + out = out c + } + printf "%s", out + }' + unset SB_INSTALL_EDIT_VALUE +} + +atomic_set_assignment_line() { + # $1 = file, $2 = plain key, $3 = rendered line, $4 = openrc|systemd + local file key replacement kind generated + file="$1"; key="$2"; replacement="$3"; kind="$4" + generated=$(mktemp "${file}.generated.XXXXXX") || return 1 + SB_INSTALL_EDIT_KEY="$key" + SB_INSTALL_EDIT_LINE="$replacement" + SB_INSTALL_EDIT_KIND="$kind" + export SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE SB_INSTALL_EDIT_KIND + awk ' + BEGIN { + key=ENVIRON["SB_INSTALL_EDIT_KEY"] + replacement=ENVIRON["SB_INSTALL_EDIT_LINE"] + kind=ENVIRON["SB_INSTALL_EDIT_KIND"] + written=0 + } + { + candidate=$0 + if (kind == "systemd") { + sub(/^[[:space:]]*Environment=/, "", candidate) + sub(/^"/, "", candidate) + } else { + sub(/^[[:space:]]*/, "", candidate) + } + if (index(candidate, key "=") == 1) { + if (!written) print replacement + written=1 + next + } + print + } + END { if (!written) print replacement } + ' "$file" > "$generated" + unset SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE SB_INSTALL_EDIT_KIND + if ! atomic_replace_preserving_mode "$file" "$generated"; then + rm -f "$generated" + return 1 + fi + rm -f "$generated" +} + +openrc_set_env() { + local file key value escaped + file="$1"; key="$2"; value="$3" + validate_single_line_value "$value" || return 1 + escaped=$(render_shell_double_quoted_value "$value") + atomic_set_assignment_line "$file" "$key" "${key}=\"${escaped}\"" openrc +} + +systemd_set_env() { + local file key value escaped + file="$1"; key="$2"; value="$3" + validate_single_line_value "$value" || return 1 + SB_INSTALL_EDIT_VALUE="$value" + export SB_INSTALL_EDIT_VALUE + escaped=$(awk 'BEGIN { + value = ENVIRON["SB_INSTALL_EDIT_VALUE"] + for (i = 1; i <= length(value); i++) { + c = substr(value, i, 1) + if (c == "\\" || c == "\"") out = out "\\" + if (c == "%") out = out "%" + out = out c + } + printf "%s", out + }') + unset SB_INSTALL_EDIT_VALUE + atomic_set_assignment_line "$file" "$key" "Environment=\"${key}=${escaped}\"" systemd +} + +redact_toml_file() { + awk ' + /^[[:space:]]*\[/ { + section = $0 + sub(/^[[:space:]]*\[/, "", section) + sub(/\][[:space:]]*$/, "", section) + } + /^[[:space:]]*[A-Za-z0-9_]+[[:space:]]*=/ { + key = $0 + sub(/^[[:space:]]*/, "", key) + sub(/[[:space:]]*=.*/, "", key) + full = (section == "" ? key : section "." key) + upper = toupper(full) + if (upper ~ /(PASSWORD|TOKEN|SECRET|ENROLLMENT_CODE)/) { + prefix = $0 + sub(/=.*/, "=", prefix) + print prefix " \"********\"" + next + } + } + { print } + ' "$1" +} + +redact_env_lines() { + awk ' + { + upper = toupper($0) + if (match(upper, /SERVERBEE_[A-Z0-9_]*(PASSWORD|TOKEN|SECRET|ENROLLMENT_CODE)[A-Z0-9_]*=/)) { + suffix = ($0 ~ /"[[:space:]]*$/ ? "\"" : "") + $0 = substr($0, 1, RSTART + RLENGTH - 1) "********" suffix + } + print + } + ' +} + sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 @@ -167,8 +340,13 @@ sha256_of() { fi } +prompt_tty_available() { + [ -r /dev/tty ] && [ -w /dev/tty ] || return 1 + (: /dev/null && (: >/dev/tty) 2>/dev/null +} + has_prompt_input() { - [ -t 0 ] || { [ -r /dev/tty ] && [ -w /dev/tty ]; } + [ -t 0 ] || prompt_tty_available } should_prompt() { @@ -184,7 +362,7 @@ prompt_read() { if [ -t 0 ]; then # shellcheck disable=SC2229 # The variable name is intentionally dynamic. read -r "$variable_name" - elif [ -r /dev/tty ] && [ -w /dev/tty ]; then + elif prompt_tty_available; then # shellcheck disable=SC2229 # The variable name is intentionally dynamic. read -r "$variable_name" < /dev/tty else @@ -357,6 +535,8 @@ tr_text() { email_label) [ "$_z" ] && echo "邮箱: " || echo "Email: " ;; result_server_ok) [ "$_z" ] && echo "ServerBee Server 安装成功!" || echo "ServerBee Server installed successfully!" ;; result_agent_ok) [ "$_z" ] && echo "ServerBee Agent 安装成功!" || echo "ServerBee Agent installed successfully!" ;; + result_server_unverified) [ "$_z" ] && echo "ServerBee Server 已安装,但未验证健康状态。" || echo "ServerBee Server installed, but health was not verified." ;; + result_agent_unverified) [ "$_z" ] && echo "ServerBee Agent 已安装,但未验证 Server 连接。" || echo "ServerBee Agent installed, but its Server connection was not verified." ;; lbl_dashboard) [ "$_z" ] && echo " 控制台:" || echo " Dashboard:" ;; lbl_username) [ "$_z" ] && echo " 用户名:" || echo " Username:" ;; lbl_password) [ "$_z" ] && echo " 密码:" || echo " Password:" ;; @@ -381,6 +561,7 @@ tr_text() { st_no_logs) [ "$_z" ] && echo " (无日志)" || echo " (no logs)" ;; st_server) echo " Server:" ;; st_dashboard) [ "$_z" ] && echo " 控制台:" || echo " Dashboard:" ;; + st_reverse_proxy) [ "$_z" ] && echo "位于反向代理之后(未记录配置的域名)" || echo "behind reverse proxy (configured domain unavailable)" ;; st_container) [ "$_z" ] && echo " 容器:" || echo " Container:" ;; st_stopped) [ "$_z" ] && echo "已停止" || echo "stopped" ;; st_image) [ "$_z" ] && echo " 镜像:" || echo " Image:" ;; @@ -630,42 +811,105 @@ migrate_legacy_layout() { # ─── Known subcommands ─────────────────────────────────────────────────────── is_known_command() { case "$1" in - install|uninstall|upgrade|status|start|stop|restart|config|env|domain) return 0 ;; + install|uninstall|upgrade|status|start|stop|restart|config|env|domain|help|version) return 0 ;; *) return 1 ;; esac } # ─── Argument parsing ───────────────────────────────────────────────────────── +option_requires_value() { + error "Option $1 requires a value. Run 'serverbee --help' for usage." +} + parse_args() { while [ $# -gt 0 ]; do case "$1" in - --method) METHOD="$2"; shift 2 ;; - --server-url) SERVER_URL="$2"; shift 2 ;; - --enrollment-code) ENROLLMENT_CODE="$2"; shift 2 ;; + --method) [ $# -ge 2 ] || option_requires_value "$1"; METHOD="$2"; shift 2 ;; + --server-url) [ $# -ge 2 ] || option_requires_value "$1"; SERVER_URL="$2"; shift 2 ;; + --enrollment-code) [ $# -ge 2 ] || option_requires_value "$1"; ENROLLMENT_CODE="$2"; shift 2 ;; --password) error "--password is no longer supported. ServerBee always generates a one-time first-run admin password; check the server logs after installation." ;; - --domain) DOMAIN="$2"; shift 2 ;; - --email) EMAIL="$2"; shift 2 ;; - --lang) LANG_CODE="$2"; normalize_lang; shift 2 ;; - --version) REQUESTED_VERSION="$2"; shift 2 ;; - --channel) RELEASE_CHANNEL="$2"; RELEASE_CHANNEL_USER_SPECIFIED=true; shift 2 ;; + --domain) [ $# -ge 2 ] || option_requires_value "$1"; DOMAIN="$2"; shift 2 ;; + --email) [ $# -ge 2 ] || option_requires_value "$1"; EMAIL="$2"; shift 2 ;; + --lang) [ $# -ge 2 ] || option_requires_value "$1"; LANG_CODE="$2"; normalize_lang; shift 2 ;; + --version) [ $# -ge 2 ] || option_requires_value "$1"; REQUESTED_VERSION="$2"; shift 2 ;; + --channel) [ $# -ge 2 ] || option_requires_value "$1"; RELEASE_CHANNEL="$2"; RELEASE_CHANNEL_USER_SPECIFIED=true; shift 2 ;; --skip-dns-check) SKIP_DNS_CHECK=true; shift ;; - --caps) set_caps_from_cli "$2"; shift 2 ;; + --caps) [ $# -ge 2 ] || option_requires_value "$1"; set_caps_from_cli "$2"; shift 2 ;; + --no-wait) NO_WAIT=true; shift ;; --purge) PURGE=true; shift ;; --yes|-y) YES=true; shift ;; -*) error "Unknown option: $1" ;; *) - if [ -z "$COMPONENT" ]; then - COMPONENT="$1" - elif [ -z "$CONFIG_KEY" ]; then - CONFIG_KEY="$1" - elif [ -z "$CONFIG_VALUE" ]; then - CONFIG_VALUE="$1" - fi + POSITIONAL_COUNT=$((POSITIONAL_COUNT + 1)) + case "$POSITIONAL_COUNT" in + 1) COMPONENT="$1" ;; + 2) CONFIG_KEY="$1" ;; + 3) CONFIG_VALUE="$1" ;; + *) error "Unexpected argument: $1" ;; + esac shift ;; esac done } +validate_parsed_args() { + case "$COMMAND" in + install|uninstall|upgrade|status|start|stop|restart) + [ "$POSITIONAL_COUNT" -le 1 ] || error "Unexpected argument: ${CONFIG_KEY}" + ;; + domain) + [ "$POSITIONAL_COUNT" -le 1 ] || error "Unexpected argument: ${CONFIG_KEY}" + ;; + config|env) + if [ "$COMPONENT" = set ]; then + [ "$POSITIONAL_COUNT" -le 3 ] || error "Too many arguments for serverbee ${COMMAND} set" + else + [ "$POSITIONAL_COUNT" -le 1 ] || error "Unexpected argument: ${CONFIG_KEY}" + fi + ;; + esac +} + +print_usage() { + local topic + topic="${1:-}" + case "$topic" in + install) + echo "Usage: serverbee install [--method binary|docker] [options]" + echo " serverbee [options]" + echo "Options: --version --channel --lang -y" + echo "Agent: --server-url --enrollment-code [--caps ] [--no-wait]" + echo "Server: --domain [--email
] [--skip-dns-check] [--no-wait]" + echo "Verification exits: 0=verified/skipped, 1=Server unhealthy, 75=Agent temporarily unverified, 78=Agent authentication rejected" + ;; + config) + echo "Usage: serverbee config [server|agent]" + echo " serverbee config set [-y]" + ;; + env) + echo "Usage: serverbee env" + echo " serverbee env set " + ;; + domain) + echo "Usage: serverbee domain setup --domain [--email
] [--skip-dns-check]" + ;; + uninstall) + echo "Usage: serverbee uninstall [--purge] [-y]" + ;; + upgrade) + echo "Usage: serverbee upgrade [server|agent] [--version ] [--channel ] [-y]" + ;; + start|stop|restart|status) + echo "Usage: serverbee ${topic} [server|agent]" + ;; + *) + echo "Usage: serverbee [options]" + echo "Commands: install, uninstall, upgrade, status, start, stop, restart, config, env, domain, version" + echo "Run 'serverbee --help' for command-specific usage." + ;; + esac +} + # ─── Platform detection ────────────────────────────────────────────────────── detect_os() { local os @@ -692,7 +936,7 @@ get_latest_version() { echo "$RESOLVED_VERSION" return fi - local tag + local tag releases_json if [ -n "$REQUESTED_VERSION" ]; then case "$REQUESTED_VERSION" in v*) tag="$REQUESTED_VERSION" ;; @@ -701,9 +945,11 @@ get_latest_version() { else case "$RELEASE_CHANNEL" in auto|stable|beta) - tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" \ + releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100") \ + || error "Failed to fetch release metadata from GitHub" + tag=$(printf '%s\n' "$releases_json" \ | awk -v channel="$RELEASE_CHANNEL" ' - BEGIN { fallback="" } + BEGIN { fallback=""; selected="" } /^ \{/ { in_release=1; tag=""; draft=0 } in_release && /"tag_name":/ { line=$0 @@ -717,16 +963,19 @@ get_latest_version() { sub(/^v/, "", version) is_prerelease=(version ~ /-/) if (tag != "" && !draft) { - if (channel == "stable" && !is_prerelease) { print tag; exit } - if (channel == "beta" && is_prerelease) { print tag; exit } + if (channel == "stable" && !is_prerelease && selected == "") selected=tag + if (channel == "beta" && is_prerelease && selected == "") selected=tag if (channel == "auto") { - if (!is_prerelease) { selected=1; print tag; exit } + if (!is_prerelease && selected == "") selected=tag if (fallback == "") fallback=tag } } in_release=0 } - END { if (channel == "auto" && !selected && fallback != "") print fallback } + END { + if (selected != "") print selected + else if (channel == "auto" && fallback != "") print fallback + } ') ;; *) error "Invalid release channel: ${RELEASE_CHANNEL} (expected auto, stable, or beta)" ;; @@ -1286,6 +1535,7 @@ compute_cap_compose_command() { args=$(compute_cap_cli_args) [ -z "$args" ] && return 0 printf ' command:\n' + printf ' - serverbee-agent\n' for token in $args; do printf ' - %s\n' "$token" done @@ -1470,6 +1720,67 @@ server_health_url() { printf 'http://%s:%s/healthz\n' "$host" "$port" } +configured_server_listen() { + local config_file + config_file="${CONFIG_DIR}/server.toml" + [ -f "$config_file" ] || return 1 + awk ' + /^[[:space:]]*\[server\][[:space:]]*$/ { in_server=1; next } + in_server && /^[[:space:]]*\[/ { exit } + in_server && /^[[:space:]]*listen[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/^[[:space:]]*"|"[[:space:]]*$/, "") + print + exit + } + ' "$config_file" +} + +server_dashboard_display() { + local method domain listen ip compose_file + method="$1" + domain="" + if [ -f "$DOMAIN_CACHE_FILE" ]; then + domain=$(head -n1 "$DOMAIN_CACHE_FILE" 2>/dev/null | tr -d '[:space:]') + fi + if [ -n "$domain" ]; then + printf 'https://%s\n' "$domain" + return 0 + fi + + if [ "$method" = binary ]; then + listen=$(configured_server_listen || true) + case "$listen" in + 127.0.0.1:*|localhost:*|\[::1\]:*) + tr_text st_reverse_proxy + return 0 + ;; + esac + elif [ "$method" = docker ]; then + compose_file="${DOCKER_DIR}/docker-compose.server.yml" + if [ -f "$compose_file" ] \ + && grep -Eq '127\.0\.0\.1:9527:9527|\[::1\]:9527:9527' "$compose_file"; then + tr_text st_reverse_proxy + return 0 + fi + fi + + ip=$(get_local_ip) + printf 'http://%s:9527\n' "$ip" +} + +write_domain_cache() { + local generated + mkdir -p "$CONFIG_DIR" + generated=$(mktemp "${DOMAIN_CACHE_FILE}.tmp.XXXXXX") || return 1 + if ! printf '%s\n' "$DOMAIN" > "$generated" \ + || ! chmod 600 "$generated" \ + || ! mv -f "$generated" "$DOMAIN_CACHE_FILE"; then + rm -f "$generated" + return 1 + fi +} + svc_health_check() { local component url component="$1" @@ -1478,6 +1789,264 @@ svc_health_check() { curl -fsS --max-time 2 "$url" >/dev/null 2>&1 } +server_install_health_check() { + if [ "$METHOD" = docker ]; then + [ "$(docker_container_state server)" = "running healthy" ] + else + [ "$(svc_is_active server)" = active ] && svc_health_check server + fi +} + +wait_for_server_install() { + local attempt + if [ "$METHOD" = docker ]; then + wait_for_docker_stability server + return + fi + attempt=0 + while [ "$attempt" -lt "$INSTALL_SERVER_HEALTH_ATTEMPTS" ]; do + server_install_health_check && return 0 + attempt=$((attempt + 1)) + [ "$attempt" -ge "$INSTALL_SERVER_HEALTH_ATTEMPTS" ] || sleep 1 + done + return 1 +} + +agent_install_logs() { + local invocation_id + if [ "$METHOD" = docker ]; then + docker compose -f "${DOCKER_DIR}/docker-compose.agent.yml" \ + logs --no-color --since "${AGENT_LOG_SINCE}" --tail 200 serverbee-agent 2>/dev/null || true + elif [ "$INIT" = systemd ]; then + invocation_id=$(systemctl show serverbee-agent --property=InvocationID --value 2>/dev/null || true) + if [ -n "$invocation_id" ]; then + journalctl "_SYSTEMD_INVOCATION_ID=${invocation_id}" --no-pager 2>/dev/null || true + else + journalctl -u serverbee-agent --since "@${AGENT_LOG_SINCE}" --no-pager 2>/dev/null || true + fi + elif [ "$INIT" = openrc ]; then + tail -n "+$((AGENT_LOG_START_LINE + 1))" "$(svc_log_path agent)" 2>/dev/null || true + else + svc_logs_tail agent 200 2>/dev/null || true + fi +} + +agent_install_exit_status() { + if [ "$METHOD" = docker ]; then + docker inspect --format '{{.State.ExitCode}}' serverbee-agent 2>/dev/null || true + elif [ "$INIT" = systemd ]; then + systemctl show serverbee-agent --property=ExecMainStatus --value 2>/dev/null || true + fi +} + +agent_install_pid() { + local process_dir executable + if [ "$METHOD" = docker ]; then + docker inspect --format '{{.State.Pid}}' serverbee-agent 2>/dev/null || true + elif [ "$INIT" = systemd ]; then + systemctl show serverbee-agent --property=MainPID --value 2>/dev/null || true + elif [ "$INIT" = openrc ]; then + # supervise-daemon's pidfile identifies the supervisor, not its Agent + # child. Resolve the exact executable from procfs instead. + for process_dir in /proc/[0-9]*; do + [ -r "${process_dir}/cmdline" ] || continue + executable=$(tr '\000' '\n' < "${process_dir}/cmdline" 2>/dev/null | head -n1) + if [ "$executable" = "${INSTALL_DIR}/serverbee-agent" ]; then + printf '%s\n' "${process_dir##*/}" + return 0 + fi + done + fi +} + +agent_server_endpoint() { + local url scheme authority host port + url="$SERVER_URL" + scheme="${url%%://*}" + [ "$scheme" != "$url" ] || return 1 + authority="${url#*://}" + authority="${authority%%/*}" + authority="${authority%%\?*}" + case "$authority" in + \[*\]:*) + host="${authority%%]*}" + host="${host#[}" + port="${authority##*:}" + ;; + \[*\]) + host="${authority#[}" + host="${host%]}" + port="" + ;; + *:*) + host="${authority%:*}" + port="${authority##*:}" + ;; + *) + host="$authority" + port="" + ;; + esac + if [ -z "$port" ]; then + case "$scheme" in + https|wss) port=443 ;; + http|ws) port=80 ;; + *) return 1 ;; + esac + fi + case "$port" in + ''|*[!0-9]*) return 1 ;; + esac + [ -n "$host" ] || return 1 + printf '%s %s\n' "$host" "$port" +} + +agent_connection_established() { + # Compatibility proof for releases or logging configurations that suppress + # the INFO-level Welcome line. Match the Agent process itself to the + # resolved Server endpoint, not merely any connection on the host. + local pid endpoint host port addresses + command -v ss >/dev/null 2>&1 || return 1 + pid=$(agent_install_pid) + case "$pid" in + ''|0|*[!0-9]*) return 1 ;; + esac + endpoint=$(agent_server_endpoint) || return 1 + host="${endpoint% *}" + port="${endpoint##* }" + if printf '%s' "$host" | grep -Eq '^[0-9]+(\.[0-9]+){3}$|:'; then + addresses="$host" + elif command -v getent >/dev/null 2>&1; then + addresses=$(getent ahosts "$host" 2>/dev/null | awk '{ print $1 }' | sort -u) + else + return 1 + fi + [ -n "$addresses" ] || return 1 + + ss -Htnp state established 2>/dev/null | awk -v pid="$pid" -v port="$port" -v addresses="$addresses" ' + BEGIN { + count = split(addresses, address, "\n") + wanted = "pid=" pid "," + } + index($0, wanted) { + peer = $4 + if (substr(peer, 1, 1) == "[") { + sub(/^\[/, "", peer) + sub(/\]:[0-9]+$/, "", peer) + } else { + sub(":[0-9]+$", "", peer) + } + if ($4 !~ (":" port "$") ) next + for (i = 1; i <= count; i++) { + if (peer == address[i]) { + print $3 "|" $4 + found = 1 + exit + } + } + } + END { exit(found ? 0 : 1) } + ' +} + +wait_for_agent_install() { + local attempt logs exit_status connection_stable connection_id current_connection + attempt=0 + connection_stable=0 + connection_id="" + AGENT_INSTALL_PROOF="" + while [ "$attempt" -lt "$INSTALL_AGENT_ATTEMPTS" ]; do + logs=$(agent_install_logs) + if printf '%s\n' "$logs" | grep -Fq 'Welcome from server '; then + AGENT_INSTALL_PROOF=welcome + return 0 + fi + if printf '%s\n' "$logs" | grep -Fq 'Permanent registration failure:'; then + return 78 + fi + exit_status=$(agent_install_exit_status) + [ "$exit_status" != 78 ] || return 78 + if current_connection=$(agent_connection_established); then + if [ "$current_connection" = "$connection_id" ]; then + connection_stable=$((connection_stable + 1)) + else + connection_id="$current_connection" + connection_stable=1 + fi + # Three observations span roughly two seconds and must identify the + # same TCP four-tuple, so separate short retries cannot look stable. + if [ "$connection_stable" -ge 3 ]; then + AGENT_INSTALL_PROOF=connection + return 0 + fi + else + connection_stable=0 + connection_id="" + fi + attempt=$((attempt + 1)) + [ "$attempt" -ge "$INSTALL_AGENT_ATTEMPTS" ] || sleep 1 + done + return 75 +} + +verify_server_install_or_exit() { + local logs + if [ "$METHOD" != docker ] && [ "$INIT" = none ]; then + NO_WAIT=true + warn "Server service was not started because no init manager was found; health verification was skipped." + return 0 + fi + if [ "$NO_WAIT" = true ]; then + warn "Server was installed, but health verification was skipped (--no-wait)." + return 0 + fi + if wait_for_server_install; then + info "Server health check passed." + return 0 + fi + if [ "$METHOD" = docker ]; then + logs=$(docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" logs --tail 20 2>/dev/null || true) + else + logs=$(svc_logs_tail server 20 2>/dev/null || true) + fi + [ -z "$logs" ] || warn "Recent serverbee-server logs:\n${logs}" + error "Server was installed, but /healthz did not become ready. The installation was kept for troubleshooting." +} + +verify_agent_install_or_exit() { + local result logs + if [ "$METHOD" != docker ] && [ "$INIT" = none ]; then + NO_WAIT=true + warn "Agent service was not started because no init manager was found; Server connection verification was skipped." + return 0 + fi + if [ "$NO_WAIT" = true ]; then + warn "Agent was installed, but Server enrollment was not verified (--no-wait). It will keep retrying in the background." + return 0 + fi + if wait_for_agent_install; then + if [ "$AGENT_INSTALL_PROOF" = welcome ]; then + info "Agent connected and received the Server welcome message." + else + info "Agent established a persistent connection to the Server endpoint." + fi + return 0 + else + result=$? + fi + logs=$(agent_install_logs) + [ -z "$logs" ] || warn "Recent serverbee-agent logs:\n${logs}" + if [ "$result" -eq 78 ]; then + if [ "$METHOD" = docker ]; then + docker compose -f "${DOCKER_DIR}/docker-compose.agent.yml" stop serverbee-agent >/dev/null 2>&1 || true + fi + printf '%b\n' "${RED}[ERROR]${NC} Agent authentication was permanently rejected. The installation was kept. Generate a fresh code in Servers → Add Server, update enrollment_code, then restart the Agent." >&2 + exit 78 + fi + warn "Agent was installed but did not connect within ${INSTALL_AGENT_ATTEMPTS}s. It will keep retrying in the background. Check 'serverbee status' and the Agent logs." + exit 75 +} + svc_restart_count() { case "$INIT" in systemd) systemctl show "serverbee-$1" --property=NRestarts --value 2>/dev/null || true ;; @@ -1514,18 +2083,15 @@ ROT } svc_write_env_file() { - # $1 = component $2 = KEY=VALUE line (optional) - local f k + # $1 = component, $2 = key (optional), $3 = value (optional) + local f f=$(svc_env_path "$1") mkdir -p "$CONFIG_DIR" - [ -f "$f" ] || : > "$f" - [ -n "${2:-}" ] || return 0 - k=${2%%=*} - if grep -q "^${k}=" "$f" 2>/dev/null; then - sed_inplace "s|^${k}=.*|$2|" "$f" - else - printf '%s\n' "$2" >> "$f" + if [ ! -f "$f" ]; then + (umask 077; : > "$f") || return 1 fi + [ -n "${2:-}" ] || return 0 + openrc_set_env "$f" "$2" "${3:-}" } create_systemd_unit_server() { @@ -1663,7 +2229,7 @@ svc_install_server() { info "Server service started and enabled" ;; openrc) - svc_write_env_file server "SERVERBEE_SERVER__DATA_DIR=${DATA_DIR}" + svc_write_env_file server SERVERBEE_SERVER__DATA_DIR "$DATA_DIR" create_openrc_service_server rc-update add serverbee-server default >/dev/null 2>&1 || true rc-service serverbee-server restart @@ -1686,7 +2252,7 @@ svc_install_agent() { info "Agent service started and enabled" ;; openrc) - svc_write_env_file agent "" + svc_write_env_file agent create_openrc_service_agent "$1" rc-update add serverbee-agent default >/dev/null 2>&1 || true rc-service serverbee-agent restart @@ -1758,6 +2324,7 @@ TOML install_cli "$version" meta_write "server" "binary" "$version" + verify_server_install_or_exit print_server_result } @@ -1786,7 +2353,7 @@ install_binary_agent() { # Generate agent.toml, or refresh enrollment fields if it already exists so # the recover flow (paste a fresh --enrollment-code) re-registers cleanly. if [ ! -f "${CONFIG_DIR}/agent.toml" ]; then - cat > "${CONFIG_DIR}/agent.toml" << TOML + (umask 077; cat > "${CONFIG_DIR}/agent.toml") << TOML server_url = "${SERVER_URL}" enrollment_code = "${ENROLLMENT_CODE}" @@ -1801,13 +2368,20 @@ TOML toml_set "${CONFIG_DIR}/agent.toml" "enrollment_code" "${ENROLLMENT_CODE}" toml_set "${CONFIG_DIR}/agent.toml" "token" "" fi + chmod 600 "${CONFIG_DIR}/agent.toml" \ + || error "Could not secure ${CONFIG_DIR}/agent.toml" ensure_caps_initialized cap_args=$(compute_cap_cli_args) + AGENT_LOG_SINCE=$(date +%s) + if [ "$INIT" = openrc ] && [ -f "$(svc_log_path agent)" ]; then + AGENT_LOG_START_LINE=$(wc -l < "$(svc_log_path agent)") + fi svc_install_agent "$cap_args" install_cli "$version" meta_write "agent" "binary" "$version" + verify_agent_install_or_exit print_agent_result } @@ -1860,16 +2434,55 @@ volumes: YAML info "Generated ${DOCKER_DIR}/docker-compose.server.yml" + docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" config -q \ + || error "Generated Server Compose file is invalid: ${DOCKER_DIR}/docker-compose.server.yml" docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" up -d info "Server container started" install_cli "$version" meta_write "server" "docker" "$version" + verify_server_install_or_exit print_server_result } +rollback_docker_agent_install() { + local compose_file compose_backup config_file config_backup config_created compose_restored + compose_file="$1" + compose_backup="$2" + config_file="$3" + config_backup="$4" + config_created="$5" + compose_restored=false + + if [ -f "$compose_backup" ]; then + if mv -f "$compose_backup" "$compose_file"; then + compose_restored=true + else + warn "Could not restore the previous Agent Compose file: ${compose_backup}" + fi + else + docker compose -f "$compose_file" down --remove-orphans >/dev/null 2>&1 || true + rm -f "$compose_file" + fi + if [ -f "$config_backup" ]; then + if ! mv -f "$config_backup" "$config_file"; then + warn "Could not restore the previous Agent config: ${config_backup}" + fi + elif [ "$config_created" = true ]; then + rm -f "$config_file" + fi + if [ "$compose_restored" = true ]; then + if docker compose -f "$compose_file" up -d; then + info "Previous Docker Agent installation restored" + else + warn "The previous Agent files were restored, but its container could not be started" + fi + fi +} + install_docker_agent() { - local version image_tag conf_dir cap_command_block + local version image_tag conf_dir cap_command_block config_created + local compose_file compose_backup config_file config_backup check_docker check_unmanaged_container "agent" @@ -1877,10 +2490,36 @@ install_docker_agent() { image_tag=$(docker_image_tag "$version") conf_dir="$(docker_conf_dir)" - mkdir -p "$conf_dir" + mkdir -p "$conf_dir" "$DOCKER_DIR" + config_created=false + config_file="${conf_dir}/agent.toml" + config_backup="${config_file}.install-rollback" + compose_file="${DOCKER_DIR}/docker-compose.agent.yml" + compose_backup="${compose_file}.install-rollback" + if [ -e "$config_backup" ] || [ -L "$config_backup" ]; then + error "Stale install rollback found: ${config_backup}. Restore or remove it before installing." + fi + if [ -e "$compose_backup" ] || [ -L "$compose_backup" ]; then + error "Stale install rollback found: ${compose_backup}. Restore or remove it before installing." + fi + if [ -f "$config_file" ]; then + cp -p "$config_file" "$config_backup" \ + || error "Could not back up existing Agent config: ${config_file}" + if ! chmod 600 "$config_backup"; then + rm -f "$config_backup" + error "Could not secure Agent config backup: ${config_backup}" + fi + fi + if [ -f "$compose_file" ]; then + cp -p "$compose_file" "$compose_backup" || { + rm -f "$config_backup" + error "Could not back up existing Compose file: ${compose_file}" + } + fi - if [ ! -f "${conf_dir}/agent.toml" ]; then - cat > "${conf_dir}/agent.toml" << TOML + if [ ! -f "$config_file" ]; then + config_created=true + if ! (umask 077; cat > "$config_file") << TOML server_url = "${SERVER_URL}" enrollment_code = "${ENROLLMENT_CODE}" @@ -1888,19 +2527,31 @@ enrollment_code = "${ENROLLMENT_CODE}" interval = 3 enable_temperature = true TOML - info "Created ${conf_dir}/agent.toml" + then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Could not create ${config_file}" + fi + info "Created ${config_file}" else - info "${conf_dir}/agent.toml exists — refreshing server_url, enrollment_code, clearing token" - toml_set "${conf_dir}/agent.toml" "server_url" "${SERVER_URL}" - toml_set "${conf_dir}/agent.toml" "enrollment_code" "${ENROLLMENT_CODE}" - toml_set "${conf_dir}/agent.toml" "token" "" + info "${config_file} exists — refreshing server_url, enrollment_code, clearing token" + if ! toml_set "$config_file" "server_url" "$SERVER_URL" \ + || ! toml_set "$config_file" "enrollment_code" "$ENROLLMENT_CODE" \ + || ! toml_set "$config_file" "token" ""; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Could not refresh ${config_file}" + fi + fi + if ! chmod 600 "$config_file"; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Could not secure ${config_file}" fi - - mkdir -p "$DOCKER_DIR" ensure_caps_initialized - cat > "${DOCKER_DIR}/docker-compose.agent.yml" << YAML + if ! cat > "$compose_file" << YAML services: serverbee-agent: image: ghcr.io/zingerlittlebee/serverbee-agent:${image_tag} @@ -1915,18 +2566,44 @@ services: - ${conf_dir}:/etc/serverbee restart: unless-stopped YAML + then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Could not generate ${compose_file}" + fi cap_command_block=$(compute_cap_compose_command) if [ -n "$cap_command_block" ]; then - printf '%s\n' "$cap_command_block" >> "${DOCKER_DIR}/docker-compose.agent.yml" + if ! printf '%s\n' "$cap_command_block" >> "$compose_file"; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Could not add Agent capabilities to ${compose_file}" + fi fi - info "Generated ${DOCKER_DIR}/docker-compose.agent.yml" - docker compose -f "${DOCKER_DIR}/docker-compose.agent.yml" up -d + info "Generated ${compose_file}" + if ! docker compose -f "$compose_file" config -q; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Generated Agent Compose file is invalid; installation rollback was attempted" + fi + AGENT_LOG_SINCE=$(date +%s) + if ! docker compose -f "$compose_file" up -d --force-recreate; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Failed to start the ServerBee Agent container; installation rollback was attempted" + fi info "Agent container started" install_cli "$version" - meta_write "agent" "docker" "$version" + if ! meta_write "agent" "docker" "$version"; then + rollback_docker_agent_install \ + "$compose_file" "$compose_backup" "$config_file" "$config_backup" "$config_created" + error "Failed to record the ServerBee Agent installation; installation rollback was attempted" + fi + rm -f "$compose_backup" "$config_backup" \ + || warn "Could not remove one or more Agent install rollback files" + verify_agent_install_or_exit print_agent_result } @@ -1973,7 +2650,11 @@ print_server_result() { ip=$(get_local_ip) pw="$(fetch_first_run_password)" echo "" - cecho "${GREEN}$(tr_text result_server_ok)${NC}" + if [ "$NO_WAIT" = true ]; then + cecho "${YELLOW}$(tr_text result_server_unverified)${NC}" + else + cecho "${GREEN}$(tr_text result_server_ok)${NC}" + fi echo "" echo "$(tr_text lbl_dashboard) http://${ip}:9527" echo "$(tr_text lbl_username) admin" @@ -1994,7 +2675,11 @@ print_server_result() { print_agent_result() { echo "" - cecho "${GREEN}$(tr_text result_agent_ok)${NC}" + if [ "$NO_WAIT" = true ]; then + cecho "${YELLOW}$(tr_text result_agent_unverified)${NC}" + else + cecho "${GREEN}$(tr_text result_agent_ok)${NC}" + fi echo "" echo "$(tr_text lbl_server_url) ${SERVER_URL}" if [ "$METHOD" = "docker" ]; then @@ -2167,11 +2852,10 @@ update_server_for_domain_docker() { [ -f "$compose_file" ] || error "Compose file not found: $compose_file" sed_inplace 's|- "9527:9527"|- "127.0.0.1:9527:9527"|' "$compose_file" - if grep -q "SERVERBEE_AUTH__SECURE_COOKIE=" "$compose_file"; then - sed_inplace 's|SERVERBEE_AUTH__SECURE_COOKIE=.*|SERVERBEE_AUTH__SECURE_COOKIE=true|' "$compose_file" - else - sed_inplace '/environment:/a\ - SERVERBEE_AUTH__SECURE_COOKIE=true' "$compose_file" - fi + compose_set_env "$compose_file" server SERVERBEE_AUTH__SECURE_COOKIE true \ + || error "Could not update secure-cookie environment in ${compose_file}" + docker compose -f "$compose_file" config -q \ + || error "Domain setup produced an invalid Compose file: ${compose_file}" docker compose -f "$compose_file" up -d } @@ -2221,6 +2905,7 @@ setup_domain() { info "Verifying HTTPS endpoint..." wait_for_https_endpoint \ || error "HTTPS verification failed for https://${DOMAIN}/healthz. Check Caddy logs and DNS propagation." + write_domain_cache || error "HTTPS is healthy, but the configured domain could not be saved to ${DOMAIN_CACHE_FILE}" echo "" cecho "${GREEN}ServerBee HTTPS domain configured successfully!${NC}" @@ -2524,7 +3209,7 @@ cmd_install() { printf '%s' "$(trp server_url_prompt "http://$(get_local_ip):9527")"; prompt_read SERVER_URL done while [ -z "$ENROLLMENT_CODE" ]; do - if [ "$YES" = true ]; then error "--enrollment-code is required for agent installation (generate a one-time code in the server UI Settings)"; fi + if [ "$YES" = true ]; then error "--enrollment-code is required for agent installation (generate a one-time code in Servers → Add Server)"; fi printf '%s' "$(tr_text enrollment_prompt)"; prompt_read ENROLLMENT_CODE done prompt_agent_capabilities @@ -2643,6 +3328,9 @@ cmd_uninstall() { esac meta_remove "$COMPONENT" + if [ "$COMPONENT" = server ]; then + rm -f "$DOMAIN_CACHE_FILE" + fi info "serverbee-${COMPONENT} has been uninstalled." if [ -f "$META_FILE" ]; then @@ -2652,6 +3340,7 @@ cmd_uninstall() { rm -f "$CLI_PATH" rm -f "$META_FILE" rm -f "$LANG_CACHE_FILE" + rm -f "$DOMAIN_CACHE_FILE" if [ "$PURGE" = true ]; then # Purge requested and nothing left to manage: remove the whole # base directory, including any orphaned files left behind by a @@ -2999,7 +3688,7 @@ cmd_upgrade() { # ─── Status command ─────────────────────────────────────────────────────────── status_component() { - local component method version service status_line since srv ip container_status image_tag ports logs_out + local component method version service status_line since srv container_status image_tag ports logs_out dashboard component="$1"; method="$2" version=$(meta_read "$component" "version") service="serverbee-${component}" @@ -3038,8 +3727,8 @@ status_component() { fi if [ "$component" = "server" ]; then - ip=$(get_local_ip) - echo "$(tr_text st_dashboard) http://${ip}:9527" + dashboard=$(server_dashboard_display "$method") + echo "$(tr_text st_dashboard) ${dashboard}" fi elif [ "$method" = "docker" ]; then @@ -3058,8 +3747,8 @@ status_component() { if [ "$component" = "server" ]; then ports=$(docker port "${service}" 2>/dev/null | head -1 || echo "") [ -n "$ports" ] && echo "$(tr_text st_port) ${ports}" - ip=$(get_local_ip) - echo "$(tr_text st_dashboard) http://${ip}:9527" + dashboard=$(server_dashboard_display "$method") + echo "$(tr_text st_dashboard) ${dashboard}" fi tr_text st_recent_logs @@ -3100,7 +3789,11 @@ cmd_status() { method="${entry##*:}" echo "" warn "Found serverbee-${comp} (${method}) but it is not managed by this script." - echo " To bring it under management, run: serverbee install ${comp} [options]" + if [ "$method" = docker ]; then + echo " Docker adoption is not supported. Remove the unmanaged container or migrate its config and data into a managed Compose installation first." + else + echo " To bring it under management, run: serverbee install ${comp} [options]" + fi done echo "" @@ -3173,10 +3866,12 @@ config_key_to_file() { } toml_set() { - local file dotted_key value section key quoted_value tmp + local file dotted_key value section key rendered_value replacement generated has_key file="$1"; dotted_key="$2"; value="$3" section=""; key="" + validate_single_line_value "$value" || return 1 + case "$dotted_key" in *.*) section="${dotted_key%%.*}" @@ -3196,67 +3891,211 @@ toml_set() { case "$value" in ''|*[!0-9]*) case "$value" in - true|false) quoted_value="$value" ;; - *) quoted_value="\"$value\"" ;; + true|false) rendered_value="$value" ;; + *) rendered_value="\"$(render_double_quoted_value "$value")\"" ;; esac ;; - *) quoted_value="$value" ;; + *) rendered_value="$value" ;; esac + replacement="${key} = ${rendered_value}" + generated=$(mktemp "${file}.generated.XXXXXX") || return 1 + SB_INSTALL_EDIT_SECTION="$section" + SB_INSTALL_EDIT_KEY="$key" + SB_INSTALL_EDIT_LINE="$replacement" + export SB_INSTALL_EDIT_SECTION SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + if [ -z "$section" ]; then - if grep -q "^${key} *=" "$file" 2>/dev/null; then - sed_inplace "s|^${key} *=.*|${key} = ${quoted_value}|" "$file" + has_key=$(awk ' + /^[[:space:]]*\[/ { in_top=0 } + BEGIN { in_top=1; key=ENVIRON["SB_INSTALL_EDIT_KEY"] } + in_top { + line=$0 + sub(/^[[:space:]]*/, "", line) + if (line ~ "^" key "[[:space:]]*=") { print "yes"; exit } + } + ' "$file") + if [ "$has_key" = yes ]; then + awk ' + BEGIN { in_top=1; written=0; key=ENVIRON["SB_INSTALL_EDIT_KEY"]; replacement=ENVIRON["SB_INSTALL_EDIT_LINE"] } + /^[[:space:]]*\[/ { in_top=0 } + in_top { + line=$0 + sub(/^[[:space:]]*/, "", line) + if (line ~ "^" key "[[:space:]]*=") { + if (!written) print replacement + written=1 + next + } + } + { print } + ' "$file" > "$generated" else - tmp=$(mktemp) - echo "${key} = ${quoted_value}" > "$tmp" - cat "$file" >> "$tmp" - mv "$tmp" "$file" + printf '%s\n' "$replacement" > "$generated" + cat "$file" >> "$generated" fi else - if grep -q "^\[${section}\]" "$file" 2>/dev/null; then - if sed -n "/^\[${section}\]/,/^\[/p" "$file" | grep -q "^${key} *="; then - tmp=$(mktemp) - awk -v sect="[${section}]" -v k="${key}" -v v="${key} = ${quoted_value}" ' - BEGIN { in_section=0 } - /^\[/ { in_section=($0 == sect) } - in_section && $0 ~ "^"k" *=" { print v; next } - { print } - ' "$file" > "$tmp" - mv "$tmp" "$file" - else - tmp=$(mktemp) - # Append the key to the end of the section's content. Trailing - # blank lines (the separator before the next section) are buffered - # and re-emitted *after* the inserted key so the section break is - # preserved instead of leaving the key glued to the next header. - awk -v sect="[${section}]" -v line="${key} = ${quoted_value}" ' - BEGIN { in_section=0; added=0; blanks="" } - { - is_blank = ($0 ~ /^[ \t]*$/) - is_header = ($0 ~ /^\[/) - if (in_section && !added && is_header) { - print line; added=1 - printf "%s", blanks; blanks="" - in_section=($0 == sect) - print; next - } - if (in_section && !added && is_blank) { - blanks = blanks $0 "\n"; next - } - printf "%s", blanks; blanks="" - if (is_header) in_section=($0 == sect) - print - } - END { if (in_section && !added) { print line; printf "%s", blanks } } - ' "$file" > "$tmp" - mv "$tmp" "$file" - fi - else - echo "" >> "$file" - echo "[${section}]" >> "$file" - echo "${key} = ${quoted_value}" >> "$file" + awk ' + BEGIN { + target="[" ENVIRON["SB_INSTALL_EDIT_SECTION"] "]" + key=ENVIRON["SB_INSTALL_EDIT_KEY"] + replacement=ENVIRON["SB_INSTALL_EDIT_LINE"] + in_target=0; section_found=0; written=0 + } + /^[[:space:]]*\[/ { + if (in_target && !written) { print replacement; written=1 } + in_target=($0 == target) + if (in_target) section_found=1 + print + next + } + in_target { + line=$0 + sub(/^[[:space:]]*/, "", line) + if (line ~ "^" key "[[:space:]]*=") { + if (!written) print replacement + written=1 + next + } + } + { print } + END { + if (in_target && !written) print replacement + if (!section_found) { + print "" + print target + print replacement + } + } + ' "$file" > "$generated" + fi + + unset SB_INSTALL_EDIT_SECTION SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + if ! atomic_replace_preserving_mode "$file" "$generated"; then + rm -f "$generated" + return 1 + fi + rm -f "$generated" +} + +compose_set_env() { + # Update one managed service using YAML double-quoted list syntax. Existing + # duplicates are collapsed and Agent files gain an environment block when + # they did not have one. + local file component env_key value service escaped replacement generated has_service has_environment + file="$1"; component="$2"; env_key="$3"; value="$4" + validate_single_line_value "$value" || return 1 + service="serverbee-${component}" + escaped=$(render_double_quoted_value "$value") + # The quotes are YAML data passed intact through ENVIRON to awk. + # shellcheck disable=SC2089 + replacement=" - \"${env_key}=${escaped}\"" + + SB_INSTALL_EDIT_SERVICE="$service" + SB_INSTALL_EDIT_KEY="$env_key" + SB_INSTALL_EDIT_LINE="$replacement" + # shellcheck disable=SC2090 + export SB_INSTALL_EDIT_SERVICE SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + has_service=$(awk ' + $0 == " " ENVIRON["SB_INSTALL_EDIT_SERVICE"] ":" { print "yes"; exit } + ' "$file") + [ "$has_service" = yes ] || { + unset SB_INSTALL_EDIT_SERVICE SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + return 1 + } + has_environment=$(awk ' + BEGIN { service=" " ENVIRON["SB_INSTALL_EDIT_SERVICE"] ":"; in_service=0 } + $0 == service { in_service=1; next } + in_service && $0 ~ /^ [^ ]/ { exit } + in_service && $0 ~ /^ environment:[[:space:]]*$/ { print "yes"; exit } + ' "$file") + generated=$(mktemp "${file}.generated.XXXXXX") || { + unset SB_INSTALL_EDIT_SERVICE SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + return 1 + } + + if [ "$has_environment" = yes ]; then + awk ' + BEGIN { + service=" " ENVIRON["SB_INSTALL_EDIT_SERVICE"] ":" + key=ENVIRON["SB_INSTALL_EDIT_KEY"] + replacement=ENVIRON["SB_INSTALL_EDIT_LINE"] + in_service=0; in_environment=0; written=0 + } + $0 == service { in_service=1; print; next } + in_service && $0 ~ /^ [^ ]/ { + if (in_environment && !written) print replacement + in_service=0; in_environment=0 + } + in_service && $0 ~ /^ environment:[[:space:]]*$/ { + in_environment=1 + print + next + } + in_environment && $0 ~ /^ [^ ]/ { + if (!written) print replacement + written=1; in_environment=0 + } + in_environment { + candidate=$0 + sub(/^[[:space:]]*-[[:space:]]*/, "", candidate) + sub(/^"/, "", candidate) + if (index(candidate, key "=") == 1) { + if (!written) print replacement + written=1 + next + } + } + { print } + END { if (in_environment && !written) print replacement } + ' "$file" > "$generated" + else + awk ' + BEGIN { service=" " ENVIRON["SB_INSTALL_EDIT_SERVICE"] ":"; replacement=ENVIRON["SB_INSTALL_EDIT_LINE"] } + { print } + $0 == service { + print " environment:" + print replacement + } + ' "$file" > "$generated" + fi + unset SB_INSTALL_EDIT_SERVICE SB_INSTALL_EDIT_KEY SB_INSTALL_EDIT_LINE + + if ! atomic_replace_preserving_mode "$file" "$generated"; then + rm -f "$generated" + return 1 + fi + rm -f "$generated" +} + +docker_set_env_transaction() { + local component compose_file env_key value backup compose_started + component="$1"; compose_file="$2"; env_key="$3"; value="$4" + backup="${compose_file}.env-rollback" + compose_started=false + if [ -e "$backup" ] || [ -L "$backup" ]; then + warn "Stale env rollback found: ${backup}" + return 1 + fi + cp -p "$compose_file" "$backup" || return 1 + if ! compose_set_env "$compose_file" "$component" "$env_key" "$value"; then + mv -f "$backup" "$compose_file" 2>/dev/null || true + return 1 + fi + if ! docker compose -f "$compose_file" config -q; then + mv -f "$backup" "$compose_file" 2>/dev/null || true + return 1 + fi + if docker compose -f "$compose_file" up -d; then + compose_started=true + fi + if [ "$compose_started" != true ]; then + if mv -f "$backup" "$compose_file"; then + docker compose -f "$compose_file" up -d >/dev/null 2>&1 || true fi + return 1 fi + rm -f "$backup" } cmd_service_single() { @@ -3271,14 +4110,14 @@ cmd_service_single() { } cmd_config() { - local key value target files_to_update file before before_file targets comp entry method confirm + local key value target files_to_update file before_file after_file redacted_before redacted_after targets comp entry method confirm detect_installed if [ "$COMPONENT" = "set" ]; then key="$CONFIG_KEY" value="$CONFIG_VALUE" [ -z "$key" ] && error "Usage: serverbee config set " - [ -z "$value" ] && error "Usage: serverbee config set " + [ "$POSITIONAL_COUNT" -ge 3 ] || error "Usage: serverbee config set " if echo "$REJECTED_KEYS" | grep -qw "$key"; then case "$key" in @@ -3309,15 +4148,23 @@ cmd_config() { if [ ! -f "$file" ]; then error "Config file not found: $file" fi - before=$(cat "$file") - toml_set "$file" "$key" "$value" - info "Updated ${key} = ${value} in ${file}" + before_file=$(mktemp) + cp -p "$file" "$before_file" + if ! toml_set "$file" "$key" "$value"; then + rm -f "$before_file" + error "Could not safely update ${key} in ${file}. Values must be one printable line." + fi + info "Updated ${key} in ${file}" echo " Changes:" - before_file=$(mktemp) - printf '%s\n' "$before" > "$before_file" - diff "$before_file" "$file" | sed 's/^/ /' || true - rm -f "$before_file" + after_file=$(mktemp) + redacted_before=$(mktemp) + redacted_after=$(mktemp) + cp -p "$file" "$after_file" + redact_toml_file "$before_file" > "$redacted_before" + redact_toml_file "$after_file" > "$redacted_after" + diff "$redacted_before" "$redacted_after" | sed 's/^/ /' || true + rm -f "$before_file" "$after_file" "$redacted_before" "$redacted_after" done if [ "$YES" = true ]; then @@ -3372,7 +4219,7 @@ cmd_config() { cecho "${BOLD}$(capitalize "$comp") config (${file})${NC}" echo "─────────────────────────────────" if [ -f "$file" ]; then - cat "$file" + redact_toml_file "$file" else echo "(file not found)" fi @@ -3404,7 +4251,9 @@ cmd_env() { raw_key="$CONFIG_KEY" value="$CONFIG_VALUE" [ -z "$raw_key" ] && error "Usage: serverbee env set " - [ -z "$value" ] && error "Usage: serverbee env set " + [ "$POSITIONAL_COUNT" -ge 3 ] || error "Usage: serverbee env set " + validate_single_line_value "$value" \ + || error "Environment values must be one printable line." env_key="$raw_key" case "$env_key" in @@ -3434,25 +4283,23 @@ cmd_env() { override_dir="/etc/systemd/system/${service}.service.d" override_file="${override_dir}/override.conf" mkdir -p "$override_dir" - if [ -f "$override_file" ] && grep -q "^Environment=${env_key}=" "$override_file" 2>/dev/null; then - sed_inplace "s|^Environment=${env_key}=.*|Environment=${env_key}=${value}|" "$override_file" - elif [ -f "$override_file" ]; then - echo "Environment=${env_key}=${value}" >> "$override_file" - else - cat > "$override_file" << EOF + if [ ! -f "$override_file" ]; then + (umask 077; cat > "$override_file") << EOF [Service] -Environment=${env_key}=${value} EOF fi + systemd_set_env "$override_file" "$env_key" "$value" \ + || error "Could not safely update ${override_file}" systemctl daemon-reload - info "Set ${env_key}=${value} in systemd override for ${service}" + info "Set ${env_key} in systemd override for ${service}" svc_action restart "$comp" 2>/dev/null || true elif [ "$INIT" = openrc ]; then - svc_write_env_file "$comp" "${env_key}=${value}" - info "Set ${env_key}=${value} in $(svc_env_path "$comp")" + svc_write_env_file "$comp" "$env_key" "$value" \ + || error "Could not safely update $(svc_env_path "$comp")" + info "Set ${env_key} in $(svc_env_path "$comp")" svc_action restart "$comp" 2>/dev/null || true else - warn "No init manager; cannot persist env for ${service}. Set ${env_key} manually." + warn "No init manager; cannot persist ${env_key} for ${service}. Set it manually." fi elif [ "$method" = "docker" ]; then @@ -3460,13 +4307,9 @@ EOF if [ ! -f "$compose_file" ]; then error "Compose file not found: $compose_file" fi - if grep -q "- ${env_key}=" "$compose_file" 2>/dev/null; then - sed_inplace "s|- ${env_key}=.*|- ${env_key}=${value}|" "$compose_file" - else - sed_inplace "/environment:/a\\ - ${env_key}=${value}" "$compose_file" - fi - info "Set ${env_key}=${value} in ${compose_file}" - docker compose -f "$compose_file" up -d + docker_set_env_transaction "$comp" "$compose_file" "$env_key" "$value" \ + || error "Could not safely update ${env_key}; the previous Compose file was restored when possible" + info "Set ${env_key} in ${compose_file}" fi done return @@ -3483,7 +4326,7 @@ EOF echo "Source: shell" shell_vars=$(env | grep '^SERVERBEE_' || true) if [ -n "$shell_vars" ]; then - printf '%s\n' "$shell_vars" | sed 's/^/ /' + printf '%s\n' "$shell_vars" | redact_env_lines | sed 's/^/ /' else echo " (none)" fi @@ -3498,7 +4341,7 @@ EOF if [ "$INIT" = openrc ]; then echo "Source: openrc env file (${service})" if [ -f "$(svc_env_path "$comp")" ] && [ -s "$(svc_env_path "$comp")" ]; then - sed 's/^/ /' "$(svc_env_path "$comp")" + redact_env_lines < "$(svc_env_path "$comp")" | sed 's/^/ /' else echo " (none)" fi @@ -3506,21 +4349,21 @@ EOF echo "Source: systemd override (${service})" override_file="/etc/systemd/system/${service}.service.d/override.conf" if [ -f "$override_file" ]; then - grep "^Environment=" "$override_file" 2>/dev/null | sed 's/^Environment=/ /' || echo " (none)" + grep "^Environment=" "$override_file" 2>/dev/null | redact_env_lines | sed 's/^Environment=/ /' || echo " (none)" else echo " (none)" fi unit_envs=$(systemctl show "$service" --property=Environment --value 2>/dev/null || echo "") if [ -n "$unit_envs" ]; then echo "Source: systemd unit (${service})" - echo "$unit_envs" | tr ' ' '\n' | sed 's/^/ /' + echo "$unit_envs" | tr ' ' '\n' | redact_env_lines | sed 's/^/ /' fi fi elif [ "$method" = "docker" ]; then echo "Source: docker-compose (${service})" compose_file="${DOCKER_DIR}/docker-compose.${comp}.yml" if [ -f "$compose_file" ]; then - grep "^ *- SERVERBEE_" "$compose_file" 2>/dev/null | sed 's/^ *- / /' || echo " (none)" + grep -E '^ *- "?SERVERBEE_' "$compose_file" 2>/dev/null | redact_env_lines | sed 's/^ *- / /' || echo " (none)" else echo " (compose file not found)" fi @@ -3610,6 +4453,32 @@ run_command() { # ─── Main ───────────────────────────────────────────────────────────────────── main() { local a prev + # Help and installer-version queries are side-effect free and must work for + # non-root users. `--version ` remains the install/upgrade pin. + case "${1:-}" in + --help|-h|help) + detect_lang + print_usage + return 0 + ;; + version) + printf 'ServerBee installer %s\n' "$INSTALLER_VERSION" + return 0 + ;; + esac + for a in "$@"; do + case "$a" in + --help|-h) + detect_lang + case "${1:-}" in + server|agent) print_usage install ;; + *) print_usage "${1:-}" ;; + esac + return 0 + ;; + esac + done + # Elevate first so the rest runs as root (re-execs under sudo/doas). require_root "$@" detect_init @@ -3635,6 +4504,7 @@ main() { else COMMAND="$1"; shift parse_args "$@" + validate_parsed_args case "$COMMAND" in install|domain) select_language ;; *) detect_lang ;; diff --git a/deploy/test-install-upgrade.sh b/deploy/test-install-upgrade.sh index b5ed67b1..72a94063 100644 --- a/deploy/test-install-upgrade.sh +++ b/deploy/test-install-upgrade.sh @@ -381,9 +381,10 @@ test_docker_server_compose_mounts_generated_config() ( docker_is_snap() { return 1; } check_docker() { :; } check_unmanaged_container() { :; } - docker() { :; } + docker() { printf '%s\n' "$*" >> "$ACTION_LOG"; } install_cli() { :; } meta_write() { :; } + verify_server_install_or_exit() { :; } print_server_result() { :; } install_docker_server >/dev/null @@ -397,10 +398,259 @@ test_docker_server_compose_mounts_generated_config() ( || fail "generated Docker server omits allocator tuning" ) +test_docker_agent_custom_caps_keep_executable_and_secure_config() ( + setup_case docker-agent-custom-caps + DEFAULT_DOCKER_DIR="$CASE_DIR" + DOCKER_DIR="$CASE_DIR" + CONFIG_DIR="${CASE_DIR}/etc" + REQUESTED_VERSION="v1.0.0-beta.1" + RESOLVED_VERSION="" + SERVER_URL="https://monitor.example.com" + ENROLLMENT_CODE="test-enrollment-code" + AGENT_CAPS_USER_SPECIFIED=true + AGENT_CAPS_SELECTED="" + + docker_is_snap() { return 1; } + check_docker() { :; } + check_unmanaged_container() { :; } + docker() { printf '%s\n' "$*" >> "$ACTION_LOG"; } + install_cli() { :; } + meta_write() { :; } + verify_agent_install_or_exit() { :; } + print_agent_result() { :; } + + install_docker_agent >/dev/null + + compose_file="${DOCKER_DIR}/docker-compose.agent.yml" + first_command=$(awk '/^ command:$/ { getline; print; exit }' "$compose_file") + assert_eq "$first_command" " - serverbee-agent" + + config_mode=$(LC_ALL=C ls -l "${CONFIG_DIR}/agent.toml" | cut -c 1-10) + assert_eq "$config_mode" "-rw-------" + grep -Fq 'up -d --force-recreate' "$ACTION_LOG" \ + || fail "Docker Agent install did not force a fresh container for the new config" +) + +test_docker_agent_start_failure_cleans_generated_files() ( + setup_case docker-agent-install-failure + DEFAULT_DOCKER_DIR="$CASE_DIR" + DOCKER_DIR="$CASE_DIR" + CONFIG_DIR="${CASE_DIR}/etc" + REQUESTED_VERSION="v1.0.0-beta.1" + RESOLVED_VERSION="" + SERVER_URL="https://monitor.example.com" + ENROLLMENT_CODE="test-enrollment-code" + AGENT_CAPS_USER_SPECIFIED=true + AGENT_CAPS_SELECTED="" + + docker_is_snap() { return 1; } + check_docker() { :; } + check_unmanaged_container() { :; } + docker() { + shift + [ "$1" = -f ] || fail "docker compose command omitted -f" + shift 2 + action="$1" + printf '%s\n' "$action" >> "$ACTION_LOG" + [ "$action" != up ] + } + install_cli() { :; } + meta_write() { :; } + verify_agent_install_or_exit() { :; } + print_agent_result() { :; } + + if (install_docker_agent) >/dev/null 2>&1; then + fail "failed Docker Agent start unexpectedly succeeded" + fi + + [ ! -e "${DOCKER_DIR}/docker-compose.agent.yml" ] \ + || fail "failed Docker Agent install left its generated Compose file" + [ ! -e "${CONFIG_DIR}/agent.toml" ] \ + || fail "failed Docker Agent install left its generated enrollment config" + assert_eq "$(cat "$ACTION_LOG")" "config +up +down" + grep -qx 'down' "$ACTION_LOG" \ + || fail "failed Docker Agent install did not tear down its partial container" +) + +test_docker_agent_config_write_failure_cleans_partial_file() ( + setup_case docker-agent-config-write-failure + DEFAULT_DOCKER_DIR="$CASE_DIR" + DOCKER_DIR="$CASE_DIR" + CONFIG_DIR="${CASE_DIR}/etc" + REQUESTED_VERSION="v1.0.0-beta.1" + RESOLVED_VERSION="" + SERVER_URL="https://monitor.example.com" + ENROLLMENT_CODE="test-enrollment-code" + + docker_is_snap() { return 1; } + check_docker() { :; } + check_unmanaged_container() { :; } + docker() { :; } + cat() { return 1; } + + if (install_docker_agent) >/dev/null 2>&1; then + fail "failed Agent config write unexpectedly succeeded" + fi + + [ ! -e "${CONFIG_DIR}/agent.toml" ] \ + || fail "failed Agent config write left a partial enrollment config" +) + +test_docker_agent_start_failure_restores_existing_files() ( + setup_case docker-agent-existing-files + DEFAULT_DOCKER_DIR="$CASE_DIR" + DOCKER_DIR="$CASE_DIR" + CONFIG_DIR="${CASE_DIR}/etc" + REQUESTED_VERSION="v1.0.0-beta.1" + RESOLVED_VERSION="" + SERVER_URL="https://new.example.com" + ENROLLMENT_CODE="new-enrollment-code" + AGENT_CAPS_USER_SPECIFIED=true + AGENT_CAPS_SELECTED="" + mkdir -p "$CONFIG_DIR" + printf '%s\n' \ + 'server_url = "https://old.example.com"' \ + 'token = "old-run-token"' > "${CONFIG_DIR}/agent.toml" + chmod 600 "${CONFIG_DIR}/agent.toml" + printf '%s\n' \ + 'services:' \ + ' preserved:' \ + ' image: example.invalid/preserved:1' > "${DOCKER_DIR}/docker-compose.agent.yml" + cp "${CONFIG_DIR}/agent.toml" "${CASE_DIR}/expected-agent.toml" + cp "${DOCKER_DIR}/docker-compose.agent.yml" "${CASE_DIR}/expected-compose.yml" + up_calls=0 + + docker_is_snap() { return 1; } + check_docker() { :; } + check_unmanaged_container() { :; } + docker() { + shift + [ "$1" = -f ] || fail "docker compose command omitted -f" + shift 2 + action="$1" + printf '%s\n' "$action" >> "$ACTION_LOG" + if [ "$action" = up ]; then + up_calls=$((up_calls + 1)) + if [ "$up_calls" -eq 1 ]; then + return 1 + fi + cmp -s "${CASE_DIR}/expected-agent.toml" "${CONFIG_DIR}/agent.toml" \ + || fail "rollback restarted Agent before restoring its config" + cmp -s "${CASE_DIR}/expected-compose.yml" "${DOCKER_DIR}/docker-compose.agent.yml" \ + || fail "rollback restarted Agent before restoring its Compose file" + fi + } + install_cli() { :; } + meta_write() { :; } + verify_agent_install_or_exit() { :; } + print_agent_result() { :; } + + if (install_docker_agent) >/dev/null 2>&1; then + fail "failed Docker Agent reinstall unexpectedly succeeded" + fi + + cmp -s "${CASE_DIR}/expected-agent.toml" "${CONFIG_DIR}/agent.toml" \ + || fail "failed Docker Agent reinstall did not restore the existing config" + cmp -s "${CASE_DIR}/expected-compose.yml" "${DOCKER_DIR}/docker-compose.agent.yml" \ + || fail "failed Docker Agent reinstall did not restore the existing Compose file" + [ ! -e "${CONFIG_DIR}/agent.toml.install-rollback" ] \ + || fail "failed Docker Agent reinstall left a config rollback file" + [ ! -e "${DOCKER_DIR}/docker-compose.agent.yml.install-rollback" ] \ + || fail "failed Docker Agent reinstall left a Compose rollback file" + assert_eq "$(cat "$ACTION_LOG")" "config +up +up" +) + +test_docker_agent_stale_rollback_symlinks_block_install() ( + for backup_kind in config compose; do + setup_case "docker-agent-stale-${backup_kind}-symlink" + DEFAULT_DOCKER_DIR="$CASE_DIR" + DOCKER_DIR="$CASE_DIR" + CONFIG_DIR="${CASE_DIR}/etc" + REQUESTED_VERSION="v1.0.0-beta.1" + RESOLVED_VERSION="" + SERVER_URL="https://monitor.example.com" + ENROLLMENT_CODE="test-enrollment-code" + AGENT_CAPS_USER_SPECIFIED=true + AGENT_CAPS_SELECTED="" + mkdir -p "$CONFIG_DIR" + if [ "$backup_kind" = config ]; then + backup_path="${CONFIG_DIR}/agent.toml.install-rollback" + else + backup_path="${DOCKER_DIR}/docker-compose.agent.yml.install-rollback" + fi + ln -s "${CASE_DIR}/missing-${backup_kind}-backup" "$backup_path" + + docker_is_snap() { return 1; } + check_docker() { :; } + check_unmanaged_container() { :; } + docker() { :; } + install_cli() { :; } + meta_write() { :; } + verify_agent_install_or_exit() { :; } + print_agent_result() { :; } + + if (install_docker_agent) >/dev/null 2>&1; then + fail "Docker Agent install accepted a stale ${backup_kind} rollback symlink" + fi + [ -L "$backup_path" ] \ + || fail "Docker Agent install modified a stale ${backup_kind} rollback symlink" + done +) + +test_non_tty_input_does_not_prompt() ( + if command -v setsid >/dev/null 2>&1; then + setsid sh -c ' + SERVERBEE_NO_MAIN=1 + export SERVERBEE_NO_MAIN + . "$1" + if has_prompt_input /dev/null 2>&1; then + exit 11 + fi + ' sh "${SCRIPT_DIR}/install.sh" \ + || fail "detached process was mistaken for an available controlling terminal" + elif ! prompt_tty_available; then + if has_prompt_input /dev/null 2>&1; then + fail "prompt_read accepted input without an available controlling terminal" + fi + fi +) + curl() { case "$*" in *'/releases?per_page=100'*) - if [ "$TEST_RELEASE_MODE" = prerelease-only ]; then + if [ "$TEST_RELEASE_MODE" = large ]; then + printf '%s\n' '[' \ + ' {' \ + ' "tag_name": "v1.0.0-beta.1",' \ + ' "draft": false' \ + ' },' + i=0 + while [ "$i" -lt 3000 ]; do + printf '%s\n' \ + ' {' \ + " \"tag_name\": \"v0.0.0-draft.${i}\"," \ + ' "draft": true' \ + ' },' + i=$((i + 1)) + done + printf '%s\n' \ + ' {' \ + ' "tag_name": "v0.0.0-draft.final",' \ + ' "draft": true' \ + ' }' \ + ']' + : > "${TEST_ROOT}/release-fetch-complete" + elif [ "$TEST_RELEASE_MODE" = prerelease-only ]; then cat <<'JSON' [ { @@ -473,6 +723,16 @@ test_auto_channel_falls_back_to_prerelease() { assert_eq "$(get_latest_version)" "v1.0.0-beta.1" } +test_release_selection_consumes_the_full_response() { + TEST_RELEASE_MODE=large + RELEASE_CHANNEL=beta + RESOLVED_VERSION="" + REQUESTED_VERSION="" + assert_eq "$(get_latest_version)" "v1.0.0-beta.1" + [ -f "${TEST_ROOT}/release-fetch-complete" ] \ + || fail "release selection stopped reading before curl completed" +} + test_install_metadata_persists_upgrade_channel() { setup_case metadata-channel CONFIG_DIR="${CASE_DIR}/etc" @@ -498,6 +758,390 @@ test_current_upgrade_persists_explicit_channel() ( assert_eq "$(meta_read agent channel)" "beta" ) +test_toml_set_roundtrips_special_characters_and_preserves_mode() ( + setup_case toml-special-characters + config_file="${CASE_DIR}/server.toml" + printf '%s\n' \ + '[oauth.github]' \ + 'client_secret = "old"' > "$config_file" + chmod 600 "$config_file" + + toml_set "$config_file" 'oauth.github.client_secret' 'a|b&c\q"d e' + + grep -Fqx 'client_secret = "a|b&c\\q\"d e"' "$config_file" \ + || fail "TOML special characters did not round-trip safely" + mode=$(LC_ALL=C ls -l "$config_file" | cut -c 1-10) + assert_eq "$mode" '-rw-------' +) + +test_compose_env_set_adds_environment_and_is_idempotent() ( + setup_case compose-env-add + compose_file="${CASE_DIR}/docker-compose.agent.yml" + printf '%s\n' \ + 'services:' \ + ' serverbee-agent:' \ + ' image: example.invalid/agent:1' \ + ' restart: unless-stopped' > "$compose_file" + + compose_set_env "$compose_file" agent SERVERBEE_LOG__LEVEL 'debug|a&b\c" d' + compose_set_env "$compose_file" agent SERVERBEE_LOG__LEVEL 'warn|x&y\z" q' + + count=$(grep -c 'SERVERBEE_LOG__LEVEL=' "$compose_file") + assert_eq "$count" 1 + grep -Fq 'SERVERBEE_LOG__LEVEL=warn|x&y\\z\" q' "$compose_file" \ + || fail "Compose env value did not round-trip safely" + grep -Fq ' environment:' "$compose_file" \ + || fail "Compose environment block was not created" +) + +test_compose_env_set_collapses_existing_duplicates() ( + setup_case compose-env-duplicates + compose_file="${CASE_DIR}/docker-compose.server.yml" + printf '%s\n' \ + 'services:' \ + ' serverbee-server:' \ + ' environment:' \ + ' - SERVERBEE_AUTH__SECURE_COOKIE=false' \ + ' - "SERVERBEE_AUTH__SECURE_COOKIE=false"' \ + ' - MALLOC_ARENA_MAX=2' \ + ' image: example.invalid/server:1' > "$compose_file" + + compose_set_env "$compose_file" server SERVERBEE_AUTH__SECURE_COOKIE true + + count=$(grep -c 'SERVERBEE_AUTH__SECURE_COOKIE=' "$compose_file") + assert_eq "$count" 1 + grep -Fq 'SERVERBEE_AUTH__SECURE_COOKIE=true' "$compose_file" \ + || fail "Compose env duplicate collapse kept the old value" +) + +test_docker_env_transaction_validates_and_restores_on_failure() ( + setup_case docker-env-transaction + compose_file="${CASE_DIR}/docker-compose.agent.yml" + printf '%s\n' \ + 'services:' \ + ' serverbee-agent:' \ + ' image: example.invalid/agent:1' > "$compose_file" + cp "$compose_file" "${CASE_DIR}/original.yml" + TEST_COMPOSE_CONFIG_FAIL=false + + docker() { + shift + [ "$1" = -f ] || fail "docker compose command omitted -f" + shift 2 + action="$1" + printf '%s\n' "$action" >> "$ACTION_LOG" + [ "$action" != config ] || [ "$TEST_COMPOSE_CONFIG_FAIL" != true ] + } + + docker_set_env_transaction agent "$compose_file" SERVERBEE_LOG__LEVEL debug \ + || fail "valid Docker env transaction failed" + assert_eq "$(cat "$ACTION_LOG")" "config +up" + [ ! -e "${compose_file}.env-rollback" ] || fail "successful env update left a rollback file" + + cp "${CASE_DIR}/original.yml" "$compose_file" + : > "$ACTION_LOG" + TEST_COMPOSE_CONFIG_FAIL=true + if docker_set_env_transaction agent "$compose_file" SERVERBEE_LOG__LEVEL broken; then + fail "invalid Compose transaction unexpectedly succeeded" + fi + cmp -s "${CASE_DIR}/original.yml" "$compose_file" \ + || fail "failed Compose validation did not restore the original file" + assert_eq "$(cat "$ACTION_LOG")" config +) + +test_openrc_env_roundtrips_shell_metacharacters() ( + setup_case openrc-env-special + env_file="${CASE_DIR}/agent.env" + : > "$env_file" + chmod 600 "$env_file" + value='a|b&c\q"d e$HOME`literal`' + + openrc_set_env "$env_file" SERVERBEE_TOKEN "$value" + actual=$(sh -c '. "$1"; printf "%s" "$SERVERBEE_TOKEN"' sh "$env_file") + assert_eq "$actual" "$value" + mode=$(LC_ALL=C ls -l "$env_file" | cut -c 1-10) + assert_eq "$mode" '-rw-------' +) + +test_systemd_env_escapes_unit_syntax_and_is_idempotent() ( + setup_case systemd-env-special + override_file="${CASE_DIR}/override.conf" + printf '%s\n' '[Service]' > "$override_file" + chmod 600 "$override_file" + + systemd_set_env "$override_file" SERVERBEE_TOKEN 'a\b"c %n' + systemd_set_env "$override_file" SERVERBEE_TOKEN 'final\value" %%' + + count=$(grep -c 'SERVERBEE_TOKEN=' "$override_file") + assert_eq "$count" 1 + grep -Fqx 'Environment="SERVERBEE_TOKEN=final\\value\" %%%%"' "$override_file" \ + || fail "systemd env value was not escaped for unit syntax" +) + +test_installer_version_matches_workspace_version() ( + workspace_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' "${SCRIPT_DIR}/../Cargo.toml" | head -n1) + assert_eq "$INSTALLER_VERSION" "$workspace_version" +) + +test_value_validation_rejects_controls_without_file_changes() ( + setup_case invalid-config-value + config_file="${CASE_DIR}/agent.toml" + printf '%s\n' 'token = "old"' > "$config_file" + cp "$config_file" "${CASE_DIR}/expected.toml" + invalid_value=$(printf 'line1\nline2') + + if toml_set "$config_file" token "$invalid_value" >/dev/null 2>&1; then + fail "TOML setter accepted a newline" + fi + cmp -s "${CASE_DIR}/expected.toml" "$config_file" \ + || fail "rejected TOML value changed the file" +) + +test_agent_install_acceptance_states() ( + INSTALL_AGENT_ATTEMPTS=3 + : > "${TEST_ROOT}/agent-log-reads" + agent_install_logs() { + reads=$(wc -l < "${TEST_ROOT}/agent-log-reads") + printf '.\n' >> "${TEST_ROOT}/agent-log-reads" + [ "$reads" -lt 1 ] || echo 'Welcome from server test-server, interval=3s' + } + wait_for_agent_install || fail "Agent Welcome log was not accepted" + + agent_install_logs() { echo 'Permanent registration failure: HTTP 401 Unauthorized'; } + set +e + wait_for_agent_install + rc=$? + set -e + assert_eq "$rc" 78 + + agent_install_logs() { echo 'connection timed out'; } + set +e + wait_for_agent_install + rc=$? + set -e + assert_eq "$rc" 75 +) + +test_agent_install_accepts_stable_endpoint_connection_without_info_logs() ( + INSTALL_AGENT_ATTEMPTS=3 + agent_install_logs() { :; } + agent_install_exit_status() { :; } + agent_connection_established() { echo '192.0.2.4:41000|203.0.113.10:443'; } + + wait_for_agent_install || fail "stable Agent endpoint connection was not accepted" + assert_eq "$AGENT_INSTALL_PROOF" connection +) + +test_agent_connection_proof_matches_pid_address_and_port() ( + METHOD=binary + INIT=systemd + SERVER_URL='https://monitor.example.com/path' + agent_install_pid() { echo 4242; } + getent() { printf '%s\n' '203.0.113.10 STREAM monitor.example.com'; } + ss() { + printf '%s\n' \ + '0 0 192.0.2.4:41000 203.0.113.10:443 users:(("serverbee-agent",pid=4242,fd=9))' \ + '0 0 192.0.2.4:41001 203.0.113.10:443 users:(("other",pid=9999,fd=9))' + } + + connection=$(agent_connection_established) \ + || fail "matching Agent PID/address/port connection was rejected" + assert_eq "$connection" '192.0.2.4:41000|203.0.113.10:443' + + ss() { + printf '%s\n' '0 0 192.0.2.4:41000 203.0.113.11:443 users:(("serverbee-agent",pid=4242,fd=9))' + } + if agent_connection_established; then + fail "Agent connection proof accepted the wrong Server address" + fi +) + +test_agent_install_rejects_changing_short_connections() ( + INSTALL_AGENT_ATTEMPTS=3 + : > "${TEST_ROOT}/short-connections" + agent_install_logs() { :; } + agent_install_exit_status() { :; } + agent_connection_established() { + count=$(wc -l < "${TEST_ROOT}/short-connections") + printf '.\n' >> "${TEST_ROOT}/short-connections" + printf '192.0.2.4:%s|203.0.113.10:443\n' "$((41000 + count))" + } + + set +e + wait_for_agent_install + rc=$? + set -e + assert_eq "$rc" 75 +) + +test_docker_agent_logs_are_scoped_to_current_install() ( + METHOD=docker + DOCKER_DIR="${TEST_ROOT}/docker-log-scope" + AGENT_LOG_SINCE=1234567890 + mkdir -p "$DOCKER_DIR" + : > "${DOCKER_DIR}/docker-compose.agent.yml" + docker() { + case "$*" in + *'logs --no-color --since 1234567890 --tail 200 serverbee-agent'*) + echo 'current install log' + ;; + *) + echo 'Welcome from server stale-container' + ;; + esac + } + + assert_eq "$(agent_install_logs)" 'current install log' +) + +test_server_install_acceptance_polls_health() ( + INSTALL_SERVER_HEALTH_ATTEMPTS=3 + : > "${TEST_ROOT}/server-health-reads" + server_install_health_check() { + reads=$(wc -l < "${TEST_ROOT}/server-health-reads") + printf '.\n' >> "${TEST_ROOT}/server-health-reads" + [ "$reads" -ge 1 ] + } + wait_for_server_install || fail "healthy Server install was rejected" + + server_install_health_check() { return 1; } + if wait_for_server_install; then + fail "unhealthy Server install was accepted" + fi +) + +test_binary_install_without_init_is_unverified_not_failed() ( + METHOD=binary + INIT=none + NO_WAIT=false + output_file="${TEST_ROOT}/no-init-output" + server_install_health_check() { fail 'no-init Server attempted a health check'; } + verify_server_install_or_exit > "$output_file" || fail "no-init Server install was rejected" + output=$(cat "$output_file") + assert_eq "$NO_WAIT" true + case "$output" in + *'health check passed'*) fail "no-init Server falsely claimed a successful health check" ;; + esac + + NO_WAIT=false + agent_install_logs() { fail 'no-init Agent attempted to read logs'; } + verify_agent_install_or_exit > "$output_file" || fail "no-init Agent install was rejected" + output=$(cat "$output_file") + assert_eq "$NO_WAIT" true + case "$output" in + *'connected and received'*) fail "no-init Agent falsely claimed a successful connection" ;; + esac +) + +test_sensitive_output_is_redacted() ( + setup_case sensitive-redaction + config_file="${CASE_DIR}/agent.toml" + printf '%s\n' \ + 'server_url = "https://example.com"' \ + 'enrollment_code = "enroll-secret"' \ + 'token = "run-secret"' \ + '[oauth.github]' \ + 'client_secret = "oauth-secret"' > "$config_file" + + output=$(redact_toml_file "$config_file") + printf '%s\n' "$output" | grep -Fq 'server_url = "https://example.com"' \ + || fail "redaction hid a non-sensitive TOML value" + for secret in enroll-secret run-secret oauth-secret; do + case "$output" in + *"$secret"*) fail "redaction leaked ${secret}" ;; + esac + done + + output=$(printf '%s\n' \ + 'SERVERBEE_LOG__LEVEL=debug' \ + 'SERVERBEE_TOKEN=run-secret' \ + 'Environment="SERVERBEE_OAUTH__GITHUB__CLIENT_SECRET=oauth-secret"' \ + | redact_env_lines) + printf '%s\n' "$output" | grep -Fq 'SERVERBEE_LOG__LEVEL=debug' \ + || fail "redaction hid a non-sensitive env value" + case "$output" in + *run-secret*|*oauth-secret*) fail "redaction leaked an env secret" ;; + esac +) + +test_status_dashboard_uses_domain_cache_and_avoids_dead_loopback_url() ( + setup_case status-domain + CONFIG_DIR="${CASE_DIR}/etc" + DOMAIN_CACHE_FILE="${CONFIG_DIR}/.install-domain" + mkdir -p "$CONFIG_DIR" + printf '%s\n' '[server]' 'listen = "127.0.0.1:9527"' > "${CONFIG_DIR}/server.toml" + + output=$(server_dashboard_display binary) + case "$output" in + *'http://'*) fail "loopback Server status exposed a dead public HTTP URL" ;; + *'reverse proxy'*) : ;; + *) fail "loopback Server status omitted reverse-proxy guidance" ;; + esac + + printf '%s\n' 'monitor.example.com' > "$DOMAIN_CACHE_FILE" + assert_eq "$(server_dashboard_display binary)" 'https://monitor.example.com' +) + +test_server_uninstall_clears_domain_cache_without_purge() ( + setup_case uninstall-domain-cache + BASE_DIR="$CASE_DIR" + INSTALL_DIR="${CASE_DIR}/bin" + CONFIG_DIR="${CASE_DIR}/etc" + DATA_DIR="${CASE_DIR}/data" + DOCKER_DIR="$CASE_DIR" + META_FILE="${CONFIG_DIR}/.install-meta" + LANG_CACHE_FILE="${CONFIG_DIR}/.install-lang" + DOMAIN_CACHE_FILE="${CONFIG_DIR}/.install-domain" + CLI_PATH="${CASE_DIR}/serverbee" + COMPONENT=server + YES=true + PURGE=false + INIT=none + RELEASE_CHANNEL=auto + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" + : > "${INSTALL_DIR}/serverbee-server" + printf '%s\n' monitor.example.com > "$DOMAIN_CACHE_FILE" + meta_write server binary v1.0.0-beta.1 + svc_remove() { :; } + + cmd_uninstall >/dev/null + + [ ! -e "$DOMAIN_CACHE_FILE" ] \ + || fail "non-purge Server uninstall retained stale domain metadata" +) + +test_help_and_version_do_not_require_root() ( + require_root() { fail 'help/version attempted privilege elevation'; } + + output=$(main --help) + printf '%s\n' "$output" | grep -Fq 'Usage: serverbee' \ + || fail "root-free help omitted usage" + output=$(main version) + printf '%s\n' "$output" | grep -Fq "$INSTALLER_VERSION" \ + || fail "version command omitted installer version" +) + +test_parser_reports_missing_values_and_extra_arguments() ( + set +e + output=$(parse_args --method 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "missing option value was accepted" + printf '%s\n' "$output" | grep -Fq 'requires a value' \ + || fail "missing option value did not produce a friendly error" + + COMMAND=status + COMPONENT="" + CONFIG_KEY="" + CONFIG_VALUE="" + set +e + output=$(parse_args server extra 2>&1 && validate_parsed_args 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "extra positional argument was accepted" +) + test_successful_upgrade test_probe_mismatch_preserves_current_binary test_server_candidate_probe_requires_exact_version @@ -514,11 +1158,38 @@ test_unhealthy_docker_upgrade_rolls_back test_restarting_docker_upgrade_rolls_back test_stale_docker_backup_blocks_upgrade test_docker_server_compose_mounts_generated_config +test_docker_agent_custom_caps_keep_executable_and_secure_config +test_docker_agent_start_failure_cleans_generated_files +test_docker_agent_config_write_failure_cleans_partial_file +test_docker_agent_start_failure_restores_existing_files +test_docker_agent_stale_rollback_symlinks_block_install +test_non_tty_input_does_not_prompt test_stable_channel_ignores_misclassified_prerelease test_stable_channel_fails_without_stable_release test_beta_channel_finds_semver_prerelease_when_metadata_is_wrong test_auto_channel_prefers_stable_release test_auto_channel_falls_back_to_prerelease +test_release_selection_consumes_the_full_response test_install_metadata_persists_upgrade_channel test_current_upgrade_persists_explicit_channel +test_toml_set_roundtrips_special_characters_and_preserves_mode +test_compose_env_set_adds_environment_and_is_idempotent +test_compose_env_set_collapses_existing_duplicates +test_docker_env_transaction_validates_and_restores_on_failure +test_openrc_env_roundtrips_shell_metacharacters +test_systemd_env_escapes_unit_syntax_and_is_idempotent +test_installer_version_matches_workspace_version +test_value_validation_rejects_controls_without_file_changes +test_agent_install_acceptance_states +test_agent_install_accepts_stable_endpoint_connection_without_info_logs +test_agent_connection_proof_matches_pid_address_and_port +test_agent_install_rejects_changing_short_connections +test_docker_agent_logs_are_scoped_to_current_install +test_server_install_acceptance_polls_health +test_binary_install_without_init_is_unverified_not_failed +test_sensitive_output_is_redacted +test_status_dashboard_uses_domain_cache_and_avoids_dead_loopback_url +test_server_uninstall_clears_domain_cache_without_purge +test_help_and_version_do_not_require_root +test_parser_reports_missing_values_and_extra_arguments printf 'PASS: install upgrade transaction tests\n'