From 4ebd6703feb5fbc3c549160dfb3437a9e0d88862 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Thu, 7 May 2026 16:37:51 +0800 Subject: [PATCH] fix: harden proxy protocol rollout safety (#494) --- go-backend/internal/store/repo/repository.go | 8 +- .../store/repo/repository_migrate_test.go | 62 ++++++ go-gost/x/socket/websocket_reporter.go | 24 ++- go-gost/x/socket/websocket_reporter_test.go | 130 +++++++++++ install.sh | 90 +++++++- test-install-scripts-proxy.sh | 203 ++++++++++++++++++ 6 files changed, 500 insertions(+), 17 deletions(-) diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 4206ec8..0debbd5 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -304,6 +304,7 @@ func autoMigrateAll(db *gorm.DB) error { m := db.Migrator() hasNode := m.HasTable(&model.Node{}) hasTunnel := m.HasTable(&model.Tunnel{}) + hasForward := m.HasTable(&model.Forward{}) for _, item := range models { if hasNode { @@ -316,6 +317,11 @@ func autoMigrateAll(db *gorm.DB) error { continue } } + if hasForward { + if _, ok := item.(*model.Forward); ok { + continue + } + } if err := db.AutoMigrate(item); err != nil { return err } @@ -396,7 +402,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { } if m.HasTable(&model.Forward{}) { - for _, field := range []string{"ProxyProtocol"} { + for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} { if m.HasColumn(&model.Forward{}, field) { continue } diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go index d1cea1f..b8b4533 100644 --- a/go-backend/internal/store/repo/repository_migrate_test.go +++ b/go-backend/internal/store/repo/repository_migrate_test.go @@ -117,6 +117,68 @@ func TestOpenBackfillsSQLiteLegacyTunnelProbeTargetColumns(t *testing.T) { } } +func TestOpenBackfillsSQLiteLegacyForwardColumns(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-forward.db") + db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + + if err := db.Exec(` + CREATE TABLE forward ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + user_name VARCHAR(100) NOT NULL, + name VARCHAR(100) NOT NULL, + tunnel_id INTEGER NOT NULL, + remote_addr TEXT NOT NULL, + strategy VARCHAR(100) NOT NULL DEFAULT 'fifo', + in_flow INTEGER NOT NULL DEFAULT 0, + out_flow INTEGER NOT NULL DEFAULT 0, + created_time INTEGER NOT NULL, + updated_time INTEGER NOT NULL, + status INTEGER NOT NULL, + inx INTEGER NOT NULL DEFAULT 0, + speed_id INTEGER + ) + `).Error; err != nil { + t.Fatalf("create legacy forward table: %v", err) + } + if err := db.Exec(` + INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, speed_id) + VALUES(1, 2, 'legacy-user', 'legacy-forward', 3, '127.0.0.1:9000', 'fifo', 0, 0, 1, 1, 1, 0, NULL) + `).Error; err != nil { + t.Fatalf("insert legacy forward: %v", err) + } + if sqlDB, _ := db.DB(); sqlDB != nil { + _ = sqlDB.Close() + } + + r, err := Open(dbPath) + if err != nil { + t.Fatalf("open migrated sqlite: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + m := r.DB().Migrator() + for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} { + if !m.HasColumn(&model.Forward{}, field) { + t.Fatalf("expected forward.%s column to exist", field) + } + } + + var maxConn, ipMaxConn, proxyProtocol int + var ipSpeedID sql.NullInt64 + if err := r.DB().Raw(`SELECT max_conn, ip_max_conn, ip_speed_id, proxy_protocol FROM forward WHERE id = 1`).Row().Scan(&maxConn, &ipMaxConn, &ipSpeedID, &proxyProtocol); err != nil { + t.Fatalf("query forward defaults: %v", err) + } + if maxConn != 0 || ipMaxConn != 0 || ipSpeedID.Valid || proxyProtocol != 0 { + t.Fatalf("expected default forward columns 0/0/NULL/0, got max_conn=%d ip_max_conn=%d ip_speed_id=%+v proxy_protocol=%d", maxConn, ipMaxConn, ipSpeedID, proxyProtocol) + } +} + func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) { db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 23c5e54..4d4d5f1 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -158,6 +158,9 @@ type WebSocketReporter struct { addr string // 保存服务器地址 secret string // 保存密钥 version string // 保存版本号 + http int + tls int + socks int preferredWSScheme string conn *websocket.Conn curBackoff time.Duration // 当前重连退避间隔 @@ -296,9 +299,9 @@ func (w *WebSocketReporter) connect() error { Socks int `json:"socks"` } - var cfg LocalConfig + cfg := LocalConfig{Http: w.http, Tls: w.tls, Socks: w.socks} if b, err := os.ReadFile("config.json"); err == nil { - json.Unmarshal(b, &cfg) + _ = json.Unmarshal(b, &cfg) } candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme) @@ -1369,7 +1372,7 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error { // 执行重启脚本 // 使用 systemd-run 在独立的 transient unit 中运行重启脚本, // 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。 - script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath) + script := buildAgentRestartScript(tmpPath, binaryPath) cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) if err := cmd.Start(); err != nil { os.Remove(tmpPath) @@ -1403,6 +1406,14 @@ func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error { return nil } +func buildAgentRestartScript(tmpPath, binaryPath string) string { + return fmt.Sprintf( + "sleep 1 && systemctl stop flux_agent && legacy_service='' && for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f \"$service_file\" ] && grep -Fq \"WorkingDirectory=/etc/gost\" \"$service_file\" && (grep -Fq \"ExecStart=/etc/gost/gost\" \"$service_file\" || (grep -Fq \"ExecStart=/usr/local/bin/gost\" \"$service_file\" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service=\"$service_file\"; break; fi; done && if [ -n \"$legacy_service\" ]; then (systemctl stop gost 2>/dev/null || true) && (systemctl disable gost 2>/dev/null || true) && rm -f /usr/local/bin/gost /etc/gost/gost \"$legacy_service\" && (systemctl daemon-reload 2>/dev/null || true); fi && mv %s %s && systemctl start flux_agent", + tmpPath, + binaryPath, + ) +} + // updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error { path := "config.json" @@ -1650,13 +1661,16 @@ func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "") fullURL := candidates[0] - fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL) + fmt.Printf("🔗 WebSocket连接URL: %s\n", sanitizeWebSocketURL(fullURL)) reporter := NewWebSocketReporter(fullURL, secret) - // 保存 addr, secret, version 供重连时使用 + // 保存 addr, secret, version 和协议能力供重连时使用 reporter.addr = addr reporter.secret = secret reporter.version = version + reporter.http = http + reporter.tls = tls + reporter.socks = socks reporter.Start() return reporter } diff --git a/go-gost/x/socket/websocket_reporter_test.go b/go-gost/x/socket/websocket_reporter_test.go index 1587510..2b43d5b 100644 --- a/go-gost/x/socket/websocket_reporter_test.go +++ b/go-gost/x/socket/websocket_reporter_test.go @@ -1,15 +1,45 @@ package socket import ( + "bytes" "errors" "io" "net/http" + "os" + "runtime" "strings" "testing" + "time" "github.com/gorilla/websocket" ) +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + os.Stdout = w + defer func() { + os.Stdout = orig + _ = w.Close() + _ = r.Close() + }() + + fn() + + _ = w.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + return buf.String() +} + func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) { candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "") @@ -133,3 +163,103 @@ func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) { t.Fatalf("expected response body in message, got %s", msg) } } + +func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) { + script := buildAgentRestartScript("/tmp/flux_agent.new", "/etc/flux_agent/flux_agent") + + if !strings.Contains(script, "systemctl stop flux_agent") { + t.Fatalf("expected script to stop flux_agent, got %s", script) + } + if !strings.Contains(script, "mv /tmp/flux_agent.new /etc/flux_agent/flux_agent") { + t.Fatalf("expected script to replace the flux_agent binary, got %s", script) + } + if !strings.Contains(script, "systemctl stop gost") { + t.Fatalf("expected script to stop the legacy gost service, got %s", script) + } + if !strings.Contains(script, "systemctl disable gost") { + t.Fatalf("expected script to disable the legacy gost service, got %s", script) + } + if !strings.Contains(script, "rm -f /usr/local/bin/gost") { + t.Fatalf("expected script to remove the legacy gost binary, got %s", script) + } + if !strings.Contains(script, "WorkingDirectory=/etc/gost") { + t.Fatalf("expected script to scope cleanup to the legacy FLVX gost service definition, got %s", script) + } + if !strings.Contains(script, "systemctl start flux_agent") { + t.Fatalf("expected script to restart flux_agent, got %s", script) + } + if strings.Contains(script, "systemctl stop flux_agent && systemctl stop gost 2>/dev/null || true") { + t.Fatalf("expected legacy gost cleanup fallback to be scoped, got %s", script) + } + if runtime.GOARCH == "" { + t.Fatalf("unexpected empty runtime arch") + } +} + +func TestStartWebSocketReporterWithConfigPreservesProtocolDefaultsWithoutConfigFile(t *testing.T) { + origDial := wsDial + defer func() { wsDial = origDial }() + + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(origWD) + }) + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("change working directory: %v", err) + } + + urls := make(chan string, 1) + wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) { + select { + case urls <- rawURL: + default: + } + return nil, nil, errors.New("dial failed") + } + + reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc", 1, 0, 1, "2.0.2") + defer reporter.Stop() + + select { + case rawURL := <-urls: + if !strings.Contains(rawURL, "http=1&tls=0&socks=1") { + t.Fatalf("expected reconnect URL to preserve startup protocol values, got %s", rawURL) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for websocket dial") + } +} + +func TestStartWebSocketReporterWithConfigLogsSanitizedURL(t *testing.T) { + origDial := wsDial + defer func() { wsDial = origDial }() + + ready := make(chan struct{}, 1) + wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) { + select { + case ready <- struct{}{}: + default: + } + return nil, nil, errors.New("dial failed") + } + + output := captureStdout(t, func() { + reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc123", 1, 0, 1, "2.0.2") + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for websocket dial") + } + reporter.Stop() + }) + + if strings.Contains(output, "secret=abc123") { + t.Fatalf("expected logged websocket URL to mask the node secret, got %s", output) + } + if !strings.Contains(output, "secret=%2A%2A%2A") { + t.Fatalf("expected logged websocket URL to include masked secret, got %s", output) + } +} diff --git a/install.sh b/install.sh index bddc1c5..7304159 100644 --- a/install.sh +++ b/install.sh @@ -24,6 +24,11 @@ get_architecture() { # 安装目录 INSTALL_DIR="/etc/flux_agent" +LEGACY_GOST_BINARY="/usr/local/bin/gost" +LEGACY_GOST_CONFIG_DIR="/etc/gost" +LEGACY_GOST_SERVICE_FILE_ETC="/etc/systemd/system/gost.service" +LEGACY_GOST_SERVICE_FILE_LIB="/lib/systemd/system/gost.service" +LEGACY_GOST_SERVICE_FILE_USR_LIB="/usr/lib/systemd/system/gost.service" # 镜像加速配置(可由面板传入或交互式询问) PROXY_ENABLED="${PROXY_ENABLED:-}" @@ -234,6 +239,69 @@ check_and_install_tcpkill() { return 0 } +json_escape() { + local value="$1" + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + printf '%s' "$value" +} + +write_flux_agent_config() { + local path="$1" + printf '{\n "addr": "%s",\n "secret": "%s"\n}\n' \ + "$(json_escape "$SERVER_ADDR")" \ + "$(json_escape "$SECRET")" > "$path" +} + +cleanup_legacy_gost_installation() { + local matched_service_files=() + local service_file="" + local removed_service_file="0" + + for service_file in "$LEGACY_GOST_SERVICE_FILE_ETC" "$LEGACY_GOST_SERVICE_FILE_LIB" "$LEGACY_GOST_SERVICE_FILE_USR_LIB"; do + if [[ ! -f "$service_file" ]]; then + continue + fi + if ! grep -Fq "WorkingDirectory=$LEGACY_GOST_CONFIG_DIR" "$service_file"; then + continue + fi + if grep -Fq "ExecStart=$LEGACY_GOST_CONFIG_DIR/gost" "$service_file" || \ + (grep -Fq "ExecStart=$LEGACY_GOST_BINARY" "$service_file" && [[ -f "$LEGACY_GOST_CONFIG_DIR/config.json" && -f "$LEGACY_GOST_CONFIG_DIR/gost.json" ]]); then + matched_service_files+=("$service_file") + fi + done + + if [[ ${#matched_service_files[@]} -eq 0 ]]; then + return 0 + fi + + if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then + systemctl stop gost 2>/dev/null || true + systemctl disable gost 2>/dev/null || true + fi + + for service_file in "${matched_service_files[@]}"; do + if [[ -f "$service_file" ]]; then + rm -f "$service_file" + removed_service_file="1" + fi + done + + if [[ -f "$LEGACY_GOST_BINARY" ]]; then + rm -f "$LEGACY_GOST_BINARY" + fi + if [[ -f "$LEGACY_GOST_CONFIG_DIR/gost" ]]; then + rm -f "$LEGACY_GOST_CONFIG_DIR/gost" + fi + + if [[ "$removed_service_file" == "1" ]]; then + systemctl daemon-reload 2>/dev/null || true + fi +} + # 获取用户输入的配置参数 get_config_params() { @@ -279,6 +347,8 @@ install_flux_agent() { mkdir -p "$INSTALL_DIR" + local tmp_binary="$INSTALL_DIR/flux_agent.new" + # 停止并禁用已有服务 if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then echo "🔍 检测到已存在的flux_agent服务" @@ -286,16 +356,17 @@ install_flux_agent() { systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启" fi - # 删除旧文件 - [[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent" - # 下载 flux_agent echo "⬇️ 下载 flux_agent 中..." - curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent" - if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then + rm -f "$tmp_binary" + curl -L "$DOWNLOAD_URL" -o "$tmp_binary" + if [[ ! -f "$tmp_binary" || ! -s "$tmp_binary" ]]; then + rm -f "$tmp_binary" echo "❌ 下载失败,请检查网络或下载链接。" exit 1 fi + cleanup_legacy_gost_installation + mv "$tmp_binary" "$INSTALL_DIR/flux_agent" chmod +x "$INSTALL_DIR/flux_agent" echo "✅ 下载完成" @@ -305,12 +376,7 @@ install_flux_agent() { # 写入 config.json (安装时总是创建新的) CONFIG_FILE="$INSTALL_DIR/config.json" echo "📄 创建新配置: config.json" - cat > "$CONFIG_FILE" </dev/null assert_equals "1" "$ask_called" "update_flux_agent should ask for proxy config before downloading" + assert_equals "1" "$cleanup_called" "update_flux_agent should clean up legacy gost before restarting the agent" assert_equals "$(build_download_url)" "$DOWNLOAD_URL" "update_flux_agent should honor the prompted proxy choice" ) @@ -155,6 +161,198 @@ test_update_flux_agent_skips_proxy_prompt_when_not_installed() ( assert_equals "0" "$ask_called" "update_flux_agent should not prompt for proxy config when the agent is missing" ) +test_install_flux_agent_preserves_legacy_gost_when_download_fails() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/install.sh" + + INSTALL_DIR=$(mktemp -d) + cat > "$INSTALL_DIR/flux_agent" <<'EOF' +#!/bin/bash +echo "old version" +EOF + chmod +x "$INSTALL_DIR/flux_agent" + SERVER_ADDR="panel.example.com:443" + SECRET="secret" + DOWNLOAD_URL="https://example.com/gost" + + local cleanup_called="0" + local rc="0" + + ask_proxy_config() { :; } + ensure_download_url_initialized() { :; } + get_config_params() { :; } + check_and_install_tcpkill() { :; } + cleanup_legacy_gost_installation() { + cleanup_called="1" + } + systemctl() { return 0; } + curl() { return 0; } + + ( install_flux_agent >/dev/null ) || rc="$?" + + assert_equals "1" "$rc" "install_flux_agent should fail when the download artifact is missing" + assert_equals "0" "$cleanup_called" "install_flux_agent should preserve legacy gost when download fails" + [[ -f "$INSTALL_DIR/flux_agent" ]] || fail "install_flux_agent should keep the existing flux_agent binary when download fails" +) + +test_update_flux_agent_preserves_legacy_gost_when_download_fails() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/install.sh" + + INSTALL_DIR=$(mktemp -d) + cat > "$INSTALL_DIR/flux_agent" <<'EOF' +#!/bin/bash +echo "old version" +EOF + chmod +x "$INSTALL_DIR/flux_agent" + cat > "$INSTALL_DIR/flux_agent.new" <<'EOF' +#!/bin/bash +echo "stale version" +EOF + chmod +x "$INSTALL_DIR/flux_agent.new" + + local cleanup_called="0" + local rc="0" + + ask_proxy_config() { + PROXY_ENABLED="false" + DOWNLOAD_URL="https://example.com/gost" + } + check_and_install_tcpkill() { :; } + cleanup_legacy_gost_installation() { + cleanup_called="1" + } + systemctl() { return 0; } + curl() { return 0; } + + update_flux_agent >/dev/null || rc="$?" + + assert_equals "1" "$rc" "update_flux_agent should fail when the download artifact is missing" + assert_equals "0" "$cleanup_called" "update_flux_agent should preserve legacy gost when download fails" + [[ ! -f "$INSTALL_DIR/flux_agent.new" ]] || fail "update_flux_agent should remove stale download artifacts before retrying" +) + +test_install_flux_agent_writes_json_safe_config() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/install.sh" + + INSTALL_DIR=$(mktemp -d) + SERVER_ADDR='panel"addr' + SECRET='sec\ret"1' + DOWNLOAD_URL="https://example.com/gost" + + ask_proxy_config() { :; } + ensure_download_url_initialized() { :; } + get_config_params() { :; } + check_and_install_tcpkill() { :; } + cleanup_legacy_gost_installation() { :; } + systemctl() { return 0; } + curl() { + local output="" + while [[ $# -gt 0 ]]; do + if [[ "$1" == "-o" ]]; then + output="$2" + shift 2 + continue + fi + shift + done + + cat > "$output" <<'EOF' +#!/bin/bash +echo "new version" +EOF + chmod +x "$output" + } + + ( install_flux_agent >/dev/null 2>/dev/null ) || true + + local actual + actual=$(<"$INSTALL_DIR/config.json") + local expected=$'{\n "addr": "panel\\"addr",\n "secret": "sec\\\\ret\\"1"\n}' + + assert_equals "$expected" "$actual" "install_flux_agent should JSON-escape config values" +) + +test_cleanup_legacy_gost_installation_removes_service_and_binary() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/install.sh" + + LEGACY_GOST_BINARY=$(mktemp) + LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp) + LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u) + LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u) + LEGACY_GOST_CONFIG_DIR=$(mktemp -d) + cat > "$LEGACY_GOST_SERVICE_FILE_ETC" < "$LEGACY_GOST_CONFIG_DIR/config.json" + : > "$LEGACY_GOST_CONFIG_DIR/gost.json" + + local systemctl_calls="" + + systemctl() { + systemctl_calls+=$'\n'"$*" + if [[ "$1" == "list-units" ]]; then + printf 'gost.service loaded active running\n' + fi + return 0 + } + + cleanup_legacy_gost_installation >/dev/null + + if [[ -e "$LEGACY_GOST_BINARY" ]]; then + fail "cleanup_legacy_gost_installation should remove the legacy gost binary" + fi + if [[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]]; then + fail "cleanup_legacy_gost_installation should remove the legacy gost service file" + fi + [[ "$systemctl_calls" == *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should stop the legacy gost service" + [[ "$systemctl_calls" == *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should disable the legacy gost service" + [[ "$systemctl_calls" == *"daemon-reload"* ]] || fail "cleanup_legacy_gost_installation should reload systemd after removing the legacy service" +) + +test_cleanup_legacy_gost_installation_preserves_unrelated_gost() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/install.sh" + + LEGACY_GOST_BINARY=$(mktemp) + LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp) + LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u) + LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u) + LEGACY_GOST_CONFIG_DIR=$(mktemp -d) + cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF' +[Unit] +Description=Unrelated Gost Service + +[Service] +WorkingDirectory=/srv/custom-gost +ExecStart=/usr/local/bin/gost -C /srv/custom-gost/gost.yaml +EOF + + local systemctl_calls="" + + systemctl() { + systemctl_calls+=$'\n'"$*" + if [[ "$1" == "list-units" ]]; then + printf 'gost.service loaded active running\n' + fi + return 0 + } + + cleanup_legacy_gost_installation >/dev/null + + [[ -e "$LEGACY_GOST_BINARY" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost binaries" + [[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost service files" + [[ "$systemctl_calls" != *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should not stop unrelated gost services" + [[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services" +) + test_install_script_accepts_proxy_url_env_without_prompt() ( set -euo pipefail load_script_without_main "$ROOT_DIR/install.sh" @@ -303,6 +501,11 @@ test_install_script_asks_for_proxy_config test_install_script_recomputes_download_url_after_prompt test_update_flux_agent_asks_for_proxy_config test_update_flux_agent_skips_proxy_prompt_when_not_installed +test_install_flux_agent_preserves_legacy_gost_when_download_fails +test_update_flux_agent_preserves_legacy_gost_when_download_fails +test_install_flux_agent_writes_json_safe_config +test_cleanup_legacy_gost_installation_removes_service_and_binary +test_cleanup_legacy_gost_installation_preserves_unrelated_gost test_install_script_accepts_proxy_url_env_without_prompt test_panel_install_script_can_disable_proxy test_panel_install_script_recomputes_compose_urls_after_prompt