diff --git a/docker-compose.yml b/docker-compose.yml index 6ac7862..aae6f03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: PLATFORM: docker DB_NAME: /data/db/acc.db STEAMCMD_LINUX_PATH: /usr/games/steamcmd - PORT_POOL_MIN: "9601" + PORT_POOL_MIN: "9600" PORT_POOL_MAX: "9699" ACC_IMAGE: acc-wine:latest ACC_SERVERS_PATH: /data/servers diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile index 47e0bd0..0e67a18 100644 --- a/docker/acc-server/Dockerfile +++ b/docker/acc-server/Dockerfile @@ -1,6 +1,5 @@ # ACC Server container: Wine + accServer.exe -# Game files are mounted read-only from the shared acc-game volume. -# Per-server config files are mounted read-write from the manager's config volume. +# Game files are bind-mounted from the host at /acc/game. FROM debian:bookworm-slim # Install Wine, Xvfb (headless display for Wine), and minimal runtime deps. @@ -12,6 +11,7 @@ RUN dpkg --add-architecture i386 && \ wine64 \ xvfb \ ca-certificates \ + tcpdump \ && \ rm -rf /var/lib/apt/lists/* @@ -19,6 +19,10 @@ RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix WORKDIR /root +ENV WINEARCH=win64 +ENV WINEPREFIX=/root/.wine +ENV WINEDEBUG=-all + # Copy entrypoint script. COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh @@ -26,11 +30,6 @@ RUN chmod +x /entrypoint.sh # Bind-mounted by DockerRuntime.Create: per-server game install (rw) VOLUME ["/acc/game"] -# Default server port — overridden at runtime via SERVER_PORT env var. ENV SERVER_PORT=9600 -ENV WINEDEBUG=-all - -EXPOSE ${SERVER_PORT}/tcp -EXPOSE ${SERVER_PORT}/udp ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh index 67d6e1b..784273d 100644 --- a/docker/acc-server/entrypoint.sh +++ b/docker/acc-server/entrypoint.sh @@ -1,26 +1,78 @@ #!/bin/bash set -e -# Use a display number derived from the server port to avoid conflicts -# between containers (each server gets its own unique display). DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) export DISPLAY=:${DISPLAY_NUM} -# Clean up any stale X lock from a previous container run. rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp & XVFB_PID=$! +TCPDUMP_PID="" + cleanup() { echo "Stopping ACC server..." - kill $XVFB_PID 2>/dev/null || true + [ -n "${TCPDUMP_PID}" ] && kill "${TCPDUMP_PID}" 2>/dev/null || true + kill "${XVFB_PID}" 2>/dev/null || true } -trap cleanup SIGTERM SIGINT +trap cleanup SIGTERM SIGINT EXIT + +# Initialize Wine prefix (no-op if already done) +wineboot -u 2>/dev/null mkdir -p /acc/game/log -echo "Starting ACC server on port ${SERVER_PORT}..." +# ─── Debug mode ─────────────────────────────────────────────────────────────── +# Set DEBUG_MODE=1 on the container to enable extra diagnostics: +# +# docker run -e DEBUG_MODE=1 ... +# or in docker-compose: +# environment: +# DEBUG_MODE: "1" +# +# Produces: +# /acc/game/log/tcpdump_.pcap — raw packet capture (open in Wireshark) +# /acc/game/log/wine_debug.log — Wine debug output +# Stderr: Wine version, WINEDEBUG channel, prefix state +# +# You can also override which Wine debug channels are enabled: +# WINEDEBUG_CHANNELS="+loaddll,+seh,+relay" (relay is very verbose) +# ───────────────────────────────────────────────────────────────────────────── +if [ "${DEBUG_MODE:-0}" = "1" ]; then + echo "[debug] Debug mode enabled for port ${SERVER_PORT}" + echo "[debug] Wine: $(wine --version 2>/dev/null || echo unknown)" + echo "[debug] WINEPREFIX: ${WINEPREFIX}" -cd /acc/game -exec wine /acc/game/accServer.exe + # Configure Wine debug channels (default: loader + exception + module info) + export WINEDEBUG="${WINEDEBUG_CHANNELS:-+loaddll,+module,+seh,+ntdll}" + echo "[debug] WINEDEBUG=${WINEDEBUG}" + + # Start packet capture on the server port — captures both TCP and UDP. + # The pcap file records every byte Kunos sends/receives so you can inspect + # the 2-byte "pakSizeMax" packet and anything that follows. + # + # NOTE: requires CAP_NET_RAW capability on the container: + # docker-compose: cap_add: [NET_RAW] + # docker run: --cap-add=NET_RAW + # Without it tcpdump will be skipped (no crash, just no capture). + CAPTURE_FILE="/acc/game/log/tcpdump_${SERVER_PORT}.pcap" + echo "[debug] Starting packet capture → ${CAPTURE_FILE}" + echo "[debug] (requires cap_add: [NET_RAW] in docker-compose — will exit silently if missing)" + tcpdump -i any -nn -s 0 -w "${CAPTURE_FILE}" \ + "port ${SERVER_PORT}" 2>/acc/game/log/tcpdump.err & + TCPDUMP_PID=$! + + # Redirect Wine debug output to a log file (Wine writes to stderr). + WINE_LOG="/acc/game/log/wine_debug.log" + echo "[debug] Wine debug log → ${WINE_LOG}" + echo "Starting ACC server on port ${SERVER_PORT} (DEBUG)..." + cd /acc/game + exec wine /acc/game/accServer.exe 2>"${WINE_LOG}" +else + # Production: suppress all Wine debug output. + export WINEDEBUG=-all + echo "Starting ACC server on port ${SERVER_PORT}..." + cd /acc/game + exec wine /acc/game/accServer.exe +fi diff --git a/local/service/config.go b/local/service/config.go index 09363f1..8b45e3e 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -420,6 +420,14 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur return &config, nil } +func (as *ConfigService) SetIgnorePrematureDisconnects(server *model.Server, value int) error { + configMap := map[string]interface{}{ + "ignorePrematureDisconnects": value, + } + _, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false) + return err +} + func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.Configuration) error { configMap := make(map[string]interface{}) configBytes, err := json.Marshal(config) diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go index 7ecd23d..addcc16 100644 --- a/local/service/docker_runtime.go +++ b/local/service/docker_runtime.go @@ -33,21 +33,21 @@ func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerReposi func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error { image := env.GetACCImage() - tcpPortStr := strconv.Itoa(server.Port) - udpPortStr := strconv.Itoa(server.Port - 1) + // ACC uses the same port number for both TCP (client connections) and UDP (car positions/ping). + portStr := strconv.Itoa(server.Port) - natTCP, err := nat.NewPort("tcp", tcpPortStr) + natTCP, err := nat.NewPort("tcp", portStr) if err != nil { - return fmt.Errorf("invalid tcp port %d: %v", server.Port, err) + return fmt.Errorf("invalid port %d: %v", server.Port, err) } - natUDP, err := nat.NewPort("udp", udpPortStr) + natUDP, err := nat.NewPort("udp", portStr) if err != nil { - return fmt.Errorf("invalid udp port %d: %v", server.Port-1, err) + return fmt.Errorf("invalid port %d: %v", server.Port, err) } portBindings := nat.PortMap{ - natTCP: []nat.PortBinding{{HostPort: tcpPortStr}}, - natUDP: []nat.PortBinding{{HostPort: udpPortStr}}, + natTCP: []nat.PortBinding{{HostPort: portStr}}, + natUDP: []nat.PortBinding{{HostPort: portStr}}, } exposedPorts := nat.PortSet{ natTCP: struct{}{}, @@ -73,6 +73,8 @@ func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error Binds: binds, PortBindings: portBindings, RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + // NET_RAW is required for tcpdump packet capture in debug mode (DEBUG_MODE=1). + CapAdd: []string{"NET_RAW"}, }, nil, nil, server.ServiceName) if err != nil { return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err) @@ -104,10 +106,19 @@ func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { } func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { - containerID, err := r.resolveContainerID(ctx, serverID) + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) if err != nil { return "", err } + if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { return "", fmt.Errorf("failed to start container: %v", err) } @@ -126,16 +137,44 @@ func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, e } func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { - containerID, err := r.resolveContainerID(ctx, serverID) + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) if err != nil { return "", err } + if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil { return "", fmt.Errorf("failed to restart container: %v", err) } return model.StatusRunning.String(), nil } +// ensureContainer returns the container ID for a server, recreating the container +// from the server's current DB record if it no longer exists in Docker. +func (r *DockerRuntime) ensureContainer(ctx context.Context, server *model.Server) (string, error) { + if server.ContainerID != "" { + if _, err := r.client.ContainerInspect(ctx, server.ContainerID); err == nil { + return server.ContainerID, nil + } + logging.Info("Container %s for server %s no longer exists, recreating", server.ContainerID[:min(12, len(server.ContainerID))], server.ID) + } + + if err := r.Create(ctx, server); err != nil { + return "", fmt.Errorf("failed to recreate container: %v", err) + } + if err := r.repo.Update(ctx, server); err != nil { + return "", fmt.Errorf("container recreated but failed to persist container ID: %v", err) + } + return server.ContainerID, nil +} + func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { server, err := r.repo.GetByID(ctx, serverID) if err != nil || server == nil { diff --git a/local/service/server.go b/local/service/server.go index 3318111..84e8325 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -574,10 +574,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error { config.TcpPort = model.IntString(port) config.UdpPort = model.IntString(port) + config.RegisterToLobby = model.IntString(1) if err := s.configService.SaveConfiguration(server, config); err != nil { return fmt.Errorf("failed to save server configuration: %v", err) } + if env.IsDockerPlatform() { + if err := s.configService.SetIgnorePrematureDisconnects(server, 0); err != nil { + logging.Error("Failed to set ignorePrematureDisconnects for Docker server: %v", err) + } + } + return nil } diff --git a/tests/test_helper.go b/tests/test_helper.go index b6d0132..b029921 100755 --- a/tests/test_helper.go +++ b/tests/test_helper.go @@ -109,7 +109,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { configFiles := map[string]string{ "configuration.json": `{ "udpPort": "9231", - "tcpPort": "9232", + "tcpPort": "9231", "maxConnections": "30", "lanDiscovery": "1", "registerToLobby": "1", @@ -198,7 +198,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { sampleConfig := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/unit/controller/controller_simple_test.go b/tests/unit/controller/controller_simple_test.go index e01581c..cd14601 100755 --- a/tests/unit/controller/controller_simple_test.go +++ b/tests/unit/controller/controller_simple_test.go @@ -294,7 +294,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { app.Get("/config", func(c *fiber.Ctx) error { config := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), @@ -316,7 +316,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { tests.AssertNoError(t, err) tests.AssertEqual(t, model.IntString(9231), response.UdpPort) - tests.AssertEqual(t, model.IntString(9232), response.TcpPort) + tests.AssertEqual(t, model.IntString(9231), response.TcpPort) tests.AssertEqual(t, model.IntString(30), response.MaxConnections) tests.AssertEqual(t, model.IntString(1), response.LanDiscovery) tests.AssertEqual(t, model.IntString(1), response.RegisterToLobby) diff --git a/tests/unit/service/cache_service_test.go b/tests/unit/service/cache_service_test.go index 95862af..2d69942 100755 --- a/tests/unit/service/cache_service_test.go +++ b/tests/unit/service/cache_service_test.go @@ -464,7 +464,7 @@ func TestServerConfigCache_Configuration(t *testing.T) { serverID := uuid.New().String() configuration := model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go index 3d441e8..ecc3d41 100755 --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -27,7 +27,7 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { tests.AssertNotNil(t, config) tests.AssertEqual(t, model.IntString(9231), config.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config.TcpPort) tests.AssertEqual(t, model.IntString(30), config.MaxConnections) tests.AssertEqual(t, model.IntString(1), config.LanDiscovery) tests.AssertEqual(t, model.IntString(1), config.RegisterToLobby) @@ -122,7 +122,7 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { tests.AssertNotNil(t, configs) tests.AssertEqual(t, model.IntString(9231), configs.Configuration.UdpPort) - tests.AssertEqual(t, model.IntString(9232), configs.Configuration.TcpPort) + tests.AssertEqual(t, model.IntString(9231), configs.Configuration.TcpPort) tests.AssertEqual(t, "Test ACC Server", configs.Settings.ServerName) tests.AssertEqual(t, "admin123", configs.Settings.AdminPassword) tests.AssertEqual(t, "spa", configs.Event.Track) @@ -264,7 +264,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { tests.AssertNotNil(t, config2) tests.AssertEqual(t, model.IntString(9231), config2.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config2.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config2.TcpPort) } func TestConfigService_Caching_EventConfig(t *testing.T) {