Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/actions/daemon/install/daemon_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,9 +641,9 @@ func installDaemonBinaries(
return state, errors.WithMessage(err, "failed to stat file")
}

err = utils.Move(fp, state.DaemonFilePath)
err = utils.ReplaceFile(fp, state.DaemonFilePath, 0755)
if err != nil {
return state, errors.WithMessage(err, "failed to move gameap-daemon binaries")
return state, errors.WithMessage(err, "failed to install gameap-daemon binary")
}
binariesInstalled = true

Expand Down
96 changes: 85 additions & 11 deletions internal/actions/panel/install/checkers_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ package install
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gameap/gameapctl/internal/pkg/gameapctl"
"github.com/gameap/gameapctl/pkg/gameap"
"github.com/gameap/gameapctl/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -191,26 +189,102 @@ func Test_filterAndCheckHostV4(t *testing.T) {
}

func Test_existingPanelDetected_RequiresPreviousInstallationOnTheSamePort(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
port := servePanelHealth(t, panelHealthHandler)

useTemporaryStateDirectory(t)
ctx := context.Background()

// A service answering /api/health without a recorded installation is not our panel.
assert.False(t, existingPanelDetected(ctx, panelInstallStateV4{Port: port}))

require.NoError(t, gameapctl.SavePanelInstallState(ctx, gameapctl.PanelInstallState{
Version: "v4",
Port: port,
}))
t.Cleanup(server.Close)

_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
require.NoError(t, err)
assert.True(t, existingPanelDetected(ctx, panelInstallStateV4{Port: port}))
assert.False(t, existingPanelDetected(ctx, panelInstallStateV4{Port: "1"}))
}

func Test_existingPanelDetected_RejectsSPAFallback(t *testing.T) {
port := servePanelHealth(t, spaFallbackHandler)

useTemporaryStateDirectory(t)
ctx := context.Background()

// A foreign service answering /health with 200 is not a GameAP panel.
assert.False(t, existingPanelDetected(ctx, port))
require.NoError(t, gameapctl.SavePanelInstallState(ctx, gameapctl.PanelInstallState{
Version: "v4",
Port: port,
}))

// index.html with 200 on every path is not a ready panel.
assert.False(t, existingPanelDetected(ctx, panelInstallStateV4{Port: port}))
}

func Test_existingPanelDetected_RejectsOtherScope(t *testing.T) {
port := servePanelHealth(t, panelHealthHandler)

useTemporaryStateDirectory(t)
ctx := context.Background()

require.NoError(t, gameapctl.SavePanelInstallState(ctx, gameapctl.PanelInstallState{
Version: "v4",
Scope: gameap.ScopeUser,
Port: port,
}))

assert.True(t, existingPanelDetected(ctx, port))
assert.False(t, existingPanelDetected(ctx, panelInstallStateV4{Scope: gameap.ScopeSystem, Port: port}))
assert.True(t, existingPanelDetected(ctx, panelInstallStateV4{Scope: gameap.ScopeUser, Port: port}))
}

func Test_existingPanelDetected_ProbesPreviousHost(t *testing.T) {
port := servePanelHealth(t, panelHealthHandler)

useTemporaryStateDirectory(t)
ctx := context.Background()

require.NoError(t, gameapctl.SavePanelInstallState(ctx, gameapctl.PanelInstallState{
Version: "v4",
Host: "127.0.0.1",
HostIP: "127.0.0.1",
Port: port,
}))

assert.True(t, existingPanelDetected(ctx, panelInstallStateV4{Port: port}))
}

func Test_panelProbeHosts(t *testing.T) {
tests := []struct {
name string
state gameapctl.PanelInstallState
expected []string
}{
{
name: "empty_state_falls_back_to_loopback",
state: gameapctl.PanelInstallState{},
expected: []string{"127.0.0.1"},
},
{
name: "ip_host_is_not_duplicated",
state: gameapctl.PanelInstallState{Host: "2.29.29.94", HostIP: "2.29.29.94"},
expected: []string{"2.29.29.94", "127.0.0.1"},
},
{
name: "domain_host_then_its_ip",
state: gameapctl.PanelInstallState{Host: "panel.example.com", HostIP: "10.0.0.5"},
expected: []string{"panel.example.com", "10.0.0.5", "127.0.0.1"},
},
{
name: "loopback_host_once",
state: gameapctl.PanelInstallState{Host: "127.0.0.1"},
expected: []string{"127.0.0.1"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, panelProbeHosts(test.state))
})
}
}

func Test_checkPortAvailabilityV4_ReplacesOccupiedDefaultPort(t *testing.T) {
Expand Down
69 changes: 45 additions & 24 deletions internal/actions/panel/install/checkers_v4.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"runtime"
"slices"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -57,7 +58,7 @@ func checkPortAvailabilityV4(ctx context.Context, state panelInstallStateV4) (pa
state.Port = defaultPortForScope(state.Scope)
}

if existingPanelDetected(ctx, state.Port) {
if existingPanelDetected(ctx, state) {
fmt.Println("Existing GameAP panel detected on port", state.Port)

return state, nil
Expand Down Expand Up @@ -114,42 +115,62 @@ func checkPortAvailabilityV4(ctx context.Context, state panelInstallStateV4) (pa
return state, nil
}

const loopbackHost = "127.0.0.1"

// existingPanelDetected reports whether the panel of a previous installation already
// answers on the port. This is common during re-installation, and such a port must not be
// treated as occupied. A previous installation on the very same port is required as well:
// any local service can answer /health with 200, and trusting one of those would leave the
// panel configured for a port it cannot bind.
func existingPanelDetected(ctx context.Context, port string) bool {
if !previouslyInstalledOnPort(ctx, port) {
// treated as occupied. A previous installation of the same scope on the very same port is
// required as well: any local service can answer with 200, and trusting one of those would
// leave the panel configured for a port it cannot bind.
//
// The previous panel listens on its configured HTTP_HOST, usually the public address rather
// than loopback, so that address is probed first. A panel that already redirects plain HTTP
// to HTTPS is not recognised here.
func existingPanelDetected(ctx context.Context, state panelInstallStateV4) bool {
prev, ok := previousInstallation(ctx, state.Scope)
if !ok || prev.Port != state.Port {
return false
}

client := &http.Client{Timeout: 2 * time.Second}

healthURL := fmt.Sprintf("http://127.0.0.1:%s/health", port)
healthReq, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return false
for _, host := range panelProbeHosts(prev) {
if checkPanelHealth(ctx, host, state.Port, panelProbeTimeout) == nil {
return true
}
}

resp, err := client.Do(healthReq)
if err != nil {
return false
return false
}

// panelProbeHosts lists the addresses a previous installation may answer on, most
// specific first: the configured host, its resolved IP, then loopback.
func panelProbeHosts(prev gameapctl.PanelInstallState) []string {
candidates := []string{prev.Host, prev.HostIP, loopbackHost}

hosts := make([]string, 0, len(candidates))
for _, host := range candidates {
if host == "" || slices.Contains(hosts, host) {
continue
}

hosts = append(hosts, host)
}
defer func() {
_ = resp.Body.Close()
}()

return resp.StatusCode == http.StatusOK
return hosts
}

func previouslyInstalledOnPort(ctx context.Context, port string) bool {
prevState, err := gameapctl.LoadPanelInstallState(ctx)
if err != nil {
return false
// previousInstallation loads the state of a previous v4 installation of the same scope.
// A panel of the other scope runs from different paths and is never replaced by this one.
func previousInstallation(ctx context.Context, scope string) (gameapctl.PanelInstallState, bool) {
prev, err := gameapctl.LoadPanelInstallState(ctx)
if err != nil || !isPrevStateV4(prev.Version) {
return gameapctl.PanelInstallState{}, false
}

if gameap.ScopeOrDefault(prev.Scope) != gameap.ScopeOrDefault(scope) {
return gameapctl.PanelInstallState{}, false
}

return isPrevStateV4(prevState.Version) && prevState.Port == port
return prev, true
}

func checkHTTPHostAvailabilityV4(ctx context.Context, state panelInstallStateV4) (panelInstallStateV4, error) {
Expand Down
133 changes: 133 additions & 0 deletions internal/actions/panel/install/health_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package install

import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
)

const (
healthOKBody = `{"status":"ok"}`

testRetryDelay = time.Millisecond
testLongRetryDelay = 10 * time.Second
testCancelAfter = 50 * time.Millisecond
testPromptReturn = time.Second
)

// servePanelHealth runs a fake panel and returns the port it listens on (127.0.0.1).
func servePanelHealth(t *testing.T, handler http.HandlerFunc) string {
t.Helper()

server := httptest.NewServer(handler)
t.Cleanup(server.Close)

_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
require.NoError(t, err)

return port
}

// panelHealthHandler answers like GameAP v4: JSON on /api/health, 404 elsewhere.
func panelHealthHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/health" {
http.NotFound(w, r)

return
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(healthOKBody))
}

// spaFallbackHandler answers every path with index.html, as the panel does for unknown routes.
func spaFallbackHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<!DOCTYPE html><html><body>GameAP</body></html>"))
}

func unavailableHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}

func localPanelState(port string) panelInstallStateV4 {
return panelInstallStateV4{Host: "127.0.0.1", Port: port}
}

func Test_waitForPanelHealthCheck_Succeeds(t *testing.T) {
port := servePanelHealth(t, panelHealthHandler)

err := waitForPanelHealthCheck(context.Background(), localPanelState(port), 1, testRetryDelay)

require.NoError(t, err)
}

func Test_waitForPanelHealthCheck_RetriesUntilReady(t *testing.T) {
var mu sync.Mutex
calls := 0

port := servePanelHealth(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
attempt := calls
mu.Unlock()

if attempt == 1 {
unavailableHandler(w, r)

return
}

panelHealthHandler(w, r)
})

err := waitForPanelHealthCheck(context.Background(), localPanelState(port), healthCheckRetries, testRetryDelay)

require.NoError(t, err)

mu.Lock()
defer mu.Unlock()
require.Equal(t, 2, calls)
}

func Test_waitForPanelHealthCheck_RejectsSPAFallback(t *testing.T) {
port := servePanelHealth(t, spaFallbackHandler)

err := waitForPanelHealthCheck(context.Background(), localPanelState(port), 1, testRetryDelay)

require.ErrorContains(t, err, errPanelNotReady.Error())
}

func Test_waitForPanelHealthCheck_ReturnsPromptlyWithCancelledContext(t *testing.T) {
port := servePanelHealth(t, unavailableHandler)

ctx, cancel := context.WithCancel(context.Background())
cancel()

started := time.Now()
err := waitForPanelHealthCheck(ctx, localPanelState(port), healthCheckRetries, healthCheckInterval)

require.ErrorIs(t, err, context.Canceled)
require.Less(t, time.Since(started), testPromptReturn)
}

func Test_waitForPanelHealthCheck_StopsWaitingWhenContextIsCancelled(t *testing.T) {
port := servePanelHealth(t, unavailableHandler)

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
time.AfterFunc(testCancelAfter, cancel)

started := time.Now()
err := waitForPanelHealthCheck(ctx, localPanelState(port), healthCheckRetries, testLongRetryDelay)

require.ErrorIs(t, err, context.Canceled)
require.Less(t, time.Since(started), testPromptReturn)
}
Loading
Loading