diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c389d4d2..4e6c45ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,7 +91,10 @@ jobs: - name: Test run: | - python -m pytest -q tools/test_linux_port.py tools/test_android_asm_convert.py tools/test_android_mirror_uploads.py tools/test_android_ce_texture_cache.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py tools/stagekit/tests tools/test_net_compare.py + python -m pytest -q tools/test_linux_port.py tools/test_android_asm_convert.py tools/test_android_mirror_uploads.py tools/test_android_ce_texture_cache.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py tools/stagekit/tests tools/test_net_compare.py tools/test_server_sim.py tools/test_egg.py tools/test_deploy_gametypes.py + # the dedicated server: --version, and --server without maps refused (exit code 2); its cases with + # clients need the retail maps, and are skipped + python -m pytest -q tools/test_dedicated_server.py python tools/test_light_storage.py - name: Compile cache @@ -317,6 +320,8 @@ jobs: run: | source ~/emsdk/emsdk_env.sh node --test "port/web/tests/*.test.js" + # (the dedicated server's admin page, port/assets/server_ui, against tools/server_sim.py) + node --test tools/server_ui_tests/ python3 -m pytest -q tools/test_web_build.py - name: Test in Chromium and Firefox @@ -349,8 +354,11 @@ jobs: # The eight builds (and the Windows builds' PDBs) as a release, tagged # build- and marked latest # (every file keeps its name from release to release, for the README's - # latest/download links); then all but the newest five build-* releases - # go, tags and all. Only for main, and only when every platform built. + # latest/download links), with their SHA256SUMS and, when the + # RELEASE_SIGNING_KEY secret is set, its Ed25519 signature by the release + # key (SHA256SUMS.sig; the public key is deploy/release-key.pub); then all + # but the newest five build-* releases go, tags and all. Only for main, + # and only when every platform built. needs: [linux, android, windows, web] if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-24.04 @@ -363,6 +371,15 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} steps: + # (tools/ci_build.py and tools/release_sign.sh, and the public key) + - uses: actions/checkout@v4 + with: + sparse-checkout: | + tools/ci_build.py + tools/release_sign.sh + deploy/release-key.pub + sparse-checkout-cone-mode: false + - uses: actions/download-artifact@v4 with: pattern: halo-* @@ -375,8 +392,24 @@ jobs: name="$(basename "$build")" (cd "$build" && zip -qr "$GITHUB_WORKSPACE/release/$name.zip" .) done + python3 tools/ci_build.py sums release ls -l release + # SHA256SUMS signed with the release key's seed (the RELEASE_SIGNING_KEY + # secret, 64 hex digits), and the signature checked against + # deploy/release-key.pub before anything is published: a secret that is + # not the key of the public key in the repository stops the release + - name: Sign the sums + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + run: | + if [ -z "$RELEASE_SIGNING_KEY" ]; then + echo "::warning::no RELEASE_SIGNING_KEY secret: the release's SHA256SUMS is not signed" + exit 0 + fi + sh tools/release_sign.sh sign release/SHA256SUMS + sh tools/release_sign.sh verify release/SHA256SUMS + - name: Publish id: publish env: @@ -388,7 +421,7 @@ jobs: subject="$(printf '%s' "${MESSAGE:-manual run}" | head -n 1)" printf '%s\n\nCommit: %s\nBuild: %s\n\nThe release builds are for playing; the debug builds stop at the first failed assertion and log it.\n' \ "$subject" "$COMMIT_URL" "$RUN_URL" > notes.md - gh release create "$tag" release/*.zip --target "$GITHUB_SHA" --latest \ + gh release create "$tag" release/* --target "$GITHUB_SHA" --latest \ --title "Build $GITHUB_RUN_NUMBER (${GITHUB_SHA:0:7})" --notes-file notes.md echo "tag=$tag" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/server-image.yml b/.github/workflows/server-image.yml new file mode 100644 index 00000000..959feab6 --- /dev/null +++ b/.github/workflows/server-image.yml @@ -0,0 +1,199 @@ +name: Server image + +# The dedicated server's container image (deploy/docker), made from a +# release's halo-linux-release.zip, and the Helm chart's checks +# (deploy/helm/opence-server). +# +# - After each successful Build of main (which publishes release build-): +# the image of that release, checked, pushed to +# ghcr.io/opencommunityedition/opence-server as sha-, build- and +# latest, and signed keyless with cosign (this workflow's GitHub identity). +# - Weekly, the latest release's image again, for Debian's fixes. +# - By hand, any release's (latest moves only for the newest). +# +# The checks (no game data in CI: it is never published): the image runs as +# 10001:10001; started on a read-only root without maps it writes its +# settings to /state, says "server: no usable maps" and exits 2; halo +# --version answers; the health check fails with nothing listening. +# +# Verify: +# cosign verify ghcr.io/opencommunityedition/opence-server:latest \ +# --certificate-identity-regexp '^https://github\.com/OpenCommunityEdition/OpenCE/\.github/workflows/server-image\.yml@' \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com +on: + workflow_run: + workflows: [Build] + types: [completed] + branches: [main] + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + inputs: + release: + description: "The release to build the image of: a tag (build-206) or latest" + default: latest + +concurrency: + group: server-image-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + IMAGE: ghcr.io/opencommunityedition/opence-server + +jobs: + chart: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - uses: azure/setup-helm@v4 + + - name: Lint + run: | + set -euo pipefail + chart=deploy/helm/opence-server + for values in "$chart"/ci/*-values.yaml; do + helm lint "$chart" --strict -f "$values" + helm template ci "$chart" -f "$values" > /dev/null + done + # the maps are never in the image: without game data the chart refuses to render + if helm template ci "$chart" > /dev/null 2>&1; then + echo "::error::the chart renders without gameData"; exit 1 + fi + + - name: Unit tests + run: | + set -euo pipefail + # (Helm 4 checks plugins' signatures, which helm-unittest does not publish) + if helm version --short | grep -q '^v3'; then verify=; else verify=--verify=false; fi + helm plugin install https://github.com/helm-unittest/helm-unittest $verify + helm unittest deploy/helm/opence-server + + image: + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + # keyless signing: the workflow's OIDC token gets a short-lived Sigstore certificate + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Release + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + WANTED: ${{ inputs.release }} + run: | + set -euo pipefail + case "$EVENT" in + workflow_run) tag="build-$RUN_NUMBER" ;; + workflow_dispatch) tag="${WANTED:-latest}" ;; + *) tag=latest ;; + esac + newest="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)" + if [ "$tag" = latest ]; then + tag="$newest" + fi + commit="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json targetCommitish -q .targetCommitish)" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern halo-linux-release.zip --dir deploy/docker + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "commit=$commit" >> "$GITHUB_OUTPUT" + echo "newest=$([ "$tag" = "$newest" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "Release $tag (commit $commit; the newest: $newest)" + + - name: Build + env: + TAG: ${{ steps.release.outputs.tag }} + COMMIT: ${{ steps.release.outputs.commit }} + run: | + set -euo pipefail + docker build --pull -t opence-server:ci \ + --build-arg RELEASE=halo-linux-release.zip \ + --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --label org.opencontainers.image.revision="$COMMIT" \ + --label org.opencontainers.image.version="$TAG" \ + --label org.opencontainers.image.licenses=AGPL-3.0-or-later \ + --label org.opencontainers.image.title="OpenCE dedicated server" \ + deploy/docker + + - name: Smoke test + run: | + set -euo pipefail + [ "$(docker image inspect -f '{{.Config.User}}' opence-server:ci)" = 10001:10001 ] \ + || { echo "::error::the image does not run as 10001:10001"; exit 1; } + docker run --rm --read-only --entrypoint /opt/opence/halo opence-server:ci --version | tee version.txt + grep -Eq '^OpenCE build-[0-9]+ \(network version [0-9]+\)$' version.txt \ + || { echo "::error::halo --version did not answer"; exit 1; } + # without maps, on a read-only root with /state the one writable folder + docker volume create smoke-state > /dev/null + status=0 + timeout 60 docker run --name smoke --read-only --cap-drop ALL --security-opt no-new-privileges \ + --tmpfs /tmp -v smoke-state:/state -e HALO_SERVER_WEB_PASSWORD=ci-smoke-password \ + opence-server:ci > server.log 2>&1 || status=$? + cat server.log + docker rm -f smoke > /dev/null + [ "$status" = 2 ] || { echo "::error::the server without maps exited with $status, not 2"; exit 1; } + grep -qF 'server: no usable maps' server.log \ + || { echo "::error::no 'server: no usable maps' line"; exit 1; } + if grep -qF 'server: ready' server.log; then echo "::error::the server said it was ready"; exit 1; fi + docker run --rm -v smoke-state:/state --entrypoint /bin/bash opence-server:ci -c 'test -s /state/config.toml' \ + || { echo "::error::no /state/config.toml"; exit 1; } + # the health check, with nothing listening + if docker run --rm --entrypoint /opt/opence/healthz.sh opence-server:ci; then + echo "::error::the health check passed with no server"; exit 1 + fi + docker volume rm smoke-state > /dev/null + + - uses: docker/login-action@v3 + if: github.repository == 'OpenCommunityEdition/OpenCE' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: sigstore/cosign-installer@v3 + if: github.repository == 'OpenCommunityEdition/OpenCE' + + - name: Push and sign + if: github.repository == 'OpenCommunityEdition/OpenCE' + env: + TAG: ${{ steps.release.outputs.tag }} + COMMIT: ${{ steps.release.outputs.commit }} + NEWEST: ${{ steps.release.outputs.newest }} + IDENTITY: ^https://github\.com/${{ github.repository }}/\.github/workflows/server-image\.yml@ + run: | + set -euo pipefail + tags=("sha-${COMMIT:0:7}" "$TAG") + if [ "$NEWEST" = true ]; then + tags+=(latest) + fi + for tag in "${tags[@]}"; do + docker tag opence-server:ci "$IMAGE:$tag" + docker push "$IMAGE:$tag" + done + ref="$(docker image inspect -f '{{index .RepoDigests 0}}' "$IMAGE:$TAG")" + test -n "$ref" + cosign sign --yes "$ref" + cosign verify "$ref" --certificate-identity-regexp "$IDENTITY" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com > /dev/null + { + echo "Pushed and signed $ref as ${tags[*]}." + echo + echo "Verify: cosign verify $IMAGE:$TAG --certificate-identity-regexp '$IDENTITY' --certificate-oidc-issuer https://token.actions.githubusercontent.com" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 96741ef9..babed4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,19 +15,20 @@ end. | Path | Contents | | --- | --- | | `source/` | The game: 466 C files of the decompilation, compiled as they are with few changes (each marked, below) | -| `port/linux/src/` | The platform layer the three ports share: Direct3D 8 on OpenGL (`d3d8_gl.c`), DirectSound and XInput on SDL3, files, threads, memory, sockets, settings (`port_config.c`), the mods folder (`port_mods.c`), internet play (`p2p*.c`) | -| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, OpenCE's gametypes (`game_modes.c`, `infection.c`, `gun_game.c`), map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), streamer mode's word filter (`text_filter*.c`), touch, FOV and more | +| `port/linux/src/` | The platform layer the three ports share: Direct3D 8 on OpenGL (`d3d8_gl.c`), DirectSound and XInput on SDL3, files, threads, memory, sockets, settings (`port_config.c`), the mods folder (`port_mods.c`), internet play (`p2p*.c`), the dedicated server's platform side (`server_*.c`: the mode, the log, the rotation's grammar, HTTP, RCON, the master servers' publisher) and the master servers' list (`master_list.c`) | +| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, OpenCE's gametypes (`game_modes.c`, `infection.c`, `gun_game.c`), map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), streamer mode's word filter (`text_filter*.c`), the dedicated server's director, commands, gametypes and status (`server_*.c`), touch, FOV and more | | `port/linux/include/` | The port's headers: the limits (`halo_port_limits.h`, with `HALO_PORT_NETWORK_VERSION`), capacities (`halo_port_capacity.h`), the prefix header, `halo_linux_source_fixups.h` (port functions the game's sources call) | | `port/include/xdk/` | Stand-ins for the Xbox SDK's headers (declarations only) | | `port/windows/` | The Windows build's own files (`win32_*.c`, headers) | | `port/android/` | The Android app: the guest runtime, the host library (`host/`), the Java app (`app/`), host imports (`host_imports.list`) | | `port/web/` | The web build: its runtime (`src/`: entry, sockets inside the page, memory watch), the page and its service worker (`site/`), the SDL port, tests | -| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers | +| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers and master servers, the dedicated server's web page (`server_ui/`) | | `port/third_party/` | Vendored libraries, each with a README naming its upstream, version and checksum | | `port/tools/` | Stand-alone tools (`cache_file_report.c`) | -| `tools/` | Build scripts (`linux_build.py`, `windows_build.py`, `android_build.py`, `web_build.py`, `ci_build.py`, `web_serve.py`), generators (`ce_menus.py`, `port_settings.py`, `hud_assets.py`, `title_assets.py`, `embed_assets.py`, `xdk_headers.py`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) | -| `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.md`) | +| `tools/` | Build scripts (`linux_build.py`, `windows_build.py`, `android_build.py`, `web_build.py`, `ci_build.py`, `web_serve.py`), generators (`ce_menus.py`, `port_settings.py`, `hud_assets.py`, `title_assets.py`, `embed_assets.py`, `server_ui_embed.py`, `xdk_headers.py`), the dedicated server's protocol simulator and remote console (`server_sim.py`, `opence_rcon.py`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) | +| `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.md`, `dedicated_server.md`, `master_protocol.md`) | | `pgo/` | Profile-guided optimisation profiles | +| `deploy/` | Running a dedicated server: systemd, Docker, Pterodactyl, Helm, PufferPanel, MCSManager, example gametypes | | `assets/` | Game data for local runs (gitignored: `maps/`, `custom_maps/`) | | `.github/workflows/` | CI: builds every platform on each push, publishes releases from `main` | @@ -94,6 +95,8 @@ python tools/test_death_timing.py python -m pytest -q tools/test_web_build.py # the web build's graph, sockets, menus node --test "port/web/tests/*.test.js" # the web page's importer and service worker node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers (Playwright) +python -m pytest -q tools/test_dedicated_server.py # the dedicated server with clients (needs assets/maps) +python -m pytest -q tools/test_server_sim.py tools/test_egg.py # the server's web page and protocol simulator, the egg python -m pytest -q tools/test_net_compare.py # net_compare.py's judging of logs, its copies and their processes python tools/net_compare.py --a build/linux/halo --b --data --swap # two builds in one network game, in step (retail maps: local only) @@ -110,6 +113,9 @@ Running the game without a person at it: several copies play on one machine on different loopback addresses (`HALO_NET_ADDRESS`, `HALO_NET_BROADCAST`). `NETCODE.md` describes the test settings. +- `halo --server` is a dedicated server (`docs/dedicated_server.md`), and + `halo --probe []` prints a game's advertisement as JSON and exits; + `tools/test_dedicated_server.py` runs both with clients. - `debug.exit_after`, `debug.hidden_window`, `debug.null_renderer`, `debug.menu_open` (start on one menu screen), `debug.telnet_console`. - Use a virtual display (Xvfb, with `SDL_VIDEO_DRIVER=x11`) for windowed @@ -231,6 +237,8 @@ The decompilation's upstream, punpckhdq/halo, is AGPL 3.0 from 17 October | [port/web/README.md](port/web/README.md) | The web build, the page, the game data in the browser, limits, how it operates, hosting on GitHub Pages | | [port/include/xdk/README.md](port/include/xdk/README.md) | How the SDK declarations were written and are checked | | [docs/custom_edition_caches.md](docs/custom_edition_caches.md) | Loading and converting Custom Edition maps, and the evidence for each layout | +| [docs/dedicated_server.md](docs/dedicated_server.md) | The dedicated server: its command line, exit codes, commands, HTTP API, RCON, events and files | +| [docs/master_protocol.md](docs/master_protocol.md) | The master servers' protocol: publishing, the probe, the signed list | | [docs/mods.md](docs/mods.md) | Mods: folders of Halo PC tag files whose sounds, bitmaps, text and models play over a map's on one machine | | [port/assets/text_filter/README.md](port/assets/text_filter/README.md) | Streamer mode's words: Surge AI's list, the review, the allowed words | | [port/assets/menus/README.md](port/assets/menus/README.md) | The menu files' format and how to write them again | diff --git a/README.md b/README.md index 379445d7..9287b502 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ release, and the web site then plays it. The [Releases](https://github.com/OpenC page keeps the last five releases. If the latest build has a problem, get an older build from that page. +Each release also has `SHA256SUMS`, the SHA-256 of each of its files, and +`SHA256SUMS.sig`, that file's Ed25519 signature by the project's release +key, whose public key is [deploy/release-key.pub](deploy/release-key.pub). +`sha256sum -c --ignore-missing SHA256SUMS` checks the downloads, and +`tools/release_sign.sh verify SHA256SUMS` (OpenSSL 3) the signature. + The Linux README also covers the controls, every setting and the multiplayer features. These are almost the same on all platforms. The settings are in `config.toml` next to the game, and many are in the menus too. The Arch-based distribution release is available on the [AUR](https://aur.archlinux.org/packages/opence-bin). diff --git a/deploy/docker/.dockerignore b/deploy/docker/.dockerignore new file mode 100644 index 00000000..e9eb7280 --- /dev/null +++ b/deploy/docker/.dockerignore @@ -0,0 +1,3 @@ +* +!healthz.sh +!*.zip diff --git a/deploy/docker/.gitignore b/deploy/docker/.gitignore new file mode 100644 index 00000000..eded80c2 --- /dev/null +++ b/deploy/docker/.gitignore @@ -0,0 +1,2 @@ +# a release zip copied here to build the image from (README.md, "Building it") +*.zip diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 00000000..2ba9245b --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1 +# +# The dedicated server (docs/dedicated_server.md) as a container image, made +# from a release's halo-linux-release.zip (the portable build: halo, its +# libSDL3.so.0, brokers.txt, masters.txt): +# +# docker build -t opence-server deploy/docker +# docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker +# docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker (a zip in deploy/docker) +# +# The game data is never in the image: mount the folder that holds maps/ at +# /data, read-only. /state is the only folder the server writes (config.toml, +# the save root with its map cache, the logs), so the root can be read-only. +# deploy/docker/README.md says how to run it. + +FROM debian:bookworm-slim AS release +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip \ + && rm -rf /var/lib/apt/lists/* +# a URL, or a file beside this Dockerfile +ARG RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip +ADD ${RELEASE} /tmp/halo-linux-release.zip +COPY healthz.sh /opt/opence/healthz.sh +RUN unzip -q /tmp/halo-linux-release.zip -d /opt/opence \ + && test -f /opt/opence/halo \ + && chmod 0755 /opt/opence/halo /opt/opence/healthz.sh \ + && if [ -f /opt/opence/halo-server ]; then chmod 0755 /opt/opence/halo-server; fi + +FROM debian:bookworm-slim +# The 32-bit C runtime; the release's own libSDL3.so.0 needs nothing more for +# a server, which opens no window or sound device. ca-certificates: the +# master servers and the update check are HTTPS. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends libc6:i386 libgcc-s1:i386 ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -g 10001 opence \ + && useradd -u 10001 -g 10001 -d /state -M -s /usr/sbin/nologin opence \ + && mkdir -p /data /state \ + && chown 10001:10001 /state +COPY --from=release /opt/opence /opt/opence +ENV HALO_SERVER=1 \ + HALO_CONFIG_FILE=/state/config.toml \ + HALO_DATA_ROOT=/data \ + HALO_SAVE_ROOT=/state/saves \ + HALO_LOG_ROOT=/state/logs \ + HALO_NET_TUNNEL_PORT=2302 \ + HALO_SERVER_WEB_BIND=0.0.0.0 \ + HALO_SERVER_WEB_PORT=8080 \ + HOME=/state +VOLUME ["/state"] +# Internet play's tunnel, and the web page, API and RCON (publish it on the +# host's loopback, or behind a proxy with TLS). System link's 5150, 5151 and +# 5153 are for a local network only. +EXPOSE 2302/udp 8080/tcp +USER 10001:10001 +WORKDIR /state +HEALTHCHECK --interval=15s --timeout=3s --start-period=60s --retries=3 CMD ["/opt/opence/healthz.sh"] +STOPSIGNAL SIGTERM +ENTRYPOINT ["/opt/opence/halo", "--server"] diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 00000000..8d2b9e99 --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,134 @@ +# The dedicated server in a container + +`ghcr.io/opencommunityedition/opence-server` is the dedicated server +(`docs/dedicated_server.md`) as a container image: a release's +`halo-linux-release.zip` on Debian 12 with the 32-bit C runtime, run as +`halo --server` by the user 10001. CI builds it from each release +(`.github/workflows/server-image.yml`) and signs it with cosign; the tags are +`latest`, the release's (`build-206`) and `sha-`. + +The game data is never in the image. Mount the folder that holds `maps/` +(from your own copy of the game: port/linux/README.md, "Game data") at +`/data`, read-only. Without it the server exits with code 2 and says which +maps it looked for. + +## Running it + +```sh +docker run -d --name halo --restart unless-stopped \ + --read-only --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \ + -p 2302:2302/udp -p 127.0.0.1:8080:8080 \ + -v /srv/halo/data:/data:ro -v halo-state:/state \ + -e HALO_SERVER_NAME="My server" \ + -e HALO_SERVER_ROTATION="bloodgulch:ctf, sidewinder:team_slayer" \ + -e HALO_SERVER_WEB_PASSWORD=changeme-please \ + ghcr.io/opencommunityedition/opence-server:latest +docker logs -f halo +``` + +Or with Compose: `compose.yaml` beside this file runs the same, with the +settings taken from the shell or a `.env` file +(`HALO_DATA=/srv/halo/data`, `HALO_SERVER_NAME=...`). Its `https` profile +adds Caddy in front of the web port, with a certificate for +`HALO_WEB_DOMAIN`: + +```sh +HALO_DATA=/srv/halo/data docker compose -f deploy/docker/compose.yaml up -d +HALO_WEB_DOMAIN=halo.example.org docker compose -f deploy/docker/compose.yaml --profile https up -d +``` + +The Compose file puts both on a network of its own, `172.30.80.0/24`, with +Caddy at the fixed address `172.30.80.10` and the server's +`HALO_SERVER_WEB_TRUSTED_PROXY` set to it: the server believes Caddy's +`X-Forwarded-For`, `-Host` and `-Proto`, so its rate limits and log name the +visitor rather than Caddy, and the session cookie is `Secure`. If that +subnet overlaps one of the host's, change the subnet, Caddy's +`ipv4_address` and the variable together. + +`docker stop` sends SIGTERM: the game in progress goes to its postgame, +the players are told, the listing is withdrawn, and the server exits 0 +within the 15 seconds Compose waits (`docker stop -t 15` otherwise). + +## Folders + +| Path | What | +| --- | --- | +| `/opt/opence` | The release: `halo`, `libSDL3.so.0`, `brokers.txt`, `masters.txt`, `healthz.sh`, the licences (read-only) | +| `/data` | The game data: `maps/`, and if you have them `custom_maps/`, `gametypes/` and `init.txt` (yours, read-only) | +| `/state` | The only folder written: `config.toml` (`HALO_CONFIG_FILE`), `publisher.key` beside it, the save root `saves/` with the map cache (about 115 MB), `logs/` (`debug.txt`, `bans.txt`, `cheaters.txt`) | + +The image sets `HALO_CONFIG_FILE=/state/config.toml` rather than linking +`config.toml` beside the executable into the volume: the settings file is +then in the volume from the first start, and nothing in `/opt/opence` is +written. The server writes every setting there at the first start, with +the generated web password if none was given. + +## Settings + +Every setting is an environment variable (port/linux/README.md, "Dedicated +server", and docs/dedicated_server.md). A variable that is set wins over +`config.toml`, and the web page's settings cannot change it. The image sets: + +| Variable | Value | +| --- | --- | +| `HALO_SERVER` | `1` | +| `HALO_CONFIG_FILE` | `/state/config.toml` | +| `HALO_DATA_ROOT` | `/data` | +| `HALO_SAVE_ROOT` | `/state/saves` | +| `HALO_LOG_ROOT` | `/state/logs` | +| `HALO_NET_TUNNEL_PORT` | `2302` | +| `HALO_SERVER_WEB_BIND` | `0.0.0.0` (within the container; publish the port on `127.0.0.1` or behind TLS) | +| `HALO_SERVER_WEB_PORT` | `8080` | + +The ones most often given: `HALO_SERVER_NAME`, `HALO_SERVER_ROTATION`, +`HALO_SERVER_MAX_PLAYERS`, `HALO_SERVER_PASSWORD`, `HALO_SERVER_PUBLIC`, +`HALO_SERVER_MOTD`, `HALO_SERVER_WEB_PASSWORD`, `HALO_NET_PUBLIC_ADDRESS`, +`HALO_SERVER_PUBLISHER_KEY` (the master servers' key, as 64 hex +characters; `docker run --rm -v halo-state:/state --keygen` makes one +into the volume instead), `HALO_SERVER_INSTANCE`, `HALO_SERVER_REGION`, +`HALO_SERVER_LOG_FORMAT=json`. + +A tunnel port other than 2302 is published as the same number on both sides +(`-e HALO_NET_TUNNEL_PORT=2303 -p 2303:2303/udp`), as the server offers its +own port to joiners. Behind a NAT that STUN cannot see through, give the +public address and port in `HALO_NET_PUBLIC_ADDRESS`. + +## Health + +`HEALTHCHECK` runs `/opt/opence/healthz.sh`, which asks `GET /healthz` on +the web port (bash's `/dev/tcp`: the image has no curl) and is healthy on a +200: the server is hosting (lobby, loading, playing or postgame) and its +frames run. With `HALO_SERVER_WEB_PORT=0` it always passes. + +## Building it + +```sh +docker build -t opence-server deploy/docker # the latest release +docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker +``` + +A build of your own (`python tools/ci_build.py linux release` leaves +`dist/halo-linux-release/`) goes in as a zip beside the Dockerfile: + +```sh +(cd dist/halo-linux-release && zip -qr ../../deploy/docker/halo-linux-release.zip .) +docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker +``` + +`RELEASE` is a URL or a file beside the Dockerfile. The image is 32-bit +x86 and runs on amd64 hosts only. + +## Networking + +Bridge networking works for internet play: the tunnel port is published one +to one, and the server needs no hole punching of its own. System link +(UDP 5150 and 5151, TCP 5150 and 5153) finds other machines only with +`--network host`. + +Verify an image's signature: + +```sh +cosign verify ghcr.io/opencommunityedition/opence-server:latest \ + --certificate-identity-regexp '^https://github\.com/OpenCommunityEdition/OpenCE/\.github/workflows/server-image\.yml@' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml new file mode 100644 index 00000000..b0302825 --- /dev/null +++ b/deploy/docker/compose.yaml @@ -0,0 +1,92 @@ +# The dedicated server with Docker Compose (deploy/docker/README.md): +# +# docker compose -f deploy/docker/compose.yaml up -d +# docker compose -f deploy/docker/compose.yaml --profile https up -d (with Caddy in front of the web port) +# +# HALO_DATA is the folder that holds maps/ (and custom_maps/, gametypes/), +# mounted read-only; settings come from the environment below or from a +# .env file beside this one. +name: opence + +services: + server: + image: ${OPENCE_IMAGE:-ghcr.io/opencommunityedition/opence-server:latest} + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + tmpfs: + - /tmp + ports: + - "${HALO_NET_TUNNEL_PORT:-2302}:${HALO_NET_TUNNEL_PORT:-2302}/udp" + # (the web page on the host's loopback only; Caddy reaches it on the + # compose network) + - "127.0.0.1:${HALO_SERVER_WEB_PORT:-8080}:${HALO_SERVER_WEB_PORT:-8080}/tcp" + volumes: + - ${HALO_DATA:-./data}:/data:ro + - state:/state + # A variable named without a value is passed only when it is set (in the + # shell or .env): one that is set wins over config.toml, and sv_set + # cannot change it. + environment: + - HALO_NET_TUNNEL_PORT=${HALO_NET_TUNNEL_PORT:-2302} + - HALO_SERVER_WEB_PORT=${HALO_SERVER_WEB_PORT:-8080} + - HALO_SERVER_NAME + - HALO_SERVER_ROTATION + - HALO_SERVER_MAX_PLAYERS + - HALO_SERVER_PASSWORD + - HALO_SERVER_PUBLIC + - HALO_SERVER_MOTD + # (8 characters or more; unset: one is made at the first start, printed + # in the log and kept in the state volume's config.toml) + - HALO_SERVER_WEB_PASSWORD + - HALO_NET_PUBLIC_ADDRESS + - HALO_SERVER_PUBLISHER_KEY + - HALO_SERVER_INSTANCE + - HALO_SERVER_REGION + - HALO_SERVER_LOG_FORMAT + # Caddy's address on the network below (the https profile): its + # X-Forwarded-For, -Host and -Proto are believed, so the page's rate + # limits and log name the visitor, and its session cookie is Secure. + # Nothing else has this address. + - HALO_SERVER_WEB_TRUSTED_PROXY=172.30.80.10 + networks: + - opence + stop_grace_period: 15s + + # HTTPS for the web page, with a certificate from Let's Encrypt for + # HALO_WEB_DOMAIN (which must resolve to this host, ports 80 and 443 open). + caddy: + image: caddy:2 + profiles: [https] + restart: unless-stopped + depends_on: [server] + ports: + - "80:80/tcp" + - "443:443/tcp" + - "443:443/udp" + command: ["caddy", "reverse-proxy", "--from", "${HALO_WEB_DOMAIN:-halo.example.org}", "--to", "server:${HALO_SERVER_WEB_PORT:-8080}"] + volumes: + - caddy-data:/data + - caddy-config:/config + networks: + opence: + # (server.web_trusted_proxy above; outside the range Docker hands + # out, so no other container takes it) + ipv4_address: 172.30.80.10 + +# A fixed subnet, so that Caddy's address is known in advance. Containers +# other than Caddy get addresses from ip_range only. Change it (and the two +# addresses above) if it overlaps a network of the host's. +networks: + opence: + ipam: + config: + - subnet: 172.30.80.0/24 + ip_range: 172.30.80.128/25 + gateway: 172.30.80.1 + +volumes: + state: + caddy-data: + caddy-config: diff --git a/deploy/docker/healthz.sh b/deploy/docker/healthz.sh new file mode 100755 index 00000000..c8829980 --- /dev/null +++ b/deploy/docker/healthz.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# The image's health check: GET /healthz on the web port, healthy on a 200 +# (the server answers 200 while it hosts and its frames run, 503 otherwise). +# Bash's /dev/tcp, as the image has no curl. +port="${HALO_SERVER_WEB_PORT:-8080}" +# (no web port: nothing to ask) +if [ -z "$port" ] || [ "$port" = 0 ]; then + exit 0 +fi +{ exec 3<>"/dev/tcp/127.0.0.1/$port"; } 2>/dev/null || exit 1 +printf 'GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:%s\r\nConnection: close\r\n\r\n' "$port" >&3 || exit 1 +IFS=' ' read -r -t 3 version status _ <&3 || exit 1 +exec 3<&- +case "$version" in +HTTP/1.*) ;; +*) exit 1 ;; +esac +[ "$status" = 200 ] diff --git a/deploy/gametypes/btb_slayer.toml b/deploy/gametypes/btb_slayer.toml new file mode 100644 index 00000000..71b2cfe6 --- /dev/null +++ b/deploy/gametypes/btb_slayer.toml @@ -0,0 +1,29 @@ +# Big team slayer: first team to 50 kills, vehicles as Halo PC places them. +# Play it with server.rotation entries such as bloodgulch:btb_slayer. +base = "team_slayer" +name = "BTB Slayer" + +[universal] +teams = true +score_to_win = 50 +lives = 0 +respawn_time = 5 +health = 100 +weapon_set = "normal" +vehicle_set = "default" +radar = true +friend_indicators = true + +[options] +time_limit = 20 +friendly_fire = "shields_only" +friendly_fire_penalty = 5 +vehicle_respawn_time = 60 +auto_team_balance = true +radar_players = "friends" +team_vehicles = "pc" + +[slayer] +no_death_bonus = false +no_kill_penalty = false +kill_in_order = false diff --git a/deploy/gametypes/ctf_classic.toml b/deploy/gametypes/ctf_classic.toml new file mode 100644 index 00000000..a674bf46 --- /dev/null +++ b/deploy/gametypes/ctf_classic.toml @@ -0,0 +1,27 @@ +# Capture the flag as players remember it: three captures, the flag must be +# at home to score. +base = "ctf" +name = "Classic CTF" + +[universal] +teams = true +score_to_win = 3 +respawn_time = 5 +weapon_set = "normal" +vehicle_set = "default" +radar = true + +[options] +time_limit = 25 +friendly_fire = "off" +vehicle_respawn_time = 90 +auto_team_balance = true +radar_players = "friends" +team_vehicles = "pc" + +[ctf] +assault = false +reset_on_capture = false +flag_must_reset = false +flag_at_home_to_score = true +single_flag_time = 0 diff --git a/deploy/gametypes/king_moving.toml b/deploy/gametypes/king_moving.toml new file mode 100644 index 00000000..9af02175 --- /dev/null +++ b/deploy/gametypes/king_moving.toml @@ -0,0 +1,17 @@ +# King of the hill whose hill moves about the map, for teams. +base = "team_king" +name = "Moving Hill" + +[universal] +teams = true +score_to_win = 3 +respawn_time = 5 +radar = true + +[options] +time_limit = 15 +friendly_fire = "off" +auto_team_balance = true + +[king] +moving_hill = true diff --git a/deploy/gametypes/lone_wolves.toml b/deploy/gametypes/lone_wolves.toml new file mode 100644 index 00000000..afba1874 --- /dev/null +++ b/deploy/gametypes/lone_wolves.toml @@ -0,0 +1,23 @@ +# Free for all slayer to 25 kills, starting with a pistol and an assault rifle. +base = "slayer" +name = "Lone Wolves" + +[universal] +teams = false +score_to_win = 25 +respawn_time = 3 +health = 100 +radar = false + +[options] +time_limit = 15 +radar_players = "none" +loadout = "custom" +primary_weapon = "pistol" +secondary_weapon = "assault_rifle" +team_vehicles = "pc" + +[slayer] +no_death_bonus = false +no_kill_penalty = false +kill_in_order = false diff --git a/deploy/halo-server b/deploy/halo-server new file mode 100755 index 00000000..85812a46 --- /dev/null +++ b/deploy/halo-server @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/halo" --server "$@" diff --git a/deploy/helm/opence-server/.helmignore b/deploy/helm/opence-server/.helmignore new file mode 100644 index 00000000..58f0a4f6 --- /dev/null +++ b/deploy/helm/opence-server/.helmignore @@ -0,0 +1,3 @@ +# Not part of the packaged chart: unit tests and CI values files. +tests/ +ci/ diff --git a/deploy/helm/opence-server/Chart.yaml b/deploy/helm/opence-server/Chart.yaml new file mode 100644 index 00000000..b2aa99fc --- /dev/null +++ b/deploy/helm/opence-server/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: opence-server +description: >- + The OpenCE dedicated server: a headless Halo: Combat Evolved host that plays games one after + another, with no window, sound or player of its own. The game data (maps) is mounted from a + volume you provide. +type: application +version: 0.1.0 +kubeVersion: ">=1.29.0-0" +# Informational only: which image runs is set by image.repository, image.tag and image.digest. +appVersion: "latest" +home: https://github.com/OpenCommunityEdition/OpenCE +sources: + - https://github.com/OpenCommunityEdition/OpenCE/tree/main/deploy/helm/opence-server +keywords: + - halo + - game-server diff --git a/deploy/helm/opence-server/README.md b/deploy/helm/opence-server/README.md new file mode 100644 index 00000000..c28d9a33 --- /dev/null +++ b/deploy/helm/opence-server/README.md @@ -0,0 +1,111 @@ +# opence-server + +The OpenCE dedicated server (`docs/dedicated_server.md`) on Kubernetes: one +pod of the image `ghcr.io/opencommunityedition/opence-server` +(`deploy/docker`), its tunnel port behind a UDP Service, its web page behind +a ClusterIP Service, probes on `GET /healthz`, and a NetworkPolicy. + +The game data is never in the image or the chart: give a volume that holds +`maps/` from your own copy of the game. + +## Quick start + +```sh +kubectl create namespace halo +# a claim (or an NFS export) holding maps/, read-only is enough +helm install halo deploy/helm/opence-server -n halo \ + --set gameData.existingClaim=halo-maps \ + --set server.name="My server" \ + --set persistence.enabled=true +kubectl -n halo logs -f deploy/halo-opence-server +``` + +The admin password of the web page is made at the first install and kept in +the release's Secret (NOTES.txt prints the command that shows it), unless +`web.password` or `web.existingSecret` gives one. Reach the page with +`kubectl -n halo port-forward deploy/halo-opence-server 8080`, or put an +Ingress with TLS in front of the `-web` Service (and allow its namespace +in `networkPolicy.webFrom`). + +## Values + +`values.yaml` describes each value. Each one under `server`, `web`, +`network` and `publisherKey` is one of the server's `HALO_*` variables: + +| Value | Variable | +| --- | --- | +| `server.name` | `HALO_SERVER_NAME` (15 characters) | +| `server.rotation` (a list) | `HALO_SERVER_ROTATION`, joined with commas | +| `server.maxPlayers` | `HALO_SERVER_MAX_PLAYERS` | +| `server.public` | `HALO_SERVER_PUBLIC` | +| `server.lobbySeconds` | `HALO_SERVER_LOBBY_SECONDS` | +| `server.motd` | `HALO_SERVER_MOTD` | +| `server.logFormat` | `HALO_SERVER_LOG_FORMAT` | +| `server.instance`, `server.region` | `HALO_SERVER_INSTANCE`, `HALO_SERVER_REGION` | +| `server.masterPublish` | `HALO_SERVER_MASTER_PUBLISH` | +| `server.password` | `HALO_SERVER_PASSWORD` (from a Secret) | +| `publisherKey` | `HALO_SERVER_PUBLISHER_KEY` (from a Secret) | +| `web.port` | `HALO_SERVER_WEB_PORT` (0: none, and no probes) | +| `web.password`, `web.existingSecret` | `HALO_SERVER_WEB_PASSWORD` (from a Secret) | +| `web.moderatorPassword` | `HALO_SERVER_WEB_MODERATOR_PASSWORD` (from a Secret) | +| `network.tunnelPort` | `HALO_NET_TUNNEL_PORT` | +| `network.publicAddress` | `HALO_NET_PUBLIC_ADDRESS` | +| `customMaps` | `HALO_CUSTOM_MAPS_ROOT=/custom_maps` | + +The chart also sets `HALO_SERVER=1`, `HALO_CONFIG_FILE=/state/config.toml`, +`HALO_DATA_ROOT=/data`, `HALO_SAVE_ROOT=/state/saves`, +`HALO_LOG_ROOT=/state/logs`, `HALO_SERVER_CONSOLE=false` (a pod has no +standard input) and, with a web port, `HALO_SERVER_WEB_BIND=0.0.0.0` (the +kubelet's probes reach the pod's address). Any other setting goes in +`extraEnv`, which comes last and so overrides the chart's. + +A variable the chart sets wins over `config.toml`, and neither `sv_set` nor +the web page's settings can change it. So the values under `server` are +unset by default (empty, `[]` or `null`): the chart then sets no variable, +and the setting is `config.toml`'s (or the server's default, which +`values.yaml` names), changeable from the web page and kept in the state +volume. Give a value only for a setting you want fixed. + +## Web password + +With neither `web.password` nor `web.existingSecret`, the chart makes an +admin password at the first install and keeps it in the release's Secret; +on an upgrade it finds that Secret again with `lookup`. `helm template`, +and GitOps tools that render with it (Argo CD, Flux's dry runs, +Kustomize's Helm support), have no cluster to look in: each render makes a +new password, and the Secret changes at every sync. There, create the Secret +yourself and name it: + +```sh +kubectl -n halo create secret generic halo-web \ + --from-literal=web-password="$(openssl rand -base64 18)" \ + --from-literal=web-moderator-password="$(openssl rand -base64 18)" +helm template halo deploy/helm/opence-server -n halo \ + --set gameData.existingClaim=halo-maps --set web.existingSecret=halo-web +``` + +(or a SealedSecret or an External Secrets object of the same name). +`web.key` and `web.moderatorKey` name its keys; the moderator's is +optional. + +## Networking + +`service.type` is `LoadBalancer` with `externalTrafficPolicy: Local` by +default, so that the server sees the players' own addresses (its bans and +limits are by address). `NodePort` works too; `ClusterIP` is for tests. The +tunnel's Service port and `network.tunnelPort` should be the same number, as +the server offers its own port to joiners; behind a NAT that STUN cannot see +through, give the forwarded address in `network.publicAddress`. + +The NetworkPolicy lets anyone reach the tunnel port and only `webFrom` the +web port, and lets the pod reach DNS, TCP 1883 (the MQTT brokers) and 443 +(the master servers) and any UDP port on public addresses. + +## Tests + +```sh +helm lint deploy/helm/opence-server --strict -f deploy/helm/opence-server/ci/full-values.yaml +helm unittest deploy/helm/opence-server +``` + +`.github/workflows/server-image.yml` runs both. diff --git a/deploy/helm/opence-server/ci/full-values.yaml b/deploy/helm/opence-server/ci/full-values.yaml new file mode 100644 index 00000000..f6c6d0b7 --- /dev/null +++ b/deploy/helm/opence-server/ci/full-values.yaml @@ -0,0 +1,87 @@ +# Every option the chart has, for lint and the schema check (not installed: the NFS server is made +# up). +image: + tag: build-206@sha256:0000000000000000000000000000000000000000000000000000000000000000 +imagePullSecrets: + - name: registry-credentials +gameData: + nfs: + server: nfs.example.com + path: /exports/halo + subPath: xbox +customMaps: + existingClaim: halo-custom-maps +server: + name: Example server + rotation: [bloodgulch:ctf_classic, sidewinder:team_slayer+score=50, hangemhigh:king_moving] + maxPlayers: 24 + public: true + lobbySeconds: 20 + motd: Welcome + logFormat: json + instance: eu-1 + region: eu + masterPublish: true + password: + existingSecret: halo-join + key: password +publisherKey: + existingSecret: halo-publisher + key: seed +web: + port: 8080 + existingSecret: halo-web + key: admin + moderatorKey: moderator +network: + tunnelPort: 2303 + publicAddress: halo.example.com:2303 +extraEnv: + - name: HALO_SERVER_EMPTY_SECONDS + value: "60" +persistence: + enabled: true + storageClass: standard + annotations: + example.com/keep: "true" +service: + type: LoadBalancer + port: 2303 + nodePort: 30703 + annotations: + example.com/address: 192.0.2.10 + externalTrafficPolicy: Local + loadBalancerSourceRanges: [0.0.0.0/0] +tolerations: + - key: dedicated + operator: Equal + value: games + effect: NoSchedule +podAnnotations: + example.com/scrape: "true" +podLabels: + team: games +probes: + liveness: + periodSeconds: 30 + readiness: + enabled: false +networkPolicy: + webFrom: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + extraEgress: + - to: + - ipBlock: + cidr: 203.0.113.0/24 + ports: + - protocol: TCP + port: 8443 +extraObjects: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: '{{ .Release.Name }}-extra' + data: + hello: world diff --git a/deploy/helm/opence-server/ci/minimal-values.yaml b/deploy/helm/opence-server/ci/minimal-values.yaml new file mode 100644 index 00000000..e5116936 --- /dev/null +++ b/deploy/helm/opence-server/ci/minimal-values.yaml @@ -0,0 +1,6 @@ +# The smallest install that renders: no maps (an emptyDir in their place), so the server exits with +# code 2 and the pod goes into CrashLoopBackOff. A real install gives gameData.existingClaim or +# gameData.nfs instead. +gameData: + volume: + emptyDir: {} diff --git a/deploy/helm/opence-server/templates/NOTES.txt b/deploy/helm/opence-server/templates/NOTES.txt new file mode 100644 index 00000000..03dfd066 --- /dev/null +++ b/deploy/helm/opence-server/templates/NOTES.txt @@ -0,0 +1,33 @@ +OpenCE dedicated server {{ .Chart.Version }}: {{ include "opence-server.fullname" . }} in namespace {{ .Release.Namespace }}. + +Rotation: {{ join ", " .Values.server.rotation | default "the server's default" }}. +{{- if kindIs "invalid" .Values.server.public }} +Listed in everyone's server browser{{ with .Values.server.name }} as "{{ . }}"{{ end }}, unless config.toml or the web page turns server.public off. +{{- else if .Values.server.public }} +Listed in everyone's server browser{{ with .Values.server.name }} as "{{ . }}"{{ end }}. +{{- else }} +Not listed: players join with the invite link from the log, or on the LAN. +{{- end }} + +The log (the ready line, players, games): + + kubectl -n {{ .Release.Namespace }} logs -f deploy/{{ include "opence-server.fullname" . }} + +Players reach UDP port {{ .Values.service.port }} through Service {{ include "opence-server.fullname" . }} ({{ .Values.service.type }}). +{{- if eq .Values.service.type "ClusterIP" }} +A ClusterIP Service is not reachable from outside the cluster: set service.type to LoadBalancer or +NodePort for internet play. +{{- end }} +{{- if gt (int .Values.web.port) 0 }} + +The web page, API and remote console: + + kubectl -n {{ .Release.Namespace }} port-forward deploy/{{ include "opence-server.fullname" . }} {{ .Values.web.port }} + open http://127.0.0.1:{{ .Values.web.port }}/ +{{- if not .Values.web.existingSecret }} + +The admin password: + + kubectl -n {{ .Release.Namespace }} get secret {{ include "opence-server.secretName" . }} -o jsonpath='{.data.{{ .Values.web.key }}}' | base64 -d +{{- end }} +{{- end }} diff --git a/deploy/helm/opence-server/templates/_helpers.tpl b/deploy/helm/opence-server/templates/_helpers.tpl new file mode 100644 index 00000000..5c107277 --- /dev/null +++ b/deploy/helm/opence-server/templates/_helpers.tpl @@ -0,0 +1,84 @@ +{{/* The objects' name: fullnameOverride, else the release name if it contains the chart's name, + else -. */}} +{{- define "opence-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- define "opence-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{- define "opence-server.labels" -}} +{{ include "opence-server.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "opence-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "opence-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* repository:tag, repository:tag@digest, or repository@digest; an empty tag is the appVersion. */}} +{{- define "opence-server.image" -}} +{{- $image := .Values.image -}} +{{- $ref := $image.repository -}} +{{- $tag := toString ($image.tag | default .Chart.AppVersion) -}} +{{- if $image.digest }}{{ $tag = regexReplaceAll "@.*$" $tag "" }}{{ if hasPrefix "sha256:" $tag }}{{ $tag = "" }}{{ end }}{{ end -}} +{{- if hasPrefix "sha256:" $tag }}{{ $ref = printf "%s@%s" $ref $tag }} +{{- else if $tag }}{{ $ref = printf "%s:%s" $ref $tag }}{{ end -}} +{{- if and $image.digest (not (contains "@" $ref)) }}{{ $ref = printf "%s@%s" $ref $image.digest }}{{ end -}} +{{- $ref -}} +{{- end }} + +{{- define "opence-server.stateClaim" -}} +{{- .Values.persistence.existingClaim | default (printf "%s-state" (include "opence-server.fullname" .)) -}} +{{- end }} + +{{/* The chart's Secret: the web passwords it makes or is given, the join password, the publisher + key. */}} +{{- define "opence-server.secretName" -}} +{{- printf "%s-secrets" (include "opence-server.fullname" .) -}} +{{- end }} + +{{/* The Secret holding the web password: web.existingSecret, else the chart's. */}} +{{- define "opence-server.webSecret" -}} +{{- .Values.web.existingSecret | default (include "opence-server.secretName" .) -}} +{{- end }} + +{{- define "opence-server.hasGameData" -}} +{{- with .Values.gameData -}} +{{- if or .existingClaim (and .nfs.server .nfs.path) .volume }}true{{ end -}} +{{- end -}} +{{- end }} + +{{- define "opence-server.hasCustomMaps" -}} +{{- with .Values.customMaps -}} +{{- if or .existingClaim (and .nfs.server .nfs.path) .volume }}true{{ end -}} +{{- end -}} +{{- end }} + +{{/* A volume source of gameData or customMaps (the dict), read-only. */}} +{{- define "opence-server.volumeSource" -}} +{{- if .existingClaim }} +persistentVolumeClaim: + claimName: {{ .existingClaim }} + readOnly: true +{{- else if and .nfs.server .nfs.path }} +nfs: + server: {{ .nfs.server }} + path: {{ .nfs.path }} + readOnly: true +{{- else }} +{{- toYaml .volume }} +{{- end }} +{{- end }} diff --git a/deploy/helm/opence-server/templates/deployment.yaml b/deploy/helm/opence-server/templates/deployment.yaml new file mode 100644 index 00000000..d5a11b92 --- /dev/null +++ b/deploy/helm/opence-server/templates/deployment.yaml @@ -0,0 +1,198 @@ +{{- if not (include "opence-server.hasGameData" .) }} +{{- fail "gameData: set existingClaim, nfs.server and nfs.path, or volume - the server needs the game's maps, which are never in the image" }} +{{- end }} +{{- $fullname := include "opence-server.fullname" . }} +{{- $secret := include "opence-server.secretName" . }} +{{- $webPort := int .Values.web.port }} +{{- $env := list }} +{{- $env = append $env (dict "name" "HALO_SERVER" "value" "1") }} +{{- $env = append $env (dict "name" "HALO_CONFIG_FILE" "value" "/state/config.toml") }} +{{- $env = append $env (dict "name" "HALO_DATA_ROOT" "value" "/data") }} +{{- $env = append $env (dict "name" "HALO_SAVE_ROOT" "value" "/state/saves") }} +{{- $env = append $env (dict "name" "HALO_LOG_ROOT" "value" "/state/logs") }} +{{- if include "opence-server.hasCustomMaps" . }} +{{- $env = append $env (dict "name" "HALO_CUSTOM_MAPS_ROOT" "value" "/custom_maps") }} +{{- end }} +{{- /* (a pod has no standard input to read commands from) */}} +{{- $env = append $env (dict "name" "HALO_SERVER_CONSOLE" "value" "false") }} +{{- $env = append $env (dict "name" "HALO_NET_TUNNEL_PORT" "value" (toString (int .Values.network.tunnelPort))) }} +{{- with .Values.network.publicAddress }} +{{- $env = append $env (dict "name" "HALO_NET_PUBLIC_ADDRESS" "value" .) }} +{{- end }} +{{- with .Values.server.name }} +{{- if gt (len .) 15 }} +{{- fail "server.name: 15 characters at most" }} +{{- end }} +{{- $env = append $env (dict "name" "HALO_SERVER_NAME" "value" .) }} +{{- end }} +{{- with .Values.server.rotation }} +{{- $env = append $env (dict "name" "HALO_SERVER_ROTATION" "value" (join ", " .)) }} +{{- end }} +{{- /* (a value left null is not set, so that config.toml, sv_set and the web page's settings can +change it) */}} +{{- if not (kindIs "invalid" .Values.server.maxPlayers) }} +{{- $env = append $env (dict "name" "HALO_SERVER_MAX_PLAYERS" "value" (toString (int .Values.server.maxPlayers))) }} +{{- end }} +{{- if not (kindIs "invalid" .Values.server.public) }} +{{- $env = append $env (dict "name" "HALO_SERVER_PUBLIC" "value" (toString .Values.server.public)) }} +{{- end }} +{{- if not (kindIs "invalid" .Values.server.lobbySeconds) }} +{{- $env = append $env (dict "name" "HALO_SERVER_LOBBY_SECONDS" "value" (toString (int .Values.server.lobbySeconds))) }} +{{- end }} +{{- with .Values.server.motd }} +{{- $env = append $env (dict "name" "HALO_SERVER_MOTD" "value" .) }} +{{- end }} +{{- with .Values.server.logFormat }} +{{- $env = append $env (dict "name" "HALO_SERVER_LOG_FORMAT" "value" .) }} +{{- end }} +{{- with .Values.server.instance }} +{{- $env = append $env (dict "name" "HALO_SERVER_INSTANCE" "value" .) }} +{{- end }} +{{- with .Values.server.region }} +{{- $env = append $env (dict "name" "HALO_SERVER_REGION" "value" .) }} +{{- end }} +{{- if not (kindIs "invalid" .Values.server.masterPublish) }} +{{- $env = append $env (dict "name" "HALO_SERVER_MASTER_PUBLISH" "value" (toString .Values.server.masterPublish)) }} +{{- end }} +{{- if .Values.server.password.existingSecret }} +{{- $env = append $env (dict "name" "HALO_SERVER_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.server.password.existingSecret "key" .Values.server.password.key))) }} +{{- else if .Values.server.password.value }} +{{- $env = append $env (dict "name" "HALO_SERVER_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.server.password.key))) }} +{{- end }} +{{- if .Values.publisherKey.existingSecret }} +{{- $env = append $env (dict "name" "HALO_SERVER_PUBLISHER_KEY" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.publisherKey.existingSecret "key" .Values.publisherKey.key))) }} +{{- else if .Values.publisherKey.value }} +{{- $env = append $env (dict "name" "HALO_SERVER_PUBLISHER_KEY" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.publisherKey.key))) }} +{{- end }} +{{- $env = append $env (dict "name" "HALO_SERVER_WEB_PORT" "value" (toString $webPort)) }} +{{- if gt $webPort 0 }} +{{- /* (the kubelet's probes reach the pod's own address, not its loopback) */}} +{{- $env = append $env (dict "name" "HALO_SERVER_WEB_BIND" "value" "0.0.0.0") }} +{{- $env = append $env (dict "name" "HALO_SERVER_WEB_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" (include "opence-server.webSecret" .) "key" .Values.web.key))) }} +{{- if .Values.web.existingSecret }} +{{- $env = append $env (dict "name" "HALO_SERVER_WEB_MODERATOR_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.web.existingSecret "key" .Values.web.moderatorKey "optional" true))) }} +{{- else if .Values.web.moderatorPassword }} +{{- $env = append $env (dict "name" "HALO_SERVER_WEB_MODERATOR_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.web.moderatorKey))) }} +{{- end }} +{{- end }} +{{- $probe := dict "httpGet" (dict "path" "/healthz" "port" "web") }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullname }} + labels: + {{- include "opence-server.labels" . | nindent 4 }} +spec: + # One server owns the tunnel port and the address players reach it at. + replicas: 1 + strategy: + {{- toYaml .Values.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "opence-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "opence-server.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + # The server never calls the Kubernetes API. + automountServiceAccountToken: false + enableServiceLinks: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: opence-server + image: {{ include "opence-server.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + {{- toYaml $env | nindent 12 }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: tunnel + containerPort: {{ .Values.network.tunnelPort }} + protocol: UDP + {{- if gt $webPort 0 }} + - name: web + containerPort: {{ $webPort }} + protocol: TCP + {{- end }} + {{- if gt $webPort 0 }} + {{- range $name := list "startup" "readiness" "liveness" }} + {{- $p := index $.Values.probes $name }} + {{- if $p.enabled }} + {{ $name }}Probe: + {{- toYaml (merge (omit $p "enabled") $probe) | nindent 12 }} + {{- end }} + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + volumeMounts: + - name: game-data + mountPath: /data + readOnly: true + {{- with .Values.gameData.subPath }} + subPath: {{ . }} + {{- end }} + # config.toml, publisher.key, the save root and its map cache, the logs + - name: state + mountPath: /state + {{- if include "opence-server.hasCustomMaps" . }} + - name: custom-maps + mountPath: /custom_maps + readOnly: true + {{- with .Values.customMaps.subPath }} + subPath: {{ . }} + {{- end }} + {{- end }} + - name: tmp + mountPath: /tmp + volumes: + - name: game-data + {{- include "opence-server.volumeSource" .Values.gameData | trim | nindent 10 }} + - name: state + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "opence-server.stateClaim" . }} + {{- else }} + emptyDir: + sizeLimit: {{ .Values.persistence.emptyDirSizeLimit }} + {{- end }} + - name: tmp + emptyDir: + sizeLimit: 64Mi + {{- if include "opence-server.hasCustomMaps" . }} + - name: custom-maps + {{- include "opence-server.volumeSource" .Values.customMaps | trim | nindent 10 }} + {{- end }} diff --git a/deploy/helm/opence-server/templates/extra-objects.yaml b/deploy/helm/opence-server/templates/extra-objects.yaml new file mode 100644 index 00000000..b058f886 --- /dev/null +++ b/deploy/helm/opence-server/templates/extra-objects.yaml @@ -0,0 +1,4 @@ +{{- range .Values.extraObjects }} +--- +{{ tpl (toYaml .) $ }} +{{- end }} diff --git a/deploy/helm/opence-server/templates/networkpolicy.yaml b/deploy/helm/opence-server/templates/networkpolicy.yaml new file mode 100644 index 00000000..f8210d95 --- /dev/null +++ b/deploy/helm/opence-server/templates/networkpolicy.yaml @@ -0,0 +1,66 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +# In: the players' UDP on the tunnel port, and the web port from webFrom alone. Out: DNS, TCP to +# the MQTT brokers and the master servers, UDP to the internet (STUN, the players' ports); nothing +# on the private ranges. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "opence-server.fullname" . }} + labels: + {{- include "opence-server.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "opence-server.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: UDP + port: {{ .Values.network.tunnelPort }} + {{- with $np.from }} + from: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if and $np.webFrom (gt (int .Values.web.port) 0) }} + - ports: + - protocol: TCP + port: {{ .Values.web.port }} + from: + {{- toYaml $np.webFrom | nindent 8 }} + {{- end }} + {{- with $np.extraIngress }} + {{- toYaml . | nindent 4 }} + {{- end }} + egress: + - to: + {{- toYaml $np.dns.to | nindent 8 }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- if or $np.internet.tcpPorts $np.internet.udp }} + - to: + - ipBlock: + cidr: {{ $np.internet.cidr }} + {{- with $np.internet.except }} + except: + {{- toYaml . | nindent 14 }} + {{- end }} + ports: + {{- range $np.internet.tcpPorts }} + - protocol: TCP + port: {{ . }} + {{- end }} + {{- if $np.internet.udp }} + # every UDP port: STUN (19302, 3478) and the players' own + - protocol: UDP + {{- end }} + {{- end }} + {{- with $np.extraEgress }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/opence-server/templates/secret.yaml b/deploy/helm/opence-server/templates/secret.yaml new file mode 100644 index 00000000..ad56eb73 --- /dev/null +++ b/deploy/helm/opence-server/templates/secret.yaml @@ -0,0 +1,44 @@ +{{- $data := dict }} +{{- if and (gt (int .Values.web.port) 0) (not .Values.web.existingSecret) }} +{{- $password := .Values.web.password }} +{{- if $password }} +{{- if lt (len $password) 8 }} +{{- fail "web.password: 8 characters or more (the server refuses a shorter one beyond loopback)" }} +{{- end }} +{{- else }} +{{- /* made once, then kept from the Secret the release already has */}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "opence-server.secretName" .) }} +{{- if and $existing $existing.data (hasKey $existing.data .Values.web.key) }} +{{- $password = index $existing.data .Values.web.key | b64dec }} +{{- else }} +{{- $password = randAlphaNum 24 }} +{{- end }} +{{- end }} +{{- $_ := set $data .Values.web.key ($password | b64enc) }} +{{- with .Values.web.moderatorPassword }} +{{- if lt (len .) 8 }} +{{- fail "web.moderatorPassword: 8 characters or more" }} +{{- end }} +{{- $_ := set $data $.Values.web.moderatorKey (. | b64enc) }} +{{- end }} +{{- end }} +{{- if and .Values.server.password.value (not .Values.server.password.existingSecret) }} +{{- $_ := set $data .Values.server.password.key (.Values.server.password.value | b64enc) }} +{{- end }} +{{- if and .Values.publisherKey.value (not .Values.publisherKey.existingSecret) }} +{{- if not (regexMatch "^[0-9a-f]{64}$" .Values.publisherKey.value) }} +{{- fail "publisherKey.value: the 64 lower-case hex characters of the seed (halo --server --keygen)" }} +{{- end }} +{{- $_ := set $data .Values.publisherKey.key (.Values.publisherKey.value | b64enc) }} +{{- end }} +{{- if $data }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "opence-server.secretName" . }} + labels: + {{- include "opence-server.labels" . | nindent 4 }} +type: Opaque +data: + {{- toYaml $data | nindent 2 }} +{{- end }} diff --git a/deploy/helm/opence-server/templates/service-web.yaml b/deploy/helm/opence-server/templates/service-web.yaml new file mode 100644 index 00000000..8f2ce5f5 --- /dev/null +++ b/deploy/helm/opence-server/templates/service-web.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.web.service.enabled (gt (int .Values.web.port) 0) }} +# The web page, its API and the remote console, inside the cluster only: for an Ingress with TLS, +# or kubectl port-forward. +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opence-server.fullname" . }}-web + labels: + {{- include "opence-server.labels" . | nindent 4 }} + {{- with .Values.web.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + selector: + {{- include "opence-server.selectorLabels" . | nindent 4 }} + ports: + - name: web + port: {{ .Values.web.service.port }} + targetPort: web + protocol: TCP +{{- end }} diff --git a/deploy/helm/opence-server/templates/service.yaml b/deploy/helm/opence-server/templates/service.yaml new file mode 100644 index 00000000..1fdc187c --- /dev/null +++ b/deploy/helm/opence-server/templates/service.yaml @@ -0,0 +1,40 @@ +# The tunnel port, UDP: the one port players need. +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opence-server.fullname" . }} + labels: + {{- include "opence-server.labels" . | nindent 4 }} + {{- with .Values.service.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- if ne .Values.service.type "ClusterIP" }} + {{- with .Values.service.externalTrafficPolicy }} + externalTrafficPolicy: {{ . }} + {{- end }} + {{- end }} + {{- if eq .Values.service.type "LoadBalancer" }} + {{- with .Values.service.loadBalancerClass }} + loadBalancerClass: {{ . }} + {{- end }} + {{- with .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} + selector: + {{- include "opence-server.selectorLabels" . | nindent 4 }} + ports: + - name: tunnel + port: {{ .Values.service.port }} + targetPort: tunnel + protocol: UDP + {{- if and (ne .Values.service.type "ClusterIP") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} diff --git a/deploy/helm/opence-server/templates/state-pvc.yaml b/deploy/helm/opence-server/templates/state-pvc.yaml new file mode 100644 index 00000000..484b2e94 --- /dev/null +++ b/deploy/helm/opence-server/templates/state-pvc.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "opence-server.stateClaim" . }} + labels: + {{- include "opence-server.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/deploy/helm/opence-server/tests/deployment_test.yaml b/deploy/helm/opence-server/tests/deployment_test.yaml new file mode 100644 index 00000000..beea2a6d --- /dev/null +++ b/deploy/helm/opence-server/tests/deployment_test.yaml @@ -0,0 +1,221 @@ +suite: deployment +templates: + - templates/deployment.yaml +set: + gameData.existingClaim: halo-maps +tests: + - it: refuses to render without game data, which is never in the image + set: + gameData.existingClaim: "" + asserts: + - failedTemplate: + errorPattern: "gameData: set existingClaim" + + - it: mounts an existing claim read-only at /data + asserts: + - equal: + path: 'spec.template.spec.volumes[0]' + value: { name: game-data, persistentVolumeClaim: { claimName: halo-maps, readOnly: true } } + - contains: + path: 'spec.template.spec.containers[0].volumeMounts' + content: { name: game-data, mountPath: /data, readOnly: true } + + - it: mounts an NFS export read-only, with a subPath + set: + gameData: { existingClaim: "", nfs: { server: nfs.example.com, path: /halo }, subPath: xbox } + asserts: + - equal: + path: 'spec.template.spec.volumes[0]' + value: { name: game-data, nfs: { server: nfs.example.com, path: /halo, readOnly: true } } + - contains: + path: 'spec.template.spec.containers[0].volumeMounts' + content: { name: game-data, mountPath: /data, readOnly: true, subPath: xbox } + + - it: takes any other volume source + set: + gameData: { existingClaim: "", volume: { emptyDir: {} } } + asserts: + - equal: + path: 'spec.template.spec.volumes[0]' + value: { name: game-data, emptyDir: {} } + + - it: runs one replica, replaced not rolled, hardened + asserts: + - equal: { path: spec.replicas, value: 1 } + - equal: { path: spec.strategy.type, value: Recreate } + - equal: { path: spec.template.spec.automountServiceAccountToken, value: false } + - equal: { path: spec.template.spec.securityContext.runAsNonRoot, value: true } + - equal: { path: spec.template.spec.securityContext.runAsUser, value: 10001 } + - equal: { path: spec.template.spec.securityContext.seccompProfile.type, value: RuntimeDefault } + - equal: { path: 'spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem', value: true } + - equal: { path: 'spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation', value: false } + - equal: { path: 'spec.template.spec.containers[0].securityContext.capabilities.drop', value: [ALL] } + - equal: { path: spec.template.spec.nodeSelector, value: { kubernetes.io/os: linux, kubernetes.io/arch: amd64 } } + - equal: { path: spec.template.spec.terminationGracePeriodSeconds, value: 15 } + - exists: { path: 'spec.template.spec.containers[0].resources.limits.memory' } + + - it: sets the server's paths and mode + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER, value: "1" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_CONFIG_FILE, value: /state/config.toml } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_DATA_ROOT, value: /data } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SAVE_ROOT, value: /state/saves } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_LOG_ROOT, value: /state/logs } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_CONSOLE, value: "false" } } + + - it: maps the values to the HALO_ variables + set: + server: + name: Test server + rotation: [bloodgulch:ctf, "sidewinder:team_slayer+score=50"] + maxPlayers: 24 + public: false + lobbySeconds: 30 + motd: Hello + logFormat: json + instance: eu-1 + region: eu + masterPublish: false + network: { tunnelPort: 2303, publicAddress: "halo.example.com:2303" } + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_NAME, value: Test server } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_ROTATION, value: "bloodgulch:ctf, sidewinder:team_slayer+score=50" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MAX_PLAYERS, value: "24" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_PUBLIC, value: "false" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOBBY_SECONDS, value: "30" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MOTD, value: Hello } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOG_FORMAT, value: json } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_INSTANCE, value: eu-1 } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_REGION, value: eu } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MASTER_PUBLISH, value: "false" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_TUNNEL_PORT, value: "2303" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_PUBLIC_ADDRESS, value: "halo.example.com:2303" } } + - equal: { path: 'spec.template.spec.containers[0].ports[0]', value: { name: tunnel, containerPort: 2303, protocol: UDP } } + + - it: leaves empty values to config.toml + asserts: + - notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_NAME, value: "" } } + - notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MOTD, value: "" } } + - notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_PUBLIC_ADDRESS, value: "" } } + + - it: leaves the settings the values do not give to config.toml, sv_set and the web page + asserts: + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_ROTATION")]' } + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_MAX_PLAYERS")]' } + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_PUBLIC")]' } + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_LOBBY_SECONDS")]' } + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_LOG_FORMAT")]' } + - notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_MASTER_PUBLISH")]' } + + - it: sets a false or zero value that is given + set: + server: { public: false, masterPublish: false, lobbySeconds: 0 } + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_PUBLIC, value: "false" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MASTER_PUBLISH, value: "false" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOBBY_SECONDS, value: "0" } } + + - it: refuses a name longer than the game's 15 characters (the values' schema, or the template) + set: + server.name: "A name too long!" + asserts: + - failedTemplate: + errorPattern: "server.name|/server/name|15 characters" + + - it: takes the web password from the chart's Secret, bound beyond loopback for the probes + release: { name: opence-server } + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_PORT, value: "8080" } } + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_BIND, value: 0.0.0.0 } } + - contains: + path: 'spec.template.spec.containers[0].env' + content: { name: HALO_SERVER_WEB_PASSWORD, valueFrom: { secretKeyRef: { name: opence-server-secrets, key: web-password } } } + - contains: { path: 'spec.template.spec.containers[0].ports', content: { name: web, containerPort: 8080, protocol: TCP } } + + - it: takes the passwords and the publisher key from existing Secrets + set: + web: { existingSecret: halo-web, key: admin, moderatorKey: moderator } + server.password: { existingSecret: halo-join, key: password } + publisherKey: { existingSecret: halo-publisher, key: seed } + asserts: + - contains: + path: 'spec.template.spec.containers[0].env' + content: { name: HALO_SERVER_WEB_PASSWORD, valueFrom: { secretKeyRef: { name: halo-web, key: admin } } } + - contains: + path: 'spec.template.spec.containers[0].env' + content: { name: HALO_SERVER_WEB_MODERATOR_PASSWORD, valueFrom: { secretKeyRef: { name: halo-web, key: moderator, optional: true } } } + - contains: + path: 'spec.template.spec.containers[0].env' + content: { name: HALO_SERVER_PASSWORD, valueFrom: { secretKeyRef: { name: halo-join, key: password } } } + - contains: + path: 'spec.template.spec.containers[0].env' + content: { name: HALO_SERVER_PUBLISHER_KEY, valueFrom: { secretKeyRef: { name: halo-publisher, key: seed } } } + + - it: probes GET /healthz on the web port + asserts: + - equal: { path: 'spec.template.spec.containers[0].startupProbe.httpGet', value: { path: /healthz, port: web } } + - equal: { path: 'spec.template.spec.containers[0].readinessProbe.httpGet', value: { path: /healthz, port: web } } + - equal: { path: 'spec.template.spec.containers[0].livenessProbe.httpGet', value: { path: /healthz, port: web } } + - equal: { path: 'spec.template.spec.containers[0].startupProbe.failureThreshold', value: 60 } + + - it: drops a probe that is turned off + set: + probes.liveness.enabled: false + asserts: + - notExists: { path: 'spec.template.spec.containers[0].livenessProbe' } + - exists: { path: 'spec.template.spec.containers[0].readinessProbe' } + + - it: has no web port, password or probes with web.port 0 + set: + web.port: 0 + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_PORT, value: "0" } } + - notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_BIND, value: 0.0.0.0 } } + - lengthEqual: { path: 'spec.template.spec.containers[0].ports', count: 1 } + - notExists: { path: 'spec.template.spec.containers[0].startupProbe' } + - notExists: { path: 'spec.template.spec.containers[0].livenessProbe' } + + - it: puts extraEnv after the chart's variables, so it can override them + set: + extraEnv: [{ name: HALO_SERVER_LOBBY_SECONDS, value: "5" }] + asserts: + - equal: + path: 'spec.template.spec.containers[0].env[-1:]' + value: { name: HALO_SERVER_LOBBY_SECONDS, value: "5" } + + - it: keeps the state in an emptyDir without persistence + asserts: + - contains: { path: spec.template.spec.volumes, content: { name: state, emptyDir: { sizeLimit: 1Gi } } } + - lengthEqual: { path: spec.template.spec.volumes, count: 3 } + + - it: uses the claim with persistence + release: { name: opence-server } + set: + persistence.enabled: true + asserts: + - contains: { path: spec.template.spec.volumes, content: { name: state, persistentVolumeClaim: { claimName: opence-server-state } } } + + - it: mounts a custom_maps claim read-only, with its variable + set: + customMaps: { existingClaim: halo-custom-maps, subPath: ce } + asserts: + - contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_CUSTOM_MAPS_ROOT, value: /custom_maps } } + - contains: + path: spec.template.spec.volumes + content: { name: custom-maps, persistentVolumeClaim: { claimName: halo-custom-maps, readOnly: true } } + - contains: + path: 'spec.template.spec.containers[0].volumeMounts' + content: { name: custom-maps, mountPath: /custom_maps, readOnly: true, subPath: ce } + + - it: takes a tag with a digest, and lets digest replace it + set: + image.tag: build-206@sha256:1111111111111111111111111111111111111111111111111111111111111111 + image.digest: sha256:2222222222222222222222222222222222222222222222222222222222222222 + asserts: + - equal: + path: 'spec.template.spec.containers[0].image' + value: ghcr.io/opencommunityedition/opence-server:build-206@sha256:2222222222222222222222222222222222222222222222222222222222222222 + + - it: uses the appVersion as the default tag + asserts: + - equal: { path: 'spec.template.spec.containers[0].image', value: "ghcr.io/opencommunityedition/opence-server:latest" } diff --git a/deploy/helm/opence-server/tests/networkpolicy_test.yaml b/deploy/helm/opence-server/tests/networkpolicy_test.yaml new file mode 100644 index 00000000..10dcfdb4 --- /dev/null +++ b/deploy/helm/opence-server/tests/networkpolicy_test.yaml @@ -0,0 +1,39 @@ +suite: network policy +templates: + - templates/networkpolicy.yaml +tests: + - it: lets anyone reach the tunnel port, and nothing else in + asserts: + - equal: { path: spec.policyTypes, value: [Ingress, Egress] } + - equal: { path: spec.ingress, value: [{ ports: [{ protocol: UDP, port: 2302 }] }] } + + - it: allows DNS, TCP 1883 and 443 and all UDP to public addresses only + asserts: + - equal: { path: 'spec.egress[0].ports', value: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }] } + - equal: { path: 'spec.egress[1].to[0].ipBlock.cidr', value: 0.0.0.0/0 } + - contains: { path: 'spec.egress[1].to[0].ipBlock.except', content: 10.0.0.0/8 } + - contains: { path: 'spec.egress[1].to[0].ipBlock.except', content: 192.168.0.0/16 } + - equal: { path: 'spec.egress[1].ports', value: [{ protocol: TCP, port: 1883 }, { protocol: TCP, port: 443 }, { protocol: UDP }] } + + - it: lets webFrom reach the web port + set: + networkPolicy.webFrom: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }] + asserts: + - equal: + path: 'spec.ingress[1]' + value: { ports: [{ protocol: TCP, port: 8080 }], from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }] } + + - it: narrows the players' sources and follows the tunnel port + set: + network.tunnelPort: 2303 + networkPolicy.from: [{ ipBlock: { cidr: 198.51.100.0/24 } }] + asserts: + - equal: + path: spec.ingress + value: [{ ports: [{ protocol: UDP, port: 2303 }], from: [{ ipBlock: { cidr: 198.51.100.0/24 } }] }] + + - it: renders nothing when off + set: + networkPolicy.enabled: false + asserts: + - hasDocuments: { count: 0 } diff --git a/deploy/helm/opence-server/tests/persistence_test.yaml b/deploy/helm/opence-server/tests/persistence_test.yaml new file mode 100644 index 00000000..b5de9808 --- /dev/null +++ b/deploy/helm/opence-server/tests/persistence_test.yaml @@ -0,0 +1,21 @@ +suite: persistence +templates: + - templates/state-pvc.yaml +release: + name: opence-server +tests: + - it: makes no claim without persistence + asserts: + - hasDocuments: { count: 0 } + - it: makes the state claim with persistence + set: + persistence: { enabled: true, size: 3Gi, storageClass: fast } + asserts: + - equal: { path: metadata.name, value: opence-server-state } + - equal: { path: spec.resources.requests.storage, value: 3Gi } + - equal: { path: spec.storageClassName, value: fast } + - it: makes none for an existing claim + set: + persistence: { enabled: true, existingClaim: mine } + asserts: + - hasDocuments: { count: 0 } diff --git a/deploy/helm/opence-server/tests/secret_test.yaml b/deploy/helm/opence-server/tests/secret_test.yaml new file mode 100644 index 00000000..a05b9cd8 --- /dev/null +++ b/deploy/helm/opence-server/tests/secret_test.yaml @@ -0,0 +1,48 @@ +suite: secret +templates: + - templates/secret.yaml +release: + name: opence-server +tests: + - it: makes a web password when none is given + asserts: + - hasDocuments: { count: 1 } + - isKind: { of: Secret } + - equal: { path: metadata.name, value: opence-server-secrets } + - matchRegex: { path: data.web-password, pattern: "^[A-Za-z0-9+/]{32}$" } + + - it: keeps a given web password, join password and publisher key + set: + web.password: correct-horse + server.password.value: letmein + publisherKey.value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + asserts: + - equal: { path: data.web-password, value: Y29ycmVjdC1ob3JzZQ== } + - equal: { path: data.password, value: bGV0bWVpbg== } + - exists: { path: data.publisher-key } + + - it: refuses a short web password + set: + web.password: short + asserts: + - failedTemplate: + errorPattern: "8 characters or more" + + - it: refuses a publisher key that is not 64 hex characters + set: + publisherKey.value: not-a-key + asserts: + - failedTemplate: + errorPattern: "64 lower-case hex" + + - it: makes none when every secret comes from elsewhere + set: + web.existingSecret: halo-web + asserts: + - hasDocuments: { count: 0 } + + - it: makes none without a web port or secrets + set: + web.port: 0 + asserts: + - hasDocuments: { count: 0 } diff --git a/deploy/helm/opence-server/tests/service_test.yaml b/deploy/helm/opence-server/tests/service_test.yaml new file mode 100644 index 00000000..a23465ec --- /dev/null +++ b/deploy/helm/opence-server/tests/service_test.yaml @@ -0,0 +1,43 @@ +suite: service +templates: + - templates/service.yaml + - templates/service-web.yaml +release: + name: opence-server +tests: + - it: exposes the tunnel port over UDP on a load balancer that keeps the players' addresses + template: templates/service.yaml + asserts: + - equal: { path: metadata.name, value: opence-server } + - equal: { path: spec.type, value: LoadBalancer } + - equal: { path: spec.externalTrafficPolicy, value: Local } + - equal: { path: 'spec.ports[0]', value: { name: tunnel, port: 2302, targetPort: tunnel, protocol: UDP } } + + - it: ignores externalTrafficPolicy and nodePort on a ClusterIP Service + template: templates/service.yaml + set: + service: { type: ClusterIP, nodePort: 30702 } + asserts: + - notExists: { path: spec.externalTrafficPolicy } + - notExists: { path: 'spec.ports[0].nodePort' } + + - it: sets a fixed node port + template: templates/service.yaml + set: + service: { type: NodePort, nodePort: 30702 } + asserts: + - equal: { path: 'spec.ports[0].nodePort', value: 30702 } + + - it: keeps the web port inside the cluster + template: templates/service-web.yaml + asserts: + - equal: { path: metadata.name, value: opence-server-web } + - equal: { path: spec.type, value: ClusterIP } + - equal: { path: 'spec.ports[0]', value: { name: web, port: 8080, targetPort: web, protocol: TCP } } + + - it: has no web Service without a web port + template: templates/service-web.yaml + set: + web.port: 0 + asserts: + - hasDocuments: { count: 0 } diff --git a/deploy/helm/opence-server/values.schema.json b/deploy/helm/opence-server/values.schema.json new file mode 100644 index 00000000..ee426339 --- /dev/null +++ b/deploy/helm/opence-server/values.schema.json @@ -0,0 +1,445 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "opence-server values", + "description": "Strict where the chart defines the structure; free-form where values pass through to Kubernetes. values.yaml describes each value.", + "type": "object", + "additionalProperties": false, + "definitions": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "volume": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingClaim": { + "type": "string" + }, + "nfs": { + "type": "object", + "additionalProperties": false, + "properties": { + "server": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "volume": { + "type": "object" + }, + "subPath": { + "type": "string" + } + } + }, + "secretValue": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + }, + "existingSecret": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + } + } + }, + "probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "initialDelaySeconds": { + "type": "integer", + "minimum": 0 + }, + "periodSeconds": { + "type": "integer", + "minimum": 1 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "failureThreshold": { + "type": "integer", + "minimum": 1 + }, + "successThreshold": { + "type": "integer", + "minimum": 1 + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "properties": { + "global": { + "type": "object" + }, + "nameOverride": { + "type": "string" + }, + "fullnameOverride": { + "type": "string" + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": [ + "string", + "number" + ] + }, + "digest": { + "type": "string", + "pattern": "^(sha256:[0-9a-f]{64})?$" + }, + "pullPolicy": { + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + } + } + }, + "imagePullSecrets": { + "type": "array" + }, + "gameData": { + "$ref": "#/definitions/volume" + }, + "customMaps": { + "$ref": "#/definitions/volume" + }, + "server": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 15, + "pattern": "^[ -~]*$" + }, + "rotation": { + "type": "array", + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1 + } + }, + "maxPlayers": { + "type": [ + "integer", + "null" + ], + "minimum": 2, + "maximum": 127 + }, + "public": { + "type": [ + "boolean", + "null" + ] + }, + "lobbySeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 300 + }, + "motd": { + "type": "string", + "maxLength": 127 + }, + "logFormat": { + "enum": [ + "", + "text", + "json", + null + ] + }, + "instance": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{0,32}$" + }, + "region": { + "type": "string", + "maxLength": 16 + }, + "masterPublish": { + "type": [ + "boolean", + "null" + ] + }, + "password": { + "$ref": "#/definitions/secretValue" + } + } + }, + "publisherKey": { + "$ref": "#/definitions/secretValue" + }, + "web": { + "type": "object", + "additionalProperties": false, + "properties": { + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "password": { + "type": "string" + }, + "existingSecret": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "moderatorPassword": { + "type": "string" + }, + "moderatorKey": { + "type": "string", + "minLength": 1 + }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "port": { + "$ref": "#/definitions/port" + }, + "annotations": { + "type": "object" + } + } + } + } + }, + "network": { + "type": "object", + "additionalProperties": false, + "properties": { + "tunnelPort": { + "$ref": "#/definitions/port" + }, + "publicAddress": { + "type": "string" + } + } + }, + "extraEnv": { + "type": "array" + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "existingClaim": { + "type": "string" + }, + "size": { + "type": "string" + }, + "storageClass": { + "type": "string" + }, + "accessModes": { + "type": "array", + "items": { + "type": "string" + } + }, + "annotations": { + "type": "object" + }, + "emptyDirSizeLimit": { + "type": "string" + } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ] + }, + "port": { + "$ref": "#/definitions/port" + }, + "nodePort": { + "type": [ + "string", + "integer" + ] + }, + "annotations": { + "type": "object" + }, + "labels": { + "type": "object" + }, + "externalTrafficPolicy": { + "enum": [ + "", + "Cluster", + "Local" + ] + }, + "loadBalancerClass": { + "type": "string" + }, + "loadBalancerSourceRanges": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "strategy": { + "type": "object" + }, + "resources": { + "type": "object" + }, + "nodeSelector": { + "type": "object" + }, + "affinity": { + "type": "object" + }, + "tolerations": { + "type": "array" + }, + "priorityClassName": { + "type": "string" + }, + "podAnnotations": { + "type": "object" + }, + "podLabels": { + "type": "object" + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 1 + }, + "podSecurityContext": { + "type": "object" + }, + "securityContext": { + "type": "object" + }, + "probes": { + "type": "object", + "additionalProperties": false, + "properties": { + "startup": { + "$ref": "#/definitions/probe" + }, + "readiness": { + "$ref": "#/definitions/probe" + }, + "liveness": { + "$ref": "#/definitions/probe" + } + } + }, + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "from": { + "type": "array" + }, + "webFrom": { + "type": "array" + }, + "dns": { + "type": "object" + }, + "internet": { + "type": "object", + "additionalProperties": false, + "properties": { + "cidr": { + "type": "string" + }, + "except": { + "type": "array", + "items": { + "type": "string" + } + }, + "tcpPorts": { + "type": "array", + "items": { + "$ref": "#/definitions/port" + } + }, + "udp": { + "type": "boolean" + } + } + }, + "extraIngress": { + "type": "array" + }, + "extraEgress": { + "type": "array" + } + } + }, + "extraObjects": { + "type": "array" + } + } +} diff --git a/deploy/helm/opence-server/values.yaml b/deploy/helm/opence-server/values.yaml new file mode 100644 index 00000000..4488c08f --- /dev/null +++ b/deploy/helm/opence-server/values.yaml @@ -0,0 +1,239 @@ +# opence-server: the OpenCE dedicated server (docs/dedicated_server.md). One replica: the server +# owns its tunnel port and the address players reach it at. + +nameOverride: "" +fullnameOverride: "" + +image: + repository: ghcr.io/opencommunityedition/opence-server + # Empty: the chart's appVersion (latest). A release's tag (build-206) or sha- pins it; + # @sha256:... as Argo CD Image Updater writes it works too. + tag: "" + # sha256:... ; replaces any digest in tag. + digest: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# The game data: the folder that holds maps/ (and custom_maps/, gametypes/ and init.txt if you have +# them), from your own copy of the game. It is never in the image. Mounted read-only at /data +# (HALO_DATA_ROOT). One source is required. +gameData: + # An existing PersistentVolumeClaim. + existingClaim: "" + # Or an NFS export. + nfs: + server: "" + path: "" + # Or any other volume source, as in a pod's volumes (without the name), e.g. + # {hostPath: {path: /srv/halo, type: Directory}}. Without maps/ui.map and the rotation's maps the + # server exits with code 2 and says which maps it looked for, and the pod goes into + # CrashLoopBackOff. + volume: {} + # The folder within the volume that holds maps/. Empty: the volume's root. + subPath: "" + +# A custom_maps folder of its own (Custom Edition maps with bitmaps.map, sounds.map and loc.map), +# mounted read-only at /custom_maps (HALO_CUSTOM_MAPS_ROOT). Optional: without one the server uses +# gameData's custom_maps/. +customMaps: + existingClaim: "" + nfs: + server: "" + path: "" + volume: {} + subPath: "" + +# The server's settings, as its HALO_* environment variables (docs/dedicated_server.md and +# port/linux/README.md, "Dedicated server"). A value given here wins over config.toml, and sv_set +# and the web page's settings cannot change it. Each is unset by default (empty, or null): the +# setting is then config.toml's, or the server's default (named below), and can be changed from +# the web page. +server: + # server.name: 15 characters of printable ASCII (default "OpenCE Server"). + name: "" + # server.rotation: map[:gametype[+rule=value...]] entries, played in turn, e.g. + # [bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer] (the default). + rotation: [] + # server.max_players: 2 to 127 (default 16). + maxPlayers: null + # server.public: list the server in everyone's server browser (default true). + public: null + # server.lobby_seconds (default 15). + lobbySeconds: null + # server.motd: a notice each player is shown on joining. + motd: "" + # server.log_format: text or json (default text). + logFormat: "" + # server.instance and server.region, as the master servers list them. + instance: "" + region: "" + # server.master_publish: publish the server to the official master servers (needs a publisher + # key, below; default true). + masterPublish: null + # server.password: the password the server browser asks for. value, or a key of an existing + # Secret; empty for none. + password: + value: "" + existingSecret: "" + key: password + +# The master servers' publisher key (HALO_SERVER_PUBLISHER_KEY): the 64 hex characters of the seed +# halo --server --keygen writes. value, or a key of an existing Secret; empty: the state volume's +# publisher.key, if there is one. +publisherKey: + value: "" + existingSecret: "" + key: publisher-key + +# The web page, its API and the remote console (server.web_port), on which the probes ask +# GET /healthz. 0 turns them off, and the probes with them. +web: + port: 8080 + # The admin password (server.web_password, 8 characters or more), from existingSecret's key, else + # from password, else made at the first install and kept in the chart's Secret. The made one is + # found again with lookup on upgrades, which helm template and GitOps tools (Argo CD, Flux's + # dry runs) cannot do: they make a new one at each render. Give password or existingSecret + # there (README.md, "Web password"). + password: "" + existingSecret: "" + key: web-password + # server.web_moderator_password, from the same Secret's moderatorKey when it has one. + moderatorPassword: "" + moderatorKey: web-moderator-password + # A ClusterIP Service for the web port, for an Ingress (with TLS) or kubectl port-forward. + service: + enabled: true + port: 8080 + annotations: {} + +network: + # network.tunnel_port: the UDP port of internet play, which the Service exposes. + tunnelPort: 2302 + # network.public_address: host[:port] players reach the tunnel port at, when the cluster is behind + # a NAT that STUN cannot see through. Empty: what STUN finds. + publicAddress: "" + +# More environment variables, as in a pod spec ({name, value} or {name, valueFrom}); they come after +# the chart's, so the same name overrides it. +extraEnv: [] + +# /state: config.toml (HALO_CONFIG_FILE), publisher.key, the save root with its map cache (about +# 115 MB for the multiplayer maps), the logs. Without persistence an emptyDir: the cache is built +# again at each start, and settings changed from the web page are lost. +persistence: + enabled: false + existingClaim: "" + size: 2Gi + storageClass: "" + accessModes: + - ReadWriteOnce + annotations: {} + emptyDirSizeLimit: 1Gi + +# The tunnel port's Service. Players reach it from outside the cluster, so a real deployment needs +# LoadBalancer or NodePort. +service: + type: LoadBalancer + port: 2302 + nodePort: "" + annotations: {} + labels: {} + # Local keeps the players' own addresses (no SNAT to a node), which bans and the server's limits + # rely on. + externalTrafficPolicy: Local + loadBalancerClass: "" + loadBalancerSourceRanges: [] + +strategy: + type: Recreate + +resources: + requests: + cpu: 100m + memory: 192Mi + limits: + memory: 600Mi + +# The image is 32-bit x86 Linux code, which runs on amd64 nodes only. +nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: amd64 +affinity: {} +tolerations: [] +priorityClassName: "" + +podAnnotations: {} +podLabels: {} + +# SIGTERM ends the game in progress, tells the players and exits 0; a second signal, or 5 seconds +# on, exits at once. +terminationGracePeriodSeconds: 15 + +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +# GET /healthz on the web port: 200 while the server hosts (lobby, loading, playing, postgame) and +# its frames run. Each probe's fields override these; enabled: false drops it. +probes: + startup: + enabled: true + periodSeconds: 2 + failureThreshold: 60 + timeoutSeconds: 2 + readiness: + enabled: true + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 2 + liveness: + enabled: true + periodSeconds: 20 + failureThreshold: 3 + timeoutSeconds: 3 + +networkPolicy: + enabled: true + # Who may reach the tunnel port. Empty: anyone (players are on the internet). + from: [] + # Who may reach the web port, as NetworkPolicy peers (an Ingress controller's namespace, say). + # Empty: no pod; kubectl port-forward and the kubelet's probes still reach it. + webFrom: [] + dns: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + # The internet: TCP to the MQTT brokers (1883) and the master servers (443), and UDP to anywhere + # (STUN, and the players' own ports). The private ranges are excepted. + internet: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 100.64.0.0/10 + - 169.254.0.0/16 + tcpPorts: + - 1883 + - 443 + udp: true + extraIngress: [] + extraEgress: [] + +# More objects to render with the release (templated). +extraObjects: [] diff --git a/deploy/init.txt.example b/deploy/init.txt.example new file mode 100644 index 00000000..b169cd6e --- /dev/null +++ b/deploy/init.txt.example @@ -0,0 +1,31 @@ +# init.txt: commands a dedicated server runs once at its start, one a line, +# as if typed at its console (docs/dedicated_server.md, "Commands"). Copy +# this file to init.txt in the data folder (server.init_file), and take the +# "# " off the lines you want. Lines starting with # or ; are comments. +# +# Settings are better given in config.toml or as HALO_* variables; these are +# for what changes from one start to the next. + +# The rotation, replacing server.rotation (and written to it): +# sv_rotation_set bloodgulch:ctf, sidewinder:team_slayer+score=50, hangemhigh:king_moving +# sv_rotation_add prisoner:slayer+time=10 +# sv_rotation_begin 2 + +# The game's name, players and notice (sv_set of the server.* settings): +# sv_name My server +# sv_maxplayers 12 +# sv_motd Welcome. Be nice; admins are watching. +# sv_public 1 +# sv_password secret + +# Any setting the server lists in sv_settings: +# sv_set server.lobby_seconds 20 +# sv_set server.empty_seconds 60 + +# A first game other than the rotation's, which goes on after it: +# sv_map bloodgulch ctf_classic + +# The state of the server, in the log: +# sv_status + +# The game's own console commands run too, with the owner's role. diff --git a/deploy/mcsmanager/README.md b/deploy/mcsmanager/README.md new file mode 100644 index 00000000..d5770dc2 --- /dev/null +++ b/deploy/mcsmanager/README.md @@ -0,0 +1,40 @@ +# The dedicated server on MCSManager + +`mcsmanager-config.json` holds two MCSManager quick-install entries for the +dedicated server (`docs/dedicated_server.md`). Each unzips a release's +`halo-linux-release.zip` into the instance's folder and runs +`./halo --server` there, stopped with `quit`. + +| Entry | How it runs | +| --- | --- | +| Host | On the node itself, which needs the 32-bit C runtime (Debian and Ubuntu: `dpkg --add-architecture i386 && apt install libc6:i386`) | +| Docker | In a container of `ghcr.io/ptero-eggs/games:source` (Debian 12 with the 32-bit libraries), the instance's folder mounted at `/workspace` | + +## Using it + +1. Add the entries to the node's quick-install list, and create an + instance from one. +2. Upload the `maps/` folder of your own copy of the game into the + instance's folder. Without `maps/ui.map` and the rotation's maps the + server exits with code 2 and says which maps it looked for. +3. Start it. + +## Folders + +The Docker entry's container is made again at each start, so it sets +`HALO_SAVE_ROOT=/workspace/saves` and `HALO_LOG_ROOT=/workspace/logs`: the +save root with the map cache (about 115 MB, built at the first start) and +the logs stay in the instance's folder rather than in the container. The +host entry keeps the save root in the home folder of the daemon's user +(`~/.local/share/halo-linux`), which every host instance of that user +shares; give each its own with +`HALO_SAVE_ROOT=saves` (in the instance's environment, or as +`env HALO_SAVE_ROOT=saves ./halo --server` for its start command). +`config.toml` is written beside `halo` at the first start. + +## Ports + +UDP 2302 is the tunnel port, the one players need; the web page, its API +and the remote console are on 127.0.0.1:8080. A second instance on the node +needs ports of its own: `HALO_NET_TUNNEL_PORT` and `HALO_SERVER_WEB_PORT`, +and for the Docker entry its published ports to match. diff --git a/deploy/mcsmanager/mcsmanager-config.json b/deploy/mcsmanager/mcsmanager-config.json new file mode 100644 index 00000000..ecc33434 --- /dev/null +++ b/deploy/mcsmanager/mcsmanager-config.json @@ -0,0 +1,139 @@ +{ + "_comment": "MCSManager quick-install entries for the OpenCE dedicated server (docs/dedicated_server.md). Each package unzips the release into the instance's folder; put the maps/ folder of your own copy of the game beside halo before the first start. Settings: config.toml beside halo, written at the first start, or HALO_* variables.", + "languages": [ + { + "label": "English", + "value": "en_us" + } + ], + "packages": [ + { + "language": "en_us", + "platform": "Linux", + "title": "OpenCE Dedicated Server", + "category": "Halo: Combat Evolved", + "runtime": "Linux x86-64 with 32-bit libraries", + "hardware": "1 CPU core, 600 MB of memory", + "size": "45 MB, and the game data you upload", + "author": "OpenCE", + "targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "description": "The dedicated server run on the node itself, which needs the 32-bit C runtime (Debian and Ubuntu: dpkg --add-architecture i386 && apt install libc6:i386). Open UDP 2302; the web page is on http://127.0.0.1:8080/.", + "remark": "Host", + "setupInfo": { + "nickname": "OpenCE Dedicated Server", + "startCommand": "./halo --server", + "stopCommand": "quit", + "ie": "utf-8", + "oe": "utf-8", + "type": "universal", + "tag": [ + "halo", + "opence" + ], + "processType": "general", + "updateCommand": "", + "crlf": 1, + "actionCommandList": [ + { + "name": "Status", + "command": "sv_status" + }, + { + "name": "Players", + "command": "sv_players" + }, + { + "name": "Next map", + "command": "sv_map_next" + }, + { + "name": "Reload settings", + "command": "sv_reload" + } + ], + "eventTask": { + "autoStart": false, + "autoRestart": true, + "ignore": false + }, + "terminalOption": { + "haveColor": false, + "pty": false + } + } + }, + { + "language": "en_us", + "platform": "Linux", + "title": "OpenCE Dedicated Server", + "category": "Halo: Combat Evolved", + "runtime": "Linux x86-64 with 32-bit libraries", + "hardware": "1 CPU core, 600 MB of memory", + "size": "45 MB, and the game data you upload", + "author": "OpenCE", + "targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "description": "The dedicated server in a container of ghcr.io/ptero-eggs/games:source (Debian 12 with the 32-bit libraries), the instance's folder mounted as its working folder; the save root (with the map cache) and the logs are kept in it, as the container is made again at each start. Publishes UDP 2302 and the web page on the node's 127.0.0.1:8080 (each instance on a node needs ports of its own).", + "remark": "Docker", + "setupInfo": { + "nickname": "OpenCE Dedicated Server", + "startCommand": "./halo --server", + "stopCommand": "quit", + "ie": "utf-8", + "oe": "utf-8", + "type": "universal", + "tag": [ + "halo", + "opence" + ], + "processType": "docker", + "updateCommand": "", + "crlf": 1, + "actionCommandList": [ + { + "name": "Status", + "command": "sv_status" + }, + { + "name": "Players", + "command": "sv_players" + }, + { + "name": "Next map", + "command": "sv_map_next" + }, + { + "name": "Reload settings", + "command": "sv_reload" + } + ], + "eventTask": { + "autoStart": false, + "autoRestart": true, + "ignore": false + }, + "terminalOption": { + "haveColor": false, + "pty": false + }, + "docker": { + "image": "ghcr.io/ptero-eggs/games:source", + "ports": [ + "2302:2302/udp", + "127.0.0.1:8080:8080/tcp" + ], + "extraVolumes": [], + "networkMode": "bridge", + "networkAliases": [], + "env": [ + "HALO_SERVER_WEB_BIND=0.0.0.0", + "HALO_SAVE_ROOT=/workspace/saves", + "HALO_LOG_ROOT=/workspace/logs" + ], + "memory": 600, + "workingDir": "/workspace/", + "changeWorkdir": true + } + } + } + ] +} diff --git a/deploy/pterodactyl/README.md b/deploy/pterodactyl/README.md new file mode 100644 index 00000000..e801a64f --- /dev/null +++ b/deploy/pterodactyl/README.md @@ -0,0 +1,94 @@ +# The dedicated server on Pterodactyl and Pelican + +`egg-opence-dedicated-server.json` is an egg (`PTDL_v2`) that installs a +release of OpenCE from GitHub and runs it as a dedicated server +(`docs/dedicated_server.md`). Pelican imports the same file. + +## Importing it + +1. Admin > Nests (Pelican: Eggs) > Import Egg, and choose the file. +2. Create a server with the egg. Its primary allocation is the tunnel port + (`network.tunnel_port`, UDP): the one port players need, from 1025 up + (the container cannot bind lower ports). +3. Upload the game data: the `maps/` folder of your own copy of the game + (port/linux/README.md, "Game data"), into the server's folder over SFTP + (the panel's SFTP details, on the Settings tab). Without `maps/ui.map` + and the rotation's maps the server stops at once with exit code 2 and + says which maps it looked for. +4. Start it. The panel shows it running once it prints its ready line + (`server: ready`, or `"msg":"ready (` with `server.log_format = "json"`). + +## Files + +| Path (in `/home/container`) | What | +| --- | --- | +| `halo`, `halo-server`, `libSDL3.so.0`, `brokers.txt`, `masters.txt`, the licences | The release (replaced on Reinstall) | +| `maps/` | The game data (yours) | +| `custom_maps/` | Custom Edition maps, with `bitmaps.map`, `sounds.map` and `loc.map` (shared with players who join) | +| `gametypes/*.toml` | Gametypes of your own (the release adds examples, never replacing yours) | +| `init.txt` | Commands run at each start (`init.txt.example` has examples) | +| `config.toml` | Every setting, written at the first start | +| `publisher.key` | The master servers' key: made by `./halo --server --keygen` on any machine with the release and uploaded, or given as the Publisher key variable instead | +| `logs/` | `debug.txt`, `bans.txt`, `cheaters.txt` | + +Reinstall (Settings tab) downloads the release again, the one the Release +variable names, and keeps `config.toml`, `init.txt`, `publisher.key`, +`gametypes/`, `custom_maps/`, `maps/` and `logs/`. + +## Variables + +Every variable is one of the server's settings, by its environment name +(docs/dedicated_server.md and port/linux/README.md, "Dedicated server"). A +variable wins over `config.toml`, and the web page's settings cannot +change it. + +| Variable | Setting | +| --- | --- | +| Release (`OPENCE_RELEASE`) | `latest` or a tag (`build-206`); used by Reinstall | +| Server name (`HALO_SERVER_NAME`) | `server.name`, 15 characters | +| Rotation (`HALO_SERVER_ROTATION`) | `server.rotation` | +| Max players (`HALO_SERVER_MAX_PLAYERS`) | `server.max_players` | +| Join password (`HALO_SERVER_PASSWORD`) | `server.password` | +| Public (`HALO_SERVER_PUBLIC`) | `server.public` | +| Message of the day (`HALO_SERVER_MOTD`) | `server.motd` | +| Lobby seconds (`HALO_SERVER_LOBBY_SECONDS`) | `server.lobby_seconds` | +| Web UI port (`HALO_SERVER_WEB_PORT`) | `server.web_port`; 0 for none | +| Web UI password (`HALO_SERVER_WEB_PASSWORD`) | `server.web_password` | +| Public address (`HALO_NET_PUBLIC_ADDRESS`) | `network.public_address` | +| Publisher key (`HALO_SERVER_PUBLISHER_KEY`) | The master servers' key itself, in place of `publisher.key` | +| Instance name (`HALO_SERVER_INSTANCE`) | `server.instance` | +| Region (`HALO_SERVER_REGION`) | `server.region` | +| Log format (`HALO_SERVER_LOG_FORMAT`) | `server.log_format` | + +The startup command sets `HALO_NET_TUNNEL_PORT` to the primary allocation's +port and `HALO_LOG_ROOT` to `logs/`. Two variables are the administrator's +only: Web UI bind (`0.0.0.0`, as the allocation's address is the node's, +not the container's) and GitHub repository (`OpenCommunityEdition/OpenCE`). + +## The web page + +The admin page, its API and the remote console need a second allocation: +the node's administrator adds one to the server (or raises its allocation +limit so the owner can, on the Network tab). Give its port as Web UI port +and a password of 8 characters or more as Web UI password, restart, and +open `http://:/`. The page has no TLS of its own: +put it behind a reverse proxy with HTTPS for anything but a private network. + +## Stopping and restarting + +The panel's Stop sends SIGINT, which the server takes as SIGTERM: the game +in progress goes to its postgame, the players are told, the listing is +withdrawn, and it exits 0. The console's `quit` command does the same, but +Wings counts an exit it did not ask for as a crash and starts the server +again; use Stop. `sv_restart` exits with code 5, which Wings also takes as a +crash and restarts, which is what it asks for. + +Updates: `server.auto_update` is for servers that own their files; on a +panel, change the Release variable (or keep `latest`) and Reinstall, by hand +or as a schedule. + +## Checking the egg + +`python tools/egg_build.py` copies `install.sh` into the egg and checks it; +`python -m pytest -q tools/test_egg.py` checks the egg and runs `install.sh` +in a `debian:bookworm-slim` container against a release served locally. diff --git a/deploy/pterodactyl/egg-opence-dedicated-server.json b/deploy/pterodactyl/egg-opence-dedicated-server.json new file mode 100644 index 00000000..1268c819 --- /dev/null +++ b/deploy/pterodactyl/egg-opence-dedicated-server.json @@ -0,0 +1,203 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-10-24T00:00:00+00:00", + "name": "OpenCE Dedicated Server", + "author": "opence@halocombatevolved.com", + "description": "A dedicated server of OpenCE, the port of Halo: Combat Evolved: hosts multiplayer games one after another, with no window, sound or player of its own. Installs a release from GitHub. The game data is yours to upload: put the maps/ folder of your own copy of the game in the server's folder over SFTP; the server does not start without it.", + "features": [], + "docker_images": { + "Debian 12 with 32-bit libraries (Pterodactyl)": "ghcr.io/ptero-eggs/games:source", + "Debian 12 with 32-bit libraries (Pelican)": "ghcr.io/pelican-eggs/games:source" + }, + "file_denylist": [], + "startup": "HALO_NET_TUNNEL_PORT={{SERVER_PORT}} HALO_LOG_ROOT=/home/container/logs ./halo --server", + "config": { + "files": "{}", + "startup": "{\r\n \"done\": [\r\n \"server: ready\",\r\n \"\\\"msg\\\":\\\"ready (\"\r\n ]\r\n}", + "logs": "{}", + "stop": "^C" + }, + "scripts": { + "installation": { + "script": "#!/bin/bash\n# The OpenCE dedicated server egg's installation script (Pterodactyl and\n# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the\n# server's folder at /mnt/server. tools/egg_build.py copies it into\n# egg-opence-dedicated-server.json.\n#\n# OPENCE_RELEASE: \"latest\" or a release tag (build-206).\n# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from.\n#\n# A reinstall replaces the release's files and keeps the owner's:\n# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and\n# logs/. The panel ignores this script's exit code, so each failure says\n# what went wrong.\n\nset -u\n\nSERVER_DIR=\"${SERVER_DIR:-/mnt/server}\"\nRELEASE=\"${OPENCE_RELEASE:-latest}\"\nREPOSITORY=\"${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}\"\nGITHUB_URL=\"${OPENCE_GITHUB_URL:-https://github.com}\"\nGITHUB_API=\"${OPENCE_GITHUB_API:-https://api.github.com}\"\nASSET=halo-linux-release.zip\n\nfail() {\n\techo \"INSTALL FAILED: $*\"\n\texit 1\n}\n\nif ! [[ \"$REPOSITORY\" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then\n\tfail \"the repository \\\"$REPOSITORY\\\" is not owner/name\"\nfi\nif [ -z \"$RELEASE\" ] || [ \"$RELEASE\" = latest ]; then\n\t# (no API call, so no rate limit)\n\turl=\"$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET\"\nelse\n\tif ! [[ \"$RELEASE\" =~ ^[A-Za-z0-9._-]+$ ]]; then\n\t\tfail \"the release \\\"$RELEASE\\\" is not a tag name (build-206) or latest\"\n\tfi\n\techo \"Looking up release $RELEASE of $REPOSITORY\"\n\trelease_json=\"$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \\\n\t\t\"$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE\")\" \\\n\t\t|| fail \"no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)\"\n\turl=\"$(printf '%s' \"$release_json\" | jq -r --arg name \"$ASSET\" \\\n\t\t'[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')\"\n\t[ -n \"$url\" ] || fail \"release $RELEASE has no $ASSET\"\nfi\n\nmkdir -p \"$SERVER_DIR\" || fail \"cannot make $SERVER_DIR\"\ncd \"$SERVER_DIR\" || fail \"cannot enter $SERVER_DIR\"\n\nwork=\"$(mktemp -d)\" || fail \"cannot make a temporary folder\"\ntrap 'rm -rf \"$work\"' EXIT\n\necho \"Downloading $url\"\ncurl -fL --retry 3 --progress-bar -o \"$work/$ASSET\" \"$url\" || fail \"the download of $url failed\"\nunzip -tq \"$work/$ASSET\" > /dev/null || fail \"$ASSET is not a zip file\"\nmkdir \"$work/release\"\nunzip -q \"$work/$ASSET\" -d \"$work/release\" || fail \"cannot unzip $ASSET\"\n[ -f \"$work/release/halo\" ] || fail \"$ASSET has no halo executable\"\n\n# the release's files, over the old release's; the owner's are never in it,\n# and gametypes/ only adds the examples not already there\nfor entry in \"$work/release\"/* \"$work/release\"/.[!.]*; do\n\t[ -e \"$entry\" ] || continue\n\tname=\"$(basename \"$entry\")\"\n\tcase \"$name\" in\n\tconfig.toml | init.txt | publisher.key | custom_maps | maps | logs)\n\t\tcontinue\n\t\t;;\n\tgametypes)\n\t\tmkdir -p gametypes\n\t\tcp -rn \"$entry\"/. gametypes/\n\t\tcontinue\n\t\t;;\n\tesac\n\trm -rf \"./$name\"\n\tcp -a \"$entry\" \"./$name\" || fail \"cannot copy $name\"\ndone\n\nchmod 0755 halo\nif [ ! -f halo-server ]; then\n\tprintf '#!/bin/sh\\nexec \"$(dirname \"$0\")/halo\" --server \"$@\"\\n' > halo-server\nfi\nchmod 0755 halo-server\nmkdir -p maps custom_maps gametypes logs\n\necho \"Checking that the server runs\"\nif version=\"$(HOME=\"$work\" HALO_SAVE_ROOT=\"$work/saves\" timeout 30 ./halo --version 2>&1)\"; then\n\techo \"$version\"\nelse\n\techo \"WARNING: ./halo --version did not run. The server needs the 32-bit C runtime\"\n\techo \"WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):\"\n\techo \"$version\"\nfi\n\nif [ ! -f maps/ui.map ]; then\n\techo \"No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)\"\n\techo \"over SFTP into the server's folder before starting it.\"\nfi\necho \"Installation completed\"\n", + "container": "ghcr.io/ptero-eggs/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Release", + "description": "A release tag (build-206) or latest. A change takes effect on Reinstall (Settings tab), which keeps config.toml, init.txt, gametypes/, custom_maps/ and maps/.", + "env_variable": "OPENCE_RELEASE", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:40|regex:/^[A-Za-z0-9._-]+$/", + "field_type": "text" + }, + { + "name": "Server name", + "description": "The game's name in the lobby and the server browser: 15 characters of printable ASCII.", + "env_variable": "HALO_SERVER_NAME", + "default_value": "OpenCE Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:15|regex:/^[\\x20-\\x7E]+$/", + "field_type": "text" + }, + { + "name": "Rotation", + "description": "The games played one after another, separated by commas: map:gametype, with +rule=value after a gametype (bloodgulch:ctf, sidewinder:team_slayer+score=50). A gametype is a built-in's name or a gametypes/.toml of yours.", + "env_variable": "HALO_SERVER_ROTATION", + "default_value": "bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:4096", + "field_type": "text" + }, + { + "name": "Max players", + "description": "2 to 127.", + "env_variable": "HALO_SERVER_MAX_PLAYERS", + "default_value": "16", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer|between:2,127", + "field_type": "text" + }, + { + "name": "Join password", + "description": "The password the server browser asks for; empty for none. The invite link joins without it.", + "env_variable": "HALO_SERVER_PASSWORD", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:64", + "field_type": "text" + }, + { + "name": "Public", + "description": "List the server in everyone's server browser.", + "env_variable": "HALO_SERVER_PUBLIC", + "default_value": "true", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:true,false", + "field_type": "text" + }, + { + "name": "Message of the day", + "description": "A notice each player is shown on joining (127 characters); empty for none.", + "env_variable": "HALO_SERVER_MOTD", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:127", + "field_type": "text" + }, + { + "name": "Lobby seconds", + "description": "Seconds the lobby counts down before each game (0 to 300).", + "env_variable": "HALO_SERVER_LOBBY_SECONDS", + "default_value": "15", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer|between:0,300", + "field_type": "text" + }, + { + "name": "Web UI port", + "description": "0: no web page. Else the port of an additional allocation (Network tab) for the admin page, its API and the remote console, at http://:/; it needs a web UI password.", + "env_variable": "HALO_SERVER_WEB_PORT", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer|between:0,65535", + "field_type": "text" + }, + { + "name": "Web UI password", + "description": "The admin password of the web page, 8 characters or more.", + "env_variable": "HALO_SERVER_WEB_PASSWORD", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|min:8|max:64", + "field_type": "text" + }, + { + "name": "Public address", + "description": "host[:port] offered to joiners first, for a node behind a NAT that STUN cannot see through; usually empty.", + "env_variable": "HALO_NET_PUBLIC_ADDRESS", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:253", + "field_type": "text" + }, + { + "name": "Publisher key", + "description": "Your approved key for the master servers that list dedicated servers: the 64 hex characters of the seed ./halo --server --keygen writes to publisher.key. Empty: the key file, if there is one; the server is not listed on the masters without one.", + "env_variable": "HALO_SERVER_PUBLISHER_KEY", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|regex:/^[0-9a-f]{64}$/", + "field_type": "text" + }, + { + "name": "Instance name", + "description": "Tells this server from your others on the master servers: letters, digits, dashes.", + "env_variable": "HALO_SERVER_INSTANCE", + "default_value": "main", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|alpha_dash|max:32", + "field_type": "text" + }, + { + "name": "Region", + "description": "A label in the server browser (eu, na-east); empty for none.", + "env_variable": "HALO_SERVER_REGION", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|alpha_dash|max:16", + "field_type": "text" + }, + { + "name": "Log format", + "description": "The console's lines: text, or json for a log collector.", + "env_variable": "HALO_SERVER_LOG_FORMAT", + "default_value": "text", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:text,json", + "field_type": "text" + }, + { + "name": "Web UI bind", + "description": "The container's address the web port listens on: all of them, as the allocation's address is the node's.", + "env_variable": "HALO_SERVER_WEB_BIND", + "default_value": "0.0.0.0", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|in:0.0.0.0", + "field_type": "text" + }, + { + "name": "GitHub repository", + "description": "owner/name the installation downloads the release from.", + "env_variable": "OPENCE_REPOSITORY", + "default_value": "OpenCommunityEdition/OpenCE", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|max:100|regex:/^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/", + "field_type": "text" + } + ] +} diff --git a/deploy/pterodactyl/install.sh b/deploy/pterodactyl/install.sh new file mode 100755 index 00000000..0fb394ad --- /dev/null +++ b/deploy/pterodactyl/install.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# The OpenCE dedicated server egg's installation script (Pterodactyl and +# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the +# server's folder at /mnt/server. tools/egg_build.py copies it into +# egg-opence-dedicated-server.json. +# +# OPENCE_RELEASE: "latest" or a release tag (build-206). +# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from. +# +# A reinstall replaces the release's files and keeps the owner's: +# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and +# logs/. The panel ignores this script's exit code, so each failure says +# what went wrong. + +set -u + +SERVER_DIR="${SERVER_DIR:-/mnt/server}" +RELEASE="${OPENCE_RELEASE:-latest}" +REPOSITORY="${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}" +GITHUB_URL="${OPENCE_GITHUB_URL:-https://github.com}" +GITHUB_API="${OPENCE_GITHUB_API:-https://api.github.com}" +ASSET=halo-linux-release.zip + +fail() { + echo "INSTALL FAILED: $*" + exit 1 +} + +if ! [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + fail "the repository \"$REPOSITORY\" is not owner/name" +fi +if [ -z "$RELEASE" ] || [ "$RELEASE" = latest ]; then + # (no API call, so no rate limit) + url="$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET" +else + if ! [[ "$RELEASE" =~ ^[A-Za-z0-9._-]+$ ]]; then + fail "the release \"$RELEASE\" is not a tag name (build-206) or latest" + fi + echo "Looking up release $RELEASE of $REPOSITORY" + release_json="$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE")" \ + || fail "no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)" + url="$(printf '%s' "$release_json" | jq -r --arg name "$ASSET" \ + '[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')" + [ -n "$url" ] || fail "release $RELEASE has no $ASSET" +fi + +mkdir -p "$SERVER_DIR" || fail "cannot make $SERVER_DIR" +cd "$SERVER_DIR" || fail "cannot enter $SERVER_DIR" + +work="$(mktemp -d)" || fail "cannot make a temporary folder" +trap 'rm -rf "$work"' EXIT + +echo "Downloading $url" +curl -fL --retry 3 --progress-bar -o "$work/$ASSET" "$url" || fail "the download of $url failed" +unzip -tq "$work/$ASSET" > /dev/null || fail "$ASSET is not a zip file" +mkdir "$work/release" +unzip -q "$work/$ASSET" -d "$work/release" || fail "cannot unzip $ASSET" +[ -f "$work/release/halo" ] || fail "$ASSET has no halo executable" + +# the release's files, over the old release's; the owner's are never in it, +# and gametypes/ only adds the examples not already there +for entry in "$work/release"/* "$work/release"/.[!.]*; do + [ -e "$entry" ] || continue + name="$(basename "$entry")" + case "$name" in + config.toml | init.txt | publisher.key | custom_maps | maps | logs) + continue + ;; + gametypes) + mkdir -p gametypes + cp -rn "$entry"/. gametypes/ + continue + ;; + esac + rm -rf "./$name" + cp -a "$entry" "./$name" || fail "cannot copy $name" +done + +chmod 0755 halo +if [ ! -f halo-server ]; then + printf '#!/bin/sh\nexec "$(dirname "$0")/halo" --server "$@"\n' > halo-server +fi +chmod 0755 halo-server +mkdir -p maps custom_maps gametypes logs + +echo "Checking that the server runs" +if version="$(HOME="$work" HALO_SAVE_ROOT="$work/saves" timeout 30 ./halo --version 2>&1)"; then + echo "$version" +else + echo "WARNING: ./halo --version did not run. The server needs the 32-bit C runtime" + echo "WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):" + echo "$version" +fi + +if [ ! -f maps/ui.map ]; then + echo "No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)" + echo "over SFTP into the server's folder before starting it." +fi +echo "Installation completed" diff --git a/deploy/pufferpanel/README.md b/deploy/pufferpanel/README.md new file mode 100644 index 00000000..e14eadb3 --- /dev/null +++ b/deploy/pufferpanel/README.md @@ -0,0 +1,49 @@ +# The dedicated server on PufferPanel + +`template.json` is a PufferPanel template for the dedicated server +(`docs/dedicated_server.md`), run in PufferPanel's host environment: it +downloads a release's `halo-linux-release.zip` into the server's folder, +runs `./halo --server` there, and stops it by writing `quit` to its +standard input. + +## Using it + +1. The node needs the 32-bit C runtime (Debian and Ubuntu: + `dpkg --add-architecture i386 && apt install libc6:i386`). The template + mounts `/lib32` and `/usr/lib32` into PufferPanel's sandbox, which does + not otherwise; without them the 32-bit `halo` cannot start. +2. Add the template (Templates, New, and paste the file), and create a + server from it. +3. Upload the `maps/` folder of your own copy of the game into the + server's folder. Without `maps/ui.map` and the rotation's maps the server + exits with code 2 and says which maps it looked for. +4. Start it. + +## Ports + +The **Tunnel port** (UDP, 2302 by default) is the one port players need. +The **Web UI port** is the web page, its API and the remote console, on +127.0.0.1 only. Its default is 8090, because PufferPanel itself listens on +8080. Each server on a node needs a tunnel port and a web port of its own +(or a web port of 0, for none). + +## Files + +Everything the server writes stays in its folder: `config.toml` (every +setting, written at the first start, with the web page's admin password if +none was given), `saves/` (`HALO_SAVE_ROOT`: the profiles and the map cache, +about 115 MB, built at the first start), `logs/` (`HALO_LOG_ROOT`: +`debug.txt`, `bans.txt`, `cheaters.txt`) and `status.json` +(`server.status_file`, the status every 2 seconds). + +The template's variables are passed as `HALO_*` variables, which win over +`config.toml`: `sv_set` and the web page cannot change those settings. + +## PufferPanel versions + +The template is in the format of PufferPanel 2's templates (`run.command` +as a string, `run.stop` as the command written to standard input). We have +not confirmed how PufferPanel 3 imports it, nor the form of its standard +input settings; if a server made from it on PufferPanel 3 does not stop +with `quit`, set its stop to send `quit` to standard input (or SIGTERM, +which the server takes as well) in the server's settings. diff --git a/deploy/pufferpanel/template.json b/deploy/pufferpanel/template.json new file mode 100644 index 00000000..50662681 --- /dev/null +++ b/deploy/pufferpanel/template.json @@ -0,0 +1,140 @@ +{ + "type": "opence", + "display": "OpenCE Dedicated Server", + "install": [ + { + "type": "download", + "files": [ + "${release_url}" + ] + }, + { + "type": "command", + "commands": [ + "unzip -o halo-linux-release.zip", + "rm -f halo-linux-release.zip", + "chmod 0755 halo halo-server", + "mkdir -p maps custom_maps gametypes saves logs", + "./halo --version" + ] + } + ], + "run": { + "command": "./halo --server", + "stop": "quit", + "workingDirectory": "", + "pre": [], + "post": [], + "environmentVars": { + "HALO_NET_TUNNEL_PORT": "${port}", + "HALO_SERVER_NAME": "${name}", + "HALO_SERVER_ROTATION": "${rotation}", + "HALO_SERVER_MAX_PLAYERS": "${max_players}", + "HALO_SERVER_PUBLIC": "${public}", + "HALO_SERVER_WEB_PORT": "${web_port}", + "HALO_SERVER_LOG_FORMAT": "${log_format}", + "HALO_SERVER_STATUS_FILE": "status.json", + "HALO_SAVE_ROOT": "saves", + "HALO_LOG_ROOT": "logs" + }, + "autostart": false, + "autorecover": true, + "autorestart": false + }, + "data": { + "release_url": { + "type": "string", + "display": "Release", + "desc": "The release's halo-linux-release.zip: .../releases/latest/download/halo-linux-release.zip, or .../releases/download//halo-linux-release.zip. Used when the server is installed again.", + "required": true, + "value": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "userEdit": true + }, + "port": { + "type": "integer", + "display": "Tunnel port", + "desc": "The UDP port of internet play (network.tunnel_port), the one port players need.", + "required": true, + "value": 2302, + "userEdit": true + }, + "name": { + "type": "string", + "display": "Server name", + "desc": "The game's name in the server browser: 15 characters of printable ASCII.", + "required": true, + "value": "OpenCE Server", + "userEdit": true + }, + "rotation": { + "type": "string", + "display": "Rotation", + "desc": "The games played one after another: map:gametype entries separated by commas.", + "required": true, + "value": "bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer", + "userEdit": true + }, + "max_players": { + "type": "integer", + "display": "Max players", + "desc": "2 to 127.", + "required": true, + "value": 16, + "userEdit": true + }, + "public": { + "type": "boolean", + "display": "Public", + "desc": "List the server in everyone's server browser.", + "required": true, + "value": true, + "userEdit": true + }, + "web_port": { + "type": "integer", + "display": "Web UI port", + "desc": "The web page, its API and the remote console, on 127.0.0.1 (server.web_port); 0 for none. Each server on a node needs a port of its own, and not PufferPanel's own (8080). The admin password is printed at the first start and kept in config.toml.", + "required": true, + "value": 8090, + "userEdit": true + }, + "log_format": { + "type": "option", + "display": "Log format", + "desc": "The console's lines.", + "required": true, + "value": "text", + "userEdit": true, + "options": [ + { + "value": "text", + "display": "Text" + }, + { + "value": "json", + "display": "JSON" + } + ] + } + }, + "environment": { + "type": "host", + "mounts": [ + "/lib32", + "/usr/lib32" + ] + }, + "supportedEnvironments": [ + { + "type": "host", + "mounts": [ + "/lib32", + "/usr/lib32" + ] + } + ], + "requirements": { + "os": "linux", + "arch": "amd64" + } +} diff --git a/deploy/release-key.pub b/deploy/release-key.pub new file mode 100644 index 00000000..afc03cdd --- /dev/null +++ b/deploy/release-key.pub @@ -0,0 +1 @@ +d04e8b101007d1641cace8b95fcca76f7f44709f6885c3d031b52394dbdf9376 diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md new file mode 100644 index 00000000..8612e716 --- /dev/null +++ b/deploy/systemd/README.md @@ -0,0 +1,52 @@ +# The dedicated server under systemd + +`halo-server@.service` runs the dedicated server (`docs/dedicated_server.md`) +as a templated unit: one instance for each folder +`/opt/opence/servers/`, all sharing one release in `/opt/opence/bin` +and one read-only copy of the game data in `/opt/opence/data`. + +| Path | What | +| --- | --- | +| `/opt/opence/bin/` | The release (`halo-linux-release.zip`, unzipped) | +| `/opt/opence/data/` | The game data, only read: `maps/`, `custom_maps/`, `gametypes/` | +| `/opt/opence/servers//` | The instance's own, the only folder it may write: `config.toml`, `init.txt`, `publisher.key`, `server.env`, `saves/` (the map cache), `logs/` | + +## Installing + +```sh +sudo useradd --system --home-dir /opt/opence --shell /usr/sbin/nologin halo +sudo install -d -o halo -g halo -m 0750 /opt/opence/servers/main +sudo cp deploy/systemd/halo-server@.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now halo-server@main +journalctl -u halo-server@main -f +``` + +Open UDP 2302 (the instance's `network.tunnel_port`) in the firewall. + +## Settings + +`server.env` in the instance's folder holds its settings as `HALO_*` +variables, one a line (port/linux/README.md, "Dedicated server"). A +variable set there wins over `config.toml`, and `sv_set` and the web page +cannot change it; leave out what you want to change from the web page. + +A second instance on the machine needs ports of its own, and its own +loopback address for system link: + +```sh +HALO_NET_TUNNEL_PORT=2303 +HALO_SERVER_WEB_PORT=8081 +HALO_NET_ADDRESS=127.0.0.2 +HALO_NET_BROADCAST=127.0.0.1 +``` + +The web port stays on 127.0.0.1 unless `server.env` says otherwise (and +then it needs a password, and TLS in front of it). + +## Exit codes + +Exit code 5 (`sv_restart`, or an update installed) restarts the server; 2 +(no usable maps) and 3 (bad configuration) do not, as a restart would fail +the same way. The unit has no console (`StandardInput=null`): commands come +from `init.txt` and the web port. diff --git a/deploy/systemd/halo-server@.service b/deploy/systemd/halo-server@.service new file mode 100644 index 00000000..1516be5f --- /dev/null +++ b/deploy/systemd/halo-server@.service @@ -0,0 +1,96 @@ +# A dedicated server under systemd (docs/dedicated_server.md), one instance +# for each folder /opt/opence/servers/: +# +# /opt/opence/bin/ the release (halo-linux-release.zip, unzipped) +# /opt/opence/data/ the game data, shared by every instance and +# only read: maps/, custom_maps/, gametypes/ +# /opt/opence/servers// the instance's own: config.toml, init.txt, +# publisher.key, server.env, logs/, saves/ +# +# server.env holds the instance's settings as HALO_* variables, one a line. +# A second instance on the machine needs its own ports, and its own loopback +# address for system link: +# +# HALO_NET_TUNNEL_PORT=2303 +# HALO_SERVER_WEB_PORT=8081 +# HALO_NET_ADDRESS=127.0.0.2 +# HALO_NET_BROADCAST=127.0.0.1 +# +# Install: +# sudo useradd --system --home-dir /opt/opence --shell /usr/sbin/nologin halo +# sudo install -d -o halo -g halo -m 0750 /opt/opence/servers/main +# sudo cp deploy/systemd/halo-server@.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now halo-server@main +# journalctl -u halo-server@main -f +# +# Open UDP 2302 (each instance's network.tunnel_port) in the firewall. The +# web port stays on 127.0.0.1 unless server.env says otherwise. +# +# Exit code 5 (sv_restart, or an update installed) restarts the server; +# 2 (no usable maps) and 3 (bad configuration) do not, as a restart would +# fail the same way. +[Unit] +Description=OpenCE dedicated server %i +Documentation=https://github.com/OpenCommunityEdition/OpenCE/blob/main/docs/dedicated_server.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=halo +Group=halo +WorkingDirectory=/opt/opence/servers/%i +Environment=HOME=/opt/opence/servers/%i +Environment=HALO_CONFIG_FILE=/opt/opence/servers/%i/config.toml +Environment=HALO_DATA_ROOT=/opt/opence/data +Environment=HALO_SAVE_ROOT=/opt/opence/servers/%i/saves +Environment=HALO_LOG_ROOT=/opt/opence/servers/%i/logs +Environment=HALO_SERVER_INIT_FILE=/opt/opence/servers/%i/init.txt +Environment=HALO_NET_TUNNEL_PORT=2302 +EnvironmentFile=-/opt/opence/servers/%i/server.env +ExecStartPre=/bin/mkdir -p /opt/opence/servers/%i/logs /opt/opence/servers/%i/saves +ExecStart=/opt/opence/bin/halo --server +# (no console: commands come from init.txt and the web port) +StandardInput=null +StandardOutput=journal +StandardError=journal +KillSignal=SIGTERM +TimeoutStopSec=15 +Restart=on-failure +RestartSec=10 +RestartForceExitStatus=5 +RestartPreventExitStatus=2 3 +# (no privileges, nothing writable but the instance's folder) +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ReadWritePaths=/opt/opence/servers/%i +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +RestrictAddressFamilies=AF_INET AF_UNIX AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +# (a 32-bit x86 program) +SystemCallArchitectures=native x86 +SystemCallFilter=@system-service +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM +UMask=0027 +MemoryMax=600M +TasksMax=64 + +[Install] +WantedBy=multi-user.target diff --git a/docs/dedicated_server.md b/docs/dedicated_server.md new file mode 100644 index 00000000..7e3ef49d --- /dev/null +++ b/docs/dedicated_server.md @@ -0,0 +1,736 @@ +# The dedicated server: reference + +`halo --server` runs the game as a host with no window, sound or player of +its own: other machines join it on the LAN, by its invite link or from the +server browser, as they join any host. `port/linux/README.md`, "Dedicated +server", says how to run one; this document is the reference for what it +takes and answers: the command line, the exit codes, the commands, the +HTTP API, the remote console (RCON) over WebSocket, the events, the log and +the files. `docs/master_protocol.md` is the master servers' protocol, which +the server publishes to and the server browser reads. + +The code is `port/linux/src/server_*.c` (the platform side: the mode, the +standard input, the log, the rotation's grammar, the HTTP and WebSocket +server, the master servers' publisher) and `port/linux/game/server_*.c` +(the game side: the director that plays one game after another, the +commands, the gametypes, the status). The Android and web builds have no +dedicated server: they compile those files to nothing, but for +`server_json.c` (the JSON reader, which the server browser's list of master +servers uses in every build) and `server_rotation.c` (the rotation's +grammar and the gametype rules), which every build compiles. + +## Panels and fleets + +A game panel (Pterodactyl, PufferPanel, MCSManager: `deploy/`) runs one +server and reads its standard output; `deploy/systemd` and `deploy/docker` +run servers on one machine. For many servers on many machines, `opence-fleet` +([github.com/OpenCommunityEdition/opence-fleet](https://github.com/OpenCommunityEdition/opence-fleet)) +is one control plane over them all: an agent on each machine runs the +servers and drives each through this document's API, as the owner from +the machine itself (`server.web_owner_password`, below). What it relies on +is named in the status's and the RCON's `capabilities`: `owner_web` (the +owner's role over the web, from loopback), `ban_address` (`sv_ban_address`, +`POST /api/bans`), `stop` (`sv_stop`, `POST /api/stop`, the `drain` event), +`event_addresses` (addresses in the player events) and `identity` (the +status's `pid`, `instance`...). + +## Command line + +| Argument | What it does | +| --- | --- | +| `--server` | Dedicated server mode (as `HALO_SERVER=1` or `server.enabled = true`) | +| `--config ` | The settings file; default `config.toml` beside the executable. `HALO_CONFIG_FILE`, when it is set, wins over `--config` | +| `--version` | Prints `OpenCE build- (network version )` and exits 0 | +| `--keygen` | Writes a new publisher key (`server.publisher_key_file`) unless one exists, prints its public key and key id, exits 0; exits 3 when the key cannot be made, read or written | +| `--sign ` | Prints the publisher key's Ed25519 signature of the text, in hex (an application's proof of key), exits 0; exits 3 without a text or a usable key | +| `--probe []` | A look at a server, for a list site that shows its state: joins nothing and never starts a server (whatever `--server`, `HALO_SERVER` or `server.enabled` say). It finds the invite's game over its tunnel (without an invite, the LAN's first) and prints it as one line of JSON, the game of the invite's host alone (never another game that answers), and exits 0; when nothing answers in 30 seconds it prints `{"ok":false,"error":"no game answered in 30 seconds"}` and exits 1. Internet play is as set (`network.online`, on by default), which an invite needs | +| `--help` | Prints these, exits 0 | + +`--version`, `--help`, `--keygen`, `--sign` and `--probe` write no settings: the +settings file is read, never completed or made. `--probe` wins over +`--server`, `HALO_SERVER` and `server.enabled`: a probe never writes to +`config.toml`. + +A probe's line: + +```json +{"ok":true,"name":"OpenCE Server","map":"bloodgulch","gametype":"ctf","players":5,"max_players":16, + "teams":true,"in_progress":true,"open":true,"dedicated":true,"network_version":38,"kind":0} +``` + +`gametype` is the game engine's name (`ctf`, `slayer`, `oddball`, `king`, +`race`, or `none`), `kind` the OpenCE gametype's kind the host sends (0 for +none), `open` whether it takes players now. + +The release has a `halo-server` script beside `halo`, which runs +`halo --server` with its arguments. + +## Exit codes and signals + +| Code | Meaning | +| --- | --- | +| 0 | Stopped: `quit`, `sv_stop` (now, or at the end of its drain), SIGTERM or SIGINT | +| 1 | A fatal error in the game (a halt, a map that failed to load); the last error lines were logged. The process exits within 5 seconds of the error, even when what runs at the exit (a hook, a listing's withdrawal) does not return | +| 2 | No usable game data: `maps/ui.map`, or every rotation map, missing or not a cache file; nothing was bound | +| 3 | Bad server configuration: no rotation entry that can be played (and `server.rotation_fallback` off), `server.web_port` past 65535, a web port the server may not bind (a port below 1024 without the right to it) or whose `server.web_bind` is an address the machine does not have, or a web password (admin, moderator or viewer) shorter than 8 characters with the web port bound beyond loopback (on loopback, a warning), or the owner's (`server.web_owner_password`) shorter than 8 wherever it is bound; `--keygen` or `--sign` failing. An entry that cannot be played (a bad gametype file among them) is left out with a warning: the server stops only when none is left | +| 4 | A port in use: the tunnel's UDP port, or the web port (another program listens on it) | +| 5 | Restart asked for (`sv_restart`, now or at the end of its drain, or an update installed: `server.auto_update = "install"` installs a new release once the server has been empty for 10 minutes, on a thread of its own: the server goes on meanwhile, and stops with 5 once it is empty again (a player who joined during the install plays on). An install that fails is tried again at the next check, 6 hours on) | +| 128 + n | Stopped by signal n: a second SIGTERM or SIGINT, or the shutdown not done 5 seconds after the first | + +SIGTERM and SIGINT stop the server: a game in progress goes to its +postgame, every machine is told, the listings are withdrawn, and it exits 0. +SIGHUP reloads the server's settings, the rotation and the gametype files +(`sv_reload`). SIGUSR1 prints the status on standard output. SIGPIPE and +SIGTTIN are ignored. + +## Standard output + +One line per event, for the operator and the panel: + +``` +2026-10-19T18:03:11Z INFO server: ready (tunnel 2302/udp, web 127.0.0.1:8080, listing public) +2026-10-19T18:03:40Z INFO player: Milenko joined (slot 0, machine 1) +2026-10-19T18:04:02Z INFO game: started: CTF on bloodgulch, 2 players +``` + +The fields are the time (UTC), the level (`INFO`, `WARN`, `ERROR`), the +category (`server`, `lobby`, `game`, `player`, `chat`, `moderation`, `net`, +`master`, `console`, `health`) and the text. `server: ready` is printed once, +when the lobby is open and, for a public server, its listing has been +published (or 10 seconds on, as the brokers are other people's): it is the +line a panel waits for. With `server.log_format = "json"` each line is an +object instead, and the line a panel waits for holds `"msg":"ready (`: + +```json +{"time":"2026-10-19T18:03:11Z","level":"info","cat":"server","msg":"ready (tunnel 2302/udp, ...)"} +``` + +Every line is also kept in the log the web's viewers read (`/api/log`, +`sv_log`, the RCON's `log` events), but for these, which go to standard +output alone: the web password a first start makes, the invite (a +private server's lets anyone in; `/api/status` gives it to admins), and +the owner's console, the commands typed on standard input or read from the +init file and what they answered (a password's command is shown as `(a +password's command)` there too). + +A health line comes once a minute: `health: players=3 frame_ms_max=4.1 +frame_ms_avg=1.20 cpu=2.1% rss=96MB` (the longest and the mean frame of +the last minute, in milliseconds). Standard error keeps the platform +layer's `halo-linux:` lines, and `debug.txt` everything, as for a player's +game. + +## Commands + +The same commands come from standard input, `server.init_file` (run once at +the start, a command a line, `#`, `;` and `//` starting comments), the telnet +console (`debug.telnet_console`, loopback), the RCON, `POST /api/command` +and the web page. Each runs on the game's thread between frames. Every +command has a role; a caller may run it if their role is that one or above: +**viewer** < **moderator** < **admin** < **owner** (standard input, the +init file and the telnet console are the owner's: whoever holds the +process; so is a sign-in to the web or the RCON with +`server.web_owner_password` from the machine itself: "Signing in"). When +standard input ends and `server.console_exit_on_eof` is on, its last line +runs, whether or not a line's end ended it, and then the server stops. + +A `` is a slot number (`sv_players` lists them) or the start of a +name; a name that begins more than one player's is refused. A `` +is a number and a unit (`45s`, `30m`, `2h`, `7d`, `1w`), a bare number of +minutes, or `forever`; ten years at most. A word in quotes (`"two words"`) +is one word. + +A command's text (a notice, a reason, `sv_set`'s value, a name) is kept to +printable ASCII: control characters (a line's end, a tab) and other bytes +outside it are dropped, as are the quotes around the whole text, and +standard input's and the init file's lines are cleaned so before they run. +The web is stricter where it takes a value as it is: `PUT /api/settings` +refuses a value with a control character (`schema`), and nothing is changed +for that setting. A command's reply is sent whole, however many lines. + +Commands that are not the server's (the game's own console commands, the +scripts' globals and functions) need the owner role: from the web or the +RCON they are refused (`not a command of the server's`) but to the owner's +session. The server's +commands come first, so `kick` and `ban` are always the server's below, not +the game's console commands of those names. + +| Command | Role | What it does | +| --- | --- | --- | +| `help [command]` | viewer | The commands, or one command's use | +| `sv_status [--json]` | viewer | The server's state, map, gametype, players, uptime, games played, network version, listing and next game | +| `sv_players [--json]` | viewer | Each player's slot, name, team, score, kills, deaths, assists, ping and machine; with the owner role or `server.log_addresses`, their address and hardware id | +| `sv_rotation` (`sv_mapcycle`), `sv_maps`, `sv_gametypes`, `sv_version`, `sv_log [n]` | viewer | Lists: the rotation, the maps there are, the gametypes, the build, the log's last n lines | +| `sv_bans` (`sv_banlist`), `sv_settings` | admin | Lists: the bans of `bans.txt`, the settings `sv_set` changes | +| `sv_masters`, `sv_publisher` | viewer | The master servers and how each answered; the publisher key's id and public key (never its seed) | +| `say ` (`sv_say`) | moderator | A notice on every machine's screen | +| `tell ` | moderator | A notice on one player's machine | +| `kick ` (`sv_kick`) | moderator | Drops the player's machine; it may join again | +| `ban [] []` (`sv_ban`) | moderator (a day at most), admin | Drops the player's machine and bans its address and hardware id, for the duration (else for ever). A moderator must give a duration of a day or less: a ban for ever, or longer, is refused | +| `unban ` (`sv_unban`) | admin | Takes a ban out of `bans.txt`; the machine may join again at once, by the LAN or the internet | +| `sv_ban_address [] []` | admin | Bans an IPv4 address (`a.b.c.d`) or a hardware id (up to 32 hex digits), its machine here or not: its line in `bans.txt` as `ban` writes it (the other part `none`), so that `sv_bans`, `unban` and `GET /api/bans` see it, and a machine present of that address or hardware id is dropped as `ban` drops it (`player_left`'s reason `banned`). Answers `ban added` (`n`: its number in `sv_bans`) | +| `mute []`, `unmute ` | moderator | The host's mute of a player's chat and voice for everyone | +| `sv_map []`, `sv_map ` | moderator | Plays that game (or a rotation entry, `map:gametype+rule=value`; a co-op one too), below | +| `sv_map_next` (`sv_next`) | moderator | Ends the game without its scores and plays the rotation's next | +| `sv_end_game` | moderator | Ends the game: its scores, then the next | +| `sv_rotation_set `, `sv_rotation_add `, `sv_rotation_del `, `sv_rotation_begin []` (`sv_mapcycle_add`, `_del`, `_begin`) | admin | Changes the rotation (written to `server.rotation`, 4096 characters at most) | +| `sv_set ` | admin | Changes one of the server's settings, now where it can be, and in `config.toml` (below); a setting its environment variable sets is refused. A value is 256 characters at most | +| `sv_name `, `sv_maxplayers `, `sv_password []`, `sv_public <0\|1>`, `sv_motd ` | admin | `sv_set` of `server.name`, `server.max_players`, `server.password`, `server.public`, `server.motd` | +| `sv_reload` | admin | Reads `config.toml`'s server settings, the rotation and the gametype files again (as SIGHUP) | +| `sv_stop [now\|game\|empty] []`, `sv_stop cancel` | owner | Stops the server (exit code 0): `now` (the default) as `quit` does; `game` once the game being played is over, `empty` once nobody plays (a drain, below); `cancel` the drain waiting. The notice is said to every machine first | +| `sv_restart [now\|game\|empty] []`, `sv_restart cancel` | owner | As `sv_stop`, with exit code 5 (restart me) | +| `quit` (`exit`) | owner | Stops the server (exit code 0) | + +`sv_map`'s game is played as soon as it can be, and its answer says when: +`next: (now)` in the lobby before its start was sent (the lobby +takes the entry, and the rotation's entry it replaced is played after it), +`(now: this game ends)` while a game is played (it ends at once, without +its scores), and `(once this game is over)` once the lobby's start was sent +or while a map loads (it is the next game). `sv_map` of the game being +played plays it again. The rotation goes on after it. + +A drain (`sv_stop` or `sv_restart` with `game` or `empty`, `POST +/api/stop`) is a stop that waits: the listings are withdrawn as `sv_public +0` withdraws them (a new invite, the masters told), without the setting +changing; new machines are refused as while a map loads (the game is not +open: the LAN's search shows it closed, a join by the invite is told the +game is not open); then the server stops. With `game` it stops once no +game is played nor about to be: at once in the lobby with nobody in it, or +with no countdown running (fewer players than `server.min_players`); +otherwise once the game played (or about to start) is over and its +postgame shown. With `empty` it stops once nobody is on the server (at once +if nobody is now). `sv_stop cancel` (or `sv_restart cancel`) ends the +drain: the server is listed again as `server.public` says and takes new +players. A `drain` event says each (`after`: `now`, `game`, `empty` or +`cancel`), and the stop that ends a drain has the `shutdown` reason +`drained`. `/api/status`'s `drain` is the drain waiting, or `null`. + +A co-op entry (`:coop`) is ended as a game is: by `sv_end_game`, +`sv_map_next`, `sv_map`, `server.empty_seconds` and `server.idle_minutes`. +It has no scores: the server goes back to the lobby at once (no postgame), +and a level won ends it with `game_ended`'s reason `score`. + +`sv_set` (and the settings of the web page, `PUT /api/settings`) changes +these settings, which `sv_settings` and `GET /api/settings` list: +`server.name`, `server.max_players`, `server.min_players`, +`server.password`, `server.public`, `server.rotation_fallback`, +`server.lobby_seconds`, `server.postgame_seconds`, `server.empty_seconds`, +`server.idle_minutes`, `server.team_start`, `server.motd`, +`server.frame_rate`, `server.web_password`, +`server.web_moderator_password`, `server.log_format`, +`server.log_addresses`, `server.status_file`, `server.auto_update`, +`server.coop_difficulty`, `server.region`, `server.master_publish`, +`server.master_publish_address`, `server.metrics`, and `network.text_chat`, +`network.voice_lobby`, `network.voice_mode`, `network.voice_kbps`, +`network.votekick`, `network.votekick_minutes`, +`network.votekick_ban_minutes`, `network.map_share`, +`network.map_share_upload`, `network.public_address`, +`network.coop_friendly_fire`, `network.coop_player_collisions`, +`network.coop_ally_friendly_fire` and `network.shield_effects`. Of them, +`server.status_file`, `server.auto_update` and `server.log_addresses` need +the owner role: from the web's admin, `sv_set` of one of them (`POST +/api/command`) answers `403 forbidden`, and `PUT /api/settings` leaves it +out, in its `errors` with the code `forbidden` and the message ` +needs the owner role (the server's console)`; the owner's session changes +them both ways. A web password is empty or +8 characters or more. A value outside a setting's choices or range is +refused (`400 failed` from `sv_set`, `schema` in `PUT /api/settings`'s +`errors`), with ` takes a whole number from to ` (or +`from `) or ` is one of ||`; a choice may be given +in either case, and is kept as the table spells it: + +| Setting | Choices or range | +| --- | --- | +| `server.max_players` | 2 to 127 | +| `server.min_players` | 1 to 16 | +| `server.lobby_seconds`, `server.postgame_seconds` | 0 to 300 | +| `server.empty_seconds`, `server.idle_minutes` | 0 or more | +| `server.frame_rate` | 30 to 120 | +| `server.team_start` | `one`, `both`, `skip` | +| `server.log_format` | `text`, `json` | +| `server.auto_update` | `off`, `check`, `install` | +| `server.coop_difficulty` | `easy`, `normal`, `hard`, `impossible` | +| `network.text_chat` | `on`, `links`, `off` | +| `network.voice_mode` | `off`, `team_proximity`, `team_enemy_proximity`, `team_global`, `team_global_enemy_proximity` | +| `network.voice_kbps` | 8 to 64 | +| `network.votekick_minutes` | 0 to 60 | +| `network.votekick_ban_minutes` | 1 to 1440 | +| `network.map_share_upload` | 16 to 20480 | +| `network.coop_friendly_fire` | `off`, `on`, `shields_only`, `explosives_only` | +| `network.shield_effects` | `stock`, `glow`, `light_show`, `overshield`, `glow_overshield`, `light_show_overshield`, `players` | + +A dedicated server's text chat is its `network.text_chat`'s, listed or +not: `on` by default (lines with links refused), `links`, or `off`, live +(`sv_set network.text_chat off`). A game a player hosts has no text chat +while it is listed in the server browser; a dedicated server is the +exception, and the players who join it from the browser's dedicated servers +have the chat as it says, as long as a master server lists it (the +masters' word that it is an approved operator's; a listing's own flag is +not enough, so a dedicated server listed by the brokers alone, before its +operator is approved, has none for them). + +The settings `sv_set` does not change (`server.web_viewer_password`, +`server.web_owner_password`, `server.web_port`, `server.web_bind`, `server.web_trusted_proxy`, the +paths, the master servers' files...) are changed in `config.toml` or their +environment variables: `sv_reload` reads the file's `server.*` settings +again, and the web port and its address are bound once, at the start. The +rotation is changed by the `sv_rotation_*` commands. + +## HTTP + +The server listens on `server.web_port` (8080; 0 for none) at +`server.web_bind` (`127.0.0.1`, an IPv4 address). Bound beyond loopback, +every web password that is set (admin, moderator, viewer) must have 8 +characters or more, else the server exits with code 3; the owner's +(`server.web_owner_password`) must have 8 or more wherever the port is +bound. The page (`/`), the API +(`/api/...`), the RCON (`/ws`), `/healthz` and `/metrics` share the port. + +Requests are HTTP/1.1 (or 1.0), `GET`, `HEAD`, `POST`, `PUT` or `DELETE` +(`HEAD` is answered as `GET` is, without the body, and counts as a read, +as a `GET`; `/api/login` takes `POST` alone), a head of 8 KB at most whose +lines end with CRLF (a bare LF, a CR inside a line, or more after the empty +line that ends it: `400 bad_request`) and a body of 64 KB at most, sent within 10 seconds of the +connection's opening or of its last answer (a connection that sends +nothing is closed then too). `Transfer-Encoding` and `Expect` are refused. +24 connections are taken at once; when they are all taken, the oldest that +waits for a request (no RCON, no request being answered) is closed for a +new one. A client that does not read its answers is read no further once +256 KB of them wait, and is closed past 2 MB. + +Every request but `/healthz` counts against its address's allowance, the +page's files, `/metrics`, a sign-in and the RCON's upgrades (`/ws`) too: 60 +a minute, or 600 with a session (429 `rate_limited` past it, with +`Retry-After: 60`). Behind `server.web_trusted_proxy`, a request's address +is the last entry of its `X-Forwarded-For` (the one the proxy appended: an +IPv4 address, `a.b.c.d` or `a.b.c.d:port`, or an IPv6 one, `v6`, `[v6]` or +`[v6]:port`), and its `X-Forwarded-Host` and `X-Forwarded-Proto` are +believed, whether or not it sends `X-Forwarded-For`; from any other +address they are ignored. IPv6 clients are counted by their /64: one +host's addresses share its allowance and its lockouts. Every answer is JSON +(`Content-Type: application/json`) unless said, with +`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, +`Referrer-Policy: same-origin` (under `no-referrer` a browser would send +the page's own changes with `Origin: null`) and +`Content-Security-Policy: default-src 'self'; frame-ancestors 'none'`. + +### Signing in + +`POST /api/login` with `{"password":"…"}` answers +`{"ok":true,"token":"<64 hex>","role":"admin","csrf":"<32 hex>","expires":}` +and sets the cookie `opence_session=; HttpOnly; SameSite=Strict; +Path=/`, with `Secure` when the request came through +`server.web_trusted_proxy` with `X-Forwarded-Proto: https` (never from +another address, whatever it says). The admin password +(`server.web_password`) gives the admin role, the moderator password +(`server.web_moderator_password`) the moderator role, the viewer password +(`server.web_viewer_password`) the viewer role; `{"by":"…"}` beside the +password names who signed in, in the log. A session lasts 12 hours, or 30 +minutes without a request (the RCON's messages do not count: a page left +open asks `GET /api/session` now and then); 16 are kept at most, of every +role. + +The owner's password (`server.web_owner_password`, empty by default: none) +gives the owner role, for a supervisor on the server's own machine (a +fleet's agent, a panel's daemon): what standard input may do, over the +API and the RCON (`quit`, `sv_stop`, `sv_restart`, `POST /api/stop`, the +owner's settings by `sv_set` and `PUT /api/settings`, the game's own +console commands by `POST /api/command`, the players' addresses and +hardware ids in `/api/players` and the events). It signs in only when the +connection's own peer address is in `127.0.0.0/8` and is not the trusted +proxy's: never by `X-Forwarded-For`, and never through +`server.web_trusted_proxy`, even one on the machine itself. From any other +address the right owner's password is refused with `403 forbidden` (the +RCON's `auth`: `{"code":"forbidden"}`, and the connection closes 1008), +and counts toward the address's lockout as a wrong password does. An +owner's session is used from the machine itself alone, as it was begun +(from elsewhere its token is no session: `401`, the RCON's `auth` with it +`denied`). A password that is also another role's gives the owner role +from the machine itself and the other role from elsewhere: give the owner +a password of its own. + +A script passes `Authorization: Bearer `. The page passes the +cookie, and on every request that changes something the header +`X-CSRF-Token: `; the `Origin` of such a request (and of the +WebSocket's) must be the server's own. An `Origin: null` is taken only +when the browser's `Sec-Fetch-Site` says `same-origin`. + +`POST /api/logout` ends the session (with the `X-CSRF-Token` by the +cookie; `GET` answers 405). `GET /api/session` answers +`{"ok":true,"role":…,"csrf":…,"expires":…}`, or 401. + +When `server.web_password`, `server.web_moderator_password`, +`server.web_viewer_password` or `server.web_owner_password` changes +(`sv_set` and the settings change the first two; `sv_reload` reads any of +them from `config.toml`), the sessions of its role end and that role's RCON +connections close (1008, `the password changed`). + +Five wrong passwords from an address (an IPv6 one's /64) in 10 minutes +lock it out for 10 minutes (429), and no more than 30 passwords a minute +are checked in all. `/api/login` takes `POST` alone (any other method: +405). + +### Errors + +```json +{"ok":false,"error":{"code":"forbidden","message":"needs the admin role"}} +``` + +| Status | Codes | +| --- | --- | +| 400 | `bad_request`, `schema` (a field missing or out of range), `failed` (the command ran and failed: its lines in `lines`), `rejected` (rotation entries: `rejected[]`), `invalid` (a gametype file: `line`) | +| 401 | `denied` (no session, or a wrong password) | +| 403 | `forbidden` (the role; the owner's password from beyond the machine), `csrf`, `origin` | +| 404 | `not_found` (also a command that is not one, a player or a ban that is not there) | +| 405 | `method` | +| 409 | `pinned` (a setting its environment variable sets), `changed` (a player's action whose slot holds another player now: the current name in `name`, nothing done) | +| 413 | `too_large` | +| 426 | `bad_request` (the WebSocket's version is not 13) | +| 429 | `rate_limited`, `locked` (with `Retry-After`) | +| 500 | `internal` (out of memory, a file that cannot be written) | +| 503 | `loading` (the game did not answer in 20 seconds, as while a map loads; with `Retry-After: 5`), `starting`, `busy` (8 RCON connections already; with `Retry-After: 5`) | + +### Endpoints + +| Method, path | Role | Body → answer | +| --- | --- | --- | +| `GET /healthz` | none | `200 {"state":"lobby"}` (or `loading`, `playing`, `postgame`) while the server hosts and its frames run; `503` with `{"state":"starting"}`, `{"state":"stalled"}` (no frame for a minute) or the other state otherwise | +| `GET /metrics` | none (`server.metrics`) | Prometheus text | +| `GET /api/status` | viewer | The status (below) | +| `GET /api/players` | viewer | `{"ok":true,"players":[…]}` (with `address` and `hwid` for the owner, or every role with `server.log_addresses`) | +| `POST /api/players/{slot}/kick` | moderator | `{"name":""}` → `{"ok":true,"lines":[…]}`, or `409 changed` when the slot holds another player | +| `POST /api/players/{slot}/ban` | moderator | `{"name":…,"duration":"1d","reason":"…"}` (no duration: for ever, which a moderator's ban cannot be: `400 failed`) | +| `POST /api/players/{slot}/mute` | moderator | `{"name":…,"minutes":10}` | +| `POST /api/players/{slot}/unmute` | moderator | `{"name":…}` | +| `POST /api/players/{slot}/tell` | moderator | `{"name":…,"text":"…"}` | +| `POST /api/say` | moderator | `{"text":"…"}` | +| `GET /api/rotation` | viewer | `{"ok":true,"index":0,"entries":[…],"next":}` | +| `PUT /api/rotation` | admin | `{"entries":["bloodgulch:ctf",…]}` → the rotation, or `400 rejected` with `"error":{…,"rejected":[{"index":1,"entry":"…","reason":"…"}]}` and nothing changed | +| `POST /api/rotation/next` | moderator | `{}`: `sv_map_next` | +| `POST /api/rotation/play` | moderator | `{"map":"…","gametype":"…"}`: `sv_map` (a map or gametype that cannot be played: `400 failed`) | +| `GET /api/gametypes` | viewer | `{"ok":true,"builtin":["slayer",…],"schema":{"fields":[…]},"files":[{"name":…,"base":…,"description":…,"error":null}]}` (`builtin`: the Xbox's gametypes, then OpenCE's; a file that cannot be read has `base` and `description` empty and `error` saying why) | +| `GET /api/gametypes/{name}` | viewer | `{"ok":true,"name":…,"text":""}` | +| `PUT /api/gametypes/{name}` | admin | `{"text":""}` → `{"ok":true}`, or `400 invalid` with `line` and `message` (a name is `a`–`z`, `0`–`9`, `_` and `-`, 32 at most) | +| `DELETE /api/gametypes/{name}` | admin | `{"ok":true}` | +| `GET /api/maps` | viewer | `{"ok":true,"maps":[{"name":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","kind":"xbox","title":"Blood Gulch"}…]}` (`kind`: `xbox` or `custom`; `title` is the map's display name, else its `name`) | +| `GET /api/bans` | admin | `{"ok":true,"bans":[{"n":0,"when":"2026-10-19 18:00:00","ip":"…","hwid":"…","players":"…","reason":"…","until":\|null}…]}` | +| `POST /api/bans` | admin | `{"ip":"203.0.113.5","hwid":"…","names":"…","duration":"1d","reason":"…"}` (`ip` or `hwid`, or both; the rest may be left out: no duration is for ever) → `{"ok":true,"n":}`: `sv_ban_address`, a machine present of the address or the hardware id dropped; `400 schema` for an address that is not IPv4, a hardware id that is not hex, a duration that is not one | +| `DELETE /api/bans/{n}` | admin | `{"ok":true}` | +| `GET /api/settings` | admin | `{"ok":true,"settings":[…]}` | +| `PUT /api/settings` | admin | `{"server.name":"…","server.max_players":16,"server.public":true,…}` (a value as JSON, a boolean, a number or a string, or its text: `"true"`, `"16"`; `null` empties it) → `{"ok":true,"applied":["server.name"],"errors":[{"name":…,"code":…,"message":…}]}`; each setting is changed or refused on its own | +| `POST /api/command` | the command's | `{"line":"sv_status"}` (511 characters at most) → `{"ok":true,"lines":["…"]}`, or a refusal (`failed`, `forbidden`, `not_found`, `pinned`) with the command's `lines` | +| `POST /api/stop` | owner | `{"after":"now\|game\|empty","notice":"…","restart":false}` (`notice` and `restart` may be left out) or `{"cancel":true}` → `{"ok":true,"lines":["…"]}`: `sv_stop` (`sv_restart` with `restart`) `after notice`, or `sv_stop cancel` (`400 failed` with no drain waiting); `400 schema` for another `after` | +| `GET /api/log?after=&limit=` | viewer | `{"ok":true,"lines":[{"n":12,"time":,"level":"info","cat":"game","text":"…"}…],"next":12,"missed":0}` (`limit` 1 to 1024, 256 by default; the chat's lines for admins only) | +| `GET /api/events?after=` | viewer | `{"ok":true,"events":[…],"next":,"missed":0}` (`player_joined` and `player_left` with `address` and `hwid` for the owner, or every role with `server.log_addresses`) | +| `GET /ws` | | The RCON (below) | +| `GET /`, `/app.js`, `/app.css`, `/icon.svg` | none | The page (`server.web_ui`) | + +`next` is the `after` of the next request: the last line's `n` (or event's +`seq`) read. An `after` past the latest (as after a restart) reads from the +start. `missed` counts lines or events that left the ring (2048 lines, 512 +events) before they were asked for. + +### The status + +```json +{"ok":true,"time":1760900000123,"name":"OpenCE Server","state":"playing", + "build":"build-206","network_version":38,"uptime":3600,"games_hosted":4, + "map":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","gametype":"ctf","variant":"CTF","teams":true, + "players":5,"max_players":16,"machines":5,"countdown":-1,"game_seconds":312,"team_scores":[2,1], + "listing":{"online":true,"public":true,"password":false,"invite":"halo://join/…"}, + "rotation":{"index":0,"count":3,"next":{"text":"sidewinder:team_slayer","map":"sidewinder","gametype":"team_slayer"}}, + "frame_ms_max":4.1,"frame_ms_avg":1.2,"tick_ms_avg":1.2, + "masters":[{"url":"https://master1.example.org","listed":true,"probe":"ok","code":"","message":"","updated":1760899990}], + "update":{"available":false,"build":""}, + "drain":null, + "pid":4021,"started":1760896400,"instance":"eu-1","region":"eu","tunnel_port":2302, + "publisher_key_id":"3f9a2c7d1e5b8a04","web_port":8080, + "capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]} +``` + +`state` is `waiting` (the menus settling), `hosting` (making the game), +`lobby`, `loading`, `playing`, `postgame` or `stopping`. `countdown` is the +lobby's seconds left, `-1` when it is not counting. `team_scores` is `null` +in a game without teams. `frame_ms_max` and `frame_ms_avg` are the longest +and the mean frame of the last minute (`tick_ms_avg` is the mean again, for +older readers). `invite` is given to admins only (empty for others). An entry of the rotation is +`{"text":"sidewinder:team_slayer+score=50","map":"sidewinder","gametype":"team_slayer","rules":{"score":50},"rejected":null}` +(`rejected` is why it cannot be played, else `null`). `drain` is the +drain waiting (`sv_stop game`...), `{"after":"game","restart":false,"since":}`, else `null`. `pid` is the server's process, `started` when it +started (seconds since 1970), `instance` the name the master servers know +it by (`server.instance`, else the slug of `server.name` they are given), +`region` `server.region`, `tunnel_port` the tunnel's UDP port +(`network.tunnel_port`; 0 when `network.online` is off), `publisher_key_id` +the publisher key's id (16 hex digits; empty without a key: `sv_publisher`) +and `web_port` `server.web_port`. `capabilities` is the RCON's hello's. The rotation's +`index` (in the status and `GET /api/rotation`) is the entry being played, +`-1` while an `sv_map` game is played or before the first lobby; `next` is +the entry after the one played last (the first while none has been). + +A player is +`{"slot":0,"name":"Milenko","team":0,"score":3,"kills":3,"deaths":1,"assists":0,"ping":42,"machine":1,"joined":1760899000,"muted":false}`; +with the owner role, or `server.log_addresses`, also +`"address":"203.0.113.5","hwid":"…"`. `team` is 0 (red), 1 (blue) or -1 +(no teams); `ping` is in milliseconds, `null` when it is not known yet. + +A setting is +`{"name":"server.name","type":"string","value":"OpenCE Server","default":"OpenCE Server","comment":"…","env":"HALO_SERVER_NAME","pinned":false,"secret":false,"choices":null,"min":null,"max":null}`; +`type` is `boolean`, `integer`, `real` or `string`, and `value` and +`default` are of that type in JSON (`true`, `16`, `"OpenCE Server"`); a +reader should also take their text (`"true"`, `"16"`), which older servers +sent. `choices` lists a word setting's words, `min` and `max` bound a +number's, each `null` where there is none (the table above). `env` is +`null` for a setting no environment variable sets. A secret one (the +passwords) has `value` and `default` empty and `"set":true` when it is +set. `pinned` is a setting +its environment variable (`env`) sets: it cannot be changed. + +A gametype schema field is +`{"key":"score_to_win","alias":"score","table":"universal","engine":"all","type":"integer","min":0,"max":999,"choices":null,"default":null,"description":"…"}` +(`table`: the gametype file's table it goes in, `universal`, `options`, +`ctf`, `slayer`, `oddball`, `king` or `race`; `engine`: `all`, or the game +engine whose table it is; `type`: `integer`, `boolean` or `choice`). + +## The RCON + +`GET /ws` with `Upgrade: websocket` and the subprotocol `opence-rcon-1`. +Every frame is text holding one JSON object. The server speaks first: + +``` +→ {"type":"hello","protocol":1,"server":"OpenCE Server","build":"build-206","network_version":38, + "capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]} +← {"type":"auth","password":"…","by":"paul"} or {"type":"auth","token":""} +→ {"type":"auth","ok":true,"role":"admin"} +→ {"type":"auth","ok":false,"error":{"code":"denied"}} and the connection closes (1008) +← {"type":"command","id":1,"line":"kick Milenko"} or {"type":"command","id":1,"command":"say","args":["two words"]} +→ {"type":"reply","id":1,"ok":true,"lines":["Milenko kicked by the host"]} +→ {"type":"reply","id":2,"ok":false,"lines":["…why…"]} (the command ran and failed) +→ {"type":"reply","id":3,"ok":false,"lines":[],"error":{"code":"busy","message":"…"}} (not run) +← {"type":"subscribe","events":["player","chat","game","moderation","status","log","master"],"after":1830} +→ {"type":"event","seq":1831,"time":1760900000123,"event":"player_joined","slot":3,"name":"Milenko",…,"replay":true} +→ {"type":"event","seq":1831,"time":…,"event":"status","status":{…the status…}} (at the subscribe, then every 2 s) +→ {"type":"event","time":…,"event":"log","n":5120,"level":"info","cat":"game","text":"…"} (no seq: n is its sequence) +← {"type":"ping"} +→ {"type":"pong"} +→ {"type":"overflow"} and the connection closes (1013): the client read too slowly +→ {"type":"error","id":…,"error":{"code":"…","message":"…"}} (an unknown type, a malformed message) +``` + +A connection signed in with the page's cookie (on the same origin) needs no +`auth` message: its `hello` is followed by `{"type":"auth","ok":true,…}`. +The `capabilities` say what the server has beyond the first release's +(`events`, `status`, `rotation`, `settings`, `bans`): `owner_web`, +`ban_address`, `stop`, `event_addresses` and `identity` ("Panels and +fleets"); a client checks for one before it uses it. The owner's password +signs in from the server's own machine alone ("Signing in"). +A command is a `line`, or a `command` and its `args`, which the server +joins with spaces, quoting an argument that holds a space or is empty (511 +characters in all at most). Ids are the client's (a number or a string), +echoed in the reply; each command gets exactly one reply. A connection +runs one command at a time: a command sent before the last one's reply is +not run. A reply whose command ran has `ok` and the command's `lines`, and +no `error`, whether it succeeded or failed; one that was not run has `ok` +false, no lines and an `error`: `denied` (not signed in), `rate_limited` +(32 commands a second, in bursts of 64), `bad_request` (no command, or too +long), `busy` (another command's reply is awaited) or `loading` (the game +did not answer in 20 seconds; the command may still have run, and is never +sent again by the server). `after` replays the events still in the ring +since that sequence number, marked `"replay":true`. A connection holds 1024 +messages for the client at most; 8 connections are taken at once (a ninth +upgrade answers `503 busy`), and each upgrade counts against its address's +allowance (above). The +event groups are `player` (`player_joined`, `player_left`, `map_share`), `chat` +(`chat`), `game` (`lobby_opened`, `countdown`, `game_started`, +`game_ended`, `postgame`, `score`, `kill`), `moderation` (`kick`, `ban`, +`unban`, `mute`, `unmute`, `speed_hack`), `status` (`status`), `log` +(`log`, each log line as `{"n","level","cat","text"}`), `master` +(`master`), and `server` (`server_started`, `reloaded`, `warning`, +`drain`, `shutdown`), which every subscriber gets. +The log's lines of the `chat` category go to the `chat` group, the others +to `log`. +`status` and `log` events go to their subscribers as they come and are +not kept in the ring: `after` does not replay them (`/api/log` reads the +log's own ring). A `status` event's `seq` is the latest event's (no new +number: the same one comes again until another event), and a `log` event +has no `seq`: its `n`, the log line's number and `/api/log`'s cursor, is +its sequence. A client keeps one cursor for the events (`seq`, of the +events of the ring) and another for the log's lines (`n`); a client that +reconnects with `after` may get events it had, and drops those whose `seq` +it has seen (a `status` event is never dropped so). + +## Events + +Every event has `seq` (rising, never reused while the server runs; a +`status` event has the latest one's, a `log` event none: above), `time` +(milliseconds since the epoch) and `event`: + +| Event | Fields | +| --- | --- | +| `server_started` | `name, build, network_version, pid, started, instance, region, tunnel_port, publisher_key_id, web_port` (as the status's), `rotation[]` (the entries' texts) | +| `lobby_opened` | `map, gametype, variant` (the gametype's name as the game shows it), `teams, index` (the rotation's entry) | +| `countdown` | `seconds` (-1: paused) | +| `game_started` | `map, gametype, variant, teams, team_scores[]` (team games), `players[]` | +| `game_ended` | `map, gametype, variant, teams, reason` (`score`, `time`, `idle`, `empty`, `command`, `next`), `duration` (seconds), `team_scores[]` (team games), `players[]` | +| `postgame` | `seconds` | +| `player_joined` | `slot, name, team, machine`; `address, hwid` for the owner, or every role with `server.log_addresses` | +| `player_left` | `slot, name, reason` (`left`; `kicked` and `banned` when the server's `kick`, `ban` or `sv_ban_address` dropped the machine, `banned` for a speed hack's drop too); `address, hwid` (as it joined) as `player_joined`'s | +| `chat` | `channel` (`everyone`, `team`, `vehicle`), `slot, name, text` (admins only; the lines the host sends on) | +| `kill` | `killer, victim` (slots; -1 for none), `weapon` (the killer's weapon's or the killing object's tag name, or null), `friendly_fire` | +| `score` | `map, gametype, variant, teams, team_scores[]` (team games), `players[]` (once a second at most, on a change) | +| `kick`, `ban`, `unban`, `mute`, `unmute` | `name, by, reason?, until?` | +| `speed_hack` | `machine, names, rate, ahead_ticks, action` (`kicked`, or `unverified`: its datagrams alone said so, and it stays) | +| `map_share` | `machine, map, state` (`asked`, `downloading` at each tenth, `downloaded` or `failed` once for each request), `progress` (0 to 100: a percentage already) | +| `warning` | `text` (a newer release, with `server.auto_update`) | +| `reloaded` | `rotation_changed, rejected[]` (`{"entry","reason"}` of each entry left out) | +| `master` | `url, listed, probe, code, message` | +| `drain` | `after` (`now`, `game`, `empty`, `cancel`), `restart` (exit code 5), `notice` (empty for none) | +| `shutdown` | `reason` (`quit`, `sv_stop`, `sv_restart`, `drained`: a drain's end, `signal `, `update installed`, `nothing to play`, `debug.exit_after`), `code` (the exit code) | + +Players in events are as in `/api/players` for the viewer role (no +addresses), but for `player_joined` and `player_left`, which carry the +player's address and hardware id (`""` when not known) to the owner, and to +every role when `server.log_addresses` is on (`/api/players`' rule), in the +RCON's events, their replay and `GET /api/events` alike. `variant` is the gametype's name as the game shows it (`Co-op` +in a co-op entry). + +## Bans + +A ban is a line of `bans.txt` (in `paths.logs`, else the data root). A +timed ban is kept out by its line's `until=` alone, and ends then. A ban +for ever, and a machine dropped for cheating, also keep the machine out +for the rest of the run by its address, its real address or its hardware +id. `sv_ban_address` and `POST /api/bans` write such a line for an address +or a hardware id with no player of it needed (the part not given is +`none`). `unban` takes the line out and readmits the machine by +the line's address or hardware id, whether it joins on the LAN or by +internet play. + +## The rotation + +`server.rotation` (and `sv_rotation_set`, `PUT /api/rotation`) is a list of +entries, `map[:gametype[+rule=value...]]`, separated by commas, semicolons +or new lines; a line that begins with `#` is a comment (to its line's end, +commas and all). The text is 4096 characters at most (the entries past it +are left out, with a warning), an entry 255 characters and the list 64 +entries. `config.toml` keeps a string setting of up to 4096 characters, so +a rotation that long is saved whole and read back after a restart. + +- The map is a multiplayer map's name (`bloodgulch`: the Xbox's + `maps/.map`, else a Custom Edition map of `custom_maps`), + `custom_maps\` (a Custom Edition map; `/` is taken for `\`), or a + level's path (`levels\test\bloodgulch\bloodgulch`). A campaign level is + played as co-op only: `:coop`, at `server.coop_difficulty`. +- The gametype (`slayer` when there is none) is a built-in's name + (`slayer`, `team_slayer`, `slayer_pro`, `elimination`, `phantoms`, + `endurance`, `rockets`, `snipers`, `stalker`, `ctf`, `ironctf`, + `ctf_pro`, `invasion`, `oddball`, `team_oddball`, `reverse_tag`, + `accumulation`, `juggernaut`, `king`, `team_king`, `king_pro`, + `crazy_king`, `race`, `team_race`, `rally`, `team_rally`, and Halo PC's + names `ffa`, `ffa_slayer`, `teamslayer`, `tslayer`, `koth`, `team_koth`, + `iron_ctf`, `teamrace`, `teamoddball`), an OpenCE gametype's (`swat`, + `infection`, `gun_game`, `hardcore`, `hardcore_ctf`, `hardcore_oddball`, + `hardcore_king`), a gametype file's name (`gametypes/.toml`), or + `file=` (a gametype a player's game saved, below). A gametype's + name is letters, digits, `_`, `-` and `.`, in any case. +- A rule is a gametype rule's name or its short name (`score`, `time`, + `weapons`, `ff`...; `GET /api/gametypes`'s schema lists them), with a + value it takes; a rule of another game engine than the gametype's is + refused. 48 rules at most; a rule given twice is the last. + +An entry that cannot be played (a map that is not there, a bad gametype +file, a rule out of range) is left out with a warning, at the start and at +`sv_reload`; `sv_rotation_set` and `PUT /api/rotation` change nothing when +one of their entries cannot be played. + +A change of the rotation (`sv_rotation_*`, `PUT /api/rotation`, +`sv_reload`) keeps its place: the next game is the entry after the one +played last, found again in the new list (the first entry only when none +has been played yet). `sv_reload` takes the rotation again when its text +changed, and also when only its entries' rejections changed (a map or a +gametype file added or taken out since). + +`server.team_start` decides how a team game starts. `one` (the default) +starts it with the players of one team. `both` waits, saying nothing, +for a player on each team. `skip`, when one player waits for a team game +that `sv_map` did not ask for, plays the rotation's next entry without +teams instead (if it has one); it is checked when the lobby opens and +again when its countdown would begin. With no entry left, the server +stops with exit code 3 (2 when no rotation map is there), or plays +`bloodgulch:slayer` with `server.rotation_fallback`. + +## Gametype files + +A gametype file, `gametypes/.toml` (the name `a`–`z`, `0`–`9`, `_` +and `-`), is a built-in or OpenCE gametype (`base`) and rules over it: + +```toml +base = "team_king" # the gametype it starts from +name = "Moving Hill" # the name the game shows: 11 characters of printable ASCII + +[universal] +score_to_win = 3 + +[options] +time_limit = 15 +friendly_fire = "off" + +[king] +moving_hill = true +``` + +Each rule goes in its table (the schema's `table`): `[universal]`, +`[options]`, and one game engine's, `[ctf]`, `[slayer]`, `[oddball]`, +`[king]` or `[race]`. A file gives each rule its full name +(`vehicle_set`, `team_vehicles`, `score_to_win`): the short names +(`vehicles`, `score`) are the rotation's alone. A file is 64 KB at most. +`deploy/gametypes` has examples. The server reads the `gametypes` folder +beside `config.toml` if there is one, else the data root's, and writes the +web page's files to it. + +A gametype a player's game saved is played with `file=`: copy the +saved gametype, as `.gametype`, into the same `gametypes` folder +(beside `config.toml` if there is one, else the data root's). + +## The listing + +A public server (`server.public`, with `network.online` and +`network.public_lobby`) is published to each master server of +`masters.txt` (`docs/master_protocol.md`). A change that a list shows (the +listing made public or private, a player joining or leaving, the name, the +map) is published within 5 seconds. Made private, the server makes a new +invite and sends the masters a closed publish, which takes it off their +lists; +`network.public_lobby = false` stops the publishing too. A master's +`Retry-After` is kept (a day at most). `key_revoked` and +`publisher_revoked` stop the publishing to that master for the run; +`unknown_key`, `publisher_pending`, `application_denied` and +`listing_taken` are logged once a minute while they last. + +## Files + +| File | What | +| --- | --- | +| `config.toml` | Every setting (beside the executable; `HALO_CONFIG_FILE`, else `--config`, names another) | +| `init.txt` (`server.init_file`) | Commands run at the start, in the data root unless a full path | +| `gametypes/.toml` | Gametype files: the `gametypes` folder beside `config.toml` if there is one, else the data root's | +| `gametypes/.gametype` | Gametypes saved by a player's game (`file=` in the rotation): the same `gametypes` folder | +| `publisher.key` | The publisher key for the master servers (beside `config.toml`). On Windows, a server's `config.toml` and `publisher.key` are given an access list of their owner alone (the entries inherited for Administrators and SYSTEM taken out) | +| `masters.txt` | The master servers (`server.masters_file`: beside `config.toml`, else beside the executable); one that names none leaves the official master, built in | +| `bans.txt`, `cheaters.txt`, `debug.txt` | In `paths.logs`, else the data root | +| `status.json` (`server.status_file`) | The status, written every 2 seconds | +| A password's file (`HALO_SERVER_WEB_PASSWORD_FILE`, `HALO_SERVER_WEB_OWNER_PASSWORD_FILE`, `HALO_SERVER_WEB_MODERATOR_PASSWORD_FILE`, `HALO_SERVER_WEB_VIEWER_PASSWORD_FILE`, `HALO_SERVER_PASSWORD_FILE`) | A container's secret: when the plain variable is not set, the file's first line (its CR and LF off) is the setting's value, which is then pinned as the plain variable's would be (`sv_set` and `PUT /api/settings` refuse it, `409 pinned`). Read once, at the start; a file that cannot be read is logged, and the setting is `config.toml`'s | + +## Releases + +Each release has `SHA256SUMS` (the SHA-256 of each of its files, as +`sha256sum` writes them: ` `, written by `tools/ci_build.py +sums`) and `SHA256SUMS.sig`: the Ed25519 signature of that file's exact +bytes by the project's release key, in 128 lowercase hex digits and a +newline (the form `opence-master-server`'s `cmd/release-sign` writes). The +public key is `deploy/release-key.pub` (64 hex digits: +`d04e8b101007d1641cace8b95fcca76f7f44709f6885c3d031b52394dbdf9376`). +`tools/release_sign.sh verify SHA256SUMS` checks the signature (OpenSSL 3), +and `sha256sum -c --ignore-missing SHA256SUMS` the files downloaded. The +build workflow signs with the `RELEASE_SIGNING_KEY` secret (the key's seed, +64 hex digits) and checks the signature against `deploy/release-key.pub` +before it publishes the release. diff --git a/docs/master_protocol.md b/docs/master_protocol.md new file mode 100644 index 00000000..343c1cba --- /dev/null +++ b/docs/master_protocol.md @@ -0,0 +1,650 @@ +# The master servers' protocol, version 1 + +A master server lists dedicated servers and vouches for who publishes them. +An operator applies to a master once with the public half of an Ed25519 +key, the *publisher key*, through the master's Discord bot; once an +approver approves the application, the +operator's servers publish to the master with requests signed by that key, +and the master lists them after probing their UDP port. The game's server +browser fetches each master's list, checks the master's signature with the +key pinned in `masters.txt`, and joins a listed game by the invite in its +listing, as it joins a game heard on the brokers. + +A master carries no game traffic: each list entry holds the host-signed +listing the game publishes to the signalling brokers (`p2p_lobby.c`), so +the join path is unchanged and nobody needs the master to be honest about a +game's details. + +This document is the contract between the game (the publisher in +`port/linux/src/server_master.c`, the probe's answer, the list client in +`port/linux/src/master_list.c`) and the masters' implementations. The +project's implementation is `opence-master` +(github.com/OpenCommunityEdition/opence-master-server, Go, AGPL-3.0-or-later), +whose `testdata/vectors.json` holds fixed keys and every value this +document derives from them, and whose `tests/conformance` is the test a +master must pass to be an official one. `docs/dedicated_server.md` is the +server's side: its settings, `--keygen` and `--sign`. + +## Conventions + +| Thing | Form | +| --- | --- | +| Hexadecimal | Lowercase, two digits a byte, no prefix | +| Base64 | The standard alphabet (RFC 4648 section 4) with `=` padding | +| Times | Unix seconds, decimal, UTC | +| JSON | UTF-8; every string the protocol defines is printable ASCII (0x20 to 0x7E) | +| Signatures | Ed25519 as RFC 8032 defines it (with SHA-512): Monocypher's `crypto_ed25519_sign` and `crypto_ed25519_check`, Go's `crypto/ed25519` | +| Transport | HTTPS. A master refuses publishes, key changes and peers' requests over plain HTTP except from loopback | +| Unknown JSON fields | Ignored by both sides, so later versions can add fields | + +## Keys and identifiers + +| Thing | Definition | +| --- | --- | +| Key file (`publisher.key`, `master.key`) | One line of 64 hexadecimal digits: the Ed25519 seed (32 bytes). Anything after `#` is a comment; blank lines are skipped. Mode 0600 | +| Public key, as text | `ed25519:` and the 32-byte public key in hexadecimal (64 digits) | +| Key id | The first 8 bytes of SHA-256 of the 32-byte public key. Written as 16 hexadecimal digits (`keyid=`, `key_id`, `Signature-Key`); in the probe's packets it is the 8 raw bytes | +| Instance | A name the operator gives each server process (`server.instance`): 1 to 32 characters of `a-z`, `0-9`, `-` and `_`, the first a letter or a digit | +| Server id | The first 16 hexadecimal digits (8 bytes) of SHA-256 of the ASCII text of the key id's 16 hexadecimal digits followed by the instance's text, with nothing between them | + +The server id hashes the key id **as its 16 characters of text**, not its 8 +bytes. With the vectors' publisher key (RFC 8032's first test seed): + +``` +seed 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 +public key ed25519:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a +key id 21fe31dfa154a261 (SHA-256 of the 32 key bytes: 21fe31dfa154a261...) +instance main +hashed "21fe31dfa154a261main" (20 ASCII bytes) +server id 4c5f15237c866f6f (SHA-256 of those 20 bytes: 4c5f15237c866f6f...) +``` + +The same instance under another key is another server id, so a server +keeps its id across restarts as long as it keeps its key and its instance. +A master refuses a public key whose point has a small order (its X25519 +form multiplied by a clamped scalar is zero, as `p2p_ed25519_verify` +checks), since anyone can make signatures that verify under one. + +`masters.txt` (the one beside `config.toml`, else the one beside the +executable) lists the masters, one per line: +`https://master1.example.org ed25519:<64 hex>`; `#` starts a comment; the +first 4 are used. A file that names none (missing, empty, or comments alone, +as the release's) leaves the official master, built into the game +(`master_list_read_defaults` in `port/linux/src/master_list.c`) and the web +page (`servers.js`): +`https://master.opencommunityedition.com ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa`. +The web build reads its site's `masters.json` before the official master. The URL is an origin with no path, or a path +the master is served under as it is (a proxy must not rewrite paths, since +the path is signed). + +## Signed requests + +A publisher's requests carry a detached signature in one header: + +``` +Authorization: OpenCE-Ed25519 keyid=<16 hex>, ts=, nonce=<32 hex>, sig= +``` + +The signature is the key's over the bytes of this string, its parts joined +by single line feeds (0x0A) and no line feed at the end: + +``` +OpenCE-Master-v1 + + + + + + +``` + +| Part | Rule | +| --- | --- | +| Master key id | The key id of the master the request is sent to: the first 8 bytes of SHA-256 of the master's list-signing public key (the key pinned for it in `masters.txt`), as 16 lowercase hexadecimal digits. It binds the request to that master: a request signed for one master does not verify at another, even within the replay window. A master checks the signature with its own key id | +| `METHOD` | The request's method in capitals: `PUT`, `DELETE`, `GET`, `POST` | +| `PATH` | The path exactly as the request line sends it, without the query string: `/v1/servers/4c5f15237c866f6f`, `/v1/me`, `/v1/publishers` | +| `ts` | The same decimal text as in the header: no sign, no leading zeros | +| `nonce` | 16 random bytes in hexadecimal (32 digits), new for each request | +| Body hash | Of the exact body bytes sent; an empty body's is SHA-256 of no bytes, `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | + +The four parameters come once each, in any order, separated by commas with +optional spaces; a publisher sends them in the order above. The master +checks, in this order, and answers the first failure: + +| Check | Failure | +| --- | --- | +| The header parses (scheme, the four parameters and their forms) | `401 bad_signature` | +| `ts` is within 300 seconds of the master's clock | `401 clock` (the body's `master_time` is the master's clock, for the server to log) | +| The key id is a publisher's, or an application's | `403 unknown_key` | +| The signature verifies, for this master's key id | `401 bad_signature` | +| The nonce was not seen with this key in the last 600 seconds | `401 replay` | + +Since `unknown_key` is answered before the signature is checked, anyone can +learn whether a key id is known to a master (a publisher's or an +application's) by sending it with any signature. This is a known property +of version 1: key ids are not secret (each is a hash of a public key, which +an operator hands out when applying), and the answer says nothing more of +the key than that the master has seen it. + +The vectors' `GET /v1/me` to the vectors' master (key id +`39f713d0a644253f`) at 1760900001 with the nonce +`f0e0d0c0b0a090807060504030201000` signs + +``` +OpenCE-Master-v1\n39f713d0a644253f\nGET\n/v1/me\n1760900001\nf0e0d0c0b0a090807060504030201000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +and sends + +``` +Authorization: OpenCE-Ed25519 keyid=21fe31dfa154a261, ts=1760900001, nonce=f0e0d0c0b0a090807060504030201000, sig=duRZ+WKAI8hbvidtC63U1XHxOSoeRN5mcrZvw4qihhlilhZjbVBCLYjsQLr4+F2C1gy6lN9/ixqevFX7dfNGDA== +``` + +## Publishing + +`PUT /v1/servers/{server_id}`, signed, with a JSON body of 16 KB at most. +A server publishes to every master in `masters.txt` every 30 seconds, and +within 5 seconds of a change (the lobby opened, a game started or ended, a +player joined or left, the name or map changed). On shutdown it publishes +`closed: true` (or `DELETE`s) and waits up to 2 seconds. + +```json +{"protocol":1,"instance":"main","name":"OpenCE Dedicated","map":"bloodgulch","gametype":"CTF","variant":"CTF", + "players":5,"max_players":16,"teams":true,"in_progress":false,"password":false,"dedicated":true, + "network_version":38,"build":"build-206","region":"eu","tunnel_port":2302, + "listing":"","closed":false,"serial":412} +``` + +(The vectors' publish, made at network version 38: "The listing" says +why the examples keep it.) The game's publisher sends `protocol`, +`instance`, `listing` (but in a tombstone), `tunnel_port`, `dedicated`, +`build`, `region` and `variant` when they are set, `address` with +`server.master_publish_address`, `serial` and `closed`; the listing holds +the rest. A server that goes private, or off the internet, sends one more +publish, `closed: true`, to each master that listed it. + +| Field | Type | Required | Meaning | +| --- | --- | --- | --- | +| `protocol` | number | yes | 1 | +| `instance` | string | yes | The instance; the path's server id must be the server id of the signing key's id and this instance, else `400 server_id` | +| `listing` | string | yes, but with `closed: true` | Base64 of the exact bytes `listing_make` makes, the bytes the game publishes to the brokers | +| `tunnel_port` | number | yes, unless `address` is given | The UDP port the game's tunnel socket is bound to (`network.tunnel_port`), 1 to 65535 | +| `address` | string | no | `"203.0.113.5:2302"`: an IP address (IPv4, or IPv6 in brackets) and a port to probe instead of the request's source address. Sent only with `server.master_publish_address`; a master serves it in its list only if its own policy allows (`MASTER_SERVE_ADDRESSES`). It must be globally routable (below); a private or loopback address is taken only from a private or loopback source | +| `dedicated` | boolean | no | A dedicated server. Also read from the listing's flag 32, which a dedicated server sets; either makes the entry dedicated. A player's public game, published with the player's publisher key, sends `false` | +| `variant` | string | no | The gametype's name as the server shows it; printable ASCII, cut to 24 | +| `build` | string | no | The game's build (`build-206`); cut to 32 | +| `region` | string | no | Up to 16 of `a-z`, `0-9` and `-` (`server.region`) | +| `serial` | number | no | A whole number the server raises at each change; kept and shown to admins | +| `closed` | boolean | no | `true`: unlist the server now | +| `name`, `map`, `gametype`, `players`, `max_players`, `teams`, `in_progress`, `password`, `network_version` | | no | Informational only: the listing is authoritative. A value that differs from the listing's is ignored, and the answer's `warnings` says so | + +The master parses the listing and checks the host's signature (below), and +takes from it the name, the map, the gametype, the players and most +players, the gametype's engine, the game's kind, the open, in-progress, +teams and password flags, and the network version, so a publish cannot +claim more than the host signed. A listing that does not parse or verify +is `400 listing`. So is one whose own time is more than 3600 seconds from +the master's clock. A listing with the same host key and a lower sequence +than the one the master holds is not taken (the newer stays; a warning +says so). A listing with the closed flag unlists the server, as +`closed: true` does. + +The master records the request's source address (behind a reverse proxy, +the address its trusted proxy forwards) to probe the server. It keeps the +addresses 7 days at most after a server's last publish, for probes and +abuse handling, and never serves them. + +The addresses a master probes and serves are an allowlist of globally +routable unicast addresses: an IPv4 address outside IANA's special-purpose +ranges (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, +`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`, +`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`); an IPv6 address in +`2000::/3` outside `2001::/23`, `2001:db8::/32`, `3fff::/20` and +`5f00::/16`; a 6to4 (`2002::/16`) or well-known NAT64 (`64:ff9b::/96`) +address whose embedded IPv4 address is global. An IPv4-mapped address is +judged as its IPv4 address. Everything else (link-local, site-local +`fec0::/10`, unique local, multicast, `64:ff9b:1::/48`, zones) is refused +(`400 schema`, `field: address`), except a private or loopback address +from a private or loopback source (a master on the same network). A +publish from a source address outside the allowlist without `address` is +refused the same way when the master probes. + +A listing's host key is published by one publisher at a time: a listing +whose host key is that of a live server of another publisher is refused, +`403 listing_taken`, so a listing's bytes copied from a list cannot be +published by someone else. The same publisher may carry it under another +server id (another key or instance). A host makes a new listing (a higher +sequence) at each of its publishes, so a live server that has taken no newer +listing of its host key for 90 seconds (3 intervals) is replaying a copy: +a publish of a newer listing of that host key takes the host key from it, +and its server is unlisted. A server an admin hid, or whose publisher or key +is not approved, holds no host key. A master shows its admins the publishes +it refused with `listing_taken`, to settle (see "Known limitations"). + +### The answer + +`200` once the publish is taken and the master's decision for now is made; +`202` while the first probe of the server's address is unanswered: + +```json +{"ok":true,"server_id":"4c5f15237c866f6f","listed":true,"interval":30,"ttl":90,"probe":"ok","reachable":true, + "observed_address":"203.0.113.5","master_time":1760900000} +{"ok":true,"server_id":"4c5f15237c866f6f","listed":false,"interval":30,"ttl":90,"probe":"pending","reachable":false, + "observed_address":"203.0.113.5","probe_address":"203.0.113.5:2302","master_time":1760900000} +``` + +| Field | Meaning | +| --- | --- | +| `listed` | Whether the list shows the server now | +| `interval` | Seconds between publishes the master expects (30) | +| `ttl` | Seconds after its last publish that a server is unlisted (90): one lost publish does not unlist it, three do | +| `probe` | `pending` (no answer yet at this address), `ok`, `failed` (three attempts unanswered), `skipped` (an admin marked the server trusted), `off` (the master does not probe) | +| `reachable` | The probe was answered | +| `observed_address` | The address the master saw the request come from | +| `probe_address` | With `pending` and `failed`: the address and port the master probes | +| `hidden`, `reason` | An admin hid the server: it is listed to nobody, and `reason` says why. With `probe: failed`, `reason` is the hint to log: `let UDP port through your firewall or NAT, or set network.public_address` | +| `closed` | `true` after `closed: true`, a tombstone or `DELETE` | +| `warnings` | Body fields the listing contradicts, which were ignored | +| `master_time` | The master's clock | + +A server with a failed probe is unlisted unless the master's policy lists +unreachable servers (`MASTER_LIST_UNREACHABLE`), as `reachable: false`. +The server logs each change of outcome once (not every 30 seconds), and +the failed probe's hint once a minute until the probe passes. + +`DELETE /v1/servers/{server_id}`, signed by any key of the server's +publisher with an empty body, unlists the server at once and answers +`200 {"ok":true,"server_id":…,"listed":false,"closed":true,…}`; `404 +not_found` for a server that is not the publisher's. + +### Limits + +| Limit | Answer | +| --- | --- | +| A server publishes at most once each 5 seconds, with a burst of 2 | `429 rate_limited` with `Retry-After` | +| 120 publishes a minute per source address, and per key | `429 rate_limited` | +| A body is 16 KB at most | `413 too_large` | +| A publisher has at most its quota of live servers at once (default 4; live: published within `ttl` and not closed) | `403 quota_exceeded` | +| An instance is published by one key of a publisher at a time | `403 instance_taken` (another key's server of that instance is live) | +| A publisher holds at most its quota and 16 server records (one per server id); for a new server id past that, its oldest records that are not live are dropped, and if none can be | `403 too_many_instances` | + +## The listing + +The listing is `p2p_lobby.c`'s (`listing_make`, `listing_read`), in network +byte order. The master reads it as `listing_read` does and checks its +signature as `listing_signed` does. + +| Offset | Size | Field | +| --- | --- | --- | +| 0 | 2 | `"HL"` | +| 2 | 1 | Format: 1 | +| 3 | 2 | Network version (`HALO_PORT_NETWORK_VERSION`, 39 now) | +| 5 | 1 | Flags (below) | +| 6 | 4 | Sequence: the host's count of its listings (the newest wins) | +| 10 | 4 | The host's Unix time when it made the listing | +| 14 | 32 | The host's Ed25519 public key (one per run of the game) | +| 46 | 16, or 56 with the password flag | The invite's token (zero in a tombstone); with the password flag the token sealed with the password's key: nonce (24), MAC (16), the token encrypted (16), XChaCha20-Poly1305 with the host's key as additional data (`p2p_seal_token`) | +| then | 1 | Players (at most 255) | +| | 1 | Most players | +| | 1 | The gametype's engine: 0 none (co-op), 1 CTF, 2 Slayer, 3 Oddball, 4 King of the Hill, 5 Race | +| | 1 + up to 32 | The game's name: a length byte, then that many bytes | +| | 1 + up to 32 | The map's name | +| | 1 + up to 24 | The gametype's name | +| | 8 | The stamp: the game's kind (`game_modes_kind`: the OpenCE mode, 1 SWAT, 2 Infection, 3 Gun Game, or 0; with 0x80 for Hardcore), then 7 reserved bytes, zero | +| | 64 | The Ed25519 signature, by the listing's key, of the 12 ASCII bytes `hceu-lobby-1` followed by every byte before the signature | + +| Flag | Meaning | +| --- | --- | +| 1 | Open: it takes players now | +| 2 | In progress: a game is under way (or loading) | +| 4 | Teams | +| 8 | Closed: a tombstone, the host stopped | +| 16 | Password: the token is sealed | +| 32 | Dedicated: a dedicated server (`halo --server` sets it, since network version 39) | + +A listing is 140 bytes at least (all names empty) and 268 at most (a +password and every name at its length). A reader refuses one outside those +sizes, without `"HL"` and format 1, with a name's length byte past its +field's maximum or past the end, or whose stamp and signature do not end it +exactly. The names are sanitised as the game's `sanitize` does: a byte +outside 0x20 to 0x7E becomes `?`, and a zero ends the name. A master also +refuses a listing whose key has a small order. + +The vectors' first listing (an open team game of a dedicated server, +sequence 412, 5 of 16 players, CTF on bloodgulch, named `OpenCE Dedicated`) +is these 169 bytes, of which the first 105 are signed. The vectors were made +at network version 38 and are kept as they are (the bytes and signatures +are fixed): their listings carry version 38 with flag 32, which the game +sets from version 39 on, so a game of version 39 reads them, checks their +signatures and hides them from its browser as another version's games, as +it does any. The examples' `network_version` of 38 in this document are the +vectors' too. + +| Field | Bytes | +| --- | --- | +| Magic, format | `484c` `01` | +| Network version 38, flags 37 (open, teams, dedicated) | `0026` `25` | +| Sequence 412, time 1760900000 | `0000019c` `68f533a0` | +| Host key | `fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025` | +| Token | `a0a1a2a3a4a5a6a7a8a9aaabacadaeaf` | +| Players 5, most 16, engine 1 (CTF) | `05` `10` `01` | +| Name, map, gametype | `10` `4f70656e434520446564696361746564`, `0a` `626c6f6f6467756c6368`, `03` `435446` | +| Stamp (kind 0) | `0000000000000000` | +| Signature | `633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f` | + +(`testdata/vectors.json` has it whole, with a password game's, a +tombstone's and an altered one that must be refused.) + +## The probe + +Before it lists a server, the master proves that the server's UDP port is +reachable from the internet and that the publisher key's holder answers on +it. It sends a datagram to the probe address (the published `address`, or +the request's source address with `tunnel_port`) from its probe socket: + +| Offset | Size | Probe (35 bytes) | +| --- | --- | --- | +| 0 | 10 | `"OPCE-PROBE"` | +| 10 | 1 | 1 | +| 11 | 8 | The master's key id, raw | +| 19 | 16 | A nonce | + +The game's tunnel socket, which dispatches what it receives by its first +bytes, recognises the magic and answers from the same socket to the +address the probe came from: + +| Offset | Size | Answer (103 bytes) | +| --- | --- | --- | +| 0 | 14 | `"OPCE-PROBE-ACK"` | +| 14 | 1 | 1 | +| 15 | 8 | The master's key id, as the probe had it | +| 23 | 16 | The nonce, as the probe had it | +| 39 | 64 | The publisher key's Ed25519 signature of the 29 bytes `"probe"`, the master's key id, the nonce | + +The server answers only a probe of exactly 35 bytes whose key id is that of +a master in its `masters.txt`, only to the probe's source, a given nonce at +most once per second, at most 2 answers a second to one source address and +at most 10 answers a second in all, so the probe cannot be used to reflect +traffic (a 103-byte answer to a 35-byte probe is the whole amplification), +and probes forged from one address cannot use up the answers a master's +probe needs. A probe whose nonce was answered in the same second counts +against neither limit. +The master takes only an answer from the probed address and port, with its +own key id, a nonce it made for that address and key, and a signature that +verifies with the key that signed the publish. + +| Rule | Value | +| --- | --- | +| Attempts | 3, 2 seconds apart; failed 2 seconds after the third | +| Again | Every 5 minutes while the server is reachable (it stays listed meanwhile); a failed server at its first publish a minute or more after the failure; at once when its probe address or key changes | +| Nonce | Opaque to the server. A master uses a fresh nonce for each probe round (the round's attempts share it), and never the same one for two rounds of one address and key. `opence-master`'s is stateless: the first 16 bytes of HMAC-SHA-256(seed, `"opence-probe-1"` 0x00 address 0x00 key id 0x00 step), the step the decimal Unix time of the round's start divided by 30; it accepts the current and the previous hour's random seed and the current and the previous step, and starts rounds of one address and key at least a step apart | +| Duplicates | A server answers a given nonce at most once per second: it remembers the nonces it answered (the last 64) with the Unix second they came in, and ignores a probe whose nonce it answered in the same second, whatever came between (A, B, A in one second: the second A is ignored). A master's retries, 2 seconds apart, are answered again; a nonce resent within the second is not | + +With the vectors' master key (id `39f713d0a644253f`) and the nonce +`101112131415161718191a1b1c1d1e1f`, the probe is +`4f5043452d50524f42450139f713d0a644253f101112131415161718191a1b1c1d1e1f`, the +signed message `70726f626539f713d0a644253f101112131415161718191a1b1c1d1e1f`, +and the publisher's answer +`4f5043452d50524f42452d41434b0139f713d0a644253f101112131415161718191a1b1c1d1e1f` +followed by the signature +`a63af355db038dff90e6c82abc0e252eddbd1ed3562580d7becaf3e4edbdd39cef7ef3163b91849ba57f6e83240b92df1efbce15dfa42f4a12981590a0234109`. + +## The list + +`GET /v1/servers`, with the optional query `network_version=`, +`dedicated=1` (or `0`), `region=`, `q=` (a case-insensitive +substring of the name, map, gametype or variant; 32 characters at most). +No signature is needed and plain HTTP is allowed: the body is signed. + +```json +{"protocol":1,"master":{"name":"OpenCE Master 1","key_id":"39f713d0a644253f","time":1760900000}, + "servers":[{"id":"4c5f15237c866f6f","publisher":{"id":"p_kz3c5lb2xvq7mdpa","name":"Milenko's servers","since":"2026-10"}, + "name":"OpenCE Dedicated","map":"bloodgulch","gametype":"CTF","variant":"CTF","players":5,"max_players":16, + "teams":true,"in_progress":false,"password":false,"open":true,"dedicated":true,"kind":0,"engine":1, + "network_version":38,"build":"build-206","region":"eu","reachable":true,"updated":1760899990, + "listing":""}]} +``` + +| Entry field | Meaning | +| --- | --- | +| `id` | The server id | +| `publisher` | The publisher's id (`p_` and up to 32 of `a-z0-9`), display name, and the year and month it was approved | +| `name`, `map`, `gametype`, `players`, `max_players`, `teams`, `in_progress`, `password`, `open`, `kind`, `engine`, `network_version` | From the listing | +| `variant`, `build`, `region` | From the publish | +| `dedicated` | The listing's flag 32 or the publish's `dedicated` | +| `reachable` | The last probe was answered (`false` for a trusted server or under `MASTER_LIST_UNREACHABLE`) | +| `updated` | The time of the server's last publish | +| `address` | Only with the operator's and the master's consent (above); absent otherwise | +| `listing` | Base64 of the listing's exact bytes, as the server published them | + +The list holds at most 512 entries: dedicated servers first, then by +players. Its headers: + +| Header | Value | +| --- | --- | +| `Signature` | `ed25519=` and the base64 of the master key's signature of the body's exact bytes (before any `Content-Encoding`) | +| `Signature-Key` | The master's key id, 16 hexadecimal digits | +| `Signature-Time` | The body's `master.time` | +| `ETag` | `"` and the first 16 hexadecimal digits of SHA-256 of the body, and `"`; `If-None-Match` with it answers `304` | +| `Cache-Control` | `public, max-age=5` (the master serves the same body for 5 seconds) | +| `Content-Encoding` | `gzip` when the request's `Accept-Encoding` asks for it | +| `Access-Control-Allow-Origin` | `*` (and `Access-Control-Expose-Headers` names the four above), for the web build | + +A client: + +1. checks `Signature` over the decompressed body bytes with the key in + `masters.txt`, and that `Signature-Key` is that key's id, and refuses + the whole body otherwise (a missing `Signature-Key` too; a client that + cannot check Ed25519, such as an old browser, refuses every list rather + than show one unchecked); +2. parses the body, and refuses it unless `protocol` is 1, `master.key_id` + is the key's id, and `master.time` is no older than 300 seconds and no + more than 300 seconds ahead of the client's clock (a list dated ahead + would be taken for longer when it is replayed later, and a clock that + far off is wrong). The time is in the signed body: `Signature-Time` only + repeats it, since a header is not signed, and a client need not read + it; +3. type-checks each entry, decodes its `listing`, runs `listing_read` and + the host signature's check on it, and skips an entry that fails rather + than the list; it shows the name, map, gametype and players of the + signed listing (an entry's own fields are the master's word, and may + differ); it filters control characters from every name, and takes at + most 512 entries (a 512-entry list is some 26,000 JSON tokens: the game + reads a list of 65,536 tokens at most); +4. merges the masters' lists: entries with the same `id` are one game (the + newest `updated` wins), and an entry whose listing key is also heard on + the brokers is one game, shown as master-listed; +5. never pings listed servers (that would tell every server the player's + address): it shows the `region`, and the ping once a game is joined. + +The vectors' signed body is +`{"protocol":1,"master":{"name":"OpenCE Master 1","key_id":"39f713d0a644253f","time":1760900000},"servers":[]}` +with `Signature: ed25519=18dmQnIfmRnC+wUwRJ/loxAH1VfASRBVnZgCfRuHrAw/2uo4thTibWnNQFScgRju+5lQtt709blK86BLT0fYCw==` +and `ETag: "cd755a50c5cfc457"`. + +`GET /v1/servers/{server_id}` answers one listed server the same way: +`{"protocol":1,"master":{…},"server":{}}` with the same headers, or +`404 not_found`. + +## Other endpoints + +| Request | Who | Answer | +| --- | --- | --- | +| `GET /v1/publishers/{id}` | anyone (the list's limit) | `{"ok":true,"id","name","since":"2026-10","status","servers":}`, or `404` | +| `POST /v1/applications`, `/v1/applications/…` | anyone | `410 applications_by_discord`: applications are made with the master's Discord bot (below), not over HTTP | +| `POST /v1/keys` | signed, an approved publisher's active key | Body `{"public_key":"ed25519:…"}`: adds a key (8 active at most). `201 {"ok":true,"key_id","status":"active"}`; `409 key_exists`; `403 too_many_keys` | +| `GET /v1/me` | signed, any key the master knows | The publisher's record: `{"ok":true,"key_id","status","publisher":{"id","name","since","status","quota","origin","keys":[{"key_id","public_key","status","updated","last_used"}],"servers":[{"id","instance","key_id","listed","probe","reachable","hidden","trusted","updated","name","players","max_players"}]}}`; for a key still in an application, `"publisher":null` and `"application":{"id","status","reason","created"}`; `403 key_revoked` | +| `GET /v1/master` | anyone | The master's name, key id, public key, contact, data policy and limits (informational: clients use the key in `masters.txt`) | +| `GET /healthz` | anyone | `200 {"ok":true,…}` while the database answers | +| `GET /metrics` | anyone (the list's limit) | Prometheus text, read at most once each 5 seconds: `master_servers{reachable}`, `master_publishers{status}`, `master_publishes_total{result}`, `master_probes_total{result}`, `master_list_requests_total`, `master_peer_requests_total`, `master_build_info{version,protocol}` | +| `GET /`, `GET /apply`, `/admin/*` | people | The list page (the list's limit), how to apply (the Discord bot, and an invite to its server), the admin pages | + +## Applications + +An operator applies with the master's Discord bot, in the master's +Discord server (the `/apply` page links to it): the bot's `/apply` command +takes the region (a menu of the master's choices) and the number of servers +at once as its options, and answers the applicant alone with a nonce for +the proof of key, +`opence-apply--<16 hex>`, good for 24 hours and bound to that Discord +user (a nonce given to another user, or to none, does not prove this one's +key), and a button that opens the application's form: a display name, the +public key, the proof, and a note. The application's name is 3 to 32 printable ASCII characters, +`servers` is 1 to 64, and the region up to 16 of `a-z`, `0-9` and `-`. The +proof of key is required unless the master's policy says otherwise +(`MASTER_REQUIRE_PROOF`, true by default): it is the publisher key's +Ed25519 signature of the nonce's ASCII text, in hexadecimal (128 digits, +what `halo --server --sign ` prints) or base64. An application +without one is shown to the approvers as unproven. A key already a +publisher's is refused, and so is a key with an application pending. The +applicant is answered on Discord, and told of the decision by a direct +message; the applicant's identity is their Discord user id and username. + +Each application is posted to the master's log channel with Approve and +Deny buttons, which only the members of the master's approver roles may +press; the admin pages decide too. Once decided, anywhere, the message +shows the outcome and who decided. The master's operator can also add an +application on its command line (`opence-master challenge` for the nonce, +then `add-application`), as the conformance test does. + +A master limits applications, since each one is posted to its approvers: +`opence-master` takes one pending application at a time and 3 a day per +Discord user, 3 a day per key, and, of applications without a proof of key, +100 an hour in all; one with a proof is not refused for the hour's number, +but past 100 applications in an hour the approvers are alerted once and the +next ones are not posted one by one (its settings change these numbers). It +deletes a denied application 30 days after its denial. + +The earlier HTTP application (`POST /v1/applications` with an e-mail +address verified by a mailed link, `GET /v1/applications/challenge`, and +the application's status by its mailed token) is gone: its endpoints +answer `410 applications_by_discord`. A master keeps the applications made +that way readable to its admins. + +## Approval sharing between masters + +The official masters share approvals, so an operator applies once. Each +master fetches each peer's `GET /v1/publishers?since=` (every minute +in `opence-master`), signed with its own master key in the request scheme +above (`keyid` is its master key id, and the signed string's master key id +is the peer's, the one it fetches from; a master answers only the peers it +is configured with, else `403 not_a_peer`). The answer is signed as the list +is (`Signature`, `Signature-Key`, `Signature-Time`, the body's +`master.time`): + +```json +{"protocol":1,"master":{"name","key_id","time"}, + "publishers":[{"id","name","status","since","updated","origin","quota", + "keys":[{"key_id","public_key","status","updated"}]}]} +``` + +A master shares only what it holds a word of its own on: the publishers it +approved or moderated (`status` is its own word: `approved`, `suspended` or +`revoked`; `null` when it only vouches for keys), and the keys it added or +revoked (`active` or `revoked`). `origin` is the key id of the master that +approved the publisher, as the sharing master knows it (a master records a +publisher new to it as come from the peer it fetched the record from, +whatever origin the record claims, and only that peer's records rename +it); `since` its approval time; `updated` the time of +the newest word in the record; `since=` returns the records updated after +it. The next fetch's `since` is the answer's `master.time` less 120 seconds, +on the peer's own clock. + +The importing master keeps each master's word apart and takes the most +restrictive of the trusted masters' words as the publisher's status +(`revoked` over `suspended` over `approved`) and the smallest quota given; +a newer word from a master replaces that master's older one. So a +suspension or revocation anywhere wins everywhere, and only the master that +suspended a publisher can lift its suspension. A key revoked by any trusted +master is revoked, for good; a key two publishers claim is refused. A +master may refuse a peer's words (`MASTER_PEERS_TRUST`); they then count +for nothing. + +## Errors + +Every error is `{"ok":false,"error":{"code":"…","message":"…","field":"…"?},"master_time":}`. +The message is for people and may change; programs read the code. + +| Status | Code | Meaning | A server's publisher should | +| --- | --- | --- | --- | +| 400 | `schema` | A field is missing, of the wrong type or out of range (`field` names it), or the body is not one JSON object | Log it once; it is a bug | +| 400 | `server_id` | The path's id is not the key's and the instance's | Log it once | +| 400 | `listing` | The listing does not parse, its signature fails, its key has a small order, or its time is an hour off | Log it once | +| 401 | `bad_signature` | The Authorization header is malformed or its signature fails | Log it once | +| 401 | `clock` | `ts` is more than 300 seconds from `master_time` | Log the difference (from the answer's `master_time`): the machine's clock is wrong | +| 401 | `replay` | The nonce was used in the last 600 seconds | Send a new nonce | +| 403 | `unknown_key` | No publisher or application has the key | Log `master: apply with key ` once a minute | +| 403 | `publisher_pending` | The key's application waits for review | Log `master: publisher pending` once a minute; keep publishing every 30 s | +| 403 | `application_denied` | The key's application was denied (`message` says why) | Log it once a minute | +| 403 | `publisher_suspended` | The publisher is suspended | Log it; keep publishing slowly | +| 403 | `publisher_revoked`, `key_revoked` | Revoked for good | Log it; stop publishing to that master | +| 403 | `quota_exceeded` | The publisher has its quota of servers live | Log it | +| 403 | `instance_taken` | Another key of the publisher has a live server of this instance | Log it; the old server expires after `ttl` | +| 403 | `listing_taken` | The listing's host key is that of another publisher's live server, which has renewed it within 90 seconds | Log it once a minute; it is someone else's listing, or the host key is shared; keep publishing | +| 403 | `too_many_instances` | The publisher holds its quota and 16 server records, none of which can be dropped | Log it; use fewer instance names | +| 403 | `tls_required` | Plain HTTP from beyond loopback | Use the `https://` URL | +| 403 | `not_a_peer` | A peer's request from a master not configured as a peer | | +| 404 | `not_found` | No such server, publisher or endpoint | | +| 409 | `key_exists` | The key added (`POST /v1/keys`) is known already | | +| 410 | `applications_by_discord` | An application sent over HTTP: applications are made with the master's Discord bot | Log it: apply with the bot's `/apply` | +| 413 | `too_large` | The body is over 16 KB | | +| 429 | `rate_limited` | Too many requests; `Retry-After` gives the seconds to wait | Wait that long (the header's name read in any case; the game waits a day at most, and 30 seconds without one) | +| 500 | `internal` | The master failed | Try again at the next interval | + +## Privacy and limits + +| What | Rule | +| --- | --- | +| Servers' addresses | Kept 7 days after the server's last publish, for probes and abuse handling; never served unless the operator and the master both chose to; not in the master's log unless its operator asks (`MASTER_LOG_ADDRESSES`) | +| Applicants | Their Discord user id and username, seen by the master's admins and approvers (the log channel) only; no address is kept | +| Applications | A denied one: deleted 30 days after the denial | +| Servers' records | A closed server's: deleted an hour after it closed; one not published again: after 24 hours (unless an admin flagged it) | +| Contacts | Discord names (and the earlier HTTP applications' e-mail addresses) are seen by the master's admins and approvers only, and are not shared with peers | +| Strings | Printable ASCII, cut to the listing's lengths: name 32, map 32, gametype and variant 24, build 32 | +| Requests | 60 list requests a minute per address (the list, one server, a publisher's page, the list page and the metrics together); the application limits above; bodies 16 KB at most; the publish limits above. Every limit by address counts an IPv6 address by its /64 | + +## Known limitations + +A listing's host key is bound to the first publisher whose live server +publishes it, not to the host's own publisher: the listing does not say +which publisher key the host chose. A publisher that copies a listing from a +list while the host's publisher is not publishing (more than `ttl`) holds +the host key, and the host's publisher is refused `listing_taken`, until the +copy goes 90 seconds without a newer listing; one that keeps copying the +host's newest listings from other lists keeps it until an admin hides its +server or suspends it. The planned fix is in the listing: the host signs the +key id of the publisher key it publishes with into the listing (a new +listing format, with a new network version), and a master takes a listing +only from that key. + +## Versions + +This is protocol 1: `protocol` in bodies, `/v1/` in paths and +`OpenCE-Master-v1` in signed strings (followed by the receiving master's +key id). A change that an older client or +server would misread is a new version, with new paths; fields may be added +to version 1, and both sides ignore what they do not know. The listing's +own format follows the game's network version: a client hides entries +whose `network_version` is not its own, as it does for the brokers' +listings. + +## Testing an implementation + +`testdata/vectors.json` in the master's repository has, from fixed seeds: +three key pairs with their key ids (the publisher's, the master's, a +host's), a server id, a signed publish and a signed `GET /v1/me` to the +vectors' master (its key id, the canonical strings, the signatures, the +headers), a probe and its answer, +four listings (a dedicated team game, a password game in progress with its +sealed token, a tombstone, and an altered listing that must be refused), a +signed list body with its headers and ETag, a small-order key that must be +refused, and a proof of key. Monocypher's `crypto_ed25519_sign`, +`crypto_ed25519_check` and `crypto_aead_lock` give the same bytes. +`tests/conformance/opence_publisher.py` is a reference publisher in Python +(keys, signatures, listings, publishing, the probe's answer), and +`tests/conformance/test_master_protocol.py` runs a master through the whole +flow: an application (on the master's command line, since the Discord bot +is beyond a test's reach), the HTTP application's `410`, a pending +publish, approval, the probe, the listed +server's exact bytes in a signed list, closing, every refusal, and two +masters sharing an approval, a suspension and a revocation. diff --git a/port/android/guest/runtime/guest_host.h b/port/android/guest/runtime/guest_host.h index e6d2b081..0511ceda 100644 --- a/port/android/guest/runtime/guest_host.h +++ b/port/android/guest/runtime/guest_host.h @@ -138,4 +138,12 @@ void host_touch_scene(int scene); for the overlay's labels */ void host_touch_bindings(const int *controls); +/* ---------- the server browser's master servers (host_https.c) */ + +/* a GET of an https:// url (port/linux/src/master_list.c): 1 with the +status, the headers ("Name: value" lines) and up to body_capacity bytes of +the body (its size in body_size), else 0 and why in error */ +int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity, + int *body_size, char *error, int error_size); + #endif diff --git a/port/android/host/host_https.c b/port/android/host/host_https.c new file mode 100644 index 00000000..e0f82b57 --- /dev/null +++ b/port/android/host/host_https.c @@ -0,0 +1,35 @@ +/* +HOST_HTTPS.C + +The server browser's requests to the master servers (host_https_get, +port/android/guest/runtime/guest_host.h; port/linux/src/master_list.c), made +in the host with the desktop's HTTPS client (port/linux/src/posix_update.c's +update_https_request, Mbed TLS, the system's certificate authorities): the +guest has no TLS of its own, and its memory is the host's to read and write. +*/ + +#include "host.h" +#include "../../linux/src/update.h" + +#include +#include +#include + +int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity, + int *body_size, char *error, int error_size) +{ + struct update_response response; + + *status = 0; + *body_size = 0; + if (!update_https_request("GET", url, NULL, NULL, 0, (unsigned long)body_capacity, &response, error, error_size)) + return 0; + *status = response.status; + snprintf(headers, (size_t)headers_size, "%s", response.headers); + if (response.body_size > (unsigned long)body_capacity) + response.body_size = (unsigned long)body_capacity; + memcpy(body, response.body, response.body_size); + *body_size = (int)response.body_size; + free(response.body); + return 1; +} diff --git a/port/android/host_imports.list b/port/android/host_imports.list index a12b4819..44789c75 100644 --- a/port/android/host_imports.list +++ b/port/android/host_imports.list @@ -62,5 +62,6 @@ host_touch_look_read host_touch_rumble host_touch_scene host_touch_bindings +host_https_get host_errno diff --git a/port/assets/menus/UNWIRED.md b/port/assets/menus/UNWIRED.md index 1a54f8b5..755c4335 100644 --- a/port/assets/menus/UNWIRED.md +++ b/port/assets/menus/UNWIRED.md @@ -10,18 +10,13 @@ do (save a setting, join a game) does not happen; the screens open and close as | Function | Uses | | | --- | --- | --- | -| `common button bar update` | 28 | PC function | +| `common button bar update` | 29 | PC function | | `direct ip connect init` | 1 | PC function | | `direct ip connect update` | 1 | PC function | | `direct ip edit field` | 4 | PC function | -| `gamespy screen update` | 1 | PC function | -| `gamespy select button` | 8 | PC function | -| `gamespy select header` | 22 | PC function | -| `gamespy select item` | 49 | PC function | -| `gamespy update filter settings` | 2 | PC function | | `gt edit list update` | 1 | PC function | | `gt select list update` | 1 | PC function | -| `mouse spinner 1wide click` | 84 | PC function | +| `mouse spinner 1wide click` | 99 | PC function | | `mp map list update` | 1 | PC function | | `mp prof init teamplay options` | 1 | PC function | | `mp prof init vehicle options` | 1 | PC function | @@ -33,4 +28,4 @@ do (save a setting, join a game) does not happen; the screens open and close as | `ss edit server name` | 2 | PC function | | `ss start game` | 2 | PC function | -Wired: `campaign menu continue`, `campaign menu init`, `controls back handler`, `controls begin binding`, `controls screen change set`, `controls screen defaults`, `controls screen init`, `controls update menu`, `difficulty item select`, `direct ip connect go`, `emit custom activation event`, `gamespy back handler`, `gamespy dismiss error`, `gamespy dismiss filters`, `gamespy screen dispose`, `gamespy screen init`, `load game list update`, `load game menu activated`, `load game menu delete finish`, `load game menu delete request`, `load game menu dispose`, `load game menu init`, `main menu quit game`, `mouse emit accept event`, `mouse emit back event`, `mouse emit x event`, `mp type set mode`, `profile manager select`, `profile set edit begin`, `single prev cl item activated`, `solo map list update`, `ss edit server password`. +Wired: `campaign menu continue`, `campaign menu init`, `controls back handler`, `controls begin binding`, `controls screen change set`, `controls screen defaults`, `controls screen init`, `controls update menu`, `difficulty item select`, `direct ip connect go`, `emit custom activation event`, `gamespy back handler`, `gamespy dismiss error`, `gamespy dismiss filters`, `gamespy screen dispose`, `gamespy screen init`, `gamespy screen update`, `gamespy select button`, `gamespy select header`, `gamespy select item`, `gamespy update filter settings`, `load game list update`, `load game menu activated`, `load game menu delete finish`, `load game menu delete request`, `load game menu dispose`, `load game menu init`, `main menu quit game`, `mouse emit accept event`, `mouse emit back event`, `mouse emit x event`, `mp type set mode`, `profile manager select`, `profile set edit begin`, `single prev cl item activated`, `solo map list update`, `ss edit server password`. diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.filters.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.filters.xml index 004e9ab2..7f78b877 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.filters.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.filters.xml @@ -3,7 +3,7 @@ - + @@ -22,10 +22,8 @@ - - - - + + @@ -34,24 +32,19 @@ - - - - - - + - + - + @@ -62,10 +55,6 @@ - - - - @@ -74,13 +63,4 @@ - - - - - - - - - diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml index bb2355fc..59e2b4cc 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml @@ -52,6 +52,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.xml index 4b3159f1..d81588f3 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.xml @@ -1,7 +1,7 @@ - + @@ -9,6 +9,7 @@ + @@ -110,8 +111,8 @@ - - + + @@ -166,12 +167,12 @@ - + - + @@ -191,7 +192,7 @@ - + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml index 2d5c5865..d9ac3884 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml @@ -105,6 +105,9 @@ + + + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index 1f100d75..edddb18f 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -216,12 +216,13 @@ - + + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.server_browser.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.server_browser.xml new file mode 100644 index 00000000..ff2b653f --- /dev/null +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.server_browser.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml index 81315b5c..d5aefbbc 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml @@ -22,6 +22,8 @@ + + @@ -89,6 +91,14 @@ + + + + + + + + @@ -107,6 +117,7 @@ + @@ -117,5 +128,6 @@ + diff --git a/port/assets/menus/ce/strings.xml b/port/assets/menus/ce/strings.xml index ad68aa60..50c69f35 100644 --- a/port/assets/menus/ce/strings.xml +++ b/port/assets/menus/ce/strings.xml @@ -287,6 +287,7 @@ + @@ -296,11 +297,6 @@ - - - - - @@ -309,14 +305,6 @@ - - - - - - - - diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json index e635176a..7abd2ec3 100644 --- a/port/assets/menus/menus.json +++ b/port/assets/menus/menus.json @@ -52,6 +52,7 @@ "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.in_game.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.multiplayer_hud.xml", + "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.server_browser.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.streamer_mode.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.extra_effects.xml", diff --git a/port/assets/network/masters.txt b/port/assets/network/masters.txt new file mode 100644 index 00000000..b428cf6d --- /dev/null +++ b/port/assets/network/masters.txt @@ -0,0 +1,12 @@ +# The master servers that list dedicated servers (docs/master_protocol.md): +# the server browser asks each for its signed list, and a dedicated server +# publishes its game to each. One master on each line: its https:// URL and +# its list-signing key, "ed25519:" and 64 hex digits; up to 4; "#" starts a +# comment. A list whose signature does not check with the key is refused. +# (The game reads this file beside config.toml: network.masters_file, and a +# dedicated server's server.masters_file. An update replaces it; keep a list +# of your own under another name.) +# +# A file that names no master, as this one, leaves the game the official +# master, built in: https://master.opencommunityedition.com. A file that +# names one or more is asked instead of it. diff --git a/port/assets/server_ui/app.css b/port/assets/server_ui/app.css new file mode 100644 index 00000000..1d1210cc --- /dev/null +++ b/port/assets/server_ui/app.css @@ -0,0 +1,261 @@ +/* The admin page (index.html), in the look of the web site's launcher + (port/web/site/style.css): navy panels between a bright rule and a blue + edge, blue labels, the system's fonts. Tables become cards on a phone. */ + +:root { + color-scheme: dark; --ground: #02060c; --head: linear-gradient(180deg, #0b3466 0%, #072852 45%, #041a35 100%); + --stage: linear-gradient(180deg, #072852 0px, #062247 320px, #031122 960px, #02060c 1500px); + --panel: rgba(0, 10, 24, 0.55); --navy: #002244; --navy-deep: #000e23; --row: rgba(0, 17, 34, 0.6); + --edge: #2299ff; --edge-bright: #36afff; --edge-dim: #1d4f86; --label: #2896ff; --text: #ffffff; --soft: #c9daf0; + --glow: rgba(54, 175, 255, 0.45); --bad: #ff9b42; --bad-text: #ffd2a8; --bad-ground: #2a1404; --red: #ff6b5e; + --blue: #4aa8ff; --log: #00070f; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; --ground: #eef3f9; --head: linear-gradient(180deg, #0b3466 0%, #0a3a73 100%); + --stage: linear-gradient(180deg, #dce8f6 0px, #eef3f9 400px); --panel: #ffffff; --navy: #e4eefa; + --navy-deep: #f4f8fd; --row: #f1f5fb; --edge: #1673d1; --edge-bright: #1f86e8; --edge-dim: #a9c4e4; + --label: #0d5fb3; --text: #0b1726; --soft: #43577a; --glow: rgba(31, 134, 232, 0.25); --bad: #c25a00; + --bad-text: #8a3d00; --bad-ground: #fff1e4; --red: #c8322a; --blue: #1663c7; --log: #f7f9fc; + } +} + +* { box-sizing: border-box; } + +html, body { margin: 0; background: var(--ground); color: var(--text); font: 500 15px/1.5 var(--font); } +body { min-height: 100vh; background: var(--stage); background-repeat: no-repeat; } + +h1, h2, h3 { margin: 0; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--label); } +h2 { font-size: 17px; margin: 18px 0 10px; } +h2:first-child { margin-top: 0; } +h3 { font-size: 13px; margin: 14px 0 4px; } +.note { margin: 6px 0 0; color: var(--soft); font-size: 13px; } +.note:empty, .error:empty { display: none; } +.error { margin: 8px 0 0; color: var(--bad-text); font-size: 14px; } +.mono, code { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; } + +/* ---------- buttons and fields: the site's */ + +button { + display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 18px; + border: 2px solid var(--edge); border-radius: 10px; background: var(--navy); color: var(--text); + font: 700 13px/1 var(--font); letter-spacing: 0.06em; text-transform: uppercase; cursor: pointer; +} +button:hover:not(:disabled) { border-color: var(--edge-bright); box-shadow: 0 0 12px var(--glow); } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, .log:focus-visible { + outline: 2px solid var(--edge-bright); outline-offset: 2px; +} +button:disabled { opacity: 0.4; cursor: not-allowed; } +button.primary { border-color: var(--edge-bright); box-shadow: 0 0 14px var(--glow); } +button.danger { border-color: var(--bad); background: var(--bad-ground); color: var(--bad-text); } +button.small { min-height: 32px; padding: 0 10px; font-size: 12px; border-radius: 8px; } + +input, select, textarea { + min-height: 40px; padding: 6px 10px; border: 2px solid var(--edge-dim); border-radius: 10px; + background: var(--navy-deep); color: var(--text); font: 16px var(--font); max-width: 100%; +} +input[type="checkbox"] { min-height: 0; width: 18px; height: 18px; accent-color: var(--edge); } +input:disabled, select:disabled, textarea:read-only { opacity: 0.6; } +textarea { font: 13px/1.5 var(--mono); width: 100%; resize: vertical; } +label { color: var(--soft); font-size: 13px; } +.check { display: inline-flex; align-items: center; gap: 6px; } + +.badge { + display: inline-block; padding: 2px 10px; border: 1px solid var(--edge-dim); border-radius: 999px; + color: var(--soft); font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; +} +.badge:empty { display: none; } +.state-playing, .link-live { border-color: var(--edge-bright); color: var(--text); } +.state-loading, .state-stopping, .link-polling { border-color: var(--bad); color: var(--bad-text); } + +/* ---------- the sign-in */ + +.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; } +.login-panel { width: min(420px, 100%); display: flex; flex-direction: column; gap: 10px; } +.login-head { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; } +.login-head h1 { font-size: 24px; } + +/* ---------- the header and the tabs */ + +.top { + display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; + padding: 12px max(16px, env(safe-area-inset-left)); background: var(--head); + border-bottom: 1px solid var(--edge-bright); color: #ffffff; +} +.top h1 { font-size: 20px; color: #4fb4ff; overflow-wrap: anywhere; } +.top .badge { color: #c9daf0; } +.top-end { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.tabs { + display: flex; gap: 4px; overflow-x: auto; padding: 10px 16px 0; border-bottom: 1px solid var(--edge-dim); + scrollbar-width: thin; +} +.tabs button { + flex: none; border-width: 2px 2px 0; border-color: transparent; border-radius: 12px 12px 0 0; + background: transparent; color: var(--label); font-size: 14px; +} +.tabs button.current { border-color: var(--edge); background: var(--navy-deep); color: var(--text); } + +main { max-width: 1280px; margin: 0 auto; padding: 18px 16px 48px; } + +/* ---------- panels */ + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr)); gap: 18px; } +.grid .wide { grid-column: 1 / -1; } +.panel { + min-width: 0; padding: 18px 20px; background: var(--panel); border-top: 1px solid var(--edge-bright); + border-bottom: 1px solid var(--label); +} +.bar, .inline, .fields { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; } +.inline input { flex: 1 1 200px; min-width: 0; } +.fields label { display: flex; flex-direction: column; gap: 4px; flex: 1 1 160px; } +.fields button { align-self: flex-end; } + +.facts { margin: 0; display: grid; gap: 4px; } +.facts > div { + display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 16px; padding: 6px 12px; + border-radius: 10px; background: var(--row); +} +.facts dt { color: var(--label); text-transform: uppercase; letter-spacing: 0.03em; font-size: 13px; } +.facts dd { margin: 0; text-align: right; overflow-wrap: anywhere; } +.facts .notice { border: 1px solid var(--bad); } + +.count { margin: 0; font-size: 40px; font-weight: 800; line-height: 1.1; } +.count .of { font-size: 18px; color: var(--soft); font-weight: 600; } +.spark { display: block; width: 100%; height: 64px; margin-top: 8px; border-radius: 8px; background: var(--row); } +.spark-line { fill: none; stroke: var(--edge-bright); stroke-width: 2; vector-effect: non-scaling-stroke; } +.spark-fill { fill: var(--glow); stroke: none; opacity: 0.5; } + +.feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 420px; + overflow-y: auto; font-size: 14px; } +.feed li { padding: 3px 8px; border-left: 3px solid var(--edge-dim); } +.feed time { color: var(--soft); font: 12px var(--mono); } +.feed .feed-bad { border-left-color: var(--bad); color: var(--bad-text); } +.feed .feed-moderation { border-left-color: var(--label); } +.feed .feed-chat { border-left-color: transparent; color: var(--soft); } + +/* ---------- tables, cards on a phone */ + +table { width: 100%; border-collapse: separate; border-spacing: 0 4px; } +th { padding: 4px 10px; color: var(--label); text-align: left; font-size: 12px; letter-spacing: 0.05em; + text-transform: uppercase; } +td { padding: 6px 10px; background: var(--row); vertical-align: middle; overflow-wrap: anywhere; } +td:first-child { border-radius: 10px 0 0 10px; } +td:last-child { border-radius: 0 10px 10px 0; } +tr.bad td, tr.muted td { color: var(--bad-text); } +.actions { display: inline-flex; flex-wrap: wrap; gap: 6px; } +.row-actions { text-align: right; } +.dossier td { background: var(--navy-deep); border: 1px solid var(--edge-dim); } +.team { display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; background: var(--soft); } +.team-0 { background: var(--red); } +.team-1 { background: var(--blue); } +td .badge { margin-left: 8px; } + +@media (max-width: 700px) { + table.cards thead { display: none; } + table.cards, table.cards tbody, table.cards tr, table.cards td { display: block; width: 100%; } + table.cards tr { margin-bottom: 10px; padding: 8px 12px; border: 2px solid var(--edge-dim); border-radius: 12px; + background: var(--row); } + table.cards td { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; background: none; + border-radius: 0; text-align: right; overflow-wrap: anywhere; } + table.cards td::before { content: attr(data-label); color: var(--label); font-size: 12px; text-transform: uppercase; + letter-spacing: 0.04em; text-align: left; } + table.cards td.row-actions { justify-content: flex-end; padding-top: 8px; } + table.cards td.row-actions::before { content: none; } + .dossier td { border: none; } + .top h1 { font-size: 17px; } + .top-end { margin-left: 0; } + .panel { padding: 14px; } + main { padding: 12px 10px 40px; } +} + +/* ---------- the rotation */ + +.rotation { margin: 0; padding: 0 0 0 28px; display: flex; flex-direction: column; gap: 6px; } +.rotation li { padding: 6px 10px; border: 2px solid transparent; border-radius: 10px; background: var(--row); } +.rotation li::marker { color: var(--label); font-weight: 700; } +.rotation li { display: list-item; } +.rotation li > span { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } +.rotation li .actions { margin-top: 6px; } +.rotation .current { border-color: var(--edge); background: var(--navy); } +.entry-text { font-family: var(--mono); overflow-wrap: anywhere; } +.rejected .entry-text { text-decoration: line-through; color: var(--soft); } +.reason { color: var(--bad-text); font-size: 13px; } + +/* ---------- gametypes */ + +.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.list li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 10px; + border-radius: 10px; background: var(--row); } +.editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } +.schema-form fieldset, #settings-fields fieldset { margin: 0 0 12px; padding: 8px 12px 12px; border: 1px solid var(--edge-dim); + border-radius: 12px; } +legend { padding: 0 6px; color: var(--label); font: 700 13px var(--mono); } +.schema-form label { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 0; } +.schema-form input, .schema-form select { width: 12em; } +.raw { display: flex; flex-direction: column; gap: 4px; font-family: var(--mono); } +@media (max-width: 900px) { .editor { grid-template-columns: 1fr; } } + +/* ---------- the console */ + +.console { display: flex; flex-direction: column; gap: 10px; } +.console-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } +.chips { display: flex; flex-wrap: wrap; gap: 4px 6px; } +.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 4px; border: 1px solid var(--edge-dim); + border-radius: 999px; font-size: 12px; } +.chip input { width: 14px; height: 14px; } +.log { + height: min(62vh, 640px); overflow-y: auto; padding: 8px 10px; border: 2px solid var(--edge-dim); + border-radius: 10px; background: var(--log); color: var(--soft); font: 12.5px/1.5 var(--mono); + overflow-wrap: anywhere; +} +.line { white-space: pre-wrap; } +.line time { color: var(--edge-dim); } +.line .cat { color: var(--label); } +.level-warn { color: var(--bad-text); } +.level-error { color: var(--bad); font-weight: 700; } +.mine { color: var(--text); } +.command-line { color: var(--edge-bright); margin-top: 4px; } +.line.bad { color: var(--bad-text); } +.line.note { color: var(--edge-dim); font-style: italic; } +.hide-server .cat-server, .hide-lobby .cat-lobby, .hide-game .cat-game, .hide-player .cat-player, +.hide-chat .cat-chat, .hide-moderation .cat-moderation, .hide-net .cat-net, .hide-master .cat-master, +.hide-console .cat-console, .hide-health .cat-health { display: none; } +.command { display: flex; gap: 8px; } +.command input { flex: 1; min-width: 0; font-family: var(--mono); } +@media (max-width: 700px) { + .log { height: 55vh; font-size: 12px; } + .command { position: sticky; bottom: 0; padding: 8px 0 env(safe-area-inset-bottom); background: var(--ground); } +} + +/* ---------- settings */ + +.setting { display: grid; grid-template-columns: minmax(0, 16em) minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; + padding: 8px 0; border-bottom: 1px solid var(--row); } +.setting .note, .setting .error { grid-column: 1 / -1; margin: 0; } +.setting-name { font: 13px var(--mono); color: var(--text); overflow-wrap: anywhere; } +.setting input[type="text"], .setting input[type="number"], .setting select, .secret input[type="password"] { width: 100%; } +.secret { display: flex; gap: 10px; align-items: center; } +.pinned .setting-name { color: var(--soft); } +@media (max-width: 700px) { .setting { grid-template-columns: 1fr; } } + +/* ---------- the dialog and the notice */ + +.sheet { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; + background: rgba(0, 4, 10, 0.78); z-index: 10; } +.sheet-body { width: min(560px, 100%); max-height: 100%; overflow: auto; border: 2px solid var(--edge); border-radius: 16px; + background: var(--navy-deep); box-shadow: 0 0 32px var(--glow); } +.sheet h2 { margin: 0; padding: 16px 20px; border-bottom: 1px solid var(--edge-bright); background: var(--head); color: #4fb4ff; } +#dialog-body { padding: 14px 20px 0; display: flex; flex-direction: column; gap: 10px; } +#dialog-body p { margin: 0; } +#dialog-body label { display: flex; flex-direction: column; gap: 4px; } +.sheet .error { padding: 0 20px; } +.sheet .actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 12px 20px; + border-top: 1px solid var(--label); } +.toast { position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%); + max-width: calc(100% - 32px); padding: 10px 18px; border: 2px solid var(--edge); border-radius: 12px; + background: var(--navy-deep); box-shadow: 0 0 24px var(--glow); z-index: 20; overflow-wrap: anywhere; } +.toast.bad { border-color: var(--bad); color: var(--bad-text); } + +[hidden] { display: none !important; } diff --git a/port/assets/server_ui/app.js b/port/assets/server_ui/app.js new file mode 100644 index 00000000..5949e9bf --- /dev/null +++ b/port/assets/server_ui/app.js @@ -0,0 +1,1526 @@ +/* +APP.JS + +The dedicated server's admin page (docs/dedicated_server.md: the HTTP API +and the RCON). It is served with Content-Security-Policy default-src 'self', +so it makes every element with createElement and sets text with textContent: +no HTML from strings, no inline styles or handlers. + +- The session is the HttpOnly cookie POST /api/login sets; the page keeps + only its CSRF token, sent on every change (X-CSRF-Token). It ends after + 30 minutes without an HTTP request (the WebSocket's do not count): the + page asks GET /api/session every 5 minutes while it is open. +- What the server sends may be older or newer than the page: settings' + values typed or as text, maps with or without a title, gametypes with or + without their engine. The page takes either. +- Events and the status come over the RCON's WebSocket (/ws, signed in by + the cookie); while it is down the page polls /api/status, /api/players, + /api/log and /api/events every 3 seconds. +- Everything but startPage uses no document: Node tests it + (tools/server_ui_tests). +*/ + +'use strict'; + +const ROLES = ['viewer', 'moderator', 'admin', 'owner']; +const CATEGORIES = ['server', 'lobby', 'game', 'player', 'chat', 'moderation', 'net', 'master', 'console', 'health']; +const GROUPS = ['player', 'chat', 'game', 'moderation', 'status', 'log', 'master']; +const BAN_PRESETS = [['1h', '1 hour'], ['1d', '1 day'], ['7d', '7 days'], ['30d', '30 days'], ['forever', 'For ever']]; +// each gametype's game engine, when /api/gametypes's builtin[] names them +// alone: the builders' of source/game/game_engine.c for the built-ins +// (port/linux/game/server_gametypes.c), with their aliases, and game_modes.c's +// for OpenCE's +const ENGINES = { + slayer: 'slayer', team_slayer: 'slayer', slayer_pro: 'slayer', elimination: 'slayer', phantoms: 'slayer', + endurance: 'slayer', rockets: 'slayer', snipers: 'slayer', + ctf: 'ctf', ironctf: 'ctf', ctf_pro: 'ctf', invasion: 'ctf', + oddball: 'oddball', team_oddball: 'oddball', reverse_tag: 'oddball', accumulation: 'oddball', juggernaut: 'oddball', + stalker: 'oddball', + king: 'king', team_king: 'king', king_pro: 'king', crazy_king: 'king', + race: 'race', team_race: 'race', rally: 'race', team_rally: 'race', + ffa: 'slayer', ffa_slayer: 'slayer', teamslayer: 'slayer', tslayer: 'slayer', koth: 'king', team_koth: 'king', + iron_ctf: 'ctf', teamrace: 'race', teamoddball: 'oddball', + swat: 'slayer', infection: 'slayer', gun_game: 'slayer', hardcore: 'slayer', hardcore_ctf: 'ctf', + hardcore_oddball: 'oddball', hardcore_king: 'king', +}; +// the settings the server's own console alone changes (server_commands.c's +// owner_settings): the page's admin is refused them +const OWNER_SETTINGS = ['server.status_file', 'server.auto_update', 'server.log_addresses']; +const SESSION_REFRESH = 5 * 60 * 1000; +const PLAYER_COMMANDS = ['tell', 'kick', 'ban', 'mute', 'unmute']; +const LOG_LINES = 2048; +const SPARK_WINDOW = 600000; + +// ---------- roles + +function roleAtLeast(role, needed) { + return ROLES.includes(role) && ROLES.indexOf(role) >= ROLES.indexOf(needed); +} + +// a moderator bans for a day at most (the server checks it too) +function banPresets(role) { + return BAN_PRESETS.map(([value, label]) => ({ value, label, allowed: roleAtLeast(role, 'admin') || /^1[hd]$/.test(value) })); +} + +// ---------- formatting + +const pad = (number) => String(number).padStart(2, '0'); + +function formatDuration(seconds) { + seconds = Math.max(0, Math.floor(Number(seconds) || 0)); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + if (seconds < 60) return `${seconds} s`; + if (minutes < 60) return `${minutes} min`; + if (hours < 48) return `${hours} h ${pad(minutes % 60)} min`; + return `${Math.floor(hours / 24)} d ${hours % 24} h`; +} + +function formatClock(seconds) { + seconds = Math.max(0, Math.floor(Number(seconds) || 0)); + const hours = Math.floor(seconds / 3600); + const rest = `${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`; + return hours ? `${hours}:${rest}` : rest.replace(/^0/, ''); +} + +function formatTime(ms) { + const date = new Date(Number(ms) || 0); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +function formatAgo(unix, nowMs = Date.now()) { + return unix ? formatDuration(nowMs / 1000 - unix) + ' ago' : '–'; +} + +function formatUntil(until, nowMs = Date.now()) { + if (until === null || until === undefined) return 'For ever'; + const left = until - nowMs / 1000; + return left <= 0 ? 'Ended' : 'For ' + formatDuration(left); +} + +const teamName = (team) => (team === 0 ? 'Red' : team === 1 ? 'Blue' : '–'); + +const STATES = { waiting: 'Starting', starting: 'Starting', hosting: 'Making the game', lobby: 'Lobby', + loading: 'Loading', playing: 'Playing', postgame: 'Postgame', stopping: 'Stopping' }; +const stateLabel = (state) => STATES[state] || String(state || '–'); + +// a refusal as a sentence (the error codes of docs/dedicated_server.md) +function errorText(result) { + const error = (result && result.error) || {}; + const message = error.message || ''; + const rejected = error.rejected || (result && result.data && result.data.rejected) || []; + switch (error.code) { + case 'network': return 'The server cannot be reached.'; + case 'denied': return message === 'wrong password' ? 'Wrong password.' : 'Signed out: sign in again.'; + case 'locked': return `Too many wrong passwords: try again in ${formatDuration(error.retry_after || 600)}.`; + case 'rate_limited': return `Too many at once: try again in ${formatDuration(error.retry_after || 60)}.`; + case 'csrf': return 'The page is out of date: reload it.'; + case 'origin': return 'Refused: the request did not come from this page.'; + case 'loading': return 'A map is loading: try again in a few seconds.'; + case 'timeout': return 'No answer in time (it may still have run).'; + case 'invalid': return error.line ? `Line ${error.line}: ${message}` : message; + case 'schema': return error.field ? `${error.field}: ${message}` : message; + case 'failed': return message || 'The command failed.'; + case 'rejected': return [message, ...rejected.map((item) => `${item.entry}: ${item.reason}`)].filter(Boolean).join('; '); + default: return message || `Refused (${error.code || (result && result.status) || 'unknown'}).`; + } +} + +// ---------- the API: fetch, with the CSRF token on changes + +function createApi(fetchFunction, base = '') { + let csrf = ''; + return { + setCsrf(token) { + csrf = token || ''; + }, + // {ok, status, data (the answer's JSON), error (a refusal's)} + async call(method, path, body) { + const headers = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (method !== 'GET') headers['X-CSRF-Token'] = csrf; + let response; + let data = null; + try { + response = await fetchFunction(base + path, { method, headers, credentials: 'same-origin', cache: 'no-store', + body: body === undefined ? undefined : JSON.stringify(body) }); + } catch { + return { ok: false, status: 0, data: {}, error: { code: 'network', message: 'the server cannot be reached' } }; + } + try { + data = await response.json(); + } catch { + // (not JSON: a proxy's page) + } + if (!data || typeof data !== 'object') data = {}; + const result = { ok: response.ok && data.ok !== false, status: response.status, data }; + if (!result.ok) { + result.error = data.error && typeof data.error === 'object' ? data.error : + { code: response.status === 401 ? 'denied' : 'unknown', message: `HTTP ${response.status}` }; + const retry = Number(response.headers && response.headers.get('Retry-After')); + if (retry && !result.error.retry_after) result.error.retry_after = retry; + } + return result; + }, + }; +} + +// ---------- the log: a cursor, and lines kept in order + +// an answer of /api/log?after= (the cursor: the highest n read): +// the lines not seen before, the next cursor (the server's next: the last +// line it read, those it kept from this role, chat's, counted too), and +// whether the numbering started again (the server restarted: it answers a +// cursor past its latest from its first line) +function takeLog(state, answer) { + const lines = (answer && Array.isArray(answer.lines) ? answer.lines : []).filter((line) => line && Number.isInteger(line.n)); + const cursor = state.cursor || 0; + const last = lines.length ? lines[lines.length - 1].n : null; + const given = answer && Number.isInteger(answer.next) ? answer.next : null; + const reset = given !== null ? given < cursor : last !== null && last < cursor; + const fresh = reset ? lines : lines.filter((line) => line.n > cursor); + const read = given !== null ? given : last !== null ? last : cursor; + const next = reset ? read : Math.max(cursor, read); + return { cursor: next, lines: fresh, reset, missed: Number(answer && answer.missed) || 0 }; +} + +// lines into a list in the order of their n, those it has left out, the +// oldest dropped past max; appended: all the new ones went at the end +function mergeLog(list, incoming, max = LOG_LINES) { + const have = new Set(list.map((line) => line.n)); + const fresh = incoming.filter((line) => !have.has(line.n)); + const tail = list.length ? list[list.length - 1].n : -Infinity; + const appended = fresh.every((line, index) => line.n > (index ? fresh[index - 1].n : tail)); + let lines = list.concat(fresh); + if (!appended) lines.sort((a, b) => a.n - b.n); + const dropped = Math.max(0, lines.length - max); + if (dropped) lines = lines.slice(dropped); + return { lines, fresh, appended, dropped }; +} + +// ---------- the player-count sparkline + +function pushSample(history, time, value, windowMs = SPARK_WINDOW) { + history.push({ time, value: Number(value) || 0 }); + while (history.length > 1 && history[1].time <= time - windowMs) history.shift(); + return history; +} + +// the samples as an SVG polyline's points (steps), the right edge now +function sparkPoints(history, now, width, height, max, windowMs = SPARK_WINDOW) { + const top = Math.max(1, max || 0, ...history.map((item) => item.value)); + const x = (time) => Math.max(0, Math.min(width, width - ((now - time) / windowMs) * width)); + const y = (value) => height - 2 - (value / top) * (height - 4); + const points = []; + history.forEach((item, index) => { + if (index) points.push([x(item.time), y(history[index - 1].value)]); + points.push([x(item.time), y(item.value)]); + }); + if (history.length) points.push([width, y(history[history.length - 1].value)]); + return points.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(' '); +} + +// ---------- events, in words: {kind, text}; quiet ones stay out of the feed + +function describeEvent(event, players) { + const name = (slot) => ((players || []).find((item) => item.slot === slot) || { name: slot < 0 ? 'the world' : `slot ${slot}` }).name; + const by = (event.by ? ` by ${event.by}` : '') + (event.reason ? `: ${event.reason}` : ''); + const gametype = event.variant || event.gametype; + const say = { + player_joined: ['player', `${event.name} joined (slot ${event.slot})`], + player_left: ['player', `${event.name} ${{ kicked: 'was kicked', banned: 'was banned' }[event.reason] || + `left (${event.reason || 'left'})`}`], + chat: ['chat', `${event.name}: ${event.text}`], + lobby_opened: ['game', `Lobby: ${gametype} on ${event.map}${event.teams ? ', teams' : ''}`], + game_started: ['game', `Game started: ${gametype} on ${event.map}${event.teams ? ', teams' : ''}`], + game_ended: ['game', `Game ended (${event.reason}) after ${formatClock(event.duration)}`], + postgame: ['game', `Postgame: ${event.seconds} s`], + kick: ['moderation', `${event.name} kicked${by}`], + ban: ['moderation', `${event.name} banned${by}`], + unban: ['moderation', `${event.name} unbanned${by}`], + mute: ['moderation', `${event.name} muted${by}`], + unmute: ['moderation', `${event.name} unmuted${by}`], + speed_hack: ['bad', `Speed hack: machine ${event.machine} (${[].concat(event.names || []).join(', ')}) at ${event.rate}×`], + master: [event.listed ? 'master' : 'bad', `${event.url}: ${event.probe}${event.message ? ', ' + event.message : ''}`], + warning: ['bad', event.text], + // (progress: in percent, 0 to 100) + map_share: ['net', `Machine ${event.machine} gets ${event.map}: ${Math.round(Number(event.progress) || 0)}%`], + server_started: ['server', `Server started: ${event.name} (${event.build})`], + reloaded: ['server', 'Settings, rotation and gametypes read again'], + shutdown: ['bad', `Stopping: ${event.reason}`], + kill: ['kill', `${name(event.killer)} killed ${name(event.victim)}`, true], + }[event.event]; + return say ? { kind: say[0], text: say[1], quiet: !!say[2] } : null; +} + +// ---------- the rotation's entries: map[:gametype[+rule=value…]] + +function parseEntryText(text) { + const match = /^([^:+\s]+)(?::([^+\s]+)((?:\+[^+\s]+)*))?$/.exec(String(text).trim()); + if (!match) return null; + const rules = {}; + for (const rule of (match[3] || '').split('+').filter(Boolean)) { + const [key, value = ''] = rule.split('='); + rules[key] = /^-?\d+$/.test(value) ? Number(value) : value === 'true' ? true : value === 'false' ? false : value; + } + return { map: match[1], gametype: match[2] || '', rules }; +} + +// "score=50 time=15" or "+score=50+time=15": an entry's rules +function rulesText(text) { + const rules = String(text || '').split(/[\s+,]+/).filter(Boolean); + const bad = rules.find((rule) => !/^[a-z_]+=[^+\s=,]+$/.test(rule)); + if (bad) throw new Error(`${bad} is not a rule (key=value)`); + return rules.map((rule) => '+' + rule).join(''); +} + +// a map of /api/maps as the rotation names it +function mapToken(map) { + if (!map) return ''; + return map.kind === 'custom' && !/^custom_maps\\/.test(map.name) ? 'custom_maps\\' + map.name : map.name; +} + +// a map of /api/maps as people know it: its title, else its name (a +// server that sends no title) +const titleOf = (map) => String((map && (map.title || map.name)) || ''); + +// POST /api/rotation/play's body for an entry: map and gametype, or the +// entry's whole text when it has rules (sv_map takes a rotation entry, +// map:gametype+rule=value, as its map) +function playBody(entry) { + const text = String(entry.text || '').replace(/\s+/g, ''); + if (entry.gametype && (text.includes('+') || Object.keys(entry.rules || {}).length)) return { map: text }; + return entry.gametype ? { map: entry.map, gametype: entry.gametype } : { map: entry.map }; +} + +function makeEntry(map, gametype, rules) { + const text = map + (gametype ? ':' + gametype + rulesText(rules) : ''); + const parsed = parseEntryText(text) || { map, rules: {} }; + return { text, map: parsed.map, gametype: gametype || '', rules: parsed.rules, rejected: null }; +} + +function rotationMove(entries, index, delta) { + const moved = entries.slice(); + const target = index + delta; + if (index < 0 || index >= entries.length || target < 0 || target >= entries.length) return moved; + moved.splice(target, 0, moved.splice(index, 1)[0]); + return moved; +} + +const rotationRemove = (entries, index) => entries.filter((_, at) => at !== index); + +function rotationInsert(entries, entry, at = entries.length) { + const result = entries.slice(); + result.splice(Math.max(0, Math.min(at, result.length)), 0, entry); + return result; +} + +// PUT /api/rotation's rejected[] on the entries it names (by index) +function markRejected(entries, rejected) { + const reasons = new Map((rejected || []).map((item) => [item.index, item.reason])); + return entries.map((entry, index) => ({ ...entry, rejected: reasons.has(index) ? reasons.get(index) : null })); +} + +// ---------- settings: a form from the server's table + +// a setting's value of /api/settings as its type: a JSON boolean or number, +// or its text ("true", "16"), as servers have sent either; a value that is +// not of its type stays as it came +function settingValue(type, value) { + if (type === 'boolean') { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + const text = String(value ?? '').trim().toLowerCase(); + if (/^(true|yes|on|1)$/.test(text)) return true; + if (/^(false|no|off|0|)$/.test(text)) return false; + return value; + } + if (type === 'integer' || type === 'real') { + if (typeof value === 'number') return value; + const text = String(value ?? '').trim(); + return text !== '' && Number.isFinite(Number(text)) ? Number(text) : value; + } + return value === null || value === undefined ? '' : String(value); +} + +// a limit (min, max) as a number, or null +const settingLimit = (value) => (value === null || value === undefined || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)); + +// the role that may change a setting: the server's own console (owner) for +// those of OWNER_SETTINGS or that the server marks so, else an admin +function settingRole(setting) { + if (ROLES.includes(setting.role)) return setting.role; + return setting.owner || OWNER_SETTINGS.includes(setting.name) ? 'owner' : 'admin'; +} + +// a setting of /api/settings with its value, default and limits typed +function normalizeSetting(setting) { + return { ...setting, value: setting.secret ? '' : settingValue(setting.type, setting.value), + default: setting.secret ? '' : settingValue(setting.type, setting.default), + min: settingLimit(setting.min), max: settingLimit(setting.max), + choices: Array.isArray(setting.choices) && setting.choices.length ? setting.choices.map(String) : null }; +} + +// whether the role may send a setting (the server refuses the rest) +const settingEditable = (setting, role) => !setting.pinned && roleAtLeast(role, settingRole(setting)); + +// a setting of /api/settings as a control: kind checkbox, number, select, +// text or secret (written only), disabled with its reason +function settingControl(raw, role) { + const setting = normalizeSetting(raw); + const choices = setting.choices; + const number = setting.type === 'integer' || setting.type === 'real'; + const control = { + name: setting.name, comment: setting.comment || '', env: setting.env || '', min: setting.min, max: setting.max, + choices, value: setting.value, disabled: false, reason: '', step: number ? (setting.type === 'integer' ? '1' : 'any') : null, + kind: setting.secret ? 'secret' : choices ? 'select' : setting.type === 'boolean' ? 'checkbox' : number ? 'number' : 'text', + }; + if (setting.secret) { + control.value = ''; + control.placeholder = setting.set ? 'Set: type to change it' : 'Not set'; + } + if (control.kind === 'select' && !choices.includes(String(control.value))) control.choices = [String(control.value), ...choices]; + const needed = settingRole(setting); + if (setting.pinned) [control.disabled, control.reason] = [true, `Set by ${setting.env}`]; + else if (needed === 'owner' && !roleAtLeast(role, 'owner')) [control.disabled, control.reason] = [true, "Only the server's console changes it"]; + else if (!roleAtLeast(role, needed)) [control.disabled, control.reason] = [true, `Needs the ${needed} role`]; + const fallback = setting.default; + control.defaultText = setting.secret ? '' : setting.type === 'boolean' ? (fallback === true ? 'on' : fallback === false ? 'off' : String(fallback)) : + fallback === '' || fallback === null || fallback === undefined ? '(empty)' : String(fallback); + return control; +} + +// the form's values ({name: a checkbox's boolean, an input's text, a +// secret's {text, clear}}) as PUT /api/settings's body: what changed, typed, +// and only what the role may change (role: the page's; none, an admin's) +function settingsChanges(settings, values, role = 'admin') { + const body = {}; + const errors = []; + for (const given of settings) { + const setting = normalizeSetting(given); + if (!(setting.name in values) || !settingEditable(setting, role)) continue; + const raw = values[setting.name]; + if (setting.secret) { + if (raw && (raw.clear || raw.text)) body[setting.name] = raw.clear ? '' : raw.text; + continue; + } + let value; + const fail = (message) => errors.push({ name: setting.name, message }); + if (setting.type === 'boolean') { + value = settingValue('boolean', raw); + if (typeof value !== 'boolean') { + fail('on or off'); + continue; + } + } else if (setting.type === 'integer' || setting.type === 'real') { + const text = String(raw ?? '').trim(); + if (!(setting.type === 'integer' ? /^-?\d+$/ : /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i).test(text)) { + fail(setting.type === 'integer' ? 'a whole number' : 'a number'); + continue; + } + value = Number(text); + if ((setting.min !== null && value < setting.min) || (setting.max !== null && value > setting.max)) { + fail(setting.min !== null && setting.max !== null ? `${setting.min} to ${setting.max}` : + setting.min !== null ? `${setting.min} or more` : `${setting.max} or less`); + continue; + } + } else { + value = String(raw ?? ''); + if (setting.choices && !setting.choices.includes(value) && value !== setting.value) { + fail('one of ' + setting.choices.join(', ')); + continue; + } + } + // (only what changed: the rest is not sent again) + if (value !== setting.value) body[setting.name] = value; + } + return { body, errors }; +} + +// ---------- gametype files: their TOML, and a form from the schema + +function stripComment(line) { + let quoted = false; + for (let index = 0; index < line.length; index++) { + if (line[index] === '"' && line[index - 1] !== '\\') quoted = !quoted; + else if (line[index] === '#' && !quoted) return line.slice(0, index); + } + return line; +} + +function tomlValue(text) { + if (text === 'true' || text === 'false') return text === 'true'; + if (/^[+-]?\d+$/.test(text)) return Number(text); + const string = /^"((?:[^"\\]|\\.)*)"$/.exec(text); + if (string) return string[1].replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' }[c] || c)); + if (/^\[[\d\s,+-]*\]$/.test(text)) return text.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean).map(Number); + return undefined; +} + +// the part of TOML gametype files use: {values: {table: {key: value}} ('' the +// top level), errors: [{line, message}]} +function parseToml(text) { + const values = { '': {} }; + const errors = []; + let table = ''; + String(text).split('\n').forEach((raw, index) => { + const line = stripComment(raw).trim(); + const fail = (message) => errors.push({ line: index + 1, message }); + const header = /^\[\s*([A-Za-z_]\w*)\s*\]$/.exec(line); + const pair = /^([A-Za-z_]\w*)\s*=\s*(.+)$/.exec(line); + if (!line) return; + if (header) { + table = header[1]; + if (values[table]) fail(`[${table}] twice`); + values[table] = values[table] || {}; + } else if (!pair) { + fail('not a key = value line'); + } else { + const value = tomlValue(pair[2].trim()); + if (value === undefined) fail(`${pair[2].trim().slice(0, 40)} is not a value`); + else if (pair[1] in values[table]) fail(`${pair[1]} twice`); + else values[table][pair[1]] = value; + } + }); + return { values, errors }; +} + +function formatTomlValue(value) { + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (Array.isArray(value)) return `[${value.join(', ')}]`; + return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'; +} + +// the text with key set in table (undefined: taken out), and the rest as it +// was: its comments, its order, its other keys +function setTomlValue(text, table, key, value) { + const lines = String(text).split('\n'); + let current = ''; + let start = table === '' ? 0 : -1; + let end = -1; + let found = -1; + lines.forEach((line, index) => { + const header = /^\s*\[\s*([A-Za-z_]\w*)\s*\]\s*(#.*)?$/.exec(line); + const pair = /^\s*([A-Za-z_]\w*)\s*=/.exec(line); + if (header) { + if (current === table && start >= 0 && end < 0) end = index; + current = header[1]; + if (current === table && start < 0) start = index + 1; + } else if (current === table && pair && pair[1] === key && found < 0) { + found = index; + } + }); + if (start >= 0 && end < 0) end = lines.length; + const line = value === undefined ? null : `${key} = ${formatTomlValue(value)}`; + if (found >= 0) { + const comment = lines[found].slice(stripComment(lines[found]).length); + if (line === null) lines.splice(found, 1); + else lines[found] = line + (comment ? ' ' + comment : ''); + } else if (line === null) { + return text; + } else if (start >= 0) { + let at = end; + while (at > start && lines[at - 1].trim() === '') at--; + lines.splice(at, 0, line); + } else { + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); + lines.push(...(lines.length ? [''] : []), `[${table}]`, line, ''); + } + return lines.join('\n'); +} + +// /api/gametypes's builtin[]: names, or {name, engine} where the server says +// each one's engine +const builtinNames = (builtin) => (Array.isArray(builtin) ? builtin : []) + .map((item) => (typeof item === 'string' ? item : item && item.name)).filter(Boolean); + +// a base gametype's engine: the server's word for it, else ENGINES's +function engineOf(base, builtin) { + const named = (Array.isArray(builtin) ? builtin : []).find((item) => item && typeof item === 'object' && item.name === base); + return (named && named.engine) || ENGINES[String(base || '').toLowerCase()] || null; +} + +// the schema's fields for a base gametype (every engine's and its own's); +// table: where a file keeps the key (the schema's, else universal or the +// engine's own) +function gametypeFields(schema, base, builtin) { + const engine = engineOf(base, builtin); + return (schema && Array.isArray(schema.fields) ? schema.fields : []) + .filter((field) => field.engine === 'all' || field.engine === engine) + .map((field) => ({ ...field, table: field.table || (field.engine === 'all' ? 'universal' : field.engine), + choices: Array.isArray(field.choices) ? field.choices : null })); +} + +// a control's text as a field's value (undefined: the base's) +function fieldValue(field, text) { + if (text === '' || text === undefined || text === null) return undefined; + if (field.type === 'boolean') return text === 'true'; + if (field.type === 'integer') return /^-?\d+$/.test(String(text).trim()) ? Number(text) : undefined; + return String(text); +} + +function newGametypeText(base, title) { + // (a file's top level takes base and name alone: server_toml.c) + return `base = ${formatTomlValue(base)}\nname = ${formatTomlValue(String(title || base).slice(0, 11))}\n`; +} + +// ---------- the console: completion and history + +// help's lines: a heading (ending in a colon), then each command indented by +// two spaces, its name, then its description (" sv_status ...") +function commandsFromHelp(lines) { + return [...new Set((lines || []).map(String).filter((line) => !/:\s*$/.test(line)) + .map((line) => (/^ {0,2}([a-z_][a-z0-9_]*)(?=\s|$)/.exec(line) || [])[1]).filter(Boolean))].sort(); +} + +function commonPrefix(words) { + let prefix = words[0] || ''; + for (const word of words) while (!word.startsWith(prefix)) prefix = prefix.slice(0, -1); + return prefix; +} + +// a Tab: the line made longer, and what it could become +function complete(line, commands, players) { + const words = String(line).split(' '); + let head = ''; + let options = []; + if (words.length === 1) { + options = commands.filter((name) => name.startsWith(words[0])); + } else if (words.length === 2 && PLAYER_COMMANDS.includes(words[0])) { + head = words[0] + ' '; + options = (players || []).map((player) => player.name).filter((name) => name.toLowerCase().startsWith(words[1].toLowerCase())) + .map((name) => (/\s/.test(name) ? `"${name}"` : name)); + } else { + return { line, options }; + } + if (options.length === 1) return { line: head + options[0] + ' ', options }; + return { line: options.length ? head + commonPrefix(options) : line, options }; +} + +function historyStep(history, index, direction) { + const next = Math.max(0, Math.min(history.length, index + direction)); + return { index: next, text: next >= history.length ? '' : history[next] }; +} + +// ---------- elements without HTML + +let doc = typeof document !== 'undefined' ? document : null; +const setDocument = (value) => { doc = value; }; +const PROPERTIES = new Set(['value', 'checked', 'disabled', 'type', 'title', 'hidden', 'id', 'name', 'placeholder', 'min', + 'max', 'step', 'readOnly', 'selected', 'htmlFor', 'maxLength', 'required', 'autocomplete']); +const FORBIDDEN = /^on|^(style|srcdoc)$|HTML$/i; + +// h('button', {class, text, data: {slot: 3}, attrs: {colspan: 2}, listen: {click}}, children): +// text is textContent, a string child a text node; a style or a handler +// attribute is refused +function h(tag, props, children) { + const node = doc.createElement(tag); + for (const [key, value] of Object.entries(props || {})) { + if (FORBIDDEN.test(key)) throw new Error(`h: ${key} is not allowed`); + if (value === undefined || value === null || (value === false && key !== 'checked')) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = String(value); + else if (key === 'listen') for (const [event, handler] of Object.entries(value)) node.addEventListener(event, handler); + else if (key === 'data' || key === 'attrs') { + for (const [name, item] of Object.entries(value)) { + if (FORBIDDEN.test(name)) throw new Error(`h: ${name} is not allowed`); + node.setAttribute((key === 'data' ? 'data-' : '') + name, String(item)); + } + } else if (PROPERTIES.has(key)) node[key] = value; + else throw new Error(`h: unknown property ${key}`); + } + for (const child of [].concat(children === undefined ? [] : children)) { + if (child !== null && child !== undefined && child !== false) { + node.appendChild(typeof child === 'object' ? child : doc.createTextNode(String(child))); + } + } + return node; +} + +const option = (value, text, selected, disabled) => h('option', { value, text, selected, disabled }); + +// ===================================================================== the page + +function startPage(root) { + const $ = (id) => doc.getElementById(id); + const api = createApi(root.fetch.bind(root)); + const page = { role: null, status: null, players: [], maps: [], gametypes: null, rotation: null, dirty: false, + settings: [], log: { cursor: 0, lines: [] }, seq: 0, history: [], historyIndex: 0, commands: [], samples: [], feed: [], + dossiers: new Set(), editor: null, timers: [] }; + const listen = (id, event, handler) => $(id).addEventListener(event, handler); + + function toast(text, bad) { + const box = $('toast'); + box.textContent = text; + box.className = 'toast' + (bad ? ' bad' : ''); + box.hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { box.hidden = true; }, bad ? 6000 : 3500); + } + + // a call; a refused session shows the sign-in again + async function call(method, path, body) { + const result = await api.call(method, path, body); + if (result.status === 401) signedOut('The session ended: sign in again.'); + return result; + } + + // what the role cannot do: disabled, and saying so + function gate() { + for (const node of doc.querySelectorAll('[data-needs]')) { + const needed = node.getAttribute('data-needs'); + node.disabled = !roleAtLeast(page.role, needed); + node.title = node.disabled ? `Needs the ${needed} role` : ''; + } + } + + const fact = (label, value, kind) => h('div', { class: kind }, [h('dt', { text: label }), h('dd', { text: value })]); + const cell = (label, content, kind) => h('td', { data: { label }, class: kind }, content); + const small = (text, enabled, action, kind, title) => h('button', { class: 'small' + (kind ? ' ' + kind : ''), text, title, + disabled: !enabled, listen: { click: action } }); + const para = (text) => h('p', { text }); + + // ---------- a question before an action, whose answer it shows + + function ask(title, body, okText, action, danger) { + $('dialog-title').textContent = title; + $('dialog-body').replaceChildren(...[].concat(body)); + $('dialog-error').textContent = ''; + $('dialog-ok').textContent = okText; + $('dialog-ok').className = danger ? 'danger' : 'primary'; + $('dialog').hidden = false; + ($('dialog-body').querySelector('input, select') || $('dialog-ok')).focus(); + ask.action = action; + } + + function closeDialog() { + $('dialog').hidden = true; + ask.action = null; + } + + listen('dialog-cancel', 'click', closeDialog); + listen('dialog', 'keydown', (event) => { if (event.key === 'Escape') closeDialog(); }); + listen('dialog-form', 'submit', async (event) => { + event.preventDefault(); + if (!ask.action) return; + $('dialog-ok').disabled = true; + const result = await ask.action(); + $('dialog-ok').disabled = false; + if (result && !result.ok) { + $('dialog-error').textContent = errorText(result); + return; + } + closeDialog(); + const lines = result && result.data && result.data.lines; + if (lines && lines.length) toast(lines.join(' · ').slice(0, 300)); + }); + + // ---------- the status + + function mapTitle(name) { + const map = page.maps.find((item) => item.name === name || mapToken(item) === name); + return map && titleOf(map) !== name ? `${titleOf(map)} (${name})` : name; + } + + function renderStatus() { + const s = page.status; + const listing = s.listing || {}; + const next = s.rotation && s.rotation.next; + const admin = roleAtLeast(page.role, 'admin'); + const masters = s.masters || []; + $('server-name').textContent = s.name; + doc.title = `${s.name}: OpenCE server`; + $('server-state').textContent = stateLabel(s.state); + $('server-state').className = 'badge state-' + s.state; + $('status-facts').replaceChildren(...[ + fact('Map', mapTitle(s.map)), + fact('Gametype', `${s.variant || s.gametype} (${s.gametype})${s.teams ? ', teams' : ''}`), + fact('State', stateLabel(s.state) + (s.countdown >= 0 ? `: starts in ${s.countdown} s` : '')), + /^(playing|postgame)$/.test(s.state) && fact('Game time', formatClock(s.game_seconds)), + s.teams && (s.team_scores || []).length === 2 && fact('Score', `Red ${s.team_scores[0]}, Blue ${s.team_scores[1]}`), + fact('Next', next ? next.text : 'None'), + fact('Machines', String(s.machines)), + fact('Uptime', formatDuration(s.uptime)), + fact('Games hosted', String(s.games_hosted)), + fact('Build', `${s.build}, network version ${s.network_version}`), + fact('Frame', `${s.frame_ms_max} ms at most, ticks ${s.tick_ms_avg} ms`), + fact('Listing', `${listing.online ? 'Online' : 'Offline'}, ${listing.public ? 'public' : 'private'}${listing.password ? ', with a password' : ''}`), + s.update && s.update.available && fact('Update', `${s.update.build} is out`, 'notice'), + ].filter(Boolean)); + $('status-count').textContent = String(s.players); + $('status-max').textContent = String(s.max_players); + $('invite').value = admin ? listing.invite || '' : ''; + $('invite').placeholder = admin ? 'No invite yet' : 'Admins see the invite link'; + $('invite-copy').disabled = !admin || !listing.invite; + $('masters').querySelector('tbody').replaceChildren(...masters.map((master) => h('tr', { class: master.listed ? null : 'bad' }, [ + cell('Master', master.url), cell('Listed', master.listed ? 'Yes' : 'No'), cell('Probe', master.probe), + cell('Answer', [master.code, master.message].filter(Boolean).join(': ') || '–'), cell('When', formatAgo(master.updated)), + ]))); + $('masters').hidden = !masters.length; + $('masters-empty').hidden = masters.length > 0; + renderAbout(); + } + + function sample(players) { + pushSample(page.samples, Date.now(), players); + const max = Math.min(page.status.max_players || 0, Math.max(4, ...page.samples.map((item) => item.value))); + const points = sparkPoints(page.samples, Date.now(), 300, 64, max); + $('spark-line').setAttribute('points', points); + $('spark-fill').setAttribute('points', points ? `${points.split(',')[0]},64 ${points} 300,64` : ''); + } + + function takeStatus(status) { + page.status = status; + renderStatus(); + sample(status.players); + } + + function addFeed(event) { + const said = describeEvent(event, page.players); + if (!said || said.quiet) return; + page.feed.unshift({ time: event.time, ...said }); + page.feed.length = Math.min(page.feed.length, 40); + $('feed').replaceChildren(...page.feed.map((item) => h('li', { class: 'feed-' + item.kind }, [ + h('time', { text: formatTime(item.time) }), ' ', item.text]))); + } + + // ---------- players: each action names the slot and the player + + function playerAction(player, action) { + const who = `slot ${player.slot}, ${player.name}`; + const send = (body) => call('POST', `/api/players/${player.slot}/${action}`, { name: player.name, ...body }); + if (action === 'kick') { + ask(`Kick ${who}?`, para('Their machine is dropped; they may join again.'), 'Kick', () => send(), true); + } else if (action === 'ban') { + const duration = h('select', {}, banPresets(page.role).map((preset, index) => + option(preset.value, preset.label + (preset.allowed ? '' : ' (admins)'), index === 1, !preset.allowed))); + const reason = h('input', { maxLength: 127, placeholder: 'Why (kept in bans.txt)' }); + ask(`Ban ${who}?`, [para('Their machine is dropped, and its address and hardware id are banned.'), + h('label', {}, ['For', duration]), h('label', {}, ['Reason', reason])], 'Ban', + () => send({ duration: duration.value, reason: reason.value.trim() }), true); + } else if (action === 'mute') { + const minutes = h('select', {}, [option('10', '10 minutes', true), option('30', '30 minutes'), option('60', '1 hour'), + option('0', 'Until unmuted')]); + ask(`Mute ${who}?`, [para('Their chat and voice, for everyone.'), h('label', {}, ['For', minutes])], 'Mute', + () => send(Number(minutes.value) ? { minutes: Number(minutes.value) } : {})); + } else if (action === 'unmute') { + ask(`Unmute ${who}?`, para('Their chat and voice reach everyone again.'), 'Unmute', () => send()); + } else { + const text = h('input', { maxLength: 127, required: true, placeholder: 'A notice on their screen' }); + ask(`Tell ${who}`, h('label', {}, ['Notice', text]), 'Send', () => send({ text: text.value })); + } + } + + function renderPlayers() { + const moderator = roleAtLeast(page.role, 'moderator'); + const hidden = 'Only for the owner, or with server.log_addresses'; + const rows = []; + for (const player of page.players) { + const key = `${player.slot}:${player.name}`; + const act = (action, label, kind) => small(label, moderator, () => playerAction(player, action), kind, + moderator ? `${label} slot ${player.slot}, ${player.name}` : 'Needs the moderator role'); + rows.push(h('tr', { class: player.muted ? 'muted' : null }, [ + cell('Slot', String(player.slot)), + cell('Name', h('span', {}, [h('span', { class: 'team team-' + player.team }), player.name, + player.muted && h('span', { class: 'badge', text: 'muted' })])), + cell('Team', teamName(player.team)), cell('Score', String(player.score)), + cell('K/D/A', `${player.kills}/${player.deaths}/${player.assists}`), cell('Ping', Number.isFinite(player.ping) ? `${player.ping} ms` : '–'), + cell('Played', formatDuration(Date.now() / 1000 - player.joined)), + cell('Actions', h('span', { class: 'actions' }, [ + small(page.dossiers.has(key) ? 'Less' : 'Dossier', true, () => { + if (!page.dossiers.delete(key)) page.dossiers.add(key); + renderPlayers(); + }), + act('kick', 'Kick', 'danger'), act('ban', 'Ban', 'danger'), + player.muted ? act('unmute', 'Unmute') : act('mute', 'Mute'), act('tell', 'Tell'), + ]), 'row-actions'), + ])); + if (page.dossiers.has(key)) { + rows.push(h('tr', { class: 'dossier' }, h('td', { attrs: { colspan: 8 } }, h('dl', { class: 'facts' }, [ + fact('Machine', String(player.machine)), fact('Joined', new Date(player.joined * 1000).toLocaleString()), + fact('Hardware id', player.hwid || hidden), + fact('Address', player.address || hidden)])))); + } + } + $('players').querySelector('tbody').replaceChildren(...rows); + $('players').hidden = !page.players.length; + $('players-empty').hidden = page.players.length > 0; + } + + async function loadPlayers() { + const result = await call('GET', '/api/players'); + if (!result.ok) return; + page.players = result.data.players || []; + renderPlayers(); + } + + // ---------- the rotation: edited here, saved whole + + async function loadRotation(force) { + if (page.dirty && !force) return; + const result = await call('GET', '/api/rotation'); + if (!result.ok) return; + page.rotation = result.data; + page.dirty = false; + renderRotation(); + } + + function editRotation(entries) { + page.rotation.entries = entries; + page.dirty = true; + renderRotation(); + } + + function renderRotation() { + const rotation = page.rotation; + const admin = roleAtLeast(page.role, 'admin'); + const moderator = roleAtLeast(page.role, 'moderator'); + const entries = rotation.entries; + $('rotation').replaceChildren(...entries.map((entry, index) => { + const current = !page.dirty && index === rotation.index; + const play = () => ask(`Play ${entry.text} now?`, para('The game in progress ends without its scores; the rotation goes on after this one.'), + 'Play now', () => call('POST', '/api/rotation/play', playBody(entry))); + return h('li', { class: (entry.rejected ? 'rejected' : '') + (current ? ' current' : '') }, [ + h('span', {}, [h('span', { class: 'entry-text', text: entry.text }), current && h('span', { class: 'badge', text: 'current' }), + entry.rejected && h('span', { class: 'reason', text: entry.rejected })]), + h('span', { class: 'actions' }, [ + small('Up', admin && index > 0, () => editRotation(rotationMove(entries, index, -1))), + small('Down', admin && index < entries.length - 1, () => editRotation(rotationMove(entries, index, 1))), + small('Remove', admin, () => editRotation(rotationRemove(entries, index)), 'danger'), + small('Play now', moderator && !entry.rejected, play, null, moderator ? 'The game in progress ends' : 'Needs the moderator role'), + ]), + ]); + })); + $('rotation-note').textContent = page.dirty ? 'Changed: Save writes it to server.rotation.' : rotation.next ? `Next: ${rotation.next.text}` : ''; + $('rotation-save').disabled = !admin || !page.dirty; + $('rotation-revert').disabled = !page.dirty; + } + + function renderPickers() { + const gametypes = page.gametypes; + const builtin = builtinNames(gametypes.builtin); + $('add-map').replaceChildren(...page.maps.slice().sort((a, b) => titleOf(a).localeCompare(titleOf(b))) + .map((map) => option(mapToken(map), `${titleOf(map)} (${map.kind === 'custom' ? 'custom' : map.name})`))); + $('add-gametype').replaceChildren( + h('optgroup', { attrs: { label: 'Built-in' } }, builtin.map((name) => option(name, name))), + h('optgroup', { attrs: { label: 'Files' } }, (gametypes.files || []).map((file) => option(file.name, `${file.name} (${file.base})`)))); + $('new-base').replaceChildren(...builtin.map((name) => option(name, name))); + } + + listen('rotation-add', 'submit', (event) => { + event.preventDefault(); + try { + editRotation(rotationInsert(page.rotation.entries, makeEntry($('add-map').value, $('add-gametype').value, $('add-rules').value))); + $('add-rules').value = ''; + } catch (error) { + toast(error.message, true); + } + }); + listen('rotation-save', 'click', async () => { + const result = await call('PUT', '/api/rotation', { entries: page.rotation.entries.map((entry) => entry.text) }); + if (result.ok) { + page.rotation = result.data; + page.dirty = false; + toast('The rotation is saved.'); + } else { + if (result.error.code === 'rejected') page.rotation.entries = markRejected(page.rotation.entries, result.error.rejected || result.data.rejected); + toast(errorText(result), true); + } + renderRotation(); + }); + listen('rotation-revert', 'click', () => loadRotation(true)); + + // ---------- gametypes: a form from the schema beside the file's text + + async function loadGametypes() { + const result = await call('GET', '/api/gametypes'); + if (!result.ok) return; + page.gametypes = { ...result.data, builtin: Array.isArray(result.data.builtin) ? result.data.builtin : [], + files: Array.isArray(result.data.files) ? result.data.files : [] }; + const admin = roleAtLeast(page.role, 'admin'); + $('gametype-files').replaceChildren(...page.gametypes.files.map((file) => h('li', {}, [ + h('span', {}, [h('strong', { text: file.name }), ' ', h('span', { class: 'note', text: `${file.base}: ${file.error || file.description || ''}` })]), + small(admin ? 'Edit' : 'View', true, () => openGametype(file.name))]))); + $('gametype-builtins').textContent = builtinNames(page.gametypes.builtin).join(', '); + renderPickers(); + } + + async function openGametype(name) { + const result = await call('GET', `/api/gametypes/${encodeURIComponent(name)}`); + if (result.ok) showEditor(name, result.data.text, false); + else toast(errorText(result), true); + } + + function showEditor(name, text, isNew) { + page.editor = { name, isNew }; + $('gametype-editor').hidden = false; + $('editor-title').textContent = (isNew ? 'New gametype: ' : 'Gametype: ') + name; + $('editor-file').textContent = name; + $('gametype-text').value = text; + $('gametype-text').readOnly = !roleAtLeast(page.role, 'admin'); + $('gametype-delete').hidden = isNew; + renderGametypeForm(); + $('gametype-editor').scrollIntoView({ block: 'start' }); + } + + function showTomlErrors() { + const parsed = parseToml($('gametype-text').value); + $('gametype-error').textContent = parsed.errors.map((error) => `Line ${error.line}: ${error.message}`).join('; '); + return parsed; + } + + // a control's change goes into the text, the rest of the text as it was + function renderGametypeForm() { + const parsed = parseToml($('gametype-text').value); + const top = parsed.values['']; + const disabled = !roleAtLeast(page.role, 'admin'); + const change = (table, key, value) => { + $('gametype-text').value = setTomlValue($('gametype-text').value, table, key, value); + if (key === 'base') renderGametypeForm(); + else showTomlErrors(); + }; + const control = (tag, props, children, table, key, field) => h(tag, { ...props, disabled, attrs: { 'data-table': table, 'data-key': key }, + listen: { change: (event) => change(table, key, field ? fieldValue(field, event.target.value) : event.target.value || undefined) } }, children); + const groups = [h('fieldset', {}, [h('legend', { text: 'The file' }), + h('label', {}, ['Base', control('select', {}, builtinNames(page.gametypes.builtin).map((name) => option(name, name, name === top.base)), '', 'base')]), + h('label', {}, ['Name', control('input', { value: top.name || '', maxLength: 11 }, [], '', 'name')]), + ])]; + const fields = gametypeFields(page.gametypes.schema, top.base, page.gametypes.builtin); + for (const table of new Set(fields.map((field) => field.table))) { + const values = parsed.values[table] || {}; + groups.push(h('fieldset', {}, [h('legend', { text: `[${table}]` }), ...fields.filter((field) => field.table === table).map((field) => { + const value = values[field.key]; + const label = field.key.replace(/_/g, ' ') + (field.alias && field.alias !== field.key ? ` (${field.alias})` : '') + + (field.type === 'integer' && field.min != null ? ` ${field.min}–${field.max}` : ''); + const choices = field.type === 'boolean' ? [['true', 'On'], ['false', 'Off']] : (field.choices || []).map((choice) => [choice, choice]); + return h('label', {}, [h('span', { text: label }), field.type === 'integer' ? + control('input', { type: 'number', step: '1', min: field.min, max: field.max, placeholder: "base's", + value: value === undefined ? '' : String(value) }, [], table, field.key, field) : + control('select', {}, [option('', "Base's", value === undefined), + ...choices.map(([item, text]) => option(item, text, value !== undefined && String(value) === item))], table, field.key, field)]); + })])); + } + $('gametype-form').replaceChildren(...groups); + showTomlErrors(); + } + + // the text typed: the form follows it + listen('gametype-text', 'input', () => { + const parsed = showTomlErrors(); + if (parsed.errors.length) return; + if ($('gametype-form').querySelector('[data-key="base"]').value !== parsed.values[''].base) { + renderGametypeForm(); + return; + } + for (const node of $('gametype-form').querySelectorAll('[data-key]:not([data-key="base"])')) { + const value = (parsed.values[node.getAttribute('data-table')] || {})[node.getAttribute('data-key')]; + node.value = value === undefined ? '' : String(value); + } + }); + listen('gametype-new', 'submit', (event) => { + event.preventDefault(); + const name = $('new-name').value.trim(); + if (page.gametypes.files.some((file) => file.name === name)) openGametype(name); + else showEditor(name, newGametypeText($('new-base').value, name), true); + }); + listen('gametype-save', 'click', async () => { + const editor = page.editor; + const result = await call('PUT', `/api/gametypes/${encodeURIComponent(editor.name)}`, { text: $('gametype-text').value }); + if (!result.ok) { + $('gametype-error').textContent = errorText(result); + return; + } + toast(`gametypes/${editor.name}.toml is saved.`); + editor.isNew = false; + $('gametype-delete').hidden = false; + $('editor-title').textContent = 'Gametype: ' + editor.name; + loadGametypes(); + }); + listen('gametype-delete', 'click', () => { + const name = page.editor.name; + ask(`Delete gametypes/${name}.toml?`, para('A rotation entry that plays it is then rejected.'), 'Delete', async () => { + const result = await call('DELETE', `/api/gametypes/${encodeURIComponent(name)}`); + if (result.ok) { + $('gametype-editor').hidden = true; + loadGametypes(); + } + return result; + }, true); + }); + listen('gametype-close', 'click', () => { $('gametype-editor').hidden = true; }); + + // ---------- bans + + async function loadBans() { + if (!roleAtLeast(page.role, 'admin')) return; + const result = await call('GET', '/api/bans'); + if (!result.ok) return; + const bans = result.data.bans || []; + $('bans').querySelector('tbody').replaceChildren(...bans.map((ban) => h('tr', {}, [ + cell('#', String(ban.n)), cell('When', ban.when), cell('Players', ban.players || '–'), cell('Address', ban.ip || '–', 'mono'), + cell('Hardware id', ban.hwid || '–', 'mono'), cell('Reason', ban.reason || '–'), cell('Until', formatUntil(ban.until)), + cell('', small('Unban', true, () => ask(`Unban ${ban.players || ban.ip}?`, para(`Ban ${ban.n} is taken out of bans.txt.`), 'Unban', + async () => { + const answer = await call('DELETE', `/api/bans/${ban.n}`); + if (answer.ok) loadBans(); + return answer; + })), 'row-actions'), + ]))); + $('bans').hidden = !bans.length; + $('bans-empty').hidden = bans.length > 0; + } + + // ---------- the console: the log, and commands + + const logNode = (line) => h('div', { class: `line level-${line.level} cat-${line.cat}` }, [ + h('time', { text: formatTime(line.time) }), ' ', h('span', { class: 'cat', text: line.cat }), ' ', line.text]); + + function follow() { + if ($('follow').checked) $('log').scrollTop = $('log').scrollHeight; + } + + function addLog(lines) { + const merged = mergeLog(page.log.lines, lines); + const view = $('log'); + page.log.lines = merged.lines; + if (!merged.appended) view.replaceChildren(...merged.lines.map(logNode)); + else { + for (let index = 0; index < merged.dropped && view.firstChild; index++) view.firstChild.remove(); + view.append(...merged.fresh.map(logNode)); + } + follow(); + } + + function addOwn(lines, kind) { + const view = $('log'); + view.append(...lines.map((text) => h('div', { class: 'line mine ' + (kind || '') }, text))); + while (view.childNodes.length > LOG_LINES + 200) view.firstChild.remove(); + follow(); + } + + listen('log', 'scroll', () => { + const view = $('log'); + $('follow').checked = view.scrollHeight - view.scrollTop - view.clientHeight < 24; + }); + listen('follow', 'change', follow); + listen('log-clear', 'click', () => $('log').replaceChildren()); + $('categories').replaceChildren(...CATEGORIES.map((category) => h('label', { class: 'chip' }, [ + h('input', { type: 'checkbox', checked: true, listen: { change: (event) => { + $('log').classList.toggle('hide-' + category, !event.target.checked); + follow(); + } } }), category]))); + + async function pollLog() { + for (let round = 0; round < 8; round++) { + const result = await call('GET', `/api/log?after=${page.log.cursor}&limit=512`); + if (!result.ok) return; + const taken = takeLog(page.log, result.data); + if (taken.reset) { + page.log.lines = []; + $('log').replaceChildren(); + addOwn(['(the server started again)'], 'note'); + } + if (taken.missed && page.log.lines.length) addOwn([`(${taken.missed} lines left the server's log before they were read)`], 'note'); + const before = page.log.cursor; + page.log.cursor = taken.cursor; + addLog(taken.lines); + // (a whole page read: more may wait; chat's lines left out of a + // moderator's answer count, by the server's next) + if (taken.reset || taken.cursor - before < 512) return; + } + } + + async function runCommand(line) { + addOwn(['> ' + line], 'command-line'); + const result = live.state === 'live' ? await live.command(line) : await call('POST', '/api/command', { line }); + const lines = (result.data && result.data.lines) || []; + if (lines.length) addOwn(lines); + // (a command that failed says why in its lines) + if (!result.ok && !(lines.length && result.error && result.error.code === 'failed')) addOwn([errorText(result)], 'bad'); + } + + listen('command-form', 'submit', (event) => { + event.preventDefault(); + const line = $('command').value.trim(); + if (!line) return; + if (page.history[page.history.length - 1] !== line) page.history.push(line); + if (page.history.length > 100) page.history.shift(); + page.historyIndex = page.history.length; + $('command').value = ''; + $('follow').checked = true; + runCommand(line); + }); + listen('command', 'keydown', (event) => { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const step = historyStep(page.history, page.historyIndex, event.key === 'ArrowUp' ? -1 : 1); + page.historyIndex = step.index; + $('command').value = step.text; + } else if (event.key === 'Tab' && $('command').value) { + const done = complete($('command').value, page.commands, page.players); + $('command').value = done.line; + $('command-hint').textContent = done.options.length > 1 ? done.options.join(' ') : 'Tab completes, Up and Down go through what was run.'; + } else { + return; + } + event.preventDefault(); + }); + + // ---------- settings: a form from the server's table + + async function loadSettings() { + if (!roleAtLeast(page.role, 'admin')) return; + const result = await call('GET', '/api/settings'); + if (!result.ok) return; + page.settings = (Array.isArray(result.data.settings) ? result.data.settings : []).map(normalizeSetting); + renderSettings(); + } + + const settingId = (name) => 'setting-' + name.replace(/\W/g, '-'); + + function renderSettings() { + const groups = new Map(); + for (const setting of page.settings) { + const c = settingControl(setting, page.role); + const id = settingId(setting.name); + const group = setting.name.split('.')[0]; + let input; + if (c.kind === 'checkbox') input = h('input', { type: 'checkbox', id, checked: c.value === true, disabled: c.disabled }); + else if (c.kind === 'select') input = h('select', { id, disabled: c.disabled }, c.choices.map((choice) => option(choice, choice, choice === String(c.value)))); + else if (c.kind === 'secret') { + input = h('span', { class: 'secret' }, [ + h('input', { type: 'password', id, placeholder: c.placeholder, disabled: c.disabled, autocomplete: 'new-password' }), + h('label', { class: 'check' }, [h('input', { type: 'checkbox', id: id + '-clear', disabled: c.disabled }), 'Clear'])]); + } else { + input = h('input', { type: c.kind, id, value: String(c.value ?? ''), min: c.min, max: c.max, step: c.step, disabled: c.disabled }); + } + const range = c.min !== null && c.max !== null ? `${c.min} to ${c.max}` : c.min !== null ? `${c.min} or more` : c.max !== null ? `${c.max} or less` : ''; + const meta = [c.comment, c.defaultText && `Default: ${c.defaultText}`, c.env, range].filter(Boolean); + if (!groups.has(group)) groups.set(group, [h('legend', { text: `[${group}]` })]); + groups.get(group).push(h('div', { class: 'setting' + (c.disabled ? ' pinned' : ''), data: { name: setting.name } }, [ + h('label', { htmlFor: id, class: 'setting-name', text: setting.name }), input, c.reason && h('span', { class: 'badge', text: c.reason }), + h('p', { class: 'note', text: meta.join(' · ') }), h('p', { class: 'error' })])); + } + $('settings-fields').replaceChildren(...[...groups.values()].map((rows) => h('fieldset', {}, rows))); + } + + function settingsValues() { + const values = {}; + for (const setting of page.settings) { + const input = $(settingId(setting.name)); + if (!input) continue; + values[setting.name] = setting.secret ? { text: input.value, clear: $(settingId(setting.name) + '-clear').checked } : + input.type === 'checkbox' ? input.checked : input.value; + } + return values; + } + + listen('settings-form', 'submit', async (event) => { + event.preventDefault(); + const { body, errors } = settingsChanges(page.settings, settingsValues(), page.role); + const showErrors = (list) => { + for (const error of list) { + const row = $('settings-fields').querySelector(`[data-name="${error.name}"] .error`); + if (row) row.textContent = error.message; + } + }; + for (const node of $('settings-fields').querySelectorAll('.error')) node.textContent = ''; + showErrors(errors); + if (errors.length || !Object.keys(body).length) { + $('settings-note').textContent = errors.length ? 'Nothing was saved: some values are wrong.' : 'Nothing changed.'; + return; + } + const result = await call('PUT', '/api/settings', body); + if (!result.ok) { + $('settings-note').textContent = errorText(result); + return; + } + const refused = result.data.errors || []; + await loadSettings(); + showErrors(refused); + $('settings-note').textContent = `Saved: ${(result.data.applied || []).join(', ') || 'nothing'}.` + + (refused.length ? ` Refused: ${refused.map((error) => error.name).join(', ')}.` : ''); + }); + listen('settings-revert', 'click', renderSettings); + + // ---------- about + + function renderAbout() { + const setting = (name) => (page.settings.find((item) => item.name === name) || {}).value; + const s = page.status || {}; + $('about-ports').replaceChildren( + fact(`${setting('network.tunnel_port') || 2302}/udp`, 'Internet play, every player (network.tunnel_port): open it'), + fact('5150/udp+tcp, 5151/udp, 5153/tcp', 'System link, on the LAN only'), + fact(`${setting('server.web_port') || location.port || 80}/tcp`, 'This page, the API and the RCON (server.web_port): not for the internet without TLS'), + fact('443/tcp, 1883/tcp out', 'The master servers; the message brokers of invites and listings')); + $('about-facts').replaceChildren(fact('Name', s.name || '–'), fact('Build', s.build || '–'), + fact('Network version', String(s.network_version ?? '–')), fact('Signed in as', page.role || '–'), fact('Address', location.host)); + } + + // ---------- the RCON's WebSocket, and polling while it is down + + const live = createLive(root.WebSocket, `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`, { + after: () => page.seq, + onState: (state) => { + $('link-state').textContent = { live: 'Live', connecting: 'Connecting' }[state] || 'Polling every 3 s'; + $('link-state').className = 'badge link-' + state; + }, + onLive: () => Promise.all([pollLog(), loadPlayers(), loadEvents()]), + onDenied: () => signedOut('The session ended: sign in again.'), + onOverflow: () => toast('The page read the server too slowly: it polls until it connects again.', true), + onEvent: (event) => { + if (event.event === 'status') return takeStatus(event.status); + if (event.event === 'log') { + // (the log's own numbering, n: not the events' seq; mergeLog keeps + // a line once) + if (!Number.isInteger(event.n)) return; + page.log.cursor = Math.max(page.log.cursor, event.n); + return addLog([{ n: event.n, time: event.time, level: event.level, cat: event.cat, text: event.text }]); + } + // (an event seen already, replayed after a reconnection or read by + // /api/events meanwhile: once) + if (Number.isInteger(event.seq) && event.seq > 0) { + if (event.seq <= page.seq) return; + page.seq = event.seq; + } + addFeed(event); + if (event.event === 'score' && Array.isArray(event.players)) { + page.players = event.players; + renderPlayers(); + } else if (/^(player_.*|kick|ban|mute|unmute|game_started)$/.test(event.event)) { + clearTimeout(page.playersTimer); + page.playersTimer = setTimeout(loadPlayers, 400); + } + if (/^(ban|unban)$/.test(event.event)) loadBans(); + if (/^(lobby_opened|reloaded)$/.test(event.event)) loadRotation(); + }, + }); + + // the events missed (at the start, the last ones: the feed) + async function loadEvents() { + const result = await call('GET', `/api/events?after=${page.seq}`); + for (const event of (result.ok && result.data.events) || []) { + if (!Number.isInteger(event.seq) || event.seq <= page.seq) continue; + page.seq = event.seq; + addFeed(event); + } + } + + async function poll() { + if (live.state === 'live' || !page.role) return; + const status = await call('GET', '/api/status'); + if (status.ok) takeStatus(status.data); + await Promise.all([loadPlayers(), pollLog(), loadEvents()]); + } + + // ---------- tabs + + function showTab(name) { + const button = doc.querySelector(`[data-tab="${name}"]`); + if (!button || button.disabled) name = 'status'; + for (const tab of doc.querySelectorAll('[data-tab]')) { + tab.setAttribute('aria-selected', String(tab.getAttribute('data-tab') === name)); + tab.classList.toggle('current', tab.getAttribute('data-tab') === name); + } + for (const section of doc.querySelectorAll('.tab')) section.hidden = section.id !== 'tab-' + name; + if (name === 'console') follow(); + if (name === 'rotation') loadRotation(); + if (name === 'bans') loadBans(); + } + + for (const tab of doc.querySelectorAll('[data-tab]')) { + tab.addEventListener('click', () => { + history.replaceState(null, '', '#' + tab.getAttribute('data-tab')); + showTab(tab.getAttribute('data-tab')); + }); + } + root.addEventListener('hashchange', () => showTab(location.hash.slice(1))); + + // ---------- the status tab's actions + + listen('next-map', 'click', () => ask('Play the next map?', para("The game in progress ends without its scores, and the rotation's next starts."), + 'Next map', () => call('POST', '/api/rotation/next', {}), true)); + listen('end-game', 'click', () => ask('End the game?', para('The game ends with its scores; the postgame shows them, then the next game starts.'), + 'End game', () => call('POST', '/api/command', { line: 'sv_end_game' }), true)); + listen('say-form', 'submit', async (event) => { + event.preventDefault(); + const text = $('say-text').value.trim(); + if (!text) return; + const result = await call('POST', '/api/say', { text }); + if (result.ok) $('say-text').value = ''; + toast(result.ok ? 'Said.' : errorText(result), !result.ok); + }); + listen('invite-copy', 'click', async () => { + try { + await navigator.clipboard.writeText($('invite').value); + toast('The invite link is copied.'); + } catch { + $('invite').select(); + } + }); + + // ---------- signing in and out + + function showLogin(message) { + $('app').hidden = true; + $('login').hidden = false; + $('login-error').textContent = message || ''; + $('login-password').focus(); + } + + function signedOut(message) { + if (!page.role) return; + page.role = null; + live.stop(); + page.timers.forEach(clearInterval); + page.timers = []; + closeDialog(); + showLogin(message); + } + + listen('login-form', 'submit', async (event) => { + event.preventDefault(); + $('login-button').disabled = true; + const result = await api.call('POST', '/api/login', { password: $('login-password').value }); + $('login-button').disabled = false; + $('login-password').value = ''; + if (result.ok) start(result.data); + else $('login-error').textContent = errorText(result); + }); + listen('logout', 'click', async () => { + await call('POST', '/api/logout', {}); + signedOut('Signed out.'); + }); + + async function start(session) { + page.role = session.role; + api.setCsrf(session.csrf); + $('login').hidden = true; + $('app').hidden = false; + $('role').textContent = page.role; + gate(); + showTab(location.hash.slice(1) || 'status'); + const [status, maps] = await Promise.all([call('GET', '/api/status'), call('GET', '/api/maps')]); + if (maps.ok) page.maps = (Array.isArray(maps.data.maps) ? maps.data.maps : []).filter((map) => map && map.name); + if (status.ok) takeStatus(status.data); + // (each on its own: one that fails leaves the others, and the + // connection, to start) + const results = await Promise.allSettled([loadPlayers(), loadRotation(true), loadGametypes(), loadBans(), loadSettings(), + pollLog(), loadEvents(), + call('POST', '/api/command', { line: 'help' }).then((help) => { if (help.ok) page.commands = commandsFromHelp(help.data.lines); })]); + for (const result of results) if (result.status === 'rejected') console.error(result.reason); + if (!page.role) return; + renderAbout(); + live.connect(); + // (the session ends after 30 minutes without an HTTP request: the + // WebSocket's traffic does not count, so the page asks now and then) + page.timers.push(setInterval(poll, 3000), setInterval(live.ping, 25000), + setInterval(() => page.status && sample(page.status.players), 15000), + setInterval(() => call('GET', '/api/session'), SESSION_REFRESH)); + } + + api.call('GET', '/api/session').then((result) => { + if (result.ok) start(result.data); + else showLogin(result.error.code === 'network' ? errorText(result) : ''); + }); +} + +// the RCON's WebSocket for the page: signed in by the cookie, subscribed to +// every group (with after: the events since the last one seen), commands +// answered by id, and connected again after a loss (2 s, doubling to 30) +function createLive(WebSocketClass, url, handlers) { + const live = { state: 'off', socket: null, next: 1, pending: new Map(), retry: 2000, timer: null }; + const settle = (id, result) => { + const resolve = live.pending.get(id); + if (live.pending.delete(id)) resolve(result); + }; + const setState = (state) => { + live.state = state; + handlers.onState(state); + }; + live.connect = () => { + if (live.socket) return; + try { + live.socket = new WebSocketClass(url, 'opence-rcon-1'); + } catch { + live.lost(); + return; + } + const socket = live.socket; + setState('connecting'); + socket.addEventListener('message', (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if (message.type === 'auth' && !message.ok) { + handlers.onDenied(message.error || {}); + } else if (message.type === 'auth') { + live.retry = 2000; + setState('live'); + socket.send(JSON.stringify(handlers.after() ? { type: 'subscribe', events: GROUPS, after: handlers.after() } : + { type: 'subscribe', events: GROUPS })); + handlers.onLive(); + } else if (message.type === 'event') { + handlers.onEvent(message); + } else if (message.type === 'reply' || message.type === 'error') { + // (a command that ran and failed: a reply, ok false, its lines and no + // error; one refused: an error) + settle(message.id, message.ok ? { ok: true, data: message } : { ok: false, data: message, + error: message.error || (message.type === 'reply' ? { code: 'failed', message: '' } : { code: 'unknown' }) }); + } else if (message.type === 'overflow') { + handlers.onOverflow(); + } + }); + // (a refused handshake: a browser says error, then close; Node error alone) + for (const name of ['close', 'error']) { + socket.addEventListener(name, () => { + if (live.socket === socket) live.lost(); + }); + } + }; + live.lost = () => { + live.socket = null; + for (const id of [...live.pending.keys()]) settle(id, { ok: false, error: { code: 'network', message: 'the connection was lost' } }); + if (live.state === 'off') return; + setState('polling'); + clearTimeout(live.timer); + live.timer = setTimeout(live.connect, live.retry); + live.retry = Math.min(30000, live.retry * 2); + }; + live.command = (line, timeout = 30000) => new Promise((resolve) => { + const id = live.next++; + live.pending.set(id, resolve); + try { + live.socket.send(JSON.stringify({ type: 'command', id, line })); + } catch { + settle(id, { ok: false, error: { code: 'network', message: 'the connection was lost' } }); + } + setTimeout(() => settle(id, { ok: false, error: { code: 'timeout', message: 'no answer in time' } }), timeout); + }); + live.ping = () => { + if (live.state === 'live') live.socket.send('{"type":"ping"}'); + }; + live.stop = () => { + live.state = 'off'; + clearTimeout(live.timer); + const socket = live.socket; + live.socket = null; + if (socket) socket.close(); + }; + return live; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + ROLES, CATEGORIES, GROUPS, roleAtLeast, banPresets, formatDuration, formatClock, formatTime, formatAgo, formatUntil, + teamName, stateLabel, errorText, createApi, takeLog, mergeLog, pushSample, sparkPoints, describeEvent, parseEntryText, + rulesText, mapToken, makeEntry, rotationMove, rotationRemove, rotationInsert, markRejected, settingControl, + settingsChanges, settingValue, normalizeSetting, settingEditable, settingRole, OWNER_SETTINGS, parseToml, setTomlValue, + formatTomlValue, engineOf, builtinNames, gametypeFields, fieldValue, newGametypeText, titleOf, playBody, + commandsFromHelp, complete, historyStep, h, setDocument, createLive, + }; +} else { + startPage(window); +} diff --git a/port/assets/server_ui/icon.svg b/port/assets/server_ui/icon.svg new file mode 100644 index 00000000..60ee9c77 --- /dev/null +++ b/port/assets/server_ui/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/port/assets/server_ui/index.html b/port/assets/server_ui/index.html new file mode 100644 index 00000000..97887e78 --- /dev/null +++ b/port/assets/server_ui/index.html @@ -0,0 +1,241 @@ + + + + + + + +OpenCE server + + + + + + + + + + + + + diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index c45df683..7cce5049 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -270,7 +270,11 @@ browser names in its gametype column ("SWAT", "HC CTF"); a browser takes a mode for a Slayer game only, and one it does not know as none. Then version 38 lets the host's shield effects be `players` (SHIELD EFFECTS' PLAYERS' OWN), a value of distributed message 91 that tells each client to draw its own -settings', not synced. +settings', not synced. Then version 39 marks a dedicated server's game (a +host with no player of its own, `halo --server`: "Dedicated servers" +below) with a flag of its LAN advertisement (`HALO_PORT_ADVERTISED_DEDICATED_FLAG`, +0x04 of the third reserved byte) and of its internet listing (32), which +the server browser shows. `port/linux/network_format.txt` records the wire format of the current version (`tools/network_format.py`): `test_linux_port.py` fails when the @@ -329,7 +333,13 @@ hardware id is in it (a line taken out unbans). Both are as the player's machine tells them: anyone with administrator or root access can change them, and players behind one address share it. The console's `kick ` drops a player as `ban` does (every machine told), but adds no line -and keeps no address out: the player may join again at once. +and keeps no address out: the player may join again at once. Besides +`bans.txt`, the host keeps out a machine dropped for cheating or banned for +ever while it runs, by its address as it joined (an internet play peer's +stand-in), its real address and its hardware id, any of them; a dedicated +server's ban for a while (`ban 30m`) is left to its `bans.txt` +line, whose `until=` ends it, and its `unban` lets the machine in again by +the line's address or hardware id (`network_game_server_port_forget_kicked_address`). The players' votes to kick a player (`network_votekick.c`): a client sends `_distributed_message_votekick` (the player's absolute index) reliably, and @@ -384,8 +394,9 @@ names of the network game's players, which the lobby has too). A machine's own scoreboard mute stays its own: it plays none of that machine's voice and shows none of its players' lines, and tells no one. The host's chat settings (`network.text_chat`: on, links allowed, or off; -always off while its game is listed in the server browser, -`p2p_hosting_listed`) go to every client in +always off while a player's game is listed in the server browser, +`p2p_hosting_listed`, but a dedicated server's own, `chat_listing_allows`) +go to every client in `_distributed_message_chat_config` (one byte of flags), reliably, as they change and every 3 seconds. A client without them (from a host without the chat, or before they come) has the chat off, and so does a client in a @@ -393,7 +404,10 @@ public game it joined from the server browser, whatever its host sends (a changed host could turn the chat on): while the tunnel reaches the host of the listing it joined (`p2p_join_listing`, `p2p_joined_listed_host`; any other invite joined since forgets it), it takes no chat line or notice and -sends none. A client refuses a line the +sends none, unless the listing was a dedicated server's that a master +server lists (`p2p_joined_listed_dedicated`: the master's word, from an +approved operator's key; a listing's own dedicated flag, which any host can +set, does not count), whose settings it follows. A client refuses a line the settings refuse before sending it, and the host refuses it again whatever the client knew, telling its machine why: a changed build gets no further. Links are found in the line's text (`chat_text_has_link`): a scheme, `www.`, @@ -466,7 +480,8 @@ do (in free for all, a team of its own). The game is closed to joins while its machines load and after it ends. A machine must join within 10 seconds of connecting; in a game, the host -drops a machine it has heard nothing from for 15 seconds, and one that +drops a machine it has heard nothing from for 15 seconds +(`network.client_timeout_seconds`), and one that joined the game in progress and has not loaded in two minutes. Until it has loaded, the machine hears none of the game's messages (which a @@ -474,6 +489,48 @@ machine in the pregame refuses, and which the others no longer need), only a pregame keep-alive every five seconds from the host (`network_server_manager.c`, `network_server_message_handler.c`). +## Dedicated servers + +A dedicated server (`halo --server`: `docs/dedicated_server.md`) is a host +as any other, with these differences, all on the host's side: + +- **No player of its own.** The host machine is in the game as machine 0 + but has no player, so its lobby counts the joined machines' players only + (`network_server_manager.c`'s `server_ok_to_countdown`, which asks + `server_director_ok_to_countdown` for `server.min_players` and + `server.team_start`), and a game with one team's players may start (the + next players to join balance the teams). The host waits for no player of + its own to precache, and a client's player that quits the game does not + end the host's (`network_client_manager.c`: only a local player's quit + leaves). +- **No ticks of its own player.** The host's own action queue may hold no + action (`player_queues_new.c`), as a game with no local player has none. +- **The director.** `port/linux/game/server_director.c` drives the lobby as + a player at the pregame screen would: it sets the map and gametype of the + rotation's entry, starts the countdown once the server's own rule allows + it (`network_game_server_port_ok_to_countdown`, the rule the server checks + each tick, then `network_game_server_port_countdown_begin`), and after the postgame + goes back to the lobby with the next entry. The clients see a lobby, a + game and a postgame as with any host. +- **Shutdown.** On SIGTERM, a game in progress goes to its postgame, and + the host tells every machine it goes (`network_game_server_graceful_shutdown`) + before it exits, so that the clients show the scores rather than a lost + connection. +- **The flags.** Since version 39 a dedicated server's LAN advertisement + carries `HALO_PORT_ADVERTISED_DEDICATED_FLAG` and its internet listing the + dedicated flag (32); a listing published to the master servers is the + same signed listing (`docs/master_protocol.md`), and the master's probe + is answered on the tunnel's socket (`p2p.c`, `server_master_probe`). +- **The timeout.** `network.client_timeout_seconds` (15 seconds by + default) sets how long a host waits for a silent machine, and two more + waits with it: how long a stream's writes may stall before its connection + is dropped (`network_connection.c`), on every machine, clients too; and + how long a host in a game rides out its network going (an address + renewed, a wifi drop: `network_game_server_network_lost`). So it is not + the host's alone, and not a dedicated server's only: each machine reads + its own setting, and nothing on the wire changes (the machines need not + agree on it). + ## Map sharing A client may join a game on a Custom Edition map it does not have diff --git a/port/linux/README.md b/port/linux/README.md index d31ba973..99593524 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -508,6 +508,8 @@ the setting for one start of the game. It has priority over the file. | `bots.teams` | `"even"` | `HALO_BOT_TEAMS` | In a team game: `"even"` puts the bots on both teams, evening them with the players; `"against"` puts them all on the team the players are not on. | | `paths.data` | `""` | `HALO_DATA_ROOT` | The data root. Refer to "Start the game". | | `paths.saves` | `""` | `HALO_SAVE_ROOT` | The save root. Refer to "Files and folders". | +| `paths.logs` | `""` | `HALO_LOG_ROOT` | The folder of `debug.txt`, `bans.txt` and `cheaters.txt`. Empty: the data root. With it, the data root can be read-only (a dedicated server's maps mounted so). | +| `paths.custom_maps` | `""` | `HALO_CUSTOM_MAPS_ROOT` | The `custom_maps` folder (Custom Edition maps, with their `bitmaps.map`, `sounds.map` and `loc.map`). Empty: the one in the data root. | | `network.address` | `""` | `HALO_NET_ADDRESS` | The IPv4 address of this machine for system link. Refer to "Play on one computer". | | `network.broadcast` | `""` | `HALO_NET_BROADCAST` | IPv4 addresses, with commas between them, that get the broadcasts of the game. Empty: 255.255.255.255. | | `network.online` | `true` | `HALO_NET_ONLINE` | `true`: internet play. `false`: system link on the local network only. | @@ -529,7 +531,7 @@ the setting for one start of the game. It has priority over the file. | `network.voice_mode` | `"team_global_enemy_proximity"` | `HALO_NET_VOICE_MODE` | When you host: who hears whom during a game. `"off"`; `"team_proximity"` (teammates who are near); `"team_enemy_proximity"` (all players who are near); `"team_global"` (all teammates); `"team_global_enemy_proximity"` (all teammates, and enemies who are near). Refer to "Voice chat". | | `network.voice_kbps` | `24` | `HALO_NET_VOICE_KBPS` | When you host: the voice quality in the lobby and in a game, in kilobits per second, `8` to `64`. | | `network.voice_proximity` | `15.0` | `HALO_NET_VOICE_PROXIMITY` | When you host: the distance in world units (1 unit is approximately 3 metres) at which players are near, for voice chat. `5` to `100`. | -| `network.text_chat` | `"on"` | `HALO_NET_TEXT_CHAT` | When you host: the text chat. `"on"`, lines with links refused; `"links"`, links allowed; `"off"`, no chat (in either case). Always off in a game listed in the server browser. TEXT CHAT in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. Refer to "Text chat". | +| `network.text_chat` | `"on"` | `HALO_NET_TEXT_CHAT` | When you host: the text chat. `"on"`, lines with links refused; `"links"`, links allowed; `"off"`, no chat (in either case). Always off in a game a player hosts that is listed in the server browser; a dedicated server's follows it, listed or not. TEXT CHAT in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. Refer to "Text chat". | | `network.votekick` | `true` | `HALO_NET_VOTEKICK` | When you host: `true`, the players can vote to kick a player. Refer to "Security". `false`: no votes. VOTE KICK in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. | | `network.votekick_minutes` | `5` | `HALO_NET_VOTEKICK_MINUTES` | When you host: the minutes that a player must play on the server before the player can start a vote to kick (`0` to `60`). To vote, a player must play for 2 minutes, or for this time if it is less. | | `network.votekick_ban_minutes` | `30` | `HALO_NET_VOTEKICK_BAN_MINUTES` | When you host: the minutes that a player who is kicked by a vote cannot join again (`1` to `1440`). | @@ -543,6 +545,53 @@ the setting for one start of the game. It has priority over the file. | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | +| `network.client_timeout_seconds` | `15` | `HALO_NET_CLIENT_TIMEOUT_SECONDS` | Hosting: the seconds the host waits for a machine it hears nothing from before it drops it (5 to 300; the stock game waits 15). The same seconds are how long each machine's reliable stream waits for its peer to read before it gives up, and how long a host in a game waits for its own network to come back (an address renewed, a wifi drop) before it ends the game; each machine reads its own setting. A longer wait keeps players whose connection stalls (a browser tab in the background), but a machine that is really gone leaves its players frozen for longer. | +| `network.public_address` | `""` | `HALO_NET_PUBLIC_ADDRESS` | Hosting: the internet address the joiners reach this machine at, `host[:port]` (the port forwarded to `network.tunnel_port`), for a server behind a NAT with one forwarded port. It is offered first, and its name is looked up again every 5 minutes. Empty: what STUN and UPnP find. | +| `network.listings` | `"both"` | `HALO_NET_LISTINGS` | What the server browser lists. `"both"`: the dedicated servers of the master servers first, then the games players host (from the brokers). `"masters"`: the master servers' only. `"brokers"`: the brokers' only. Refer to "Server browser". | +| `network.masters_file` | `"masters.txt"` | `HALO_NET_MASTERS_FILE` | The file of the master servers the server browser asks for dedicated servers, next to `config.toml` unless a full path: on each line an `https://` URL and the master's key (`ed25519:`), 4 at most. A file that names none (missing, or comments alone, as the release's) leaves the official master, built in (`https://master.opencommunityedition.com`). Refer to "Dedicated server". | +| `network.browser_source` | `"all"` | `HALO_NET_BROWSER_SOURCE` | The server browser's games. `"all"` (ALL GAMES): the dedicated servers first, then the games players host; `"dedicated"`: the dedicated servers of the master servers; `"players"` (P2P GAMES): the games players host. The tab at the top left of the server browser changes it. | +| `network.browser_show_empty`, `network.browser_show_full` | `true` | `HALO_NET_BROWSER_SHOW_EMPTY`, `HALO_NET_BROWSER_SHOW_FULL` | `false`: the server browser does not list the games with no player, the full games. The server browser's Filters screen sets them. | +| `network.browser_maps` | `"yes"` | `HALO_NET_BROWSER_MAPS` | `"yes"`: the server browser lists the games on maps this computer does not have. `"no"`: it does not. `"downloadable"`: only those of a Custom Edition map that the host shares. | +| `network.browser_gametype` | `"any"` | `HALO_NET_BROWSER_GAMETYPE` | The server browser lists the games of one kind only: `"any"`, `"ctf"`, `"slayer"`, `"oddball"`, `"king"` or `"race"` (the Filters screen). | +| `network.browser_sort` | `"servers"` | `HALO_NET_BROWSER_SORT` | The server browser's order. `"servers"`: dedicated servers first, then by players. `"players"`, `"name"`, `"map"`, `"type"` or `"locked"` (games with a password first). A `-` before one reverses it (`"-players"`). A column's title sorts by it, and again the other way. | +| `server.enabled` | `false` | `HALO_SERVER` | `true`: run as a dedicated server, as `halo --server` does. Refer to "Dedicated server". The `server.*` settings are written to `config.toml` only by a dedicated server. | +| `server.name` | `"OpenCE Server"` | `HALO_SERVER_NAME` | The game's name in the lobby and the server browser: 15 characters of printable ASCII. | +| `server.max_players` | `16` | `HALO_SERVER_MAX_PLAYERS` | The most players (2 to 127). | +| `server.min_players` | `1` | `HALO_SERVER_MIN_PLAYERS` | The players in the lobby before it counts down to a game (1 to 16). | +| `server.password` | `""` | `HALO_SERVER_PASSWORD` | The password the server browser asks for (an invite link joins without it). Empty: none. It is never logged. `HALO_SERVER_PASSWORD_FILE` names a file whose first line is it instead (a container's secret; pinned, as the variable would be). | +| `server.public` | `true` | `HALO_SERVER_PUBLIC` | `true`: the server is in everyone's server browser (on the master servers, with `network.online`). `false`: players join with its invite link, or on the LAN. | +| `server.rotation` | `"bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer"` | `HALO_SERVER_ROTATION` | The games, played one after another: `map[:gametype[+rule=value...]]`, separated by commas, semicolons or new lines, 64 at most (an entry 255 characters, the whole 4096); a line that begins with `#` is a comment. A map is a multiplayer map's name (`bloodgulch`), `custom_maps\` or a level's path; a gametype is a built-in gametype's name (`slayer`, `team_slayer`, `ctf`...), an OpenCE gametype's (`swat`...), a gametype file's (`gametypes/.toml`) or `file=`. An entry that cannot be played is left out with a warning. A change of the rotation (a command, the web page, `sv_reload`) keeps its place: the next game is the entry after the one played last. Refer to `docs/dedicated_server.md`, "The rotation". | +| `server.rotation_fallback` | `false` | `HALO_SERVER_ROTATION_FALLBACK` | `true`: with no entry of `server.rotation` that can be played, play `bloodgulch:slayer`. `false`: stop (exit code 3). | +| `server.lobby_seconds` | `15` | `HALO_SERVER_LOBBY_SECONDS` | The seconds the lobby counts down once `server.min_players` are in (0 to 300; `0`: the game starts at once). | +| `server.postgame_seconds` | `12` | `HALO_SERVER_POSTGAME_SECONDS` | The seconds the scores show after a game (0 to 300). | +| `server.empty_seconds` | `30` | `HALO_SERVER_EMPTY_SECONDS` | The seconds a game with nobody in it goes on before it ends. `0`: it does not end. A game whose last player leaves ends at once, as the game ends it. | +| `server.idle_minutes` | `0` | `HALO_SERVER_IDLE_MINUTES` | The minutes a game goes on with no score before it ends. `0`: it does not end. | +| `server.team_start` | `"one"` | `HALO_SERVER_TEAM_START` | A team game. `"one"`: it starts with the players of one team (the next players to join balance the teams). `"both"`: it waits, saying nothing, for a player on each team. `"skip"`: while one player waits, a team game (not one `sv_map` asked for) gives way to the rotation's next entry without teams, if it has one; checked when the lobby opens and again when its countdown would begin. | +| `server.motd` | `""` | `HALO_SERVER_MOTD` | A notice each machine sees when it joins (127 characters). Empty: none. | +| `server.frame_rate` | `60` | `HALO_SERVER_FRAME_RATE` | The frames a second (30 to 120); the game's ticks stay at 30 a second. A server with no other machine runs 10 frames a second. | +| `server.console`, `server.console_exit_on_eof` | `true`, `false` | `HALO_SERVER_CONSOLE`, `HALO_SERVER_CONSOLE_EXIT_ON_EOF` | `true`: the server takes commands from the standard input; it stops when the standard input ends if `server.console_exit_on_eof` is `true` (a panel can close it), after running the last line, whether or not a line's end ended it. | +| `server.init_file` | `"init.txt"` | `HALO_SERVER_INIT_FILE` | The commands run once at the start, one on each line: in the data root unless a full path. Empty: none. | +| `server.web_port`, `server.web_bind` | `8080`, `"127.0.0.1"` | `HALO_SERVER_WEB_PORT`, `HALO_SERVER_WEB_BIND` | The TCP port and IPv4 address of the web page, its API and the remote console (RCON). `0`: none. With an address beyond loopback, each web password that is set must have 8 characters or more, else the server stops (exit code 3, as for a port it may not bind or an address the machine does not have; a port in use: exit code 4); put a reverse proxy with TLS in front of it. Each server on a machine needs its own port. Read at the start only. | +| `server.web_password` | `""` | `HALO_SERVER_WEB_PASSWORD` | The admin password of the web page (8 characters or more). Empty: the server makes one at the first start, shows it once and writes it here. | +| `server.web_owner_password` | `""` | `HALO_SERVER_WEB_OWNER_PASSWORD` | A password with the owner role, for a supervisor on the server's own machine (a fleet's agent, a panel): over the API and the RCON it does what the standard input does (`quit`, `sv_stop`, `sv_restart`, the owner's settings, the game's console commands, the players' addresses). It signs in from `127.0.0.0/8` alone, never through `server.web_trusted_proxy`; from another address it is refused (`403 forbidden`) and counted as a wrong password. 8 characters or more (else the server exits with code 3). Empty: none. `sv_set` does not change it (`config.toml` and `sv_reload`). | +| `server.web_moderator_password`, `server.web_viewer_password` | `""` | `HALO_SERVER_WEB_MODERATOR_PASSWORD`, `HALO_SERVER_WEB_VIEWER_PASSWORD` | Other passwords: the moderator role (kick, mute, say, the next map, bans of one day at most) and the viewer role (the status, the players and the log; no changes). 8 characters or more when the web port is bound beyond loopback (else the server exits with code 3; on loopback, a warning). Empty: none. `sv_set` changes the moderator password, not the viewer password (`config.toml` and `sv_reload`). Each web password's variable has a `_FILE` form (`HALO_SERVER_WEB_PASSWORD_FILE`, `HALO_SERVER_WEB_OWNER_PASSWORD_FILE`...): when the variable is not set, the file's first line is the password, pinned as the variable would be; read at the start. | +| `server.web_ui` | `true` | `HALO_SERVER_WEB_UI` | `false`: the web port serves the API and RCON, but not the admin page. | +| `server.web_trusted_proxy` | `""` | `HALO_SERVER_WEB_TRUSTED_PROXY` | The address of a reverse proxy in front of the web port: its `X-Forwarded-For` (the last entry, the one the proxy added, an IPv4 or IPv6 address with or without a port), `-Host` and `-Proto` headers are believed, and a sign-in through it with `X-Forwarded-Proto: https` gets a `Secure` cookie. Empty: none. | +| `server.metrics` | `true` | `HALO_SERVER_METRICS` | `true`: the web port serves `/metrics` (Prometheus) without a password. | +| `server.log_format` | `"text"` | `HALO_SERVER_LOG_FORMAT` | The standard output's lines: `"text"` or `"json"`. | +| `server.log_addresses` | `false` | `HALO_SERVER_LOG_ADDRESSES` | `true`: the players' internet addresses and hardware IDs are in the log, the events and the API for every role (the owner's console always has them). | +| `server.log_debug_echo` | `false` | `HALO_SERVER_LOG_DEBUG_ECHO` | `true`: the lines of `debug.txt` are also written to the standard error. | +| `server.status_file` | `""` | `HALO_SERVER_STATUS_FILE` | A file the status (as `/api/status` gives it) is written to every 2 seconds, for a panel without HTTP. Empty: none. | +| `server.auto_update` | `"off"` | `HALO_SERVER_AUTO_UPDATE` | `"check"`: every 6 hours, the server looks for a newer release and logs it. `"install"`: it also installs it once the server has been empty for 10 minutes (on a thread of its own, the server playing on meanwhile), and stops with exit code 5, once it is empty, to be started again; an install that fails is tried again at the next check. `"off"`: neither. | +| `server.upnp` | `false` | `HALO_SERVER_UPNP` | `true`: ask the router (UPnP) to forward the tunnel's port (as `network.allow_upnp`, for a server at home). | +| `server.coop_difficulty` | `"normal"` | `HALO_SERVER_COOP_DIFFICULTY` | The difficulty of co-op entries of the rotation (`:coop`): `"easy"`, `"normal"`, `"hard"` or `"impossible"`. | +| `server.list_on_brokers` | `false` | `HALO_SERVER_LIST_ON_BROKERS` | `true`: the server is also listed with the games players host (through the brokers), for server browsers without master servers. | +| `server.master_publish` | `true` | `HALO_SERVER_MASTER_PUBLISH` | `true`: a public server is published to the master servers, signed with the publisher key. | +| `server.masters_file` | `"masters.txt"` | `HALO_SERVER_MASTERS_FILE` | The master servers the server is published to, next to `config.toml` unless a full path: on each line an `https://` URL and the master's key (`ed25519:`). None named: the official master, built in. | +| `server.publisher_key_file` | `"publisher.key"` | `HALO_SERVER_PUBLISHER_KEY_FILE` | The operator's Ed25519 key for the master servers (`halo --server --keygen` makes it), next to `config.toml` unless a full path. In a container, `HALO_SERVER_PUBLISHER_KEY` can hold the key itself. | +| `server.instance` | `""` | `HALO_SERVER_INSTANCE` | A short name that tells this server from the operator's other servers on the master servers (letters, digits, `-` and `_`). Empty: made from `server.name`. | +| `server.region` | `""` | `HALO_SERVER_REGION` | A region the server browser shows (`eu`, `na-east`...): 16 characters. Empty: none. | +| `server.master_publish_address` | `false` | `HALO_SERVER_MASTER_PUBLISH_ADDRESS` | `true`: the master servers can give this server's address to players. `false`: only their probe uses it. | | `discord.application_id` | the application of the project | `HALO_DISCORD_APPLICATION` | The Discord application for invites. Empty: no Discord. | | `update.auto` | `true` | `HALO_UPDATE_AUTO` | `true`: at start-up, the game looks for a new version. Refer to "Updates". `false`: the game does not look. | | `streamer.enabled` | `false` | `HALO_STREAMER_MODE` | `true`: streamer mode hides offensive words, as stars, in the names and text of other players, maps and mods, at the levels below. Refer to "Streamer mode". STREAMER MODE in Settings > Network Setup > Streamer Mode. | @@ -552,7 +601,7 @@ the setting for one start of the game. It has priority over the file. | `crash_reports.upload` | `"ask"` | `HALO_CRASH_REPORTS` | Windows only. `"yes"`: the game sends a report of each crash to the developers. `"no"`: the game sends no reports. `"ask"`: the game asks at the next crash and writes the answer here. Refer to "Crash reports" in [port/windows/README.md](../windows/README.md#crash-reports). | | `debug.update_answer` | `""` | `HALO_UPDATE_ANSWER` | The answer to the update question, for automatic tests: `yes`, `no` or `never`. Empty: the game asks. | | `debug.fixed_step` | `false` | `HALO_FIXED_STEP` | `true`: a game of this computer alone runs one tick a frame, whatever time has passed, so that it plays the same every time (tests). | -| `debug.exit_after` | `0.0` | `HALO_EXIT_AFTER` | The game stops after this number of seconds (from when its window opens; with `debug.null_renderer`, or no display, from its first frame). `0`: never. | +| `debug.exit_after` | `0.0` | `HALO_EXIT_AFTER` | The game stops after this number of seconds (from when its window opens; with `debug.null_renderer`, or no display, from its first frame; a dedicated server stops as `quit` stops it). `0`: never. | | `debug.screenshot_directory`, `debug.screenshot_every` | `""`, `0` | `HALO_SCREENSHOT_DIR`, `HALO_SCREENSHOT_EVERY` | The game writes each Nth frame to this folder as a BMP file. | | `debug.hidden_window`, `debug.null_renderer` | `false` | `HALO_HIDDEN_WINDOW`, `HALO_NULL_RENDERER` | `true`: no visible window, or no graphics. | | `debug.gpu_stats`, `debug.gpu_trace_frame`, `debug.gpu_trace_constants`, `debug.gpu_dump_shaders`, `debug.texture_dump_directory`, `debug.texture_log`, `debug.gl_debug`, `debug.texture_no_cache` | off | `HALO_GPU_STATS`, `HALO_GPU_TRACE`, `HALO_GPU_TRACE_CONSTANTS`, `HALO_GPU_DUMP_SHADERS`, `HALO_TEXTURE_DUMP`, `HALO_TEXTURE_LOG`, `HALO_GL_DEBUG`, `HALO_TEXTURE_NO_CACHE` | Tools to find problems in the graphics: counts for each frame, all the GL state of one frame, the GLSL code, the textures. | @@ -874,14 +923,18 @@ ON (the default: a line with a link, such as `discord.gg/...`, `example.com`, `www.`, an address, or `example [dot] com`, is refused), WITH LINKS, or OFF. A domain that is a word as well (`.me`, `.to`, `.is`) counts only with a path after it (`youtu.be/...`), so that `go.to` and -`he.is` are not refused. A game listed in +`he.is` are not refused. A game a player hosts that is listed in the server browser (a public internet game) never has the text chat, whatever it says (TEXT CHAT shows OFF there, and cannot be changed, while LISTING is PUBLIC); a private game, joined by its invite, and a LAN game have -it as chosen. A player who joins a game from the server browser has no text -chat in it even if its host turns it on: their game takes no line from that -host and sends none. Pressing T where the chat is off says so, and a refused line -tells its sender why. +it as chosen. A player who joins such a game from the server browser has no +text chat in it even if its host turns it on: their game takes no line from +that host and sends none. A dedicated server is the exception: its own +`network.text_chat` decides (on by default, and its operator can turn it +off, or allow links), listed or not, and a player who joins it from the +server browser's dedicated servers (a master server's list, which names +approved operators alone) has the chat as it says. Pressing T where the chat +is off says so, and a refused line tells its sender why. On the scoreboard (right-click, then click the player), Mute voice and chat mutes a player for you alone: you no longer see their lines or hear their @@ -989,6 +1042,74 @@ host sends with the game (network version 37), and otherwise the game engine's (SLAYER, CTF, KING OF THE HILL). The browser of LAN games, and the preview of a game before joining it, name it the same way. +The browser lists two kinds of games: the dedicated servers that the +master servers list (`masters.txt`, `network.masters_file`; the official +master, `https://master.opencommunityedition.com`, built in, when the file +names none), and the games +that players host, which come through the brokers. `network.listings` +chooses the kinds that the browser asks for: both (the default), only the +master servers, or only the brokers. + +- The top-left tab chooses the games that the list shows: ALL GAMES (at + first), DEDICATED or P2P GAMES (left and right, `network.browser_source`). + When `network.listings` is `"masters"`, the tab shows DEDICATED only; + when it is `"brokers"`, P2P GAMES only. + PLAYERS, the sum of the players in the games shown, is in the middle tab. +- The column titles sort the list (A or Enter on a title, or a click). + The titles are: server name, map, game, players, the lock (the games + with a password first) and the dedicated icon (dedicated servers first). + A second choice of the same title reverses the order, and an arrow on + the title shows the direction. The default order is dedicated servers + first, then the games with the most players, then by name. The browser + keeps the order in `network.browser_sort`. +- FILTERS opens the Filters screen of Custom Edition. It has SHOW EMPTY + SERVERS, SHOW FULL SERVERS, GAME TYPE (ANY or one game engine) and SHOW + MISSING MAPS (NO, YES, or IF DOWNLOADABLE, for a Custom Edition map that + Map Sharing can download when you join). OK writes the + `network.browser_*` settings. Settings > Network Setup > SERVER BROWSER + writes the same settings, and also shows LISTS, SORT BY (DEDICATED FIRST, + PLAYERS, NAME, MAP, GAME TYPE or LOCKED FIRST; an order that a title reversed + shows as the same choice) and MASTER SERVERS + (the number of master servers that answered the last request of the + browser, for example `2 OF 2`). The PING and TEAMPLAY OPTIONS rows of + Custom Edition are not on the Filters screen. +- A dedicated server's row shows the dedicated icon of Custom Edition. + Instead of a ping, a dedicated server shows its region (EU, NA-E), + because the browser does not ping the servers that the master servers + list. The row shows JOINING while a join runs. A game of another network + version stays in the list in grey, after the other games, with NEWER or + OLDER. Its row does not join the game: it shows the message about the + version. The server count above the list adds the number of dedicated + servers (`SERVERS: 8 (5 dedicated)`) when the list shows all the games on + one page. PAGE shows only when the list has more than one page. +- The two lines below the list describe the selected game. The first line + shows its players, map, game and region. The second line shows where the + game comes from: "Dedicated server by , listed on N master + servers" (the operator's name comes from the master server, and only this + line shows it), "Hosted by a player (anonymous listing)", "Password + required", or, for a game of another version, the update that it needs. + During a join, the second line shows the steps of the join, and how many + seconds it has taken. The steps are: the request to the brokers for the + host, the answer of the host and the connection attempt (UDP hole + punching), and the connection to the host. If the join fails, the line + shows the reason: + - the host did not answer; + - no route to the host (the line says strict NAT when that is the cause); + - the host refused the join: the game is full or closed, this machine is + banned, the password is wrong, the version is different, or the invite + changed. + The browser shows a refusal when it opens again after the main menu + error. +- If no master server answers but the brokers do, the second line shows "No + master server answered: showing player-hosted games only", and the list + shows the games from the brokers. An error panel covers the list in two + cases: internet play is off, or nothing answers (no master server and no + broker) after 15 seconds. OK, Enter or B closes the panel. +- In the lobby of a dedicated server, the panel at the right shows the + name of the server and "dedicated" on its first line. It shows "Waiting + for players" while the server waits for enough players + (`server.min_players`) to start its countdown. + How it operates (`src/p2p_lobby.c`): - The host of a public game publishes a listing: the invite, and the name, @@ -1240,6 +1361,108 @@ the invite with the invite button of Discord. When a person accepts it, that person joins the game. If the game does not operate, Discord starts it. The game sends the activity only to a Discord client of the same user. +## Dedicated server + +`halo --server` (or `server.enabled = true`) runs the game as a host with no +window, sound or player of its own, for a VPS, a container or a game panel. +It hosts the games of `server.rotation` one after another: the lobby counts +down when `server.min_players` are in, the game is played, its scores show, +and the lobby opens with the next entry. Players join it as they join any +host: on the LAN, with its invite link, or from the server browser. +`docs/dedicated_server.md` is the reference: the command line, the exit +codes, the commands, the HTTP API, the remote console, the events and the +files. + +```sh +./halo --server # the settings of config.toml +HALO_SERVER_ROTATION="bloodgulch:ctf, prisoner:slayer+score=25" ./halo --server +./halo --server --config /srv/opence/config.toml +``` + +The server needs the game data (`maps/`, as for the game); without +`maps/ui.map` it stops at once with exit code 2. At the first start it +writes the `server.*` settings to `config.toml` (they are written only by a +server) and a web password if there is none. It writes one line for each +event to the standard output (`server.log_format = "json"` for JSON +lines), and the game's own log to `debug.txt` as the game does +(`paths.logs` moves it, so that the data root can be read-only). + +- **Commands.** The standard input, `init.txt` (`server.init_file`) and the + web page take the same commands: `sv_status`, `sv_players`, `say`, `tell`, + `kick`, `ban 2h `, `unban`, `mute`, `sv_map`, + `sv_map_next`, `sv_end_game`, `sv_rotation_set`, `sv_set + `, `sv_reload`, `sv_ban_address `, + `sv_stop [now|game|empty] [notice]`, `sv_restart`, `quit`. `help` lists + them. `sv_stop game` (or `empty`) withdraws the listings, refuses new + players and stops once the game is over (or nobody plays), without + cutting a game short; `sv_stop cancel` undoes it. The game's own console + commands need the owner role: the standard input and `init.txt` take + them, and the web page and the remote console with + `server.web_owner_password` from the machine itself; with another + password they do not (and `kick` and `ban` are always the server's). +- **Web page.** `server.web_port` (8080, on `127.0.0.1`) serves an admin + page, a JSON API, a remote console over WebSocket, `/healthz` and + `/metrics`. Sign in with `server.web_password` (the admin); the moderator + and viewer passwords give fewer rights, and `server.web_owner_password`, + from the machine itself alone, the owner's. To reach it from another + computer, use an SSH tunnel, or a reverse proxy with TLS, + `server.web_bind` and `server.web_trusted_proxy`; bound beyond loopback, + every web password must have 8 characters or more. +- **Signals.** SIGTERM and SIGINT stop the server cleanly (a game in + progress shows its scores, every machine is told, the listings are + withdrawn); SIGHUP reads the settings and the rotation again. +- **Listing.** A public server (`server.public`) with `network.online` is + published to the master servers of `masters.txt`, signed with the + operator's key: `halo --server --keygen` makes `publisher.key` and prints + the public key, which the operator gives to a master server's Discord + bot: `/apply` in the master's Discord server (the master's `/apply` page + links to it), with `halo --server --sign `'s proof of key. Until a + master approves it, the server is not listed + there (its log says so), but players can join it with its invite link + and on the LAN. A change the lists show (public or private, the + players, the name, the map) is published within 5 seconds; made + private, the server makes a new invite and takes itself off the + masters' lists, as `network.public_lobby = false` does + (`docs/dedicated_server.md`, "The listing"). `server.list_on_brokers` + also lists it with the games players host. A player's public game is published to the masters too + when `publisher.key` is next to `config.toml` (not as a dedicated + server), and withdrawn when it ends. `docs/master_protocol.md` is the + protocol. +- **Ports.** The tunnel's UDP port (`network.tunnel_port`, 2302 for a + server) must be reachable from the internet; `network.public_address` + names the address and forwarded port behind a NAT. The web port is TCP + and best kept private. +- **Updates.** `server.auto_update = "install"` installs a new release once + the server has been empty for 10 minutes (on a thread of its own: the + server goes on meanwhile), and stops with exit code 5, once it is empty, + so that what runs it (systemd, Docker, a panel) starts the new build. An + install that fails is tried again at the next check, 6 hours later. +- **Probe.** `halo --probe ` prints the state of the invite's game + (a server's name, map, gametype and players) as one line of JSON, for a + list site, and exits: the game of the invite's host alone. It never + starts a server, whatever the settings say, and writes nothing to + `config.toml`. + +`deploy/` has a systemd unit (`deploy/systemd`), a Docker image and compose +file (`deploy/docker`), a Pterodactyl and Pelican egg (`deploy/pterodactyl`), +a Helm chart (`deploy/helm/opence-server`), PufferPanel and MCSManager +templates (`deploy/pufferpanel`, `deploy/mcsmanager`), example gametype +files (`deploy/gametypes`, described in `docs/dedicated_server.md`, +"Gametype files": their rules have their full names, `team_vehicles`, not +the rotation's short names) and an example `init.txt` +(`deploy/init.txt.example`). The README of each of the first six folders +says how to use it. `deploy/release-key.pub` is the public key of the +releases' signed `SHA256SUMS` (`docs/dedicated_server.md`, "Releases"). + +**Fleets.** For many servers on many machines, beside the panels, +`opence-fleet` +([github.com/OpenCommunityEdition/opence-fleet](https://github.com/OpenCommunityEdition/opence-fleet)) +is one control plane over them all: an agent on each machine runs its +servers and drives each through the API as the owner, from the machine +itself (`server.web_owner_password`), drains a server before an update +(`sv_stop game`, `POST /api/stop`) and bans an address on every server at +once (`POST /api/bans`); `docs/dedicated_server.md`, "Panels and fleets". + ## Mods A mod is a folder in `mods/` in the data root with a `mod.toml` and a diff --git a/port/linux/game/map_share.c b/port/linux/game/map_share.c index 79d4b47b..7f53feaa 100644 --- a/port/linux/game/map_share.c +++ b/port/linux/game/map_share.c @@ -66,6 +66,7 @@ what it says it has grows. #include "custom_edition_cache.h" #include "custom_edition_maps.h" #include "map_share.h" +#include "server_director.h" #include "../src/p2p.h" #include @@ -499,6 +500,10 @@ struct host_machine unsigned long requested_at; boolean request_waiting; struct distributed_map_share_request waiting_request; + /* the download's end (done, or failed) said, in the log and a dedicated + server's event: once a request (host_request clears it), however often + the machine says it */ + boolean end_said; }; struct map_share_globals @@ -3234,6 +3239,7 @@ static void host_request( machine->downloading = TRUE; error(_error_silent, "map sharing: machine #%ld asks for %s (files 0x%X)", machine_index, request->level_name, (unsigned)request->wanted); + server_event_map_share(machine_index, request->level_name, "asked", 0, 0); } /* a machine's request, as it comes: taken at once if the last it made was @@ -3360,15 +3366,29 @@ static void host_progress( (machine->done > machine->advanced_done && machine->done == machine->offered_total) || progress->state == _progress_done) { - machine->advanced_done = machine->done; + /* (a dedicated server's event at each tenth it passes, going on: + what it says it has never goes back) */ + if (progress->state == _progress_downloading && machine->offered_total && + machine->done > machine->advanced_done && + (unsigned long long)machine->done * 10 / machine->offered_total != + (unsigned long long)machine->advanced_done * 10 / machine->offered_total) + { + server_event_map_share(machine_index, machine->request.level_name, "downloading", machine->done, + machine->offered_total); + } + if (machine->done > machine->advanced_done) + machine->advanced_done = machine->done; machine->advanced_at = machine->heard_at | 1; } machine->downloading = progress->state != _progress_failed; machine->downloaded = progress->state == _progress_done; - if (progress->state != _progress_downloading) + if (progress->state != _progress_downloading && !machine->end_said) { + machine->end_said = TRUE; error(_error_silent, "map sharing: machine #%ld %s %s", machine_index, progress->state == _progress_done ? "downloaded" : "could not download", machine->request.level_name); + server_event_map_share(machine_index, machine->request.level_name, + progress->state == _progress_done ? "downloaded" : "failed", machine->done, machine->offered_total); } } diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 8e324e59..12ff31ff 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -86,6 +86,9 @@ their handlers open opens. #include "text/draw_string.h" /* (internet play's server browser: the platform layer's) */ #include "../src/p2p.h" +/* (the server browser's master servers, and Server Setup's publisher key) */ +#include "../src/master_list.h" +#include "../src/server_master.h" #include "text_filter.h" #include @@ -244,7 +247,9 @@ static boolean spinner_item(struct widget_instance const *widget) return widget->parent && widget->parent->definition_tag_index == widget->definition_tag_index; } -/* the value shown for the setting's: the same one, else the nearest number */ +/* the value shown for the setting's: the same one, else the nearest number; +a word with "-" before it (network.browser_sort's order reversed: "-players") +the word's */ static short setting_value_index(struct pc_menu_setting const *setting, char const *current) { double current_number, value_number, distance = 0.0; @@ -255,6 +260,14 @@ static short setting_value_index(struct pc_menu_setting const *setting, char con if (!_stricmp(setting->values[index], current)) return index; } + if (current[0] == '-' && !text_is_number(current, ¤t_number)) + { + for (index = 0; index < setting->value_count; index++) + { + if (!_stricmp(setting->values[index], current + 1)) + return index; + } + } if (!text_is_number(current, ¤t_number)) return 0; for (index = 0; index < setting->value_count; index++) @@ -665,14 +678,15 @@ static void text_set(struct widget_instance *text_box, wchar_t const *text) text_set_length(text_box, text, ROW_TEXT_LENGTH); } -/* one of our string lists' strings, on one line (its line breaks spaces) */ -static void string_get(char const *list, short index, wchar_t *text) +/* one of our string lists' strings, on one line (its line breaks spaces), +of up to size characters */ +static void words_get(char const *list, short index, wchar_t *text, short size) { long tag_index = tag_loaded('ustr', list); wchar_t const *string = tag_index != NONE ? unicode_string_list_get_string(tag_index, index) : NULL; long length = 0; - for (; string && *string && length < ROW_TEXT_LENGTH - 1; string++) + for (; string && *string && length < size - 1; string++) { wchar_t character = *string == '\r' || *string == '\n' ? ' ' : *string; @@ -684,6 +698,11 @@ static void string_get(char const *list, short index, wchar_t *text) text[length] = 0; } +static void string_get(char const *list, short index, wchar_t *text) +{ + words_get(list, index, text, ROW_TEXT_LENGTH); +} + /* the list's row that has the focus, else NONE (its buttons) */ static short focused_row(struct widget_instance *list) { @@ -1536,6 +1555,56 @@ static void video_rows_show(struct widget_instance *list) } } +/* Network Setup's SERVER BROWSER's MASTER SERVERS (tools/port_settings.py: +its words): how many of masters.txt's answered when the browser last asked, +" OF "; none listed, not asked yet, or off (network.listings +is "brokers"). masters.txt is read again every few seconds */ +#define SERVER_BROWSER_WORDS \ + "pc\\main_menu\\settings_select\\player_setup\\player_profile_edit\\network_setup\\server_browser\\words" +enum +{ + _master_servers_word_of, + _master_servers_word_none, + _master_servers_word_not_asked, + _master_servers_word_off, +}; + +static void master_servers_show(struct widget_instance *list) +{ + static int listed; + static unsigned long read_time; + static boolean read; + struct widget_instance *value = named(list, "master_servers_value", 0); + wchar_t text[ROW_TEXT_LENGTH], word[ROW_TEXT_LENGTH]; + int count, answered, asked; + + if (!value) + return; + if (!read || system_milliseconds() - read_time > 3000) + { + static char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; + static unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; + + listed = master_list_read(config_string("network.masters_file"), urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + read_time = system_milliseconds(); + read = TRUE; + } + master_list_counts(&count, &answered, &asked); + if (master_list_policy() == _master_listings_brokers) + words_get(SERVER_BROWSER_WORDS, _master_servers_word_off, text, NUMBEROF(text)); + else if (!listed) + words_get(SERVER_BROWSER_WORDS, _master_servers_word_none, text, NUMBEROF(text)); + else if (!asked) + words_get(SERVER_BROWSER_WORDS, _master_servers_word_not_asked, text, NUMBEROF(text)); + else + { + words_get(SERVER_BROWSER_WORDS, _master_servers_word_of, word, NUMBEROF(word)); + usnprintf(text, NUMBEROF(text) - 1, L"%d %s %d", answered, word, count); + text[NUMBEROF(text) - 1] = 0; + } + text_set(value, text); +} + /* Graphics' button style follows the pending HUD choice, including Defaults. The remaining options close the gap; the help and bottom buttons stay put. */ static void hud_button_style_row_show(struct widget_instance *list) @@ -2680,6 +2749,9 @@ Xbox's networking, run by the engine's port entry points #define GAMETYPE_ROWS 10 #define MAXIMUM_GAMETYPES 100 #define BROWSER_ROWS 15 +/* the server browser's source tab's words and arrows while another item +has the focus (lobby_browser_source) */ +#define TAB_UNFOCUSED_ALPHA 0.45f #define LOBBY_ROWS 11 #define TEXT_FIELD_LENGTH 128 #define PLAYLIST_READ_ONLY_BIT 0x40000000UL @@ -2751,6 +2823,8 @@ boolean ui_widget_port_browse(struct widget_instance *widget, struct event_recor boolean ui_widget_port_open(struct widget_instance *widget, char const *name, boolean *widget_deleted); boolean network_game_client_advertised_game_in_progress(void *client, struct advertised_game *game); byte network_game_client_advertised_game_kind(void *client, struct advertised_game *game); +boolean network_game_client_advertised_game_dedicated(void *client, struct advertised_game *game); +void network_game_client_tell_version(unsigned int theirs, boolean distributed); boolean ui_widget_port_join(struct widget_instance *widget, void *advertised_game, char const *lobby_name, boolean *widget_deleted); boolean ui_widget_port_multiplayer_player(short controller_index, long profile_index); @@ -2918,9 +2992,9 @@ static void text_field_show(struct widget_instance *value, char const *text, boo if (editing) { - text_field_shown_time = system_milliseconds(); struct key_stroke key; + text_field_shown_time = system_milliseconds(); while (input_get_key(&key)) { size_t length = strlen(text_field.text); @@ -3424,6 +3498,11 @@ VOTING), PASSWORD's and MAP SHARING's (its help_strings, tools/port_settings.py) #define COOPERATIVE_VOICE_OPTIONS_HELP 13 #define SERVER_PASSWORD_HELP 14 #define MAP_SHARE_OPTIONS_HELP 15 +/* (LISTING's PUBLIC on a machine with a publisher key: server_master.c) */ +#define SERVER_LISTING_PUBLISHER_HELP 16 + +/* whether this machine has a publisher key (read as Server Setup opens) */ +static boolean server_settings_publisher; /* co-op's EXTRA ENEMIES' choices (network.coop_enemies_mode's values, as gametype_options' coop_extra_enemies_spinner has them, in this order): its @@ -3508,6 +3587,12 @@ static boolean server_settings_initialize(struct widget_instance *list) if ((spinner = named(list, "listing_spinner", 0)) != NULL) spinner->parameters.list.selected_index = server_settings_private() ? 1 : 0; p2p_set_hosting_public(multiplayer.mode == _multiplayer_mode_host_internet && !server_settings_private()); + { + unsigned char public_key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + + server_settings_publisher = server_master_publisher(public_key, key_id) != 0; + } return TRUE; } @@ -3580,8 +3665,13 @@ static void server_settings_update(struct widget_instance *list) server_settings_private_set(!public); p2p_set_hosting_public(public); + /* (PUBLIC: through the brokers, and the master servers too with a + publisher key) */ if (help && list->focused_child == named(list, "op_listing", 0)) - help->parameters.text_box.string_list_index = (short)(10 + spinner->parameters.list.selected_index); + { + help->parameters.text_box.string_list_index = public && server_settings_publisher ? + SERVER_LISTING_PUBLISHER_HELP : (short)(10 + spinner->parameters.list.selected_index); + } } /* PASSWORD (a PUBLIC internet game's): its stars, NONE if it has none */ row = named(list, "op_password", 0); @@ -3699,6 +3789,16 @@ static boolean game_from_peer(struct advertised_game const *game) return p2p_peer_address(game->xnaddr + 2, &address) != 0; } +/* (network_test.c's probe) whether the client's advertised game at the +index is a peer's (game_from_peer) */ +boolean menu_advertised_game_from_peer(long index) +{ + void *client = global_network_game_client_get(); + struct advertised_game *games = client ? network_game_client_get_available_games(client) : NULL; + + return games && index >= 0 && index < MAXIMUM_ADVERTISED_GAMES && game_from_peer(&games[index]); +} + static boolean advertised_in_progress(struct advertised_game *game) { return network_game_client_advertised_game_in_progress(global_network_game_client_get(), game); @@ -3737,8 +3837,12 @@ static short browser_row_index(struct widget_instance *row) } /* the ping each row shows (-1: none), coloured by pc_menu_text_color: green -under PING_GOOD milliseconds, yellow under PING_FAIR, red above */ +under PING_GOOD milliseconds, yellow under PING_FAIR, red above; a row's +status word (JOINING, NEWER, OLDER) in yellow (marked), and a game of +another network version's row grey */ static short browser_row_pings[BROWSER_ROWS]; +static boolean browser_row_marked[BROWSER_ROWS]; +static boolean browser_row_greyed[BROWSER_ROWS]; enum { PING_GOOD = 80, @@ -3747,6 +3851,7 @@ enum #define PING_COLOR_GOOD 0x5ED38CFFUL #define PING_COLOR_FAIR 0xE8C547FFUL #define PING_COLOR_POOR 0xE86A5AFFUL +#define ROW_COLOR_GREYED 0x8A8F99FFUL /* ui_widget.c: a text's colour in place of its own (FALSE: its own): a server browser row's ping by how good it is */ @@ -3756,12 +3861,19 @@ boolean pc_menu_text_color(struct widget_instance *widget, real_argb_color *colo short ping; unsigned long rgba; - if (row < 0 || row >= BROWSER_ROWS || strcmp(widget->name, "server_item_ping")) + if (row < 0 || row >= BROWSER_ROWS || strncmp(widget->name, "server_item_", 12)) return FALSE; ping = browser_row_pings[row]; - if (ping < 0) + if (strcmp(widget->name, "server_item_ping")) + rgba = ROW_COLOR_GREYED; + else if (browser_row_marked[row]) + rgba = PING_COLOR_FAIR; + else if (ping >= 0) + rgba = ping < PING_GOOD ? PING_COLOR_GOOD : ping < PING_FAIR ? PING_COLOR_FAIR : PING_COLOR_POOR; + else + rgba = ROW_COLOR_GREYED; + if (rgba == ROW_COLOR_GREYED && !browser_row_greyed[row]) return FALSE; - rgba = ping < PING_GOOD ? PING_COLOR_GOOD : ping < PING_FAIR ? PING_COLOR_FAIR : PING_COLOR_POOR; color->red = (real)((rgba >> 24) & 0xFF) / 255.0f; color->green = (real)((rgba >> 16) & 0xFF) / 255.0f; color->blue = (real)((rgba >> 8) & 0xFF) / 255.0f; @@ -3786,10 +3898,13 @@ static void focus_off_hidden(struct widget_instance *bar) } } -/* whether the browser's list's child can take focus (once visible) */ +/* whether the browser's list's child can take focus (once visible): the +server browser's tab (its source) and column titles (its order) too */ static boolean browser_item_usable(struct widget_instance *child) { - return browser_row_index(child) != NONE || !strcmp(child->name, "join_game_button_bar"); + return browser_row_index(child) != NONE || !strcmp(child->name, "join_game_button_bar") || + (multiplayer.mode == _multiplayer_mode_server_browser && + (!strcmp(child->name, "op_browser_mode") || !strcmp(child->name, "join_game_header"))); } /* the browser's focus, off what cannot have it (at the start, or a row whose @@ -3847,20 +3962,26 @@ static boolean browser_initialize(struct widget_instance *screen, struct event_r multiplayer.mode == _multiplayer_mode_direct_link ? "header_direct_link" : "header_server_browser"; char const *const titles[] = { "header_internet", "header_lan", "header_direct_link", "header_server_browser" }; char const *const unused[] = { "op_browser_mode", "join_game_button_update", "join_game_button_filters" }; + boolean server_browser = multiplayer.mode == _multiplayer_mode_server_browser; short index; server_browser_initialize(screen); for (index = 0; index < NUMBEROF(titles); index++) visible_set(named(screen, titles[index], 0), !strcmp(titles[index], title)); + /* (the server browser's tab, its source of games, and FILTERS are its + own; Update stays hidden: REFRESH does what it did) */ for (index = 0; index < NUMBEROF(unused); index++) - visible_set(named(screen, unused[index], 0), FALSE); + visible_set(named(screen, unused[index], 0), server_browser && index != 1); + if (server_browser && named(screen, "browser_mode_spinner", 0)) + setting_load(named(screen, "browser_mode_spinner", 0)); visible_set(named(screen, "button_clipboard", 0), multiplayer.mode == _multiplayer_mode_direct_link); visible_set(named(screen, "join_game_button_refresh", 0), multiplayer.mode == _multiplayer_mode_server_browser); { struct widget_instance *list = named(screen, "join_game_items_list", 0); struct widget_instance *child; - /* (only the games' rows and the buttons take focus: not the column titles, which sort nothing, nor the ticker) */ + /* (only the games' rows, the buttons and the server browser's tab + and column titles take focus: not the ticker) */ for (child = list ? list->child : NULL; child; child = child->next) child->disabled = !browser_item_usable(child); if (list) @@ -3868,8 +3989,9 @@ static boolean browser_initialize(struct widget_instance *screen, struct event_r } visible_set(named(screen, "scroll_up_button", 0), FALSE); visible_set(named(screen, "scroll_down_button", 0), FALSE); - /* (the columns' sort arrows, both of each over its title: the list is - not sorted) */ + /* (the columns' sort arrows, both of each over its title: LAN's and + Direct Link's lists are not sorted; the server browser shows its order's, + lobby_browser_sort_arrows) */ for (index = 0; named(screen, "header_sort_arrows", index); index++) visible_set(named(screen, "header_sort_arrows", index), FALSE); multiplayer.game_chosen = 0; @@ -3975,27 +4097,134 @@ static void game_map_name(struct advertised_game const *game, wchar_t *text) } /* ---- Join Game > Server Browser: internet play's public games (their -hosts' listings, p2p_lobby.c). Joining one joins its invite (as Direct -Link's PASTE LINK); once its host is reached its game is among the client's, -and an A press (posted) joins it, or shows its lobby if under way */ +hosts' listings, p2p_lobby.c: the brokers' player-hosted games and the +master servers' dedicated servers, master_list.c). Joining one joins its +invite (as Direct Link's PASTE LINK); once its host is reached its game is +among the client's, and an A press (posted) joins it, or shows its lobby if +under way. + +What it shows of them is the network.browser_* settings': the top-left tab +(ALL GAMES, DEDICATED, P2P GAMES), the Filters screen (CE's, which +Network Setup's SERVER BROWSER sets too) and the column titles, which sort +the rows (again: the other way). A game of another network version is +shown greyed, NEWER or OLDER, and is not joined. The line below the rows +says where the chosen game was listed and, while it is joined, how the join +goes (p2p_join_progress); the error panel covers the list when nothing can +be listed */ #define LOBBY_BROWSER_GAMES 256 /* milliseconds: a host not reached in this long is given up (p2p's own wait is longer) */ #define LOBBY_BROWSER_JOIN_TIMEOUT 30000 /* how long the browser looks before it says it found none, and shows a -join that failed */ +join that failed; and before it says nothing answers */ #define LOBBY_BROWSER_LOOK_TIME 6000 #define LOBBY_BROWSER_FAILED_TIME 8000 +#define LOBBY_BROWSER_UNREACHABLE_TIME 15000 +/* the line below the rows: two rows' worth */ +#define TICKER_TEXT_LENGTH (ROW_TEXT_LENGTH * 2) +/* between the parts of the line below the rows */ +#define TICKER_SEPARATOR L" \x00B7 " + +/* the browser's words (tools/port_settings.py's BROWSER_WORDS, in order) */ +#define BROWSER_WORDS "pc\\main_menu\\multiplayer_type_select\\join_game\\browser_words" +enum +{ + _browser_word_of, + _browser_word_players, + _browser_word_player, + _browser_word_under_way, + _browser_word_full, + _browser_word_dedicated_by, + _browser_word_listed_on, + _browser_word_master, + _browser_word_masters, + _browser_word_dedicated_server, + _browser_word_player_hosted, + _browser_word_password, + _browser_word_newer_build, + _browser_word_older_build, + _browser_word_asking, + _browser_word_answered, + _browser_word_connected, + _browser_word_no_answer, + _browser_word_no_route, + _browser_word_refused_full, + _browser_word_refused_closed, + _browser_word_refused_banned, + _browser_word_refused_password, + _browser_word_refused_version, + _browser_word_refused_token, + _browser_word_strict_nat, + _browser_word_online_off, + _browser_word_lobby_off, + _browser_word_looking, + _browser_word_newer_games, + _browser_word_none_found, + _browser_word_none_match, + _browser_word_masters_down, + _browser_word_dedicated, + _browser_word_joining, + _browser_word_newer, + _browser_word_older, + _browser_word_failed, + _browser_word_live, + _browser_word_closed, + _browser_word_error_online_off, + _browser_word_error_unreachable, +}; + +/* the words p2p_join_progress gives a join that failed, and the browser's +for each */ +static struct +{ + char const *reason; + short word; +} const join_failures[] = +{ + { "no_answer", _browser_word_no_answer }, + { "no_route", _browser_word_no_route }, + { "full", _browser_word_refused_full }, + { "closed", _browser_word_refused_closed }, + { "banned", _browser_word_refused_banned }, + { "password", _browser_word_refused_password }, + { "version", _browser_word_refused_version }, + { "token", _browser_word_refused_token }, +}; + +/* the column titles (their widgets' names) and the order each sorts by +(NONE: none; the dedicated servers' icon's is the default order) */ +static struct +{ + char const *name; + short order; +} const sort_headers[] = +{ + { "op_sort_header_server_name", P2P_LOBBY_SORT_NAME }, + { "op_sort_header_map", P2P_LOBBY_SORT_MAP }, + { "op_sort_header_type", P2P_LOBBY_SORT_TYPE }, + { "op_sort_header_players", P2P_LOBBY_SORT_PLAYERS }, + { "op_sort_header_locked", P2P_LOBBY_SORT_LOCKED }, + { "op_sort_header_dedicated", P2P_LOBBY_SORT_SERVERS }, + { "op_sort_header_classic", NONE }, + { "op_sort_header_ping", NONE }, +}; static struct { struct p2p_listing games[LOBBY_BROWSER_GAMES]; - /* the games found, the first on the rows, the one last focused (Join's) */ - short count, first, chosen; + /* the games shown (of those found), the first on the rows, the one last + focused (Join's) */ + short count, found, first, chosen; /* whether it has shown a game (the first has the focus) */ boolean shown; unsigned long begin_time; + /* the order (network.browser_sort's); the source the tab showed */ + short order; + boolean reverse; + short source; + /* the error panel was shown since the browser opened */ + boolean error_shown; /* a game being joined: its host, its name, since when, the controller that asked; ready: its game reached (the A press posted) */ boolean joining, ready; @@ -4003,12 +4232,17 @@ static struct char name[P2P_LISTING_NAME_SIZE + 1]; unsigned long join_time; short controller; + /* whether the game joined is a dedicated server's (the lobby's line) */ + boolean joining_dedicated; /* when the press was posted (again if it was lost) */ unsigned long ready_time; - /* the last that failed, and when */ + /* the press was posted: the host's game may yet refuse the join, which + the browser says when it opens again */ + boolean refusal_watched; + /* the last that failed, when, and why (a _browser_word_*) */ char failed_name[P2P_LISTING_NAME_SIZE + 1]; unsigned long failed_time; - boolean failed_nat_strict; + short failed_word; /* a game with a password chosen: the password screen's (its own copy: the rows' are read again each frame), and a join it began, which the browser takes up again as it comes back */ @@ -4016,6 +4250,42 @@ static struct boolean password_joined; } lobby_browser; +/* whether the game this machine joined last is a dedicated server's: its +advertisement's flag, or its listing's (the lobby's line, lobby_update) */ +static boolean lobby_joined_dedicated; + +static void lobby_dedicated_set(struct advertised_game *game, boolean listed) +{ + void *client = global_network_game_client_get(); + + lobby_joined_dedicated = listed || + (game && client && network_game_client_advertised_game_dedicated(client, game)); +} + +/* one of the browser's words, of up to length characters */ +static void browser_word(short word, wchar_t *text, short length) +{ + words_get(BROWSER_WORDS, word, text, length); +} + +/* the word appended to the text (of size characters) */ +static void text_append(wchar_t *text, short size, wchar_t const *word) +{ + size_t length = ustrlen(text); + + if ((short)length < size - 1) + usnprintf(text + length, size - 1 - length, L"%s", word); + text[size - 1] = 0; +} + +static void text_append_word(wchar_t *text, short size, short word) +{ + wchar_t piece[TICKER_TEXT_LENGTH]; + + browser_word(word, piece, NUMBEROF(piece)); + text_append(text, size, piece); +} + /* the row of the browser's chosen game, NONE if it is not on the rows */ static short browser_chosen_row(void) { @@ -4027,17 +4297,54 @@ static short browser_chosen_row(void) row < BROWSER_ROWS ? row : NONE; } +/* the word of a join's failure (p2p_join_progress's reason), NONE if none */ +static short join_failure_word(char const *reason) +{ + short index; + + for (index = 0; index < NUMBEROF(join_failures); index++) + { + if (!strcmp(reason, join_failures[index].reason)) + return join_failures[index].word; + } + return NONE; +} + static void lobby_browser_begin(void) { + int reverse; + lobby_browser.count = 0; + lobby_browser.found = 0; lobby_browser.first = 0; lobby_browser.chosen = 0; lobby_browser.shown = FALSE; + lobby_browser.error_shown = FALSE; + lobby_browser.source = NONE; + lobby_browser.order = (short)p2p_lobby_sort_parse(config_string("network.browser_sort"), &reverse); + lobby_browser.reverse = reverse != 0; /* (going on with the join the password screen began) */ lobby_browser.joining = lobby_browser.password_joined; lobby_browser.ready = FALSE; lobby_browser.password_joined = FALSE; lobby_browser.begin_time = system_milliseconds(); + /* (the host's game refused the join posted last time: why, now) */ + if (lobby_browser.refusal_watched) + { + char reason[16]; + int stage; + short word; + + lobby_browser.refusal_watched = FALSE; + p2p_join_progress(&stage, reason, sizeof(reason)); + word = stage == P2P_JOIN_FAILED ? join_failure_word(reason) : NONE; + if (word != NONE) + { + csmemcpy(lobby_browser.failed_name, lobby_browser.name, sizeof(lobby_browser.failed_name)); + lobby_browser.failed_word = word; + lobby_browser.failed_time = lobby_browser.begin_time ? lobby_browser.begin_time : 1; + } + } p2p_lobby_browse(TRUE); } @@ -4057,6 +4364,19 @@ static void text_to_wide(char const *text, wchar_t *wide, short size) wide[index] = 0; } +/* a master server's region label as shown, in capitals (EU, NA-E) */ +static void region_to_wide(char const *region, wchar_t *wide, short size) +{ + short index; + + text_to_wide(region, wide, size); + for (index = 0; wide[index]; index++) + { + if (wide[index] >= 'a' && wide[index] <= 'z') + wide[index] = (wchar_t)(wide[index] - 'a' + 'A'); + } +} + /* the games whose names are valid, as a host keeps the names of the machines and players that join it (network_game_server_clean_name): each name cleaned (player_name_clean), and a game whose name has nothing left @@ -4085,6 +4405,84 @@ static short lobby_browser_valid_games(struct p2p_listing *games, short count) return written; } +/* what a game's map is to this machine (p2p_lobby_filter): a Custom +Edition map it has not can be downloaded as it joins, if map downloads are +on (its host shares it, as hosts do unless they turned it off) */ +static int lobby_browser_map_state(char const *map) +{ + char const *const *names; + short index; + short count; + + if (custom_edition_level_name(map)) + { + if (custom_edition_maps_display_index(map) != NONE) + return P2P_LOBBY_MAP_INSTALLED; + return config_boolean("network.map_download") ? P2P_LOBBY_MAP_DOWNLOADABLE : P2P_LOBBY_MAP_MISSING; + } + count = xbox_multiplayer_map_count(ui_widget_port_multiplayer_maps(&names, NULL)); + for (index = 0; index < count; index++) + { + if (!_stricmp(scenario_name(names[index]), scenario_name(map))) + return P2P_LOBBY_MAP_INSTALLED; + } + return campaign_level_of(map) != NONE ? P2P_LOBBY_MAP_INSTALLED : P2P_LOBBY_MAP_MISSING; +} + +/* the settings' choices of games shown (the tab's source: source) */ +static void lobby_browser_choices(struct p2p_lobby_choices *choices, short source) +{ + static char const *const engines[] = { "any", "ctf", "slayer", "oddball", "king", "race" }; + char const *maps = config_string("network.browser_maps"); + char const *engine = config_string("network.browser_gametype"); + short index; + + choices->source = source; + choices->show_empty = config_boolean("network.browser_show_empty"); + choices->show_full = config_boolean("network.browser_show_full"); + choices->engine_type = 0; + for (index = 0; index < NUMBEROF(engines); index++) + { + if (!strcmp(engine, engines[index])) + choices->engine_type = index; + } + choices->maps = !strcmp(maps, "no") ? P2P_LOBBY_MAPS_NO : + !strcmp(maps, "downloadable") ? P2P_LOBBY_MAPS_DOWNLOADABLE : P2P_LOBBY_MAPS_YES; +} + +/* the top-left tab: the source of the games shown (network.browser_source, +written as it changes); under network.listings = "masters" the dedicated +servers alone, under "brokers" the games players host alone (the tab and +its setting kept as they were) */ +static short lobby_browser_source(struct widget_instance *list) +{ + struct widget_instance *spinner = named(list, "browser_mode_spinner", 0); + struct pc_menu_setting *setting = spinner ? pc_menu_setting_get(spinner->definition_tag_index) : NULL; + short source; + + if (!spinner || !setting) + return P2P_LOBBY_SOURCE_ALL; + /* (CE's box behind the tab, whose highlight showed it had the focus, is + gone: its outline clashed with the screen's own. The tab is dimmed + instead, its words and arrows, but while it has the focus) */ + spinner->alpha_modifier = list->focused_child == spinner->parent ? 1.0f : TAB_UNFOCUSED_ALPHA; + if (master_list_policy() == _master_listings_masters) + spinner->parameters.list.selected_index = P2P_LOBBY_SOURCE_DEDICATED; + else if (master_list_policy() == _master_listings_brokers) + spinner->parameters.list.selected_index = P2P_LOBBY_SOURCE_PLAYERS; + source = (short)PIN(spinner->parameters.list.selected_index, 0, setting->value_count - 1); + if (lobby_browser.source != NONE && source != lobby_browser.source && + master_list_policy() == _master_listings_both) + { + config_write("network.browser_source", setting->values[source]); + /* (a list of other games: from its top) */ + lobby_browser.first = 0; + lobby_browser.chosen = 0; + } + lobby_browser.source = source; + return source; +} + /* the game being joined, once its host is reached (the client's game from it), else NULL */ static struct advertised_game *lobby_browser_joined_game(void) @@ -4123,19 +4521,266 @@ static void lobby_browser_focus_row(struct widget_instance *list, short row) } } -/* "gamespy screen update" for the server browser: the rows (scrolled on -at their ends), the line below them, the counts over the titles */ +/* the column titles' arrows: the one the list is sorted by shows the way +(up: A to Z, or the fewest first), in white (its bitmaps' second frame: the +first, blue, is lost on the title's blue) */ +static void lobby_browser_sort_arrows(struct widget_instance *list) +{ + short index; + + for (index = 0; index < NUMBEROF(sort_headers); index++) + { + struct widget_instance *header = named(list, sort_headers[index].name, 0); + struct widget_instance *arrows = named(header, "header_sort_arrows", 0); + short order = sort_headers[index].order; + boolean sorted = order != NONE && order == lobby_browser.order; + /* (names, maps and types first go A to Z; players and the others + the most first) */ + boolean up = (order == P2P_LOBBY_SORT_NAME || order == P2P_LOBBY_SORT_MAP || order == P2P_LOBBY_SORT_TYPE) != + lobby_browser.reverse; + struct widget_instance *up_arrow = named(arrows, "header_sort_up_arrow", 0); + struct widget_instance *down_arrow = named(arrows, "header_sort_down_arrow", 0); + + visible_set(arrows, sorted); + visible_set(up_arrow, up); + visible_set(down_arrow, !up); + if (up_arrow) + up_arrow->animation.current_frame_index = 1; + if (down_arrow) + down_arrow->animation.current_frame_index = 1; + } +} + +/* the list and the title, hidden under a panel (their backgrounds let +them through) */ +static void browser_list_show(struct widget_instance *screen, boolean shown) +{ + visible_set(named(screen, "join_game_items_list", 0), shown); + visible_set(named(screen, "header_join_game", 0), shown); +} + +/* the error panel over the list, with its words (NONE: hidden, the focus +back on the list) */ +static void lobby_browser_error(struct widget_instance *screen, short word) +{ + struct widget_instance *panel = named(screen, "gamespy_error_fullscreen", 0); + struct widget_instance *list = named(screen, "join_game_items_list", 0); + + if (!panel) + return; + if (word != NONE) + { + wchar_t text[TICKER_TEXT_LENGTH]; + long tag_index = tag_loaded('ustr', BROWSER_WORDS); + wchar_t const *string = tag_index != NONE ? unicode_string_list_get_string(tag_index, word) : NULL; + + /* (its line breaks kept: the panel's text is a box) */ + ustrncpy(text, string ? string : L"", NUMBEROF(text) - 1); + text[NUMBEROF(text) - 1] = 0; + text_set_length(named(panel, "error_words", 0), text, TICKER_TEXT_LENGTH); + visible_set(named(panel, "error_text_box", 0), FALSE); + lobby_browser.error_shown = TRUE; + ui_play_audio_feedback_sound(SOUND_ERROR); + } + panel->visible = word != NONE; + browser_list_show(screen, word == NONE); + if (word != NONE) + screen->focused_child = panel; + else if (list && screen->focused_child == panel) + screen->focused_child = list; +} + +/* the Filters screen over the list (filters_show) or not: shown, it shows +the settings; its OK writes those changed ("gamespy update filter +settings"), and CANCEL or B leaves them */ +static boolean setting_shown_load(struct widget_instance *spinner, struct pc_menu_setting *setting) +{ + (void)setting; + return setting_load(spinner); +} + +static void filters_show(struct widget_instance *screen, boolean show, boolean save) +{ + struct widget_instance *filters = named(screen, "filters_screen", 0); + struct widget_instance *list = named(screen, "join_game_items_list", 0); + + if (!filters) + return; + if (show) + settings_each(filters, setting_shown_load); + else if (save && filters->visible) + settings_each(filters, setting_changed_save); + filters->visible = show; + browser_list_show(screen, !show); + if (show) + screen->focused_child = filters; + else if (list && screen->focused_child == filters) + screen->focused_child = list; +} + +/* "gamespy back handler" (B) and "gamespy dismiss error/filters": a panel +over the server browser closed, if one is shown; FALSE if none is */ +static boolean browser_panel_close(struct widget_instance *widget) +{ + struct widget_instance *screen = screen_of(widget); + struct widget_instance *filters = named(screen, "filters_screen", 0); + struct widget_instance *panel = named(screen, "gamespy_error_fullscreen", 0); + + if (filters && filters->visible) + { + filters_show(screen, FALSE, FALSE); + return TRUE; + } + if (panel && panel->visible) + { + lobby_browser_error(screen, NONE); + return TRUE; + } + return FALSE; +} + +/* Filters' help: its row's, for the value shown (the PC version's +cap_filter_options: empty and full servers' NO and YES, the game type's, +and missing maps' NO, YES and the port's IF DOWNLOADABLE) */ +static void filters_help(struct widget_instance *list) +{ + struct widget_instance *description = list->parameters.list.extended_description; + static struct + { + char const *row; + short first; + boolean by_value; + } const helps[] = + { + { "op_empty_servers", 0, TRUE }, + { "op_full_servers", 2, TRUE }, + { "op_game_type", 5, FALSE }, + { "op_installed_maps", 9, TRUE }, + }; + struct widget_instance *row = list->focused_child; + short index; + + if (strcmp(list->name, "filters_options_menu")) + return; + for (index = 0; row && description && index < NUMBEROF(helps); index++) + { + struct widget_instance *spinner; + + if (strcmp(row->name, helps[index].row)) + continue; + for (spinner = row->child; spinner && spinner->type != 2 /* spinner */; spinner = spinner->next) + ; + description->parameters.text_box.string_list_index = (short)(helps[index].first + + (helps[index].by_value && spinner ? spinner->parameters.list.selected_index : 0)); + } +} + +/* "gamespy select header": the list sorted by the column chosen; the one +it is sorted by, the other way (network.browser_sort) */ +static boolean lobby_browser_sort_choose(struct widget_instance *header) +{ + char text[32]; + short index; + + for (index = 0; index < NUMBEROF(sort_headers); index++) + { + if (strcmp(header->name, sort_headers[index].name)) + continue; + if (sort_headers[index].order == NONE) + return TRUE; + lobby_browser.reverse = sort_headers[index].order == lobby_browser.order ? !lobby_browser.reverse : FALSE; + lobby_browser.order = sort_headers[index].order; + p2p_lobby_sort_text(lobby_browser.order, lobby_browser.reverse, text, sizeof(text)); + config_write("network.browser_sort", text); + ui_play_audio_feedback_sound(SOUND_CURSOR); + return TRUE; + } + return TRUE; +} + +/* the line below the rows of the chosen game: its players, map, type and +region, and where it was listed (or what it needs) */ +static void lobby_browser_game_lines(struct p2p_listing const *game, wchar_t *players, wchar_t *rules, short size) +{ + wchar_t word[TICKER_TEXT_LENGTH], map[ROW_TEXT_LENGTH]; + + usnprintf(players, size - 1, L"%d ", game->player_count); + text_append_word(players, size, _browser_word_of); + usnprintf(word, NUMBEROF(word) - 1, L" %d ", game->maximum_player_count); + text_append(players, size, word); + text_append_word(players, size, game->maximum_player_count == 1 ? _browser_word_player : _browser_word_players); + map_display_name(game->map, map); + text_append(players, size, TICKER_SEPARATOR); + text_append(players, size, map); + text_append(players, size, TICKER_SEPARATOR); + text_append(players, size, game_kind_name(game->engine_type, game->kind, FALSE)); + if (game->region[0]) + { + region_to_wide(game->region, word, NUMBEROF(word)); + text_append(players, size, TICKER_SEPARATOR); + text_append(players, size, word); + } + if (!game->open || game->in_progress) + { + text_append(players, size, TICKER_SEPARATOR); + text_append_word(players, size, !game->open ? _browser_word_full : _browser_word_under_way); + } + rules[0] = 0; + if (game->network_version != HALO_PORT_NETWORK_VERSION) + { + text_append_word(rules, size, game->network_version > HALO_PORT_NETWORK_VERSION ? + _browser_word_newer_build : _browser_word_older_build); + return; + } + if (game->dedicated && game->master) + { + /* (the publisher's name, which only this line shows: an operator + cannot pass for a server by its name) */ + text_append_word(rules, size, game->publisher[0] ? _browser_word_dedicated_by : _browser_word_dedicated_server); + if (game->publisher[0]) + { + text_to_wide(game->publisher, word, NUMBEROF(word)); + text_append(rules, size, L" "); + text_append(rules, size, word); + } + text_append(rules, size, L", "); + text_append_word(rules, size, _browser_word_listed_on); + usnprintf(word, NUMBEROF(word) - 1, L" %d ", MAX(1, game->master_count)); + text_append(rules, size, word); + text_append_word(rules, size, game->master_count > 1 ? _browser_word_masters : _browser_word_master); + } + else + text_append_word(rules, size, game->dedicated ? _browser_word_dedicated_server : _browser_word_player_hosted); + if (game->locked) + { + text_append(rules, size, TICKER_SEPARATOR); + text_append_word(rules, size, _browser_word_password); + } +} + +/* "gamespy screen update" for the server browser: the games shown, the +rows (scrolled on at their ends), the line below them, the counts over the +titles, the titles' arrows; a join going on; the error panel */ static void lobby_browser_update(struct widget_instance *list) { struct widget_instance *stats = list->parameters.list.extended_description; + struct widget_instance *screen = screen_of(list); struct widget_instance *row; short focused = browser_row_index(list->focused_child); - wchar_t text[ROW_TEXT_LENGTH * 2]; + wchar_t text[TICKER_TEXT_LENGTH], rules[TICKER_TEXT_LENGTH]; unsigned long now = system_milliseconds(); + struct p2p_lobby_choices choices; + int join_stage = P2P_JOIN_NONE; + int masters, masters_answered, masters_asked; + boolean masters_down; short chosen; - lobby_browser.count = lobby_browser_valid_games(lobby_browser.games, + lobby_browser.found = lobby_browser_valid_games(lobby_browser.games, (short)p2p_lobby_games(lobby_browser.games, LOBBY_BROWSER_GAMES)); + lobby_browser_choices(&choices, lobby_browser_source(list)); + lobby_browser.count = (short)p2p_lobby_filter(lobby_browser.games, lobby_browser.found, &choices, + lobby_browser_map_state); + p2p_lobby_sort(lobby_browser.games, lobby_browser.count, lobby_browser.order, lobby_browser.reverse); if (focused == BROWSER_ROWS - 1 && lobby_browser.first + BROWSER_ROWS < lobby_browser.count && list_scrolls_at_end()) { lobby_browser.first++; @@ -4168,15 +4813,19 @@ static void lobby_browser_update(struct widget_instance *list) for (row = list->child; row; row = row->next) { short index = browser_row_index(row); + short place; struct p2p_listing const *game; + boolean other_version; if (index == NONE) continue; + place = index; index = (short)(index + lobby_browser.first); row->visible = index < lobby_browser.count; if (index >= lobby_browser.count) continue; game = &lobby_browser.games[index]; + other_version = game->network_version != HALO_PORT_NETWORK_VERSION; text_to_wide(game->name, text, ROW_TEXT_LENGTH); text_set(named(row, "server_item_server_name", 0), text); map_display_name(game->map, text); @@ -4184,26 +4833,54 @@ static void lobby_browser_update(struct widget_instance *list) text_set(named(row, "server_item_type", 0), game_kind_name(game->engine_type, game->kind, TRUE)); usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d/%d", game->player_count, game->maximum_player_count); text_set(named(row, "server_item_players", 0), text); - /* (its host's ping, once probed: p2p_lobby.c) */ - browser_row_pings[index - lobby_browser.first] = game->failed ? -1 : game->ping; - usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d", game->ping); - text_set(named(row, "server_item_ping", 0), game->failed ? L"FAILED" : game->ping >= 0 ? text : - !game->open ? L"CLOSED" : game->in_progress ? L"LIVE" : L"-"); - /* (the lock: a game with a password. Its bitmap's first frame is - empty, the PC version's for no lock; the next is the lock, in the - menus' blue) */ + /* (its status: JOINING, NEWER or OLDER, in yellow; its host's ping, + once probed (p2p_lobby.c), else FAILED, CLOSED, LIVE; a master + server's, which is not probed, its region) */ + browser_row_pings[place] = game->failed || other_version ? -1 : game->ping; + browser_row_greyed[place] = other_version; + browser_row_marked[place] = TRUE; + if (lobby_browser.joining && !memcmp(game->identifier, lobby_browser.identifier, 6)) + browser_word(_browser_word_joining, text, ROW_TEXT_LENGTH); + else if (other_version) + { + browser_word(game->network_version > HALO_PORT_NETWORK_VERSION ? _browser_word_newer : _browser_word_older, + text, ROW_TEXT_LENGTH); + } + else + { + browser_row_marked[place] = FALSE; + if (game->failed) + browser_word(_browser_word_failed, text, ROW_TEXT_LENGTH); + else if (game->ping >= 0) + usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d", game->ping); + else if (game->master && game->region[0]) + region_to_wide(game->region, text, ROW_TEXT_LENGTH); + else if (!game->open) + browser_word(_browser_word_closed, text, ROW_TEXT_LENGTH); + else if (game->in_progress) + browser_word(_browser_word_live, text, ROW_TEXT_LENGTH); + else + usnprintf(text, ROW_TEXT_LENGTH - 1, L"-"); + } + text_set(named(row, "server_item_ping", 0), text); + /* (the lock: a game with a password; the server: a dedicated + server's (CE's own icon). Their bitmaps' first frame is empty, the + PC version's for none; the next is the icon, in the menus' blue) */ { struct widget_instance *lock = named(row, "server_item_locked", 0); + struct widget_instance *dedicated = named(row, "server_item_dedicated", 0); visible_set(lock, game->locked); if (lock) lock->animation.current_frame_index = 1; + visible_set(dedicated, game->dedicated); + if (dedicated) + dedicated->animation.current_frame_index = 1; } - visible_set(named(row, "server_item_dedicated", 0), FALSE); visible_set(named(row, "server_item_classic", 0), FALSE); } /* joining: once the host is reached, its game joined (by an A press, on - its row); given up after a while */ + its row); given up after a while, or once p2p says it failed */ if (lobby_browser.ready && now - lobby_browser.ready_time > 3000 && now - lobby_browser.join_time <= LOBBY_BROWSER_JOIN_TIMEOUT) { @@ -4212,6 +4889,9 @@ static void lobby_browser_update(struct widget_instance *list) } if (lobby_browser.joining) { + char reason[16]; + + p2p_join_progress(&join_stage, reason, sizeof(reason)); if (lobby_browser.ready) { /* (its press posted: waiting for it, or for the timeout) */ @@ -4223,6 +4903,7 @@ static void lobby_browser_update(struct widget_instance *list) short index; lobby_browser.ready = TRUE; + lobby_browser.refusal_watched = TRUE; for (index = 0; index < lobby_browser.count; index++) { if (!memcmp(lobby_browser.games[index].identifier, lobby_browser.identifier, 6) && @@ -4234,79 +4915,137 @@ static void lobby_browser_update(struct widget_instance *list) lobby_browser.ready_time = now; ui_widget_port_post_button(lobby_browser.controller, BUTTON_A); } - else if (now - lobby_browser.join_time > LOBBY_BROWSER_JOIN_TIMEOUT) + else if (now - lobby_browser.join_time > LOBBY_BROWSER_JOIN_TIMEOUT || join_stage == P2P_JOIN_FAILED) { + /* (why: p2p's word, else the stage it got to. A strict NAT is + what stops a host that answered: the router's UPnP, or a + forwarded network.tunnel_port, lets it through) */ + short word = join_stage == P2P_JOIN_FAILED ? join_failure_word(reason) : NONE; + + if (word == NONE) + word = join_stage == P2P_JOIN_ANSWERED ? _browser_word_no_route : _browser_word_no_answer; + if (word == _browser_word_no_route && p2p_nat_strict()) + word = _browser_word_strict_nat; p2p_lobby_mark_failed(lobby_browser.identifier); csmemcpy(lobby_browser.failed_name, lobby_browser.name, sizeof(lobby_browser.failed_name)); lobby_browser.failed_time = now ? now : 1; - lobby_browser.failed_nat_strict = p2p_nat_strict(); + lobby_browser.failed_word = word; lobby_browser.joining = FALSE; platform_log("menus: could not reach the server browser's game %s", lobby_browser.name); ui_play_audio_feedback_sound(SOUND_ERROR); } } - /* the line below the rows */ + /* the master servers: none answered while the brokers do is told on the + line; nothing answering at all, and internet play off, on the panel */ + if (!lobby_browser.error_shown && !config_boolean("network.online")) + lobby_browser_error(screen, _browser_word_error_online_off); + master_list_counts(&masters, &masters_answered, &masters_asked); + masters_down = master_list_policy() != _master_listings_brokers && masters > 0 && masters_asked >= masters && + !masters_answered; + if (!lobby_browser.error_shown && !lobby_browser.found && config_boolean("network.online") && + config_boolean("network.public_lobby") && now - lobby_browser.begin_time > LOBBY_BROWSER_UNREACHABLE_TIME) + { + boolean brokers = master_list_policy() != _master_listings_masters && p2p_lobby_brokers_reached(); + boolean masters_reached = master_list_policy() != _master_listings_brokers && masters > 0 && !masters_down; + + if (!brokers && !masters_reached) + lobby_browser_error(screen, _browser_word_error_unreachable); + } + /* the line below the rows: the chosen game's (or the list's state), and + where it was listed, or how its join goes */ { wchar_t name[P2P_LISTING_NAME_SIZE + 1]; + text[0] = rules[0] = 0; if (!config_boolean("network.online")) - usnprintf(text, NUMBEROF(text) - 1, L"Internet play is off (Settings)"); + browser_word(_browser_word_online_off, text, NUMBEROF(text)); else if (!config_boolean("network.public_lobby")) - usnprintf(text, NUMBEROF(text) - 1, L"The server browser is off (network.public_lobby)"); - else if (lobby_browser.joining) - { - text_to_wide(lobby_browser.name, name, NUMBEROF(name)); - usnprintf(text, NUMBEROF(text) - 1, L"Connecting to %s...", name); - } - else if (lobby_browser.failed_time && now - lobby_browser.failed_time < LOBBY_BROWSER_FAILED_TIME) - { - text_to_wide(lobby_browser.failed_name, name, NUMBEROF(name)); - /* (and why, when this network's NAT is what stops it: the router's - UPnP, or a forwarded network.tunnel_port, lets it through) */ - usnprintf(text, NUMBEROF(text) - 1, lobby_browser.failed_nat_strict ? - L"Strict NAT, turn on UPnP: could not reach %s" : - L"Could not reach %s", name); - } + browser_word(_browser_word_lobby_off, text, NUMBEROF(text)); else if (!lobby_browser.count) { /* (an old build sees none of the games: it says why) */ int newer = p2p_lobby_newer_games(); if (now - lobby_browser.begin_time < LOBBY_BROWSER_LOOK_TIME) - usnprintf(text, NUMBEROF(text) - 1, L"Looking for public games..."); + browser_word(_browser_word_looking, text, NUMBEROF(text)); else if (newer) { - usnprintf(text, NUMBEROF(text) - 1, L"%d %s on a newer version: update the game to join", newer, - newer == 1 ? L"game is" : L"games are"); + browser_word(_browser_word_newer_games, text, NUMBEROF(text)); + usnprintf(name, NUMBEROF(name) - 1, L" %d", newer); + text_append(text, NUMBEROF(text), name); } else - usnprintf(text, NUMBEROF(text) - 1, L"No public games found"); + browser_word(lobby_browser.found ? _browser_word_none_match : _browser_word_none_found, text, NUMBEROF(text)); } else if (chosen < lobby_browser.count) + lobby_browser_game_lines(&lobby_browser.games[chosen], text, rules, NUMBEROF(rules)); + if (lobby_browser.joining) { - struct p2p_listing const *game = &lobby_browser.games[chosen]; - wchar_t gametype[P2P_LISTING_GAMETYPE_SIZE + 1]; + /* (its stage, and for how long: p2p_join_progress) */ + short word = join_stage == P2P_JOIN_CONNECTED || lobby_browser.ready ? _browser_word_connected : + join_stage == P2P_JOIN_ANSWERED ? _browser_word_answered : _browser_word_asking; - text_to_wide(game->gametype, gametype, NUMBEROF(gametype)); - usnprintf(text, NUMBEROF(text) - 1, L"%s: %d %s of %d%s%s", gametype, game->player_count, - game->player_count == 1 ? L"player" : L"players", game->maximum_player_count, - !game->open ? L", full or starting" : game->in_progress ? L", under way" : L"", - game->locked ? L", password" : L""); + text_to_wide(lobby_browser.name, name, NUMBEROF(name)); + browser_word(word, rules, NUMBEROF(rules)); + usnprintf(text, NUMBEROF(text) - 1, L" %lu s", (now - lobby_browser.join_time) / 1000); + text_append(rules, NUMBEROF(rules), text); + text_set_length(named(list, "ticker_rules_info", 0), rules, TICKER_TEXT_LENGTH); + /* (the game joined's own line, wherever the focus is) */ + text[0] = 0; + for (chosen = 0; chosen < lobby_browser.count; chosen++) + { + if (!memcmp(lobby_browser.games[chosen].identifier, lobby_browser.identifier, 6)) + { + lobby_browser_game_lines(&lobby_browser.games[chosen], text, rules, NUMBEROF(rules)); + break; + } + } + if (!text[0]) + ustrncpy(text, name, NUMBEROF(text) - 1); + chosen = lobby_browser.chosen; + text[NUMBEROF(text) - 1] = 0; + text_set_length(named(list, "ticker_player_info", 0), text, TICKER_TEXT_LENGTH); } else - text[0] = 0; - text[NUMBEROF(text) - 1] = 0; - text_set(named(list, "ticker_player_info", 0), text); - text_set(named(list, "ticker_rules_info", 0), L""); + { + if (lobby_browser.failed_time && now - lobby_browser.failed_time < LOBBY_BROWSER_FAILED_TIME) + { + text_to_wide(lobby_browser.failed_name, text, NUMBEROF(text)); + browser_word(lobby_browser.failed_word, rules, NUMBEROF(rules)); + } + else if (masters_down && (!lobby_browser.count || + (chosen < lobby_browser.count && !lobby_browser.games[chosen].dedicated))) + { + browser_word(_browser_word_masters_down, rules, NUMBEROF(rules)); + } + text_set_length(named(list, "ticker_player_info", 0), text, TICKER_TEXT_LENGTH); + text_set_length(named(list, "ticker_rules_info", 0), rules, TICKER_TEXT_LENGTH); + } } + /* the counts over the titles: the players beside the page (the tab holds + the source), the servers and how many are dedicated */ if (stats) { - wchar_t label[ROW_TEXT_LENGTH]; + wchar_t label[ROW_TEXT_LENGTH], word[ROW_TEXT_LENGTH]; long players = 0; - short index; + short index, dedicated = 0; + boolean one_page = lobby_browser.count <= BROWSER_ROWS; for (index = 0; index < lobby_browser.count; index++) + { players += lobby_browser.games[index].player_count; + dedicated += lobby_browser.games[index].dedicated ? 1 : 0; + } + /* (the page only when there are more than one, and the dedicated + servers' count when every game is listed on one: the tab is not wide + enough for all of them. The players as far in from the panel's left + edge, 301, as the servers are from its right, 630 (their place is + CE's); the page centred, at 466, between them) */ + if (named(stats, "player_count_label", 0)) + named(stats, "player_count_label", 0)->horizontal_offset = 312; + if (named(stats, "page_count_label", 0)) + named(stats, "page_count_label", 0)->horizontal_offset = 466 - 170 / 2; + visible_set(named(stats, "page_count_label", 0), !one_page); string_get(JOIN_GAME_LABELS, _join_game_label_players, label); usnprintf(text, ROW_TEXT_LENGTH - 1, L"%s %ld", label, players); text_set(named(stats, "player_count_label", 0), text); @@ -4316,8 +5055,15 @@ static void lobby_browser_update(struct widget_instance *list) text_set(named(stats, "page_count_label", 0), text); string_get(JOIN_GAME_LABELS, _join_game_label_servers, label); usnprintf(text, ROW_TEXT_LENGTH - 1, L"%s %d", label, lobby_browser.count); + if (dedicated && one_page && lobby_browser.source == P2P_LOBBY_SOURCE_ALL) + { + browser_word(_browser_word_dedicated, word, NUMBEROF(word)); + usnprintf(label, ROW_TEXT_LENGTH - 1, L" (%d %s)", dedicated, word); + text_append(text, ROW_TEXT_LENGTH, label); + } text_set(named(stats, "server_count_label", 0), text); } + lobby_browser_sort_arrows(list); browser_focus(list, browser_chosen_row()); } @@ -4333,16 +5079,21 @@ static boolean lobby_browser_join(struct p2p_listing const *game, short controll csmemcpy(lobby_browser.name, game->name, sizeof(lobby_browser.name)); lobby_browser.joining = TRUE; lobby_browser.ready = FALSE; + lobby_browser.refusal_watched = FALSE; + lobby_browser.failed_time = 0; + lobby_browser.joining_dedicated = game->dedicated != 0; lobby_browser.join_time = system_milliseconds(); lobby_browser.controller = controller; ui_play_audio_feedback_sound(SOUND_FORWARD); return TRUE; } -/* the server browser's rows and buttons: Refresh asks the hosts again; a -row (or Join) joins its game's invite, a game with a password once its -screen has it; the A press posted once its host is reached joins its game -(or shows its lobby, if under way) */ +/* the server browser's rows and buttons: Refresh asks the hosts and the +master servers again; Filters shows its screen; a row (or Join) joins its +game's invite, a game with a password once its screen has it, one of +another network version not at all (the player is told why); the A press +posted once its host is reached joins its game (or shows its lobby, if +under way) */ static boolean lobby_browser_select(struct widget_instance *widget, short controller, short row, boolean *widget_deleted) { @@ -4357,6 +5108,7 @@ static boolean lobby_browser_select(struct widget_instance *widget, short contro lobby_browser.joining = lobby_browser.ready = FALSE; if (!found) return campaign_fail(); + lobby_dedicated_set(found, lobby_browser.joining_dedicated); if (advertised_in_progress(found)) { /* (player 1 joining it: others join them there) */ @@ -4379,6 +5131,12 @@ static boolean lobby_browser_select(struct widget_instance *widget, short contro ui_play_audio_feedback_sound(SOUND_FORWARD); return TRUE; } + if (strstr(widget->name, "button_filters")) + { + filters_show(screen_of(widget), TRUE, FALSE); + ui_play_audio_feedback_sound(SOUND_FORWARD); + return TRUE; + } if (row == NONE && !strstr(widget->name, "button_join")) return TRUE; if (lobby_browser.joining) @@ -4387,6 +5145,11 @@ static boolean lobby_browser_select(struct widget_instance *widget, short contro if (index >= lobby_browser.count) return campaign_fail(); game = &lobby_browser.games[index]; + if (game->network_version != HALO_PORT_NETWORK_VERSION) + { + network_game_client_tell_version(game->network_version, TRUE); + return campaign_fail(); + } if (!game->open || !config_boolean("network.online")) return campaign_fail(); /* (a game with a password: its screen asks for it, then joins) */ @@ -4543,6 +5306,7 @@ static void browser_update(struct widget_instance *list) /* (a Direct Link game's host: the tunnel's ping) */ ping = multiplayer.mode == _multiplayer_mode_direct_link ? p2p_peer_ping(game->xnaddr + 2) : -1; browser_row_pings[index] = (short)ping; + browser_row_marked[index] = browser_row_greyed[index] = FALSE; usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d", ping); text_set(named(row, "server_item_ping", 0), ping >= 0 ? text : advertised_in_progress(game) ? L"LIVE" : L""); visible_set(named(row, "server_item_locked", 0), FALSE); @@ -4568,8 +5332,8 @@ static void browser_update(struct widget_instance *list) game->maximum_player_count, game->machine_count, advertised_in_progress(game) ? L": under way" : L""); } text[NUMBEROF(text) - 1] = 0; - text_set(named(list, "ticker_player_info", 0), text); - text_set(named(list, "ticker_rules_info", 0), L""); + text_set_length(named(list, "ticker_player_info", 0), text, TICKER_TEXT_LENGTH); + text_set_length(named(list, "ticker_rules_info", 0), L"", TICKER_TEXT_LENGTH); } if (stats) { @@ -4623,6 +5387,7 @@ static boolean browser_join(struct widget_instance *widget, short controller, bo return campaign_fail(); if (!multiplayer_player(controller)) return FALSE; + lobby_dedicated_set(multiplayer.games[multiplayer.game_chosen], FALSE); return ui_widget_port_join(widget, multiplayer.games[multiplayer.game_chosen], LOBBY_NAME, widget_deleted); } @@ -5067,6 +5832,13 @@ void menu_functions_text_box_drawn(struct widget_instance *widget, rectangle2d c } } +/* the lobby's words (tools/port_settings.py's lobby_words) */ +#define LOBBY_WORDS "pc\\main_menu\\multiplayer_type_select\\lobby\\lobby_words" +enum +{ + _lobby_word_dedicated, +}; + /* "port lobby update" */ static void lobby_update(struct widget_instance *list) { @@ -5119,13 +5891,29 @@ static void lobby_update(struct widget_instance *list) wchar_t gametype[NUMBEROF(game->variant.human_readable_game_description) + 1]; /* (an OpenCE gametype's kind: game_modes.c) */ wchar_t const *kind = game_modes_variant_kind_name(&game->variant, &game->variant_options); + /* (a dedicated server's game: its name first, and it waits for + players while its director holds the countdown) */ + boolean dedicated = lobby_joined_dedicated && !global_network_game_server_get(); + wchar_t name[NUMBEROF(game->name) + ROW_TEXT_LENGTH]; + name[0] = 0; + if (dedicated) + { + wchar_t word[ROW_TEXT_LENGTH]; + + ustrncpy(name, game->name, NUMBEROF(game->name)); + name[NUMBEROF(game->name)] = 0; + words_get(LOBBY_WORDS, _lobby_word_dedicated, word, NUMBEROF(word)); + text_append(name, NUMBEROF(name), TICKER_SEPARATOR); + text_append(name, NUMBEROF(name), word); + text_append(name, NUMBEROF(name), L"\r\n"); + } ustrncpy(gametype, game->variant.human_readable_game_description, NUMBEROF(gametype) - 1); gametype[NUMBEROF(gametype) - 1] = 0; - usnprintf(text, NUMBEROF(text) - 1, L"%s\r\n%s\r\n%d of %d players\r\n\r\n%s", gametype, + usnprintf(text, NUMBEROF(text) - 1, L"%s%s\r\n%s\r\n%d of %d players\r\n\r\n%s", name, gametype, kind ? kind : engine_names[PIN(game->variant.game_engine_index, 0, 5)], lobby_player_count, game->maximum_players, - seconds > 0 ? L"Starting in:" : game->machine_count < 2 ? L"Waiting for players" : L""); + seconds > 0 ? L"Starting in:" : game->machine_count < 2 || dedicated ? L"Waiting for players" : L""); text[NUMBEROF(text) - 1] = 0; if (seconds > 0) { @@ -5289,6 +6077,7 @@ static boolean preview_join(struct widget_instance *widget, short controller, bo return campaign_fail(); if (!lobby_local_player_count() && !multiplayer_player(controller)) return FALSE; + lobby_dedicated_set(game, lobby_joined_dedicated && p2p_joined_listed_host()); return ui_widget_port_join(widget, game, LOBBY_NAME, widget_deleted); } @@ -6127,7 +6916,7 @@ static void gametype_option_help(struct widget_instance *list) } } /* (the server browser's filters, hidden: their helps are fewer than - their values) */ + their values, filters_help) */ if (!description || !list->focused_child || !strncmp(list->name, "filters", 7)) return; for (row = list->child; row; row = row->next) @@ -6259,11 +7048,10 @@ boolean pc_menu_event_function_invoke( else if (!strcmp(name, "emit custom activation event")) { struct widget_instance *top = widget; + boolean deleted; while (top->parent) top = top->parent; - boolean deleted; - ui_widget_port_dispatch_event(top, EVENT_CUSTOM_ACTIVATION, event_controller(widget, event), &deleted); if (deleted) *widget_deleted = TRUE; @@ -6294,6 +7082,22 @@ boolean pc_menu_event_function_invoke( { text_field_end(FALSE); } + /* (the server browser's panels: B, or their own buttons, close them + over the list) */ + else if ((!strcmp(name, "gamespy back handler") || !strcmp(name, "gamespy dismiss error") || + !strcmp(name, "gamespy dismiss filters")) && browser_panel_close(widget)) + { + ui_play_audio_feedback_sound(SOUND_BACK); + } + else if (!strcmp(name, "gamespy select header")) + { + return lobby_browser_sort_choose(widget); + } + else if (!strcmp(name, "gamespy update filter settings")) + { + filters_show(screen_of(widget), FALSE, TRUE); + ui_play_audio_feedback_sound(SOUND_FORWARD); + } else if (!strcmp(name, "port setup edit")) { return gametype_setup_edit(); @@ -6625,7 +7429,10 @@ void pc_menu_game_data_function_invoke( else if (!strcmp(name, "get edit game settings name")) gametype_edit_name(widget); else if (!strcmp(name, "game settings lists text update")) + { gametype_option_help(widget); + filters_help(widget); + } else if (!strcmp(name, "mp edit profile set rule text")) gametype_engine_name(widget); else if (!strcmp(name, "mp prof vehicles update")) @@ -6638,6 +7445,7 @@ void pc_menu_game_data_function_invoke( { video_rows_show(widget); hud_button_style_row_show(widget); + master_servers_show(widget); settings_help(widget); } else if (!strcmp(name, "controls update menu")) diff --git a/port/linux/game/network_chat.c b/port/linux/game/network_chat.c index 647b24e5..b5618f86 100644 --- a/port/linux/game/network_chat.c +++ b/port/linux/game/network_chat.c @@ -38,13 +38,16 @@ without the chat drops the three kinds as unknown: its players neither send nor see lines. The host's settings (network.text_chat: on, links allowed, or off; off in a -game listed in the server browser) go to every client -(_distributed_message_chat_config). A line they refuse is refused by its -sender's machine and again by the host, which tells the sender why; a +game a player hosts that is listed in the server browser) go to every +client (_distributed_message_chat_config). A line they refuse is refused by +its sender's machine and again by the host, which tells the sender why; a client without them (from a host without the chat) has the chat off. A client in a public game it joined from the server browser has the chat off whatever the host says (a changed host could turn it on): it takes no line -from it and sends none. +from it and sends none. A dedicated server is the exception on both sides +(chat_listing_allows): its network.text_chat holds when it is listed, and +a client that joined it from a master server's list (an approved +operator's, by its key, not the listing's own word) follows it. display.text_chat hides the chat on this machine. The host may type "/mute [minutes]" (20 when it gives none, a day @@ -82,6 +85,7 @@ mutes. #include "network_chat.h" #include "text_filter.h" #include "halo_keyboard.h" +#include "server_director.h" #include @@ -95,6 +99,9 @@ unsigned long config_changes(void); /* p2p_lobby.c's, p2p.c's */ int p2p_hosting_listed(void); int p2p_joined_listed_host(void); +int p2p_joined_listed_dedicated(void); +/* server_mode.c's: a dedicated server (halo --server) */ +int halo_server_mode(void); /* ---------- constants */ @@ -927,8 +934,21 @@ static boolean chat_host( return game_connection() == _game_connection_network_server; } +/* whether a game's text chat may be on: not in a public game (listed in +the server browser), unless its host is a dedicated server, whose own +network.text_chat then decides. On the host, listed is its listing and +dedicated whether it runs halo --server; on a client, listed is the game +joined from the browser and dedicated whether a master server listed it as +a dedicated server's */ +static boolean chat_listing_allows( + boolean listed, + boolean dedicated) +{ + return !listed || dedicated; +} + /* (the host) its chat settings: network.text_chat's, the chat off in a game -listed in the server browser */ +a player hosts that is listed in the server browser */ static byte chat_host_config_flags( void) { @@ -936,7 +956,8 @@ static byte chat_host_config_flags( byte flags = 0; /* (in either case: HALO_NET_TEXT_CHAT=OFF is off) */ - if (setting && !chat_names_equal(setting, "off") && !p2p_hosting_listed()) + if (setting && !chat_names_equal(setting, "off") && + chat_listing_allows(p2p_hosting_listed() != 0, halo_server_mode() != 0)) { flags |= (byte)FLAG(_chat_config_allowed_bit); if (chat_names_equal(setting, "links")) @@ -946,8 +967,8 @@ static byte chat_host_config_flags( } /* (a client) whether its game is a public one it joined from the server -browser (p2p.c): no chat, whatever its host says. A public dedicated -server's listing, once there are those, would say it may have one */ +browser (p2p.c): no chat, whatever its host says, unless a master server +lists it as a dedicated server's (chat_listing_allows) */ static boolean chat_joined_listed( void) { @@ -955,13 +976,15 @@ static boolean chat_joined_listed( } /* the host's chat settings: the host's own, or as it sent them (none, and -no chat, till it does; none in a public game joined from the browser) */ +no chat, till it does; none in a public game a player hosts, joined from +the browser) */ static byte chat_config_flags( void) { if (chat_host()) return chat_host_config_flags(); - return chat_config.known && !chat_joined_listed() ? chat_config.flags : 0; + return chat_config.known && chat_listing_allows(chat_joined_listed(), p2p_joined_listed_dedicated() != 0) ? + chat_config.flags : 0; } static void chat_send_to_clients(void *message, byte type, word size); @@ -1220,6 +1243,7 @@ static void chat_host_send( if (!sender || sender->quit_out_of_game) return; sender_vehicle = chat_player_vehicle(sender); + server_event_chat(player_index, channel, text); chat_message_new(&message, player_index, chat_channel_flags(channel), text); for (index = 0; index < count; index++) { @@ -1400,6 +1424,18 @@ void network_chat_host_mute( chat_host_unmute_name(name); } +boolean network_chat_host_mute_named( + char const *name, + short minutes, + boolean mute) +{ + if (!chat_host() || !name || !name[0]) + return FALSE; + if (!mute) + return chat_host_unmute_name(name); + return chat_host_mute_name(name, minutes > 0 ? minutes : CHAT_MUTE_DEFAULT_MINUTES); +} + boolean network_chat_host_name_muted( wchar_t const *name) { @@ -1474,9 +1510,11 @@ void network_chat_handle_down( struct distributed_chat_line line; char text[CHAT_TEXT_SIZE]; - /* (none in a public game joined from the server browser, the host's - notices too: its chat is off, whatever the host says) */ - if (game_connection() != _game_connection_network_client || !from_stream || chat_joined_listed()) + /* (none in a public game a player hosts, joined from the server + browser, the host's notices too: its chat is off, whatever the host + says; a master's dedicated server's are taken) */ + if (game_connection() != _game_connection_network_client || !from_stream || + !chat_listing_allows(chat_joined_listed(), p2p_joined_listed_dedicated() != 0)) return; csmemcpy(&line, entries, sizeof(line)); if (!chat_text_clean(text, line.text, CHAT_TEXT_SIZE)) diff --git a/port/linux/game/network_chat.h b/port/linux/game/network_chat.h index e1b81da4..cc93b865 100644 --- a/port/linux/game/network_chat.h +++ b/port/linux/game/network_chat.h @@ -50,6 +50,11 @@ short network_chat_host_mute_minutes(short player_index); their lines and their voice, for /mute's minutes when it gives none (every machine told), or unmuted */ void network_chat_host_mute(short player_index, boolean mute); +/* (the host: a dedicated server's mute and unmute commands, +server_commands.c) a player's name (ASCII, 11 characters) muted for everyone +for minutes (0: /mute's), every machine told, or unmuted: FALSE if it could +not be */ +boolean network_chat_host_mute_named(char const *name, short minutes, boolean mute); /* (the host: network_voice.c) whether a player's name (the network game's, in the lobby too; at most 12 characters, ended or not) is muted for everyone: their machine's voice goes no further */ diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index c6191b55..e2c1a834 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -82,6 +82,7 @@ machine (their datum identifiers need not be). #include "map_share.h" #include "latency_meter.h" #include "profile_sections.h" +#include "server_director.h" #include #include @@ -3367,6 +3368,7 @@ void network_distributed_player_killed( death->killed_by_vehicle = *killing_player_index == NONE && killing_object && killing_object->object.type == _object_type_vehicle; distributed_statistics_due = TRUE; + server_event_player_killed(*killing_player_index, *killing_object_index, dead_player_index, *friendly_fire); } else if (game_connection() == _game_connection_network_client && death->valid) { @@ -4181,23 +4183,27 @@ their address, Discord user and names, and why; separated by tabs, each part kept to the characters allowed and their lengths (what a player could tell: their Discord user and names). The Discord user of the machine at the index, if it is one in the game (NONE: none) */ +static boolean distributed_write_record( + char const *file_name, + char const *address, + char const *hardware_id, + char const *discord_id, + char const *discord_name, + char const *names, + char const *reason, + unsigned long until); + static void distributed_write_player_record( char const *file_name, char const *address, long machine_index, char const *names, - char const *reason) + char const *reason, + unsigned long until) { char discord_id[DISCORD_ID_SIZE] = ""; char discord_name[DISCORD_NAME_SIZE] = ""; char hardware_id[40] = ""; - char kept_names[96]; - char kept_reason[96]; - char kept_address[32]; - char when[32] = ""; - time_t now = time(NULL); - struct tm *local = localtime(&now); - FILE *file; if (machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES && game_in_progress()) { @@ -4207,6 +4213,31 @@ static void distributed_write_player_record( } if (machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES) p2p_hardware_id_sanitize(hardware_id, 33, network_game_server_machine_hardware_id(machine_index)); + distributed_write_record(file_name, address, hardware_id, discord_id, discord_name, names, reason, until); +} + +/* (the host) the line itself: its parts as distributed_write_player_record +says (a hardware id, a Discord id or name empty for none); TRUE if it was +written */ +static boolean distributed_write_record( + char const *file_name, + char const *address, + char const *hardware_id, + char const *discord_id, + char const *discord_name, + char const *names, + char const *reason, + unsigned long until) +{ + char kept_names[96]; + char kept_reason[96]; + char kept_address[32]; + char when[32] = ""; + time_t now = time(NULL); + struct tm *local = localtime(&now); + FILE *file; + boolean written; + distributed_printable(kept_names, sizeof(kept_names), names); distributed_printable(kept_reason, sizeof(kept_reason), reason); distributed_printable(kept_address, sizeof(kept_address), address); @@ -4216,16 +4247,23 @@ static void distributed_write_player_record( if (!file) { error(_error_log, "could not open %s to add a player to it", file_name); - return; + return FALSE; } /* (the Discord user as the machine told it, which it may say is anyone's: marked so) */ - fprintf(file, "%s\tip=%s\thwid=%s\tdiscord_username=%s%s\tdiscord_id=%s%s\tplayers=%s\treason=%s\n", when, + fprintf(file, "%s\tip=%s\thwid=%s\tdiscord_username=%s%s\tdiscord_id=%s%s\tplayers=%s\treason=%s", when, kept_address, hardware_id[0] ? hardware_id : "none", discord_name[0] ? discord_name : "none", discord_name[0] ? " (self-reported)" : "", discord_id[0] ? discord_id : "none", discord_id[0] ? " (self-reported)" : "", kept_names, kept_reason); - fclose(file); + /* (a ban for a while: its end, in seconds since 1970; a line without one + is for ever) */ + if (until) + fprintf(file, "\tuntil=%lu", until); + fputc('\n', file); + written = !ferror(file); + written = fclose(file) == 0 && written; + return written; } /* (the host) a player dropped for cheating: in CHEATERS_FILE, and banned @@ -4238,8 +4276,42 @@ static void distributed_log_cheater( char address[32]; distributed_machine_address_text(machine_index, address, sizeof(address)); - distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason); - distributed_write_player_record(BANS_FILE, address, machine_index, names, reason); + distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason, 0); + distributed_write_player_record(BANS_FILE, address, machine_index, names, reason, 0); +} + +/* (the host: a dedicated server's ban command, server_commands.c) the next +ban's reason and end (seconds since 1970; 0: for ever), for its line */ +static char distributed_next_ban_reason[96]; +static unsigned long distributed_next_ban_until; +/* (sv_ban_address's: the next ban's line written already) */ +static boolean distributed_next_ban_unrecorded; + +void network_distributed_ban_details( + char const *reason, + unsigned long until) +{ + snprintf(distributed_next_ban_reason, sizeof(distributed_next_ban_reason), "%s", reason ? reason : ""); + distributed_next_ban_until = until; +} + +void network_distributed_ban_unrecorded( + boolean unrecorded) +{ + distributed_next_ban_unrecorded = unrecorded; +} + +boolean network_distributed_ban_record( + char const *address, + char const *hardware_id, + char const *names, + char const *reason, + unsigned long until) +{ + char kept_hardware_id[40]; + + p2p_hardware_id_sanitize(kept_hardware_id, 33, hardware_id); + return distributed_write_record(BANS_FILE, address, kept_hardware_id, "", "", names, reason, until); } /* (the host: network_server_manager.c) whether a machine of this address @@ -4255,21 +4327,28 @@ boolean network_distributed_banned( char kept_hardware_id[40]; FILE *file; boolean banned = FALSE; + size_t length, hardware_id_length; p2p_hardware_id_sanitize(kept_hardware_id, 33, hardware_id); if (address) distributed_address_text(address, text, sizeof(text)); else text[0] = 0; + length = csstrlen(text); + hardware_id_length = csstrlen(kept_hardware_id); file = fopen(BANS_FILE, "r"); if (!file) return FALSE; while (!banned && fgets(line, sizeof(line), file)) { - char const *ip = strstr(line, "ip="); + char const *ip = strstr(line, "\tip="); char const *hwid = strstr(line, "hwid="); - size_t length = csstrlen(text); - size_t hardware_id_length = csstrlen(kept_hardware_id); + char const *until = strstr(line, "\tuntil="); + + /* (a ban for a while that has ended) */ + if (until && strtoul(until + 7, NULL, 10) <= (unsigned long)time(NULL)) + continue; + ip = ip ? ip + 1 : strstr(line, "ip="); /* (the whole address: 1.2.3.4 is not 1.2.3.45) */ if (length && ip && !strncmp(ip + 3, text, length) && @@ -4294,8 +4373,9 @@ boolean network_distributed_banned( /* (the host: network_server_manager.c, its ban command) a machine banned by the host: its line in BANS_FILE, and every machine told (the Discord user of the machine at the index, if it is one in the game; its address, -host byte order) */ -void network_distributed_ban( +host byte order). The ban's end (network_distributed_ban_details: 0, for +ever) */ +unsigned long network_distributed_ban( long machine_index, unsigned long address, char const *names) @@ -4306,9 +4386,17 @@ void network_distributed_ban( char discord_name[DISCORD_NAME_SIZE] = ""; char discord[DISCORD_ID_SIZE + DISCORD_NAME_SIZE + 16] = ""; char notice[MAXIMUM_NOTICE_LENGTH]; + unsigned long until = distributed_next_ban_until; distributed_address_text(address, text, sizeof(text)); - distributed_write_player_record(BANS_FILE, text, machine_index, names, "banned by the host"); + if (!distributed_next_ban_unrecorded) + { + distributed_write_player_record(BANS_FILE, text, machine_index, names, + distributed_next_ban_reason[0] ? distributed_next_ban_reason : "banned by the host", until); + } + distributed_next_ban_reason[0] = 0; + distributed_next_ban_until = 0; + distributed_next_ban_unrecorded = FALSE; distributed_printable(kept_names, sizeof(kept_names), names); if (game_in_progress() && machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES) { @@ -4327,6 +4415,7 @@ void network_distributed_ban( console_warning("%s", notice); error(_error_log, "%s", notice); } + return until; } /* (the host: network_server_manager.c, its kick command) players kicked by @@ -4421,6 +4510,7 @@ static void distributed_note_client_clock( names, discord, rate); distributed_send_notice(text); distributed_log_cheater(machine_index, names, reason); + server_event_speed_hack(machine_index, names, rate, ahead, "kicked"); } network_game_server_kick_machine(machine_index, TRUE); clock->fast_windows = 0; @@ -4447,7 +4537,8 @@ static void distributed_note_client_clock( snprintf(reason, sizeof(reason), "unverified speed hack (its datagrams only, not dropped: " "game ran %.2f times as fast)", rate); distributed_machine_address_text(machine_index, address, sizeof(address)); - distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason); + distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason, 0); + server_event_speed_hack(machine_index, names, rate, ahead, "unverified"); clock->logged_unverified = TRUE; } } diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h index df097a4e..7b4ff1a7 100644 --- a/port/linux/game/network_distributed.h +++ b/port/linux/game/network_distributed.h @@ -305,6 +305,17 @@ real distributed_vitality_unpack(word value); (machine_index; NONE: the host's own) */ void distributed_send_notice(char const *text); void distributed_send_notice_to_machine(long machine_index, char const *text); +/* (the host: a dedicated server's ban command) the next ban's reason and end +(seconds since 1970; 0: for ever), written in its bans.txt line */ +void network_distributed_ban_details(char const *reason, unsigned long until); +/* (the host: a dedicated server's sv_ban_address) a ban's line in bans.txt +for an address (its text; "none" for none) and a hardware id (empty for +none), as the ban command writes one: TRUE if it was written */ +boolean network_distributed_ban_record(char const *address, char const *hardware_id, char const *names, + char const *reason, unsigned long until); +/* whether the next ban drops its machine and keeps it out without its line +in bans.txt (network_distributed_ban_record wrote it) */ +void network_distributed_ban_unrecorded(boolean unrecorded); /* (the host) the names of a client machine's players, in ASCII */ void distributed_machine_player_names(long machine_index, char *names, long size); /* (the host) a client machine's real IPv4 address (host byte order): an diff --git a/port/linux/game/network_test.c b/port/linux/game/network_test.c index f4fa2505..77b7abf7 100644 --- a/port/linux/game/network_test.c +++ b/port/linux/game/network_test.c @@ -24,6 +24,11 @@ Automated system link sessions for testing the netcode without the menus and then as "join". It logs what it lists. - "log" hosts and joins nothing: the game that runs (map_name, in init.txt, plays one without the menus) is logged, as below. +- "probe" (halo --probe []: a list site's look at a server) searches + as "join" does, joins nothing, and prints the first game it finds (with + an invite, only its host's, over its tunnel; without, one on the LAN) on + the standard output as one line of JSON, then exits (1, with ok false, if + none is found in PROBE_SECONDS). Once the game runs, every second each machine logs where every player's unit is, so the machines' views of the game can be compared, and the host @@ -104,6 +109,17 @@ void network_damage_statistics(long *sent_reports, long *dealt_reports, long *re /* xinput_sdl.c's */ void test_input_hold_action(int hold); void test_input_press(unsigned long buttons); +/* menu_functions.c's: whether the client's advertised game at the index is +an internet play peer's (over the tunnel), not the LAN's */ +boolean menu_advertised_game_from_peer(long index); + +enum +{ + /* how long a probe looks for a game (halo --probe), and the client's + advertised games (network_client_manager.c's) */ + PROBE_SECONDS = 30, + PROBE_MAXIMUM_GAMES = 9, +}; enum { @@ -112,6 +128,7 @@ enum _network_test_join, _network_test_browse, _network_test_log, + _network_test_probe, }; static struct @@ -130,6 +147,11 @@ static struct real start_delay; real menu_seconds; boolean set_up; + /* (the hosted game's whole invite logged, for the test that joins it: + the game's own lines give its first digits only, since players post + their logs. This is debug.network_test's host alone, a test's machine, + whose log nobody posts) */ + boolean invite_logged; real setup_seconds; boolean started; boolean joined; @@ -151,6 +173,7 @@ static struct boolean browsing; boolean browse_listed; float browse_seconds; + float probe_seconds; /* debug.network_test_cues: each line and its tick from the cues' start, the first tick every player of cue_players has a unit (NONE until then) */ struct @@ -222,6 +245,10 @@ static void network_test_read_settings( { network_test.mode = _network_test_log; } + else if (!strcmp(setting, "probe")) + { + network_test.mode = _network_test_probe; + } network_test.public_game = config_boolean("debug.network_test_public") != 0; network_test.start_delay = (real)config_real("debug.network_test_start"); network_test.kill_interval = (real)config_real("debug.network_test_kill"); @@ -391,9 +418,16 @@ static void network_test_log_players( } { long sent, received, corrections; - struct object_iterator objects; long ground_items = 0; + long creates, deletes, failures, removed; + long sent_reports, dealt_reports, rejected_reports, replayed_events; + /* this machine's player and where its camera is (a player that never + spawns leaves it where it began) */ + long local_player_index = local_player_get_player_index(0); + struct observer_result const *camera = observer_get_camera(0); + long divergence_samples, divergence_apart; + real divergence_mean, divergence_largest; object_iterator_new(&objects, _object_mask_weapon | _object_mask_equipment, 0); while (object_iterator_next(&objects)) @@ -408,19 +442,8 @@ static void network_test_log_players( } } network_distributed_statistics(&sent, &received, &corrections); - long creates, deletes, failures, removed; - - long sent_reports, dealt_reports, rejected_reports, replayed_events; - network_distributed_item_statistics(&creates, &deletes, &failures, &removed); network_damage_statistics(&sent_reports, &dealt_reports, &rejected_reports, &replayed_events); - /* this machine's player and where its camera is (a player that never - spawns leaves it where it began) */ - long local_player_index = local_player_get_player_index(0); - struct observer_result const *camera = observer_get_camera(0); - long divergence_samples, divergence_apart; - real divergence_mean, divergence_largest; - network_distributed_divergence(&divergence_samples, &divergence_apart, &divergence_mean, &divergence_largest); platform_log("network test: tick %ld%s | items %ld (+%ld -%ld !%ld x%ld) | %s to %ld | sent %ld received %ld corrected %ld" @@ -1051,6 +1074,73 @@ static void network_test_pickup( } } +/* (a probe: halo --probe) the first game found, printed as JSON on the +standard output, and the end; when given_up, that none was. With an +invite (p2p.c joins the command line's), the invite's host's game alone: +the tunnel's peers are its host only, and a game on the LAN is another */ +static void network_test_probe_answer( + boolean given_up) +{ + static char const *const engines[] = { "none", "ctf", "slayer", "oddball", "king", "race" }; + struct network_advertised_game_summary game; + long index; + int stage; + boolean invited = p2p_join_progress(&stage, NULL, 0) != P2P_JOIN_NONE; + + for (index = 0; index < PROBE_MAXIMUM_GAMES; index++) + { + char name[2 * NUMBEROF(game.name) + 1]; + char map[2 * sizeof(game.map_name) + 1]; + char const *last; + long character, length = 0; + + if (!network_game_client_port_advertised_game_summary(index, &game) || + (invited && !menu_advertised_game_from_peer(index))) + { + continue; + } + /* (printable ASCII, quotes and backslashes escaped) */ + for (character = 0; character < (long)NUMBEROF(game.name) && game.name[character]; character++) + { + char ascii = game.name[character] >= 0x20 && game.name[character] < 0x7F ? (char)game.name[character] : '?'; + + if (ascii == '"' || ascii == '\\') + name[length++] = '\\'; + name[length++] = ascii; + } + name[length] = 0; + /* (the map's name, the last part of its path) */ + last = strrchr(game.map_name, '\\'); + last = last ? last + 1 : game.map_name; + for (length = 0, character = 0; last[character]; character++) + { + char ascii = (unsigned char)last[character] >= 0x20 && (unsigned char)last[character] < 0x7F ? + last[character] : '?'; + + if (ascii == '"' || ascii == '\\') + map[length++] = '\\'; + map[length++] = ascii; + } + map[length] = 0; + printf("{\"ok\":true,\"name\":\"%s\",\"map\":\"%s\",\"gametype\":\"%s\",\"players\":%d," + "\"max_players\":%d,\"teams\":%s,\"in_progress\":%s,\"open\":%s,\"dedicated\":%s," + "\"network_version\":%u,\"kind\":%u}\n", name, map, + game.engine_type >= 0 && game.engine_type < (short)NUMBEROF(engines) ? engines[game.engine_type] : "none", + game.player_count, game.maximum_player_count, game.has_teams ? "true" : "false", + game.in_progress ? "true" : "false", game.open ? "true" : "false", game.dedicated ? "true" : "false", + (unsigned)game.version, (unsigned)game.kind); + fflush(stdout); + exit(0); + } + if (given_up) + { + printf("{\"ok\":false,\"error\":\"no game answered in %d seconds%s\"}\n", PROBE_SECONDS, + invited ? " from the invite's host" : ""); + fflush(stdout); + exit(1); + } +} + void network_test_update( boolean main_menu_loaded, real seconds) @@ -1466,6 +1556,16 @@ void network_test_update( break; } case _network_test_host: + if (network_test.set_up && !network_test.local && !network_test.invite_logged) + { + char invite[256]; + + if (p2p_invite_link(invite, sizeof(invite))) + { + network_test.invite_logged = TRUE; + platform_log("network test: invite %s", invite); + } + } if (!network_test.set_up) { network_test.set_up = TRUE; @@ -1559,6 +1659,21 @@ void network_test_update( } } break; + case _network_test_probe: + if (!network_test.set_up) + { + network_test.set_up = TRUE; + dispose_global_network_game_client(); + dispose_global_network_game_server(); + if (create_global_network_game_client()) + game_connection_set(_game_connection_network_client); + } + else + { + network_test.probe_seconds += seconds; + network_test_probe_answer(network_test.probe_seconds >= PROBE_SECONDS); + } + break; case _network_test_join: if (!network_test.set_up) { diff --git a/port/linux/game/server_commands.c b/port/linux/game/server_commands.c new file mode 100644 index 00000000..0a0712b1 --- /dev/null +++ b/port/linux/game/server_commands.c @@ -0,0 +1,2088 @@ +/* +SERVER_COMMANDS.C + +A dedicated server's commands (server_director.h; docs/dedicated_server.md, +"Commands"): the lines the server's owner, its web page and its remote +console give, whichever they came from, run on the game's thread as the +console's are (hs.c hands each line here first: server_command_try). Each +command has a role, checked where the line is run (the HTTP API checks it +too, before it asks). Halo PC's dedicated server's names are kept where they +mean the same (sv_name, sv_maxplayers, sv_map, sv_mapcycle_add...). A +password's command is never logged nor answered with the password. +*/ + +#include "cseries.h" +#include "main/console.h" +#include "main/main.h" +#include "hs/hs.h" +#include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "networking/network_server_manager.h" +#include "networking/network_server_manager_internal.h" +#include "game/game.h" +#include "game/game_engine.h" +#include "game/players.h" +#include "network_chat.h" +#include "network_distributed.h" +#include "server_director.h" +#include "server_gametypes.h" + +#include "../src/p2p.h" /* port: port/linux/src/p2p.c */ +#include "../src/server_console.h" /* port: port/linux/src/server_console.c */ +#include "../src/server_events.h" /* port: port/linux/src/server_events.c */ +#include "../src/server_json.h" /* port: port/linux/src/server_json.c */ +#include "../src/server_log.h" /* port: port/linux/src/server_log.c */ +#include "../src/server_master.h" /* port: port/linux/src/server_master.c */ +#include "../src/posix_server.h" /* port: port/linux/src/posix_server.c */ +#include "../src/port_config.h" /* port: port/linux/src/port_config.c */ + +#include +#include +#include +#include + +void platform_log(char const *format, ...); +void platform_translate_path(char const *xbox_path, char *host_path, unsigned long host_path_size); + +#if HALO_SERVER_BUILD + +enum +{ + MAXIMUM_WORDS = 8, + /* a moderator's ban's longest (an admin's has none) */ + MODERATOR_BAN_SECONDS = 24 * 60 * 60, + /* bans.txt's lines the ban commands read */ + MAXIMUM_BAN_LINES = 2048, + BAN_LINE_SIZE = 512, + /* say's lines a minute from the web (the owner's are not counted) */ + SAY_PER_MINUTE = 20, + MAXIMUM_SLOTS = HALO_PORT_MAXIMUM_NETWORK_PLAYERS, + NOTICE_SIZE = 128, +}; + +#define BANS_FILE "d:\\bans.txt" + +static struct +{ + int role; + int origin; + /* say's: the minute counted, and the lines said in it */ + unsigned long say_minute; + long say_count; + /* a line's output captured whole (server_commands_run), not through + the console's lines */ + boolean capturing; +} server_commands = { _server_role_owner }; + +int server_commands_role( + void) +{ + return server_commands.role; +} + +/* ---------- output */ + +/* a line of the command's reply: whole into the capture its line is run +with (server_console_output), whatever its length (sv_status --json's); +else the console's, whose lines hold 254 characters, a piece at a time */ +static void say( + char const *format, + ...) +{ + static char text[SERVER_COMMAND_OUTPUT_SIZE]; + va_list arguments; + + va_start(arguments, format); + vsnprintf(text, sizeof(text), format, arguments); + va_end(arguments); + if (server_commands.capturing) + { + /* (an empty line is no line to the capture) */ + server_console_output(text[0] ? text : " "); + } + else + { + long length = (long)strlen(text); + long at = 0; + + do + { + console_printf(FALSE, "%.*s", (int)MIN(length - at, 200), text + at); + at += 200; + } + while (at < length); + } +} + +/* ---------- words */ + +/* the words of a line: separated by spaces, a quoted one whole; how many */ +static long split_words( + char *line, + char **words, + long maximum) +{ + long count = 0; + char *cursor = line; + + while (*cursor && count < maximum) + { + while (*cursor == ' ' || *cursor == '\t') + cursor++; + if (!*cursor) + break; + if (*cursor == '"') + { + words[count++] = ++cursor; + while (*cursor && *cursor != '"') + cursor++; + } + else + { + words[count++] = cursor; + while (*cursor && *cursor != ' ' && *cursor != '\t') + cursor++; + } + if (*cursor) + *cursor++ = 0; + } + return count; +} + +/* the text after the line's first count words (a notice's, a name's) */ +static char const *rest_after_words( + char const *line, + long count) +{ + long index; + + for (index = 0; index < count; index++) + { + while (*line == ' ' || *line == '\t') + line++; + if (*line == '"') + { + line++; + while (*line && *line != '"') + line++; + if (*line) + line++; + } + else + { + while (*line && *line != ' ' && *line != '\t') + line++; + } + } + while (*line == ' ' || *line == '\t') + line++; + return line; +} + +/* a text kept to printable ASCII, its quotes taken off, size - 1 at most */ +static void printable_text( + char *destination, + long size, + char const *source) +{ + long length = 0, first, last, index; + + /* (the printable characters' first and last but spaces, inside the + quotes around them if there are: they go before the text is cut) */ + for (first = 0; source && source[first] && ((unsigned char)source[first] <= 0x20 || + (unsigned char)source[first] >= 0x7F); first++) + { + } + for (last = source ? (long)strlen(source) - 1 : -1; last >= first && ((unsigned char)source[last] <= 0x20 || + (unsigned char)source[last] >= 0x7F); last--) + { + } + if (last > first && source[first] == '"' && source[last] == '"') + { + first++; + last--; + } + for (index = first; index <= last && length < size - 1; index++) + { + if ((unsigned char)source[index] >= 0x20 && (unsigned char)source[index] < 0x7F) + destination[length++] = source[index]; + } + destination[length] = 0; + while (length > 0 && destination[length - 1] == ' ') + destination[--length] = 0; +} + +/* a duration: "30m", "2h", "7d", "1w", "45s", a bare number of minutes, or +"forever" (0): its seconds, -1 if it is none (ten years at most) */ +static long parse_duration( + char const *text) +{ + char *end; + long number; + long unit = 60; + + if (!csstrcasecmp(text, "forever") || !csstrcasecmp(text, "permanent") || !strcmp(text, "0")) + return 0; + if (!isdigit((unsigned char)text[0])) + return -1; + number = strtol(text, &end, 10); + if (number <= 0 || number > 100000) + return -1; + switch (*end) + { + case 0: case 'm': unit = 60; break; + case 's': unit = 1; break; + case 'h': unit = 60 * 60; break; + case 'd': unit = 24 * 60 * 60; break; + case 'w': unit = 7 * 24 * 60 * 60; break; + case 'y': unit = 365 * 24 * 60 * 60; break; + default: return -1; + } + if (*end && end[1]) + return -1; + if ((double)number * unit > 10.0 * 365 * 24 * 60 * 60) + return -1; + return number * unit; +} + +static boolean parse_integer( + char const *text, + long minimum, + long maximum, + long *value) +{ + char *end; + long number; + + if (!text || !text[0]) + return FALSE; + number = strtol(text, &end, 10); + if (*end || number < minimum || number > maximum) + return FALSE; + *value = number; + return TRUE; +} + +/* ---------- players */ + +static struct network_game *server_game( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + return network_server ? network_game_server_get_game(network_server) : NULL; +} + +/* whether name begins with the length characters of text, in either case */ +static boolean name_begins_with( + char const *name, + char const *text, + long length) +{ + long index; + + for (index = 0; index < length; index++) + { + if (!name[index] || tolower((unsigned char)name[index]) != tolower((unsigned char)text[index])) + return FALSE; + } + return TRUE; +} + +/* a player by slot number or the start of their name (in either case; the +whole name first): the slot, else NONE (said why) */ +static long find_player( + char const *command, + char const *text) +{ + struct network_game *game = server_game(); + char *end; + long number, slot, found = NONE, matches = 0, exact = NONE, exact_count = 0; + long length = (long)strlen(text); + + if (!game) + { + say("%s: the server is not hosting yet", command); + return NONE; + } + if (!text[0]) + { + say("%s: give a player's slot (sv_players) or name", command); + return NONE; + } + number = strtol(text, &end, 10); + if (!*end && isdigit((unsigned char)text[0])) + { + if (number < 0 || number >= MAXIMUM_SLOTS || !network_player_is_valid(&game->players[number])) + { + say("%s: no player in slot %ld (sv_players lists them)", command, number); + return NONE; + } + return number; + } + for (slot = 0; slot < MAXIMUM_SLOTS; slot++) + { + char name[16]; + + if (!server_status_player_name(slot, name, sizeof(name))) + continue; + if (name_begins_with(name, text, length)) + { + found = slot; + matches++; + if ((long)strlen(name) == length) + { + exact = slot; + exact_count++; + } + } + } + if (exact_count == 1) + return exact; + if (exact_count > 1) + { + say("%s: %ld players are named \"%s\": give a slot (sv_players)", command, exact_count, text); + return NONE; + } + if (matches > 1) + { + say("%s: %ld players' names begin with \"%s\": give more of it, or a slot", command, matches, text); + return NONE; + } + if (!matches) + { + say("%s: no player's name begins with \"%s\"", command, text); + return NONE; + } + return found; +} + +/* ---------- bans.txt */ + +static char ban_lines[MAXIMUM_BAN_LINES][BAN_LINE_SIZE]; + +/* bans.txt's lines (empty ones left out); how many, and whether that is +all of it */ +static long read_bans( + boolean *whole) +{ + FILE *file = fopen(BANS_FILE, "r"); + char line[BAN_LINE_SIZE]; + long count = 0; + + *whole = TRUE; + if (!file) + return 0; + while (fgets(line, sizeof(line), file)) + { + if (!strchr(line, '\n') && !feof(file)) + *whole = FALSE; + line[strcspn(line, "\r\n")] = 0; + if (!line[0]) + continue; + if (count == MAXIMUM_BAN_LINES) + { + *whole = FALSE; + break; + } + csstrcpy(ban_lines[count++], line); + } + fclose(file); + return count; +} + +/* a field of a bans.txt line ("ip", "reason", "until"), into text */ +static boolean ban_field( + char const *line, + char const *name, + char *text, + long size) +{ + char key[32]; + char const *at; + long length; + + snprintf(key, sizeof(key), "\t%s=", name); + at = strstr(line, key); + if (!at) + return FALSE; + at += strlen(key); + length = (long)strcspn(at, "\t"); + snprintf(text, (size_t)size, "%.*s", (int)MIN(length, size - 1), at); + return TRUE; +} + +/* bans.txt written again from the lines, but for the one left out (put in +place whole) */ +static boolean write_bans( + long count, + long left_out) +{ + /* (the lines and their ends: as big as the lines read can be) */ + static char text[MAXIMUM_BAN_LINES * BAN_LINE_SIZE]; + char path[1024]; + unsigned long size = 0; + long index; + + /* (written whole by the host's path, then put in place: the game's + fopen takes Xbox paths) */ + platform_translate_path(BANS_FILE, path, sizeof(path)); + for (index = 0; index < count; index++) + { + if (index != left_out) + { + long length = (long)strlen(ban_lines[index]); + + csmemcpy(text + size, ban_lines[index], length); + size += (unsigned long)length; + text[size++] = '\n'; + } + } + return posix_server_write_file(path, text, size) != 0; +} + +void server_commands_bans_json( + struct server_json *json) +{ + boolean whole; + long count = read_bans(&whole); + long index; + + server_json_array_begin(json, "bans"); + for (index = 0; index < count; index++) + { + char field[128]; + char when[32]; + + server_json_object_begin(json, NULL); + server_json_integer(json, "n", index); + snprintf(when, sizeof(when), "%.*s", (int)strcspn(ban_lines[index], "\t"), ban_lines[index]); + server_json_string(json, "when", when); + server_json_string(json, "ip", ban_field(ban_lines[index], "ip", field, sizeof(field)) ? field : ""); + server_json_string(json, "hwid", ban_field(ban_lines[index], "hwid", field, sizeof(field)) ? field : ""); + server_json_string(json, "players", ban_field(ban_lines[index], "players", field, sizeof(field)) ? field : ""); + server_json_string(json, "reason", ban_field(ban_lines[index], "reason", field, sizeof(field)) ? field : ""); + if (ban_field(ban_lines[index], "until", field, sizeof(field))) + server_json_integer(json, "until", strtoul(field, NULL, 10)); + else + server_json_null(json, "until"); + server_json_object_end(json); + } + server_json_array_end(json); +} + +/* ---------- the commands */ + +struct server_command +{ + char const *name; + /* Halo PC's dedicated server's name, or a short one (NULL: none) */ + char const *alias; + int role; + char const *usage; + char const *description; + boolean (*run)(char const *line, long count, char **words); +}; + +static struct server_command const server_command_table[]; +static long server_command_count(void); + +static boolean command_help(char const *line, long count, char **words) +{ + long index; + + if (count >= 2) + { + for (index = 0; index < server_command_count(); index++) + { + struct server_command const *command = &server_command_table[index]; + + if (!csstrcasecmp(words[1], command->name) || (command->alias && !csstrcasecmp(words[1], command->alias))) + { + say("%s %s", command->name, command->usage); + say(" %s (%s)", command->description, server_role_name(command->role)); + if (command->alias) + say(" also %s", command->alias); + return TRUE; + } + } + say("help: no command is named %s", words[1]); + return FALSE; + } + say("the server's commands (help says more; the game's console commands run too):"); + for (index = 0; index < server_command_count(); index++) + { + struct server_command const *command = &server_command_table[index]; + + if (command->role <= server_commands.role) + say(" %-18s %s", command->name, command->description); + } + return TRUE; +} + +static boolean command_status(char const *line, long count, char **words) +{ + struct server_json json; + struct server_entry const *current = server_director_current(); + struct server_entry const *next = server_director_next(); + struct network_game *game = server_game(); + long uptime = (long)server_mode_uptime(); + char invite[P2P_LISTING_INVITE_SIZE] = ""; + + if (count >= 2 && !strcmp(words[1], "--json")) + { + server_json_begin(&json); + server_status_write(&json, server_commands.role); + say("%s", server_json_text(&json)); + server_json_free(&json); + return TRUE; + } + say("%s: %s, up %ldh%02ldm, %ld game%s played; OpenCE %s, network version %d", config_string("server.name"), + server_status_state_name(server_director_state()), uptime / 3600, (uptime / 60) % 60, + server_director_games_hosted(), server_director_games_hosted() == 1 ? "" : "s", server_mode_build(), + HALO_PORT_NETWORK_VERSION); + if (current) + { + long players = 0, slot; + + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + players += network_player_is_valid(&game->players[slot]) != 0; + say("game: %s on %s, %ld of %d players", current->gametype, current->map_name, players, + game ? (int)game->maximum_players : 0); + } + if (next) + say("next: %s on %s", next->gametype, next->map_name); + say("listing: %s%s", !config_boolean("network.online") ? "internet play off (LAN only)" : + config_boolean("server.public") ? "public" : "by invite only", config_string("server.password")[0] ? + ", with a password" : ""); + if (server_commands.role >= _server_role_admin && p2p_invite_link(invite, sizeof(invite)) && invite[0]) + say("invite: %s", invite); + return TRUE; +} + +static boolean command_players(char const *line, long count, char **words) +{ + struct network_game *game = server_game(); + struct server_json json; + long slot, listed = 0; + boolean addresses = server_commands.role >= _server_role_owner || config_boolean("server.log_addresses"); + + if (count >= 2 && !strcmp(words[1], "--json")) + { + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_status_players(&json, server_commands.role); + server_json_object_end(&json); + say("%s", server_json_text(&json)); + server_json_free(&json); + return TRUE; + } + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + { + struct network_player const *player = &game->players[slot]; + char name[16]; + long ping = NONE; + struct player_datum *datum; + + if (!server_status_player_name(slot, name, sizeof(name))) + continue; + datum = game_engine_running() ? distributed_player(player->player_list_index) : NULL; + if (datum) + ping = distributed_player_ping(player->player_list_index); + if (addresses) + { + unsigned long address = distributed_machine_real_address(player->machine_index); + char const *hardware_id = network_game_server_machine_hardware_id(player->machine_index); + + say("%3ld %-12s team %d machine %d ping %s%ld %lu.%lu.%lu.%lu hwid %s", slot, name, + (int)player->team_index, (int)player->machine_index, ping == NONE ? "-" : "", ping == NONE ? 0 : ping, + (address >> 24) & 255, (address >> 16) & 255, (address >> 8) & 255, address & 255, + hardware_id && hardware_id[0] ? hardware_id : "none"); + } + else + { + say("%3ld %-12s team %d machine %d ping %s%ld", slot, name, (int)player->team_index, + (int)player->machine_index, ping == NONE ? "-" : "", ping == NONE ? 0 : ping); + } + if (datum) + { + say(" score %ld kills %d deaths %d assists %d", + game_engine && game_engine->get_player_score ? + game_engine->get_player_score(DATUM_INDEX_TO_ABSOLUTE_INDEX(player->player_list_index), + _get_score_individual) : 0L, + datum->statistics.kills[0], datum->statistics.deaths, datum->statistics.assists[0]); + } + listed++; + } + if (!listed) + say("no players"); + return TRUE; +} + +static boolean command_rotation(char const *line, long count, char **words) +{ + struct server_rotation const *rotation = server_director_rotation(); + long index; + long playing = server_director_index(); + + for (index = 0; index < rotation->count; index++) + { + struct server_entry const *entry = &rotation->entries[index]; + + say("%c%2ld %s%s%s", index == playing ? '>' : ' ', index, entry->text, entry->rejected[0] ? " (left out: " : "", + entry->rejected[0] ? entry->rejected : ""); + if (entry->rejected[0]) + say(" )"); + } + if (!rotation->count) + say("the rotation is empty"); + return TRUE; +} + +static boolean command_maps(char const *line, long count, char **words) +{ + struct server_json json; + struct server_json_document document; + long index; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_status_maps(&json); + server_json_object_end(&json); + if (server_json_parse(&document, server_json_text(&json), (int)strlen(server_json_text(&json)), 16)) + { + int maps = server_json_member(&document, 0, "maps"); + + for (index = 0; server_json_element(&document, maps, index) >= 0; index++) + { + int map = server_json_element(&document, maps, index); + char name[128], kind[16]; + + if (server_json_get_string(&document, server_json_member(&document, map, "name"), name, sizeof(name)) && + server_json_get_string(&document, server_json_member(&document, map, "kind"), kind, sizeof(kind))) + { + say("%-24s %s", name, kind); + } + } + server_json_document_free(&document); + } + server_json_free(&json); + return TRUE; +} + +static boolean command_gametypes(char const *line, long count, char **words) +{ + char text[1024] = ""; + long index, length = 0; + char const *name; + + for (index = 0; (name = server_gametype_builtin_name(index)) != NULL; index++) + length += snprintf(text + length, sizeof(text) - (size_t)length, "%s%s", index ? ", " : "", name); + say("built in: %s", text); + say("and gametypes/.toml files, and file= for gametypes/.gametype"); + return TRUE; +} + +static boolean command_bans(char const *line, long count, char **words) +{ + boolean whole; + long lines = read_bans(&whole); + long index; + + for (index = 0; index < lines; index++) + { + char until[32]; + + if (ban_field(ban_lines[index], "until", until, sizeof(until)) && + strtoul(until, NULL, 10) <= posix_server_unix_seconds()) + { + say("%3ld (ended) %s", index, ban_lines[index]); + } + else + { + say("%3ld %s", index, ban_lines[index]); + } + } + if (!lines) + say("no bans"); + if (!whole) + say("(bans.txt has more lines, or longer ones, than the first %d read)", MAXIMUM_BAN_LINES); + return TRUE; +} + +/* the settings sv_set may change: a prefix ends with a "." */ +static char const *const settable_settings[] = +{ + "server.name", "server.max_players", "server.min_players", "server.password", "server.public", + "server.rotation_fallback", "server.lobby_seconds", "server.postgame_seconds", "server.empty_seconds", + "server.idle_minutes", "server.team_start", "server.motd", "server.frame_rate", "server.web_password", + "server.web_moderator_password", "server.log_format", "server.log_addresses", "server.status_file", + "server.auto_update", "server.coop_difficulty", "server.region", "server.master_publish", + "server.master_publish_address", "server.metrics", + "network.text_chat", "network.voice_lobby", "network.voice_mode", "network.voice_kbps", "network.votekick", + "network.votekick_minutes", "network.votekick_ban_minutes", "network.map_share", "network.map_share_upload", + "network.public_address", "network.coop_friendly_fire", "network.coop_player_collisions", + "network.coop_ally_friendly_fire", "network.shield_effects", +}; + +/* those of them the owner's console alone changes (the standard input, the +init file): a file the server writes to, the update it installs, and the +addresses it shows every role */ +static char const *const owner_settings[] = +{ + "server.status_file", "server.auto_update", "server.log_addresses", +}; + +static boolean setting_owners( + char const *name) +{ + long index; + + for (index = 0; index < (long)NUMBEROF(owner_settings); index++) + { + if (!strcmp(name, owner_settings[index])) + return TRUE; + } + return FALSE; +} + +static boolean setting_settable( + char const *name) +{ + long index; + + for (index = 0; index < (long)NUMBEROF(settable_settings); index++) + { + if (!strcmp(name, settable_settings[index])) + return TRUE; + } + return FALSE; +} + +/* the values the settings sv_set changes take, as port_config.c's comments +say them (and the director and the game hold them to): a word of the +choices ("a|b|c", in either case), or a number from the least to the most +(NULL: none) */ +static struct +{ + char const *name; + char const *choices; + char const *minimum; + char const *maximum; +} const setting_values[] = +{ + { "server.max_players", NULL, "2", "127" }, + { "server.min_players", NULL, "1", "16" }, + { "server.lobby_seconds", NULL, "0", "300" }, + { "server.postgame_seconds", NULL, "0", "300" }, + { "server.empty_seconds", NULL, "0", NULL }, + { "server.idle_minutes", NULL, "0", NULL }, + { "server.frame_rate", NULL, "30", "120" }, + { "server.team_start", "one|both|skip", NULL, NULL }, + { "server.log_format", "text|json", NULL, NULL }, + { "server.auto_update", "off|check|install", NULL, NULL }, + { "server.coop_difficulty", "easy|normal|hard|impossible", NULL, NULL }, + { "network.text_chat", "on|links|off", NULL, NULL }, + { "network.voice_mode", "off|team_proximity|team_enemy_proximity|team_global|team_global_enemy_proximity", NULL, + NULL }, + { "network.voice_kbps", NULL, "8", "64" }, + { "network.votekick_minutes", NULL, "0", "60" }, + { "network.votekick_ban_minutes", NULL, "1", "1440" }, + { "network.map_share_upload", NULL, "16", "20480" }, + { "network.coop_friendly_fire", "off|on|shields_only|explosives_only", NULL, NULL }, + { "network.shield_effects", "stock|glow|light_show|overshield|glow_overshield|light_show_overshield|players", NULL, + NULL }, +}; + +/* the setting's values (setting_values), NONE if any is taken */ +static long setting_values_index( + char const *name) +{ + long index; + + for (index = 0; index < (long)NUMBEROF(setting_values); index++) + { + if (!strcmp(name, setting_values[index].name)) + return index; + } + return NONE; +} + +/* whether the value is one of the choices ("a|b|c"), in either case: TRUE +with the choice as the choices spell it */ +static boolean setting_choice( + char const *choices, + char const *value, + char *choice, + long size) +{ + long length = (long)strlen(value); + + while (*choices) + { + long choice_length = (long)strcspn(choices, "|"); + + if (choice_length == length && name_begins_with(choices, value, length)) + { + snprintf(choice, (size_t)size, "%.*s", (int)choice_length, choices); + return TRUE; + } + choices += choice_length + (choices[choice_length] == '|'); + } + return FALSE; +} + +/* a value of the setting's type as JSON: a boolean, a number, a text */ +static void setting_json_value( + struct server_json *json, + char const *key, + char const *type, + char const *text) +{ + if (!strcmp(type, "boolean")) + server_json_boolean(json, key, !csstrcasecmp(text, "true")); + else if (!strcmp(type, "integer")) + server_json_integer(json, key, strtol(text, NULL, 10)); + else if (!strcmp(type, "real")) + server_json_real(json, key, strtod(text, NULL)); + else + server_json_string(json, key, text); +} + +boolean server_commands_setting_secret( + char const *name) +{ + return !strcmp(name, "server.password") || !strcmp(name, "server.web_password") || + !strcmp(name, "server.web_moderator_password"); +} + +/* the settable settings, for the API (GET /api/settings) */ +void server_commands_settings_json( + struct server_json *json) +{ + struct config_setting_info info; + long index; + + server_json_array_begin(json, "settings"); + for (index = 0; config_setting_info(index, &info); index++) + { + char value[400]; + long values = setting_values_index(info.name); + + if (!setting_settable(info.name)) + continue; + config_text(info.name, value, sizeof(value)); + server_json_object_begin(json, NULL); + server_json_string(json, "name", info.name); + server_json_string(json, "type", info.type); + /* (each value of its type: true, 16, 1.5, "text"; a secret's never) */ + if (server_commands_setting_secret(info.name)) + { + server_json_string(json, "value", ""); + server_json_boolean(json, "set", value[0] != 0); + server_json_boolean(json, "secret", TRUE); + server_json_string(json, "default", ""); + } + else + { + setting_json_value(json, "value", info.type, value); + server_json_boolean(json, "secret", FALSE); + setting_json_value(json, "default", info.type, info.default_text); + } + server_json_string(json, "comment", info.comment); + if (info.environment) + server_json_string(json, "env", info.environment); + else + server_json_null(json, "env"); + server_json_boolean(json, "pinned", info.pinned); + if (values != NONE && setting_values[values].choices) + { + char const *choice = setting_values[values].choices; + + server_json_array_begin(json, "choices"); + while (*choice) + { + long length = (long)strcspn(choice, "|"); + + server_json_string_length(json, NULL, choice, (int)length); + choice += length + (choice[length] == '|'); + } + server_json_array_end(json); + } + else + { + server_json_null(json, "choices"); + } + if (values != NONE && setting_values[values].minimum) + server_json_integer(json, "min", strtol(setting_values[values].minimum, NULL, 10)); + else + server_json_null(json, "min"); + if (values != NONE && setting_values[values].maximum) + server_json_integer(json, "max", strtol(setting_values[values].maximum, NULL, 10)); + else + server_json_null(json, "max"); + server_json_object_end(json); + } + server_json_array_end(json); +} + +/* a setting changed (sv_set): FALSE and why if it cannot be */ +boolean server_commands_set_setting( + char const *name, + char const *value, + char *reason, + long reason_size) +{ + struct config_setting_info info; + long index; + long values = setting_values_index(name); + char choice[64]; + + for (index = 0; config_setting_info(index, &info) && strcmp(info.name, name); index++) + { + } + if (!info.name || strcmp(info.name, name) || !setting_settable(name)) + { + snprintf(reason, (size_t)reason_size, "%s is not a setting sv_set changes (sv_settings lists them)", name); + return FALSE; + } + if (info.pinned) + { + snprintf(reason, (size_t)reason_size, "%s is set by %s for this run", name, info.environment); + return FALSE; + } + if (setting_owners(name) && server_commands.role < _server_role_owner) + { + snprintf(reason, (size_t)reason_size, "%s needs the owner role (the server's console)", name); + return FALSE; + } + /* (printable text only: a line's end or another control character in a + value is no setting's) */ + { + char const *character; + + for (character = value; *character; character++) + { + if ((unsigned char)*character < 0x20 || *character == 0x7F) + { + snprintf(reason, (size_t)reason_size, "%s's value has a control character", name); + return FALSE; + } + } + } + /* (each type's text checked, and the setting's values: config_write + takes any) */ + if (!strcmp(info.type, "integer")) + { + long minimum = values != NONE && setting_values[values].minimum ? + strtol(setting_values[values].minimum, NULL, 10) : -2147483647L; + long maximum = values != NONE && setting_values[values].maximum ? + strtol(setting_values[values].maximum, NULL, 10) : 2147483647L; + long number; + + if (!parse_integer(value, minimum, maximum, &number)) + { + if (values != NONE && setting_values[values].maximum) + snprintf(reason, (size_t)reason_size, "%s takes a whole number from %ld to %ld", name, minimum, maximum); + else if (values != NONE) + snprintf(reason, (size_t)reason_size, "%s takes a whole number from %ld", name, minimum); + else + snprintf(reason, (size_t)reason_size, "%s takes a whole number", name); + return FALSE; + } + } + else if (!strcmp(info.type, "string") && values != NONE && setting_values[values].choices) + { + if (!setting_choice(setting_values[values].choices, value, choice, sizeof(choice))) + { + snprintf(reason, (size_t)reason_size, "%s is one of %s", name, setting_values[values].choices); + return FALSE; + } + value = choice; + } + else if (!strcmp(info.type, "boolean")) + { + if (csstrcasecmp(value, "true") && csstrcasecmp(value, "false") && strcmp(value, "1") && strcmp(value, "0") && + csstrcasecmp(value, "on") && csstrcasecmp(value, "off")) + { + snprintf(reason, (size_t)reason_size, "%s takes true or false", name); + return FALSE; + } + value = !csstrcasecmp(value, "true") || !strcmp(value, "1") || !csstrcasecmp(value, "on") ? "true" : "false"; + } + if ((!strcmp(name, "server.web_password") || !strcmp(name, "server.web_moderator_password")) && value[0] && + strlen(value) < 8) + { + snprintf(reason, (size_t)reason_size, "a web password has 8 characters or more"); + return FALSE; + } + if (strlen(value) > 256) + { + snprintf(reason, (size_t)reason_size, "the value is longer than 256 characters"); + return FALSE; + } + if (!config_write(name, value)) + server_log(_server_log_warning, "server", "config.toml cannot be written: %s holds for this run", name); + if (!strcmp(name, "server.log_format")) + server_log_set_json(!strcmp(value, "json")); + if (!strncmp(name, "server.", 7)) + server_director_apply_settings(); + return TRUE; +} + +/* (the web's: server_http.c) as server_commands_set_setting, with the +session's role (an admin's at most, but from this machine an owner's), and +an HTTP status for a refusal: 409 for a setting its environment variable +sets, 404 for none sv_set changes, 403 for the owner's, else 400 */ +int server_commands_set_setting_text( + char const *name, + char const *value, + int role, + char *reason, + long reason_size, + int *code) +{ + struct config_setting_info info; + long index; + + *code = 400; + for (index = 0; config_setting_info(index, &info) && strcmp(info.name, name); index++) + { + } + if (!info.name || strcmp(info.name, name) || !setting_settable(name)) + *code = 404; + else if (info.pinned) + *code = 409; + else if (setting_owners(name) && role < _server_role_owner) + *code = 403; + { + int previous_role = server_commands.role; + boolean set; + + server_commands.role = MIN(role, _server_role_owner); + set = server_commands_set_setting(name, !strcmp(value, "null") ? "" : value, reason, reason_size); + server_commands.role = previous_role; + return set; + } +} + +static boolean command_settings(char const *line, long count, char **words) +{ + struct config_setting_info info; + long index; + + for (index = 0; config_setting_info(index, &info); index++) + { + char value[400]; + + if (!setting_settable(info.name)) + continue; + config_text(info.name, value, sizeof(value)); + say("%-32s %s%s", info.name, server_commands_setting_secret(info.name) ? (value[0] ? "(set)" : "(none)") : value, + info.pinned ? " (set by its environment variable)" : ""); + } + return TRUE; +} + +static boolean command_version(char const *line, long count, char **words) +{ + say("OpenCE %s, network version %d", server_mode_build(), HALO_PORT_NETWORK_VERSION); + return TRUE; +} + +static boolean command_log(char const *line, long count, char **words) +{ + static struct server_log_line lines[200]; + long wanted = 20; + unsigned long latest = server_log_latest(); + unsigned long next, missed; + long read, index; + + if (count >= 2 && !parse_integer(words[1], 1, (long)NUMBEROF(lines), &wanted)) + { + say("sv_log: give how many lines (1 to %d)", (int)NUMBEROF(lines)); + return FALSE; + } + read = server_log_read(latest > (unsigned long)wanted ? latest - (unsigned long)wanted : 0, lines, (int)wanted, &next, + &missed); + /* (said into the log itself as it is read: the lines copied first) */ + for (index = 0; index < read; index++) + say("%s: %s", lines[index].category, lines[index].text); + return TRUE; +} + +static boolean command_masters(char const *line, long count, char **words) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_master_status(&json); + server_json_object_end(&json); + say("%s", server_json_text(&json)); + server_json_free(&json); + return TRUE; +} + +static boolean command_publisher(char const *line, long count, char **words) +{ + unsigned char public_key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + char hex[2 * SERVER_MASTER_PUBLIC_KEY_SIZE + 1]; + long index; + + if (!server_master_publisher(public_key, key_id)) + { + say("there is no publisher key (halo --server --keygen makes one; HALO_SERVER_PUBLISHER_KEY holds one)"); + return FALSE; + } + for (index = 0; index < SERVER_MASTER_PUBLIC_KEY_SIZE; index++) + snprintf(hex + 2 * index, 3, "%02x", public_key[index]); + say("publisher key id %s, public key ed25519:%s", key_id, hex); + return TRUE; +} + +/* a notice: every machine's (machine NONE), or one's */ +static void send_notice( + long machine_index, + char const *text) +{ + char notice[NOTICE_SIZE]; + + snprintf(notice, sizeof(notice), "%s", text); + if (!game_in_progress() || !game_engine_running()) + { + /* (in the lobby the notice reaches no client: said here) */ + say("(in the lobby: no machine sees a notice until a game runs) %s", notice); + return; + } + if (machine_index == NONE) + distributed_send_notice(notice); + else + distributed_send_notice_to_machine(machine_index, notice); +} + +static boolean command_say(char const *line, long count, char **words) +{ + char text[NOTICE_SIZE]; + unsigned long minute = posix_server_unix_seconds() / 60; + + printable_text(text, sizeof(text), rest_after_words(line, 1)); + if (!text[0]) + { + say("say: give the text"); + return FALSE; + } + if (server_commands.role < _server_role_owner) + { + if (minute != server_commands.say_minute) + { + server_commands.say_minute = minute; + server_commands.say_count = 0; + } + if (++server_commands.say_count > SAY_PER_MINUTE) + { + say("say: %d lines a minute at most", SAY_PER_MINUTE); + return FALSE; + } + } + send_notice(NONE, text); + server_log(_server_log_info, "chat", "server: %s", text); + return TRUE; +} + +static boolean command_tell(char const *line, long count, char **words) +{ + struct network_game *game = server_game(); + char text[NOTICE_SIZE]; + long slot; + + if (count < 3) + { + say("tell: give a player and the text"); + return FALSE; + } + slot = find_player("tell", words[1]); + if (slot == NONE) + return FALSE; + printable_text(text, sizeof(text), rest_after_words(line, 2)); + send_notice(game->players[slot].machine_index, text); + return TRUE; +} + +static void moderation_event( + char const *event, + char const *name, + char const *reason, + unsigned long until) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", event); + server_json_string(&json, "name", name); + server_json_string(&json, "by", server_role_name(server_commands.role)); + if (reason) + server_json_string(&json, "reason", reason); + if (until) + server_json_integer(&json, "until", until); + server_json_object_end(&json); + server_event_add("moderation", server_json_text(&json)); + server_json_free(&json); +} + +static boolean command_kick(char const *line, long count, char **words) +{ + struct network_game *game = server_game(); + char name[16]; + long slot; + + if (count < 2) + { + say("kick: give a player's slot (sv_players) or name"); + return FALSE; + } + slot = find_player("kick", words[1]); + if (slot == NONE) + return FALSE; + server_status_player_name(slot, name, sizeof(name)); + if (!network_game_server_kick_machine_of_player(game->players[slot].machine_index, FALSE)) + return FALSE; + server_director_player_removed(game->players[slot].machine_index, "kicked"); + server_log(_server_log_info, "moderation", "%s kicked (%s)", name, server_role_name(server_commands.role)); + moderation_event("kick", name, NULL, 0); + return TRUE; +} + +/* whether the role may ban for so long (0: for ever): a moderator for a +day at most */ +static boolean ban_duration_allowed( + int role, + long seconds) +{ + return role >= _server_role_admin || (seconds > 0 && seconds <= MODERATOR_BAN_SECONDS); +} + +static boolean command_ban(char const *line, long count, char **words) +{ + struct network_game *game = server_game(); + char name[16]; + char reason[96] = ""; + long slot, seconds = 0; + long reason_words = 2; + unsigned long until = 0; + + if (count < 2) + { + say("ban: give a player's slot (sv_players) or name, then a duration (30m, 2h, 7d, forever) and a reason"); + return FALSE; + } + if (count >= 3) + { + seconds = parse_duration(words[2]); + if (seconds >= 0) + reason_words = 3; + else + seconds = 0; + } + if (!ban_duration_allowed(server_commands.role, seconds)) + { + say("ban: a moderator's ban is for a day at most (give a duration: 30m, 2h, 1d)"); + return FALSE; + } + slot = find_player("ban", words[1]); + if (slot == NONE) + return FALSE; + printable_text(reason, sizeof(reason), rest_after_words(line, reason_words)); + for (count = 0; reason[count]; count++) + { + if (reason[count] == '\t') + reason[count] = ' '; + } + server_status_player_name(slot, name, sizeof(name)); + if (seconds > 0) + until = posix_server_unix_seconds() + (unsigned long)seconds; + { + /* (the reason, and who gave it: the reason cut to leave its room) */ + char full_reason[sizeof(reason) + 32]; + + if (server_commands.role < _server_role_owner) + { + snprintf(full_reason, sizeof(full_reason), "%s (by the %s)", reason[0] ? reason : "banned by the server", + server_role_name(server_commands.role)); + } + else + { + snprintf(full_reason, sizeof(full_reason), "%s", reason[0] ? reason : "banned by the server"); + } + network_distributed_ban_details(full_reason, until); + } + if (!network_game_server_kick_machine_of_player(game->players[slot].machine_index, TRUE)) + { + network_distributed_ban_details(NULL, 0); + return FALSE; + } + server_director_player_removed(game->players[slot].machine_index, "banned"); + if (until) + { + say("%s banned for %ld minutes", name, seconds / 60); + server_log(_server_log_info, "moderation", "%s banned until %lu (%s)", name, until, + server_role_name(server_commands.role)); + } + else + { + server_log(_server_log_info, "moderation", "%s banned (%s)", name, server_role_name(server_commands.role)); + } + moderation_event("ban", name, reason[0] ? reason : NULL, until); + return TRUE; +} + +static boolean command_unban(char const *line, long count, char **words) +{ + boolean whole; + long lines = read_bans(&whole); + long number, index, found = NONE; + + if (count < 2) + { + say("unban: give a ban's number (sv_bans), an address or a hardware id"); + return FALSE; + } + if (!whole) + { + say("unban: bans.txt has more lines (or longer ones) than the server reads: take the line out by hand"); + return FALSE; + } + if (parse_integer(words[1], 0, lines - 1, &number)) + { + found = number; + } + else + { + for (index = 0; index < lines && found == NONE; index++) + { + char field[128]; + + if ((ban_field(ban_lines[index], "ip", field, sizeof(field)) && !strcmp(field, words[1])) || + (ban_field(ban_lines[index], "hwid", field, sizeof(field)) && !strcmp(field, words[1]))) + { + found = index; + } + } + } + if (found == NONE) + { + say("unban: no ban %s (sv_bans lists them)", words[1]); + return FALSE; + } + if (!write_bans(lines, found)) + { + say("unban: bans.txt cannot be written"); + return FALSE; + } + /* (and its machine let into the game it was dropped from: by the line's + address, the real one an internet play peer's stand-in has, or its + hardware id) */ + { + char field[128]; + char hardware_id[128] = ""; + unsigned int bytes[4]; + unsigned long address = 0; + + if (ban_field(ban_lines[found], "ip", field, sizeof(field)) && + sscanf(field, "%u.%u.%u.%u", &bytes[0], &bytes[1], &bytes[2], &bytes[3]) == 4 && + bytes[0] < 256 && bytes[1] < 256 && bytes[2] < 256 && bytes[3] < 256) + { + address = (unsigned long)bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]; + } + if (!ban_field(ban_lines[found], "hwid", hardware_id, sizeof(hardware_id)) || !strcmp(hardware_id, "none")) + hardware_id[0] = 0; + network_game_server_port_forget_kicked_address(address, hardware_id); + } + say("unbanned: %s", ban_lines[found]); + server_log(_server_log_info, "moderation", "ban %ld taken out (%s)", found, server_role_name(server_commands.role)); + /* (its players' names: the line's address and hardware id are the + owner's to see, and the event goes to every role) */ + { + char players[128]; + + if (!ban_field(ban_lines[found], "players", players, sizeof(players))) + snprintf(players, sizeof(players), "ban %ld", found); + moderation_event("unban", players, NULL, 0); + } + return TRUE; +} + +/* an address's text (a.b.c.d) checked: TRUE with the address (host byte +order) and its text again, written plainly */ +static boolean parse_ipv4( + char const *text, + unsigned long *address, + char *kept, + long size) +{ + unsigned int bytes[4]; + char end; + + if (strspn(text, "0123456789.") != strlen(text) || + sscanf(text, "%3u.%3u.%3u.%3u%c", &bytes[0], &bytes[1], &bytes[2], &bytes[3], &end) != 4 || + bytes[0] > 255 || bytes[1] > 255 || bytes[2] > 255 || bytes[3] > 255) + { + return FALSE; + } + *address = (unsigned long)bytes[0] << 24 | (unsigned long)bytes[1] << 16 | (unsigned long)bytes[2] << 8 | bytes[3]; + snprintf(kept, (size_t)size, "%u.%u.%u.%u", bytes[0], bytes[1], bytes[2], bytes[3]); + return *address != 0; +} + +/* a hardware id's text checked: hex digits (in either case), 32 at most; +TRUE with it in lower case */ +static boolean parse_hardware_id( + char const *text, + char *kept, + long size) +{ + long length = (long)strlen(text); + long index; + + if (!length || length > 2 * P2P_HARDWARE_ID_BYTES || length >= size || + (long)strspn(text, "0123456789abcdefABCDEF") != length) + { + return FALSE; + } + for (index = 0; index <= length; index++) + kept[index] = (char)tolower((unsigned char)text[index]); + return TRUE; +} + +/* a ban of an address, a hardware id or both, with no player needed +(sv_ban_address, POST /api/bans): its line in bans.txt as ban writes one +(so that sv_bans, unban and the joins read it), and each machine present of +that address or hardware id dropped as ban drops it. 200 with the new ban's +number (sv_bans'), else 400 (a part that will not do) or 500 (bans.txt not +written) and why */ +static int ban_address( + char const *ip, + char const *hwid, + char const *names, + long seconds, + char const *reason, + char *why, + long why_size, + long *number) +{ + struct network_game *game = server_game(); + char address_text[32] = "none"; + char hardware_id[2 * P2P_HARDWARE_ID_BYTES + 1] = ""; + char kept_names[64]; + char kept_reason[96]; + char full_reason[sizeof(kept_reason) + 32]; + char dropped[96] = ""; + boolean done[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + unsigned long address = 0, until = 0; + boolean whole; + long slot, index; + + if ((!ip || !ip[0]) && (!hwid || !hwid[0])) + { + snprintf(why, (size_t)why_size, "give an address (a.b.c.d) or a hardware id"); + return 400; + } + if (ip && ip[0] && !parse_ipv4(ip, &address, address_text, sizeof(address_text))) + { + snprintf(why, (size_t)why_size, "%s is not an IPv4 address (a.b.c.d)", ip); + return 400; + } + if (hwid && hwid[0] && !parse_hardware_id(hwid, hardware_id, sizeof(hardware_id))) + { + snprintf(why, (size_t)why_size, "a hardware id is up to %d hex digits", 2 * P2P_HARDWARE_ID_BYTES); + return 400; + } + printable_text(kept_names, sizeof(kept_names), names); + printable_text(kept_reason, sizeof(kept_reason), reason); + /* (the reason, and who gave it, as ban writes it) */ + if (server_commands.role < _server_role_owner) + { + snprintf(full_reason, sizeof(full_reason), "%s (by the %s)", kept_reason[0] ? kept_reason : + "banned by the server", server_role_name(server_commands.role)); + } + else + { + snprintf(full_reason, sizeof(full_reason), "%s", kept_reason[0] ? kept_reason : "banned by the server"); + } + if (seconds > 0) + until = posix_server_unix_seconds() + (unsigned long)seconds; + if (!network_distributed_ban_record(address_text, hardware_id, kept_names, full_reason, until)) + { + snprintf(why, (size_t)why_size, "bans.txt cannot be written"); + return 500; + } + *number = read_bans(&whole) - 1; + /* (each machine present of the address or the hardware id: dropped and + kept out as ban does, its line written already) */ + csmemset(done, 0, sizeof(done)); + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + { + long machine_index; + char const *machine_hardware_id; + char name[16]; + + if (!network_player_is_valid(&game->players[slot])) + continue; + machine_index = game->players[slot].machine_index; + if (machine_index < 0 || machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES || done[machine_index]) + continue; + machine_hardware_id = network_game_server_machine_hardware_id(machine_index); + if (!(address && distributed_machine_real_address(machine_index) == address) && + !(hardware_id[0] && machine_hardware_id && !strcmp(machine_hardware_id, hardware_id))) + { + continue; + } + done[machine_index] = TRUE; + /* (its players' names, for the event, before they go) */ + for (index = 0; index < MAXIMUM_SLOTS; index++) + { + if (network_player_is_valid(&game->players[index]) && game->players[index].machine_index == machine_index && + server_status_player_name(index, name, sizeof(name))) + { + snprintf(dropped + strlen(dropped), sizeof(dropped) - strlen(dropped), "%s%s", dropped[0] ? ", " : "", + name); + } + } + network_distributed_ban_details(full_reason, until); + network_distributed_ban_unrecorded(TRUE); + if (network_game_server_kick_machine_of_player(machine_index, TRUE)) + { + server_director_player_removed(machine_index, "banned"); + } + else + { + network_distributed_ban_details(NULL, 0); + network_distributed_ban_unrecorded(FALSE); + } + } + server_log(_server_log_info, "moderation", "ban %ld added: %s%s%s (%s)", *number, + config_boolean("server.log_addresses") ? (address ? address_text : "") : address ? "an address" : "", + address && hardware_id[0] ? " and " : "", + hardware_id[0] ? (config_boolean("server.log_addresses") ? hardware_id : "a hardware id") : "", + server_role_name(server_commands.role)); + moderation_event("ban", kept_names[0] ? kept_names : dropped, kept_reason[0] ? kept_reason : NULL, until); + return 200; +} + +static boolean command_ban_address(char const *line, long count, char **words) +{ + char why[128]; + long seconds = 0, reason_words = 2, number = 0; + boolean address; + + if (count < 2) + { + say("sv_ban_address: give an address (a.b.c.d) or a hardware id, then a duration (30m, 2h, 7d, forever) and " + "a reason"); + return FALSE; + } + if (count >= 3) + { + seconds = parse_duration(words[2]); + if (seconds >= 0) + reason_words = 3; + else + seconds = 0; + } + address = strchr(words[1], '.') != NULL; + if (ban_address(address ? words[1] : "", address ? "" : words[1], "", seconds, rest_after_words(line, reason_words), + why, sizeof(why), &number) != 200) + { + say("sv_ban_address: %s", why); + return FALSE; + } + if (seconds > 0) + say("ban %ld added, for %ld minutes", number, seconds / 60); + else + say("ban %ld added", number); + return TRUE; +} + +/* (the web's: server_http.c, POST /api/bans) ban_address with the session's +role, the duration as text (empty: for ever): 200 with the ban's number, or +400 or 500 and why */ +int server_commands_ban_address_text( + char const *ip, + char const *hwid, + char const *names, + char const *duration, + char const *reason, + int role, + char *message, + long message_size, + long *number) +{ + int previous_role = server_commands.role; + long seconds = 0; + int answer; + + if (duration && duration[0]) + { + seconds = parse_duration(duration); + if (seconds < 0) + { + snprintf(message, (size_t)message_size, "duration is 30m, 2h, 7d, 1w or forever (ten years at most)"); + return 400; + } + } + server_commands.role = role; + answer = ban_address(ip, hwid, names, seconds, reason, message, message_size, number); + server_commands.role = previous_role; + return answer; +} + +static boolean command_mute(char const *line, long count, char **words) +{ + char name[16]; + long slot, minutes = 0; + boolean mute = csstrcasecmp(words[0], "unmute") != 0; + + if (count < 2) + { + say("%s: give a player's slot (sv_players) or name", words[0]); + return FALSE; + } + if (mute && count >= 3 && !parse_integer(words[2], 1, 24 * 60, &minutes)) + { + say("mute: give the minutes (1 to %d)", 24 * 60); + return FALSE; + } + slot = find_player(words[0], words[1]); + if (slot == NONE) + return FALSE; + server_status_player_name(slot, name, sizeof(name)); + if (!network_chat_host_mute_named(name, (short)minutes, mute)) + { + say("%s: %s %s", words[0], name, mute ? "cannot be muted now" : "is not muted"); + return FALSE; + } + moderation_event(mute ? "mute" : "unmute", name, NULL, 0); + return TRUE; +} + +static boolean command_map(char const *line, long count, char **words) +{ + char entry[SERVER_ENTRY_TEXT_SIZE]; + char reason[SERVER_REASON_SIZE]; + + if (count < 2) + { + say("sv_map: give a map, and a gametype: sv_map bloodgulch ctf"); + return FALSE; + } + /* ("map gametype", or an entry as the rotation's: map:gametype+rule=1) */ + if (count >= 3 && !strchr(words[1], ':')) + snprintf(entry, sizeof(entry), "%s:%s", words[1], words[2]); + else + snprintf(entry, sizeof(entry), "%s", words[1]); + if (!server_director_interpose(entry, reason, sizeof(reason))) + { + say("sv_map: %s", reason); + return FALSE; + } + say("next: %s (%s)", entry, reason); + return TRUE; +} + +static boolean command_map_next(char const *line, long count, char **words) +{ + if (!server_director_end_game(_server_end_next, TRUE)) + { + say("%s: no game is being played", words[0]); + return FALSE; + } + return TRUE; +} + +static boolean command_end_game(char const *line, long count, char **words) +{ + if (!server_director_end_game(_server_end_command, FALSE)) + { + say("sv_end_game: no game is being played"); + return FALSE; + } + return TRUE; +} + +static boolean command_rotation_set(char const *line, long count, char **words) +{ + struct server_json json; + char const *text = rest_after_words(line, 1); + boolean set; + + if (!text[0]) + { + say("sv_rotation_set: give the entries, separated by commas"); + return FALSE; + } + server_json_begin(&json); + server_json_object_begin(&json, NULL); + set = server_director_set_rotation(text, TRUE, &json); + server_json_object_end(&json); + if (!set) + say("sv_rotation_set: not changed: %s", server_json_text(&json)); + server_json_free(&json); + return set; +} + +/* the rotation as text, an entry added or taken out */ +static boolean command_rotation_add(char const *line, long count, char **words) +{ + static char text[SERVER_ROTATION_MAXIMUM_TEXT + SERVER_ENTRY_TEXT_SIZE + 4]; + char const *entry = rest_after_words(line, 1); + struct server_entry parsed; + boolean set; + long length; + + if (!server_rotation_parse_entry(entry, (int)strlen(entry), &parsed)) + { + say("%s: give an entry: map:gametype", words[0]); + return FALSE; + } + /* (Halo PC's sv_mapcycle_add ) */ + if (count >= 3 && !strchr(words[1], ':')) + { + char joined[SERVER_ENTRY_TEXT_SIZE]; + + snprintf(joined, sizeof(joined), "%s:%s", words[1], words[2]); + server_rotation_parse_entry(joined, (int)strlen(joined), &parsed); + } + server_rotation_text(server_director_rotation(), text, SERVER_ROTATION_MAXIMUM_TEXT); + length = (long)strlen(text); + snprintf(text + length, sizeof(text) - (size_t)length, "%s%s", length ? ", " : "", parsed.text); + set = server_director_set_rotation(text, TRUE, NULL); + if (!set) + say("%s: %s cannot be played%s%s", words[0], parsed.text, parsed.rejected[0] ? ": " : "", parsed.rejected); + return set; +} + +static boolean command_rotation_del(char const *line, long count, char **words) +{ + static struct server_rotation rotation; + static char text[SERVER_ROTATION_MAXIMUM_TEXT + 1]; + long index; + + rotation = *server_director_rotation(); + if (count < 2 || !parse_integer(words[1], 0, rotation.count - 1, &index)) + { + say("%s: give an entry's number (sv_rotation lists them)", words[0]); + return FALSE; + } + memmove(&rotation.entries[index], &rotation.entries[index + 1], + sizeof(rotation.entries[0]) * (size_t)(rotation.count - index - 1)); + rotation.count--; + server_rotation_text(&rotation, text, sizeof(text)); + if (!server_director_set_rotation(text, TRUE, NULL)) + { + say("%s: the rotation would have nothing playable", words[0]); + return FALSE; + } + return TRUE; +} + +static boolean command_rotation_begin(char const *line, long count, char **words) +{ + long index = 0; + + if (count >= 2 && !parse_integer(words[1], 0, SERVER_ROTATION_MAXIMUM_ENTRIES - 1, &index)) + { + say("%s: give an entry's number (sv_rotation lists them)", words[0]); + return FALSE; + } + if (!server_director_rotation_begin(index)) + { + say("%s: entry %ld cannot be played", words[0], index); + return FALSE; + } + say("the next game is entry %ld", index); + return TRUE; +} + +static boolean command_set(char const *line, long count, char **words) +{ + char reason[SERVER_REASON_SIZE]; + char value[300]; + + if (count < 2) + { + say("sv_set: give a setting and its value (sv_settings lists them)"); + return FALSE; + } + printable_text(value, sizeof(value), rest_after_words(line, 2)); + if (!server_commands_set_setting(words[1], value, reason, sizeof(reason))) + { + say("sv_set: %s", reason); + return FALSE; + } + say("%s = %s", words[1], server_commands_setting_secret(words[1]) ? (value[0] ? "(set)" : "(none)") : value); + return TRUE; +} + +/* sv_name, sv_maxplayers... as sv_set of their settings */ +static boolean command_set_alias(char const *line, long count, char **words) +{ + static struct + { + char const *command; + char const *setting; + } const aliases[] = + { + { "sv_name", "server.name" }, + { "sv_maxplayers", "server.max_players" }, + { "sv_password", "server.password" }, + { "sv_public", "server.public" }, + { "sv_motd", "server.motd" }, + }; + char reason[SERVER_REASON_SIZE]; + char value[300]; + long index; + + for (index = 0; index < (long)NUMBEROF(aliases) && csstrcasecmp(words[0], aliases[index].command); index++) + { + } + if (index == (long)NUMBEROF(aliases)) + return FALSE; + printable_text(value, sizeof(value), rest_after_words(line, 1)); + if (!strcmp(aliases[index].setting, "server.name") && strlen(value) > 15) + { + say("%s: a name has 15 characters at most", words[0]); + return FALSE; + } + if (!strcmp(aliases[index].setting, "server.max_players")) + { + long number; + + if (!parse_integer(value, 2, HALO_PORT_MAXIMUM_NETWORK_PLAYERS - 1, &number)) + { + say("%s: give a number from 2 to %d", words[0], HALO_PORT_MAXIMUM_NETWORK_PLAYERS - 1); + return FALSE; + } + } + if (!server_commands_set_setting(aliases[index].setting, value, reason, sizeof(reason))) + { + say("%s: %s", words[0], reason); + return FALSE; + } + say("%s = %s", aliases[index].setting, server_commands_setting_secret(aliases[index].setting) ? + (value[0] ? "(set)" : "(none)") : value); + return TRUE; +} + +static boolean command_reload(char const *line, long count, char **words) +{ + server_director_reload(); + return TRUE; +} + +static boolean command_quit(char const *line, long count, char **words) +{ + server_director_quit(SERVER_EXIT_STOPPED, "quit"); + return TRUE; +} + +/* sv_stop and sv_restart: now, or once the game is over or the server +empty (its listings withdrawn and new players refused meanwhile), or the +stop waiting cancelled; a notice said to every machine first */ +static boolean command_stop(char const *line, long count, char **words) +{ + static char const *const modes[NUMBER_OF_SERVER_DRAINS] = { "now", "game", "empty", "cancel" }; + boolean restart = !csstrcasecmp(words[0], "sv_restart"); + char notice[NOTICE_SIZE]; + short after = _server_drain_now; + long notice_words = 1; + + if (count >= 2) + { + for (after = 0; after < NUMBER_OF_SERVER_DRAINS && csstrcasecmp(words[1], modes[after]); after++) + { + } + if (after == NUMBER_OF_SERVER_DRAINS) + { + say("%s: now, game, empty or cancel, then a notice for the players", words[0]); + return FALSE; + } + notice_words = 2; + } + printable_text(notice, sizeof(notice), rest_after_words(line, notice_words)); + if (notice[0]) + { + send_notice(NONE, notice); + server_log(_server_log_info, "chat", "server: %s", notice); + } + if (after == _server_drain_cancel) + { + if (!server_director_drain_cancel(notice)) + { + say("%s: no stop is waiting", words[0]); + return FALSE; + } + say("the stop is cancelled: the server is listed and open again"); + return TRUE; + } + if (after == _server_drain_game) + say("stopping once this game is over (exit code %d); the listings withdrawn, new players refused", + restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED); + else if (after == _server_drain_empty) + say("stopping once nobody plays (exit code %d); the listings withdrawn, new players refused", + restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED); + else + say("stopping (exit code %d)", restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED); + server_director_drain(after, restart, notice); + return TRUE; +} + +static struct server_command const server_command_table[] = +{ + { "help", NULL, _server_role_viewer, "[command]", "the commands, or one's use", command_help }, + { "sv_status", NULL, _server_role_viewer, "[--json]", "the server and its game", command_status }, + { "sv_players", NULL, _server_role_viewer, "[--json]", "the players", command_players }, + { "sv_rotation", "sv_mapcycle", _server_role_viewer, "", "the rotation", command_rotation }, + { "sv_maps", NULL, _server_role_viewer, "", "the multiplayer maps there are", command_maps }, + { "sv_gametypes", NULL, _server_role_viewer, "", "the gametypes there are", command_gametypes }, + { "sv_bans", "sv_banlist", _server_role_admin, "", "bans.txt's bans", command_bans }, + { "sv_settings", NULL, _server_role_admin, "", "the settings sv_set changes", command_settings }, + { "sv_version", NULL, _server_role_viewer, "", "the build and its network version", command_version }, + { "sv_log", NULL, _server_role_viewer, "[lines]", "the log's last lines", command_log }, + { "sv_masters", NULL, _server_role_viewer, "", "the master servers and their answers", command_masters }, + { "sv_publisher", NULL, _server_role_viewer, "", "the publisher key's id and public key", command_publisher }, + { "say", "sv_say", _server_role_moderator, "", "a notice on every machine", command_say }, + { "tell", NULL, _server_role_moderator, " ", "a notice on a player's machine", command_tell }, + { "kick", "sv_kick", _server_role_moderator, "", "drops a player's machine (it may join again)", + command_kick }, + { "ban", "sv_ban", _server_role_moderator, " [30m|2h|7d|forever] [reason]", + "drops a player's machine and bans its address and hardware id (a moderator's: a day at most)", command_ban }, + { "unban", "sv_unban", _server_role_admin, "", "takes a ban out of bans.txt", + command_unban }, + { "sv_ban_address", NULL, _server_role_admin, " [30m|2h|7d|forever] [reason]", + "bans an address or hardware id, its player here or not", command_ban_address }, + { "mute", NULL, _server_role_moderator, " [minutes]", "mutes a player's chat and voice for everyone", + command_mute }, + { "unmute", NULL, _server_role_moderator, "", "unmutes a player", command_mute }, + { "sv_map", NULL, _server_role_moderator, " [gametype]", "plays that game now (the rotation goes on after)", + command_map }, + { "sv_map_next", "sv_next", _server_role_moderator, "", "ends the game, no scores, and plays the next", + command_map_next }, + { "sv_end_game", NULL, _server_role_moderator, "", "ends the game: its scores, then the next", command_end_game }, + { "sv_rotation_set", NULL, _server_role_admin, "", "replaces the rotation", command_rotation_set }, + { "sv_rotation_add", "sv_mapcycle_add", _server_role_admin, "", "adds an entry to the rotation", + command_rotation_add }, + { "sv_rotation_del", "sv_mapcycle_del", _server_role_admin, "", "takes an entry out of the rotation", + command_rotation_del }, + { "sv_rotation_begin", "sv_mapcycle_begin", _server_role_admin, "[number]", + "the rotation's next game is that entry", command_rotation_begin }, + { "sv_set", NULL, _server_role_admin, " ", "changes a setting (sv_settings lists them)", + command_set }, + { "sv_name", NULL, _server_role_admin, "", "the server's name (15 characters)", command_set_alias }, + { "sv_maxplayers", NULL, _server_role_admin, "<2-127>", "the most players", command_set_alias }, + { "sv_password", NULL, _server_role_admin, "[password]", "the server browser's password (none without one)", + command_set_alias }, + { "sv_public", NULL, _server_role_admin, "<0|1>", "listed in the server browser, or by invite only", + command_set_alias }, + { "sv_motd", NULL, _server_role_admin, "", "the notice each machine sees as it joins", command_set_alias }, + { "sv_reload", NULL, _server_role_admin, "", "reads the server's settings and the rotation again", command_reload }, + { "sv_stop", NULL, _server_role_owner, "[now|game|empty|cancel] [notice]", + "stops the server now, once the game is over, or once it is empty", command_stop }, + { "sv_restart", NULL, _server_role_owner, "[now|game|empty|cancel] [notice]", + "as sv_stop, with exit code 5 (to be started again)", command_stop }, + { "quit", "exit", _server_role_owner, "", "stops the server", command_quit }, +}; + +static long server_command_count( + void) +{ + return (long)NUMBEROF(server_command_table); +} + +/* ---------- running a line */ + +boolean server_command_try( + char const *expression, + boolean *result) +{ + char copy[SERVER_COMMAND_LINE_SIZE]; + char *words[MAXIMUM_WORDS]; + long count, index; + char const *line = expression; + + if (!halo_server_mode()) + return FALSE; + while (*line == ' ' || *line == '\t') + line++; + snprintf(copy, sizeof(copy), "%s", line); + count = split_words(copy, words, MAXIMUM_WORDS); + if (!count) + return FALSE; + for (index = 0; index < server_command_count(); index++) + { + struct server_command const *command = &server_command_table[index]; + + if (csstrcasecmp(words[0], command->name) && (!command->alias || csstrcasecmp(words[0], command->alias))) + continue; + if (command->role > server_commands.role) + { + say("%s: needs the %s role", command->name, server_role_name(command->role)); + *result = FALSE; + return TRUE; + } + *result = command->run(line, count, words); + return TRUE; + } + /* (the game's own commands, scripts and globals, which crash, save and + change maps: the owner's alone) */ + if (server_commands.role < _server_role_owner) + { + say("%s: not a command of the server's, and the game's console needs the owner role (the server's " + "console)", words[0]); + *result = FALSE; + return TRUE; + } + return FALSE; +} + +char *server_commands_run( + char const *line, + int role, + int origin, + boolean *succeeded) +{ + int previous_role = server_commands.role; + int previous_origin = server_commands.origin; + boolean previous_capturing = server_commands.capturing; + boolean captured = server_console_capture_begin(); + + server_commands.role = role; + server_commands.origin = origin; + server_commands.capturing = captured; + *succeeded = hs_compile_and_evaluate(line); + server_commands.role = previous_role; + server_commands.origin = previous_origin; + server_commands.capturing = previous_capturing; + return captured ? server_console_capture_end() : NULL; +} + +#else + +boolean server_command_try(char const *expression, boolean *result) +{ + return FALSE; +} + +char *server_commands_run(char const *line, int role, int origin, boolean *succeeded) +{ + *succeeded = FALSE; + return NULL; +} + +int server_commands_role(void) +{ + return 0; +} + +#endif diff --git a/port/linux/game/server_director.c b/port/linux/game/server_director.c new file mode 100644 index 00000000..b0cb9890 --- /dev/null +++ b/port/linux/game/server_director.c @@ -0,0 +1,2060 @@ +/* +SERVER_DIRECTOR.C + +A dedicated server's director (server_director.h; docs/dedicated_server.md): +what the game lacks to host one game after another with no player of its +own. Once the main menu is up, it hosts as the pregame screen's fast setup +does (as network_test.c's host does), with no player, and plays the +rotation (server.rotation, server_rotation.c) a game at a time: the entry's +map and gametype set in the lobby, the lobby's countdown held until +server.min_players are in and then started (server.lobby_seconds), the +game played (joins in progress taken, as any host takes them), ended when +nobody has been in it for server.empty_seconds or no score has changed for +server.idle_minutes, its scores shown server.postgame_seconds, and back to +the lobby with the next entry. It also takes the owner's commands (the +standard input, the init file: server_console.c), the web's +(server_http.c), the signals, and stops cleanly: the game ended, every +machine told, the listings withdrawn. + +Called from the main loop every frame (main.c). +*/ + +#include "cseries.h" +#include "memory/data.h" +#include "main/main.h" +#include "bink/bink_playback.h" +#include "interface/player_ui.h" +#include "interface/ui_widget.h" +#include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "networking/network_server_manager.h" +#include "networking/network_server_manager_internal.h" +#include "networking/network_client_manager.h" +#include "game/game.h" +#include "game/game_engine.h" +#include "game/players.h" +#include "text/unicode.h" +#include "game_modes.h" +#include "network_distributed.h" +#include "server_director.h" +#include "server_gametypes.h" + +#include "../src/p2p.h" /* port: port/linux/src/p2p.c, p2p_lobby.c */ +#include "../src/server_console.h" /* port: port/linux/src/server_console.c */ +#include "../src/server_events.h" /* port: port/linux/src/server_events.c */ +#include "../src/server_http.h" /* port: port/linux/src/server_http.c */ +#include "../src/server_json.h" /* port: port/linux/src/server_json.c */ +#include "../src/server_log.h" /* port: port/linux/src/server_log.c */ +#include "../src/server_master.h" /* port: port/linux/src/server_master.c */ +#include "../src/posix_server.h" /* port: port/linux/src/posix_server.c */ + +#include +#include +#include + +/* the platform layer's (port/linux/src/port_config.c) */ +const char *config_string(char const *name); +int config_boolean(char const *name); +long config_integer(char const *name); +double config_real(char const *name); +int config_write(char const *name, char const *value); +int config_reload(char const *prefix); +void platform_log(char const *format, ...); +extern boolean debug_no_drawing; +/* (menu_functions.c's) */ +extern boolean ui_widget_port_cooperative_level_choose(char const *map_name, short difficulty); +/* (port/linux/src/updater.c's) */ +int updater_server_check(void); +long updater_server_available(void); +int updater_server_install_start(void); +int updater_server_install_poll(char *error, int size); + +#if HALO_SERVER_BUILD + +enum +{ + /* the network game server's states (network_server_manager.c's) */ + SERVER_PREGAME = 0, + SERVER_INGAME = 1, + SERVER_POSTGAME = 2, + /* seconds the main menu settles before hosting, and between tries */ + MENU_SETTLE_SECONDS = 2, + HOSTING_RETRY_SECONDS = 5, + /* seconds the lobby waits before its entry is set (the pregame screen + sets its own first) */ + LOBBY_SETUP_SECONDS = 1, + /* seconds the scores show when nobody played the game */ + EMPTY_POSTGAME_SECONDS = 1, + /* seconds the machines are given to hear the server go */ + QUIT_SECONDS = 1, + /* the deadline of a shutdown that hangs (a map loading) */ + QUIT_DEADLINE_SECONDS = 5, + /* seconds between the status file's writes, and the health lines */ + STATUS_FILE_SECONDS = 2, + HEALTH_SECONDS = 60, + /* seconds a joined player waits for the message of the day (the game + loading on its machine) */ + MOTD_DELAY_SECONDS = 5, + /* server.auto_update: seconds between looks for a new release, and the + seconds the server must have been empty before one is installed */ + UPDATE_CHECK_SECONDS = 6 * 60 * 60, + UPDATE_EMPTY_SECONDS = 10 * 60, + MAXIMUM_SLOTS = HALO_PORT_MAXIMUM_NETWORK_PLAYERS, + MAXIMUM_MACHINES = HALO_PORT_MAXIMUM_NETWORK_MACHINES, + /* the game's name's characters, with its end (network_game's) */ + NETWORK_GAME_NAME_LENGTH = 16, + /* seconds a machine's players leaving are said to be kicked or banned + after the command that dropped it */ + LEAVE_REASON_SECONDS = 10, +}; + +static char const *const server_end_names[NUMBER_OF_SERVER_ENDS] = + { "score", "time", "idle", "empty", "command", "next" }; + +static struct +{ + boolean started; + short state; + real state_seconds; + real menu_seconds; + /* the rotation, and the entry of it played last (the next is the one + after it), and whether one has been (or sv_rotation_begin chose the + next): if not, the first entry is next whatever the rotation becomes */ + struct server_rotation rotation; + long index; + boolean index_anchored; + /* the entry sv_map put before the rotation's next */ + struct server_entry interposed; + boolean has_interposed; + /* the entry this lobby or game plays: its index in the rotation (NONE: + none, or the interposed one), and whether it is the interposed one + (another sv_map since puts a new one in its place) */ + struct server_entry current; + boolean current_set; + long current_index; + boolean current_is_interposed; + boolean entry_applied; + real lobby_seconds; + real map_checked_seconds; + /* the game: its seconds, the seconds it has been empty and without a + score, why it ended, and whether its scores are skipped */ + real game_seconds; + real empty_seconds; + real idle_seconds; + long score_sum; + short end_reason; + boolean end_asked; + boolean skip_postgame; + /* the game's end said (game_ended) */ + boolean game_over_said; + boolean back_to_lobby; + real postgame_wait; + long games_hosted; + /* stopping */ + boolean quitting; + int exit_code; + real quit_seconds; + /* a stop waiting for the game's end or for nobody to play (sv_stop, + sv_restart: _server_drain_game or _empty), since when (seconds since + 1970), and whether it restarts (exit code 5) */ + boolean drain_active; + short drain_after; + boolean drain_restart; + unsigned long drain_since; + /* the players seen, for their joins and leaves (each slot's name and + machine), and when each was seen join (the message of the day) */ + char seen_names[MAXIMUM_SLOTS][16]; + long seen_machines[MAXIMUM_SLOTS]; + /* ... and each one's address and hardware id as it joined (the events' + for the readers who see them) */ + char seen_addresses[MAXIMUM_SLOTS][16]; + char seen_hardware_ids[MAXIMUM_SLOTS][40]; + /* why each machine's players leave, a moment after a command dropped it + ("kicked", "banned"; NULL: they left), and the seconds since */ + char const *leave_reasons[MAXIMUM_MACHINES]; + real leave_reason_seconds[MAXIMUM_MACHINES]; + long long seen_joined[MAXIMUM_SLOTS]; + real motd_waits[MAXIMUM_SLOTS]; + boolean motd_sent[MAXIMUM_SLOTS]; + char invite[P2P_LISTING_INVITE_SIZE]; + /* the ready line, once the lobby is open (and listed), 10 s on at most */ + boolean ready_said; + real ready_wait; + /* the status file's and the health line's clocks */ + real status_file_seconds; + real health_seconds; + real score_event_seconds; + long last_countdown_seconds; + /* the master servers' state, as last published */ + char master_state[1024]; + /* server.auto_update: the seconds to the next look, the newer build + last told of, and the seconds the server has been empty */ + real update_check_seconds; + long update_told; + real update_empty_seconds; + /* the install of it under way (on a thread of its own: the server goes + on), and the build it installs; done, the server stops once empty */ + boolean update_installing; + boolean update_installed; + long update_installing_build; +} server; + +/* ---------- the server's game */ + +static struct network_game *server_game( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + return network_server ? network_game_server_get_game(network_server) : NULL; +} + +static short server_network_state( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + return network_server ? (short)network_game_server_get_state(network_server, NULL) : NONE; +} + +static long server_player_count( + void) +{ + struct network_game *game = server_game(); + long count = 0; + long index; + + for (index = 0; game && index < MAXIMUM_SLOTS; index++) + { + if (network_player_is_valid(&game->players[index])) + count++; + } + return count; +} + +/* the players in the game being played (not those who quit it) */ +static long server_players_in_game( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + long count = 0; + + if (!player_data || !game_engine_running()) + return server_player_count(); + data_iterator_new(&iterator, player_data); + while ((player = (struct player_datum *)data_iterator_next(&iterator)) != NULL) + { + if (!player->quit_out_of_game) + count++; + } + return count; +} + +static void server_set_state( + short state) +{ + if (server.state == state) + return; + server.state = state; + server.state_seconds = 0.0f; +} + +short server_director_state( + void) +{ + return server.state; +} + +struct server_rotation const *server_director_rotation( + void) +{ + return &server.rotation; +} + +long server_director_index( + void) +{ + return server.current_set && !server.current_is_interposed ? server.current_index : NONE; +} + +struct server_entry const *server_director_current( + void) +{ + return server.current_set ? &server.current : NULL; +} + +long server_director_games_hosted( + void) +{ + return server.games_hosted; +} + +real server_director_game_seconds( + void) +{ + return server.state == _server_state_playing || server.state == _server_state_postgame ? server.game_seconds : 0.0f; +} + +/* ---------- events */ + +/* a variant's name, as the lobby shows it, in printable ASCII */ +static void server_variant_name( + struct game_variant const *variant, + char *name, + long size) +{ + long index; + + for (index = 0; index < (long)NUMBEROF(variant->human_readable_game_description) - 1 && + variant->human_readable_game_description[index] && index < size - 1; index++) + { + name[index] = player_name_character_ascii(variant->human_readable_game_description[index]); + } + name[index] = 0; +} + +/* an event of the game: its name and the players (as viewers see them) */ +static void server_game_event( + char const *name, + char const *reason) +{ + struct server_json json; + struct network_game *game = server_game(); + boolean cooperative = server.current_set && !strcmp(server.current.gametype, "coop"); + long red, blue; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", name); + if (server.current_set) + { + server_json_string(&json, "map", server.current.map_name); + server_json_string(&json, "gametype", server.current.gametype); + } + if (game) + { + char variant[32]; + + server_variant_name(&game->variant, variant, sizeof(variant)); + server_json_string(&json, "variant", cooperative ? "Co-op" : variant); + server_json_boolean(&json, "teams", !cooperative && game->variant.universal_variant.teams); + } + if (reason) + { + server_json_string(&json, "reason", reason); + server_json_integer(&json, "duration", (long long)server.game_seconds); + } + if (server_status_team_scores(&red, &blue)) + { + server_json_array_begin(&json, "team_scores"); + server_json_integer(&json, NULL, red); + server_json_integer(&json, NULL, blue); + server_json_array_end(&json); + } + server_json_array_begin(&json, "players"); + { + long slot; + + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + { + if (network_player_is_valid(&game->players[slot])) + server_status_player(&json, slot, _server_role_viewer); + } + } + server_json_array_end(&json); + server_json_object_end(&json); + server_event_add("game", server_json_text(&json)); + server_json_free(&json); +} + +static void server_simple_event( + char const *group, + char const *name, + char const *key, + long long value) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", name); + if (key) + server_json_integer(&json, key, value); + server_json_object_end(&json); + server_event_add(group, server_json_text(&json)); + server_json_free(&json); +} + +/* ---------- the rotation */ + +/* the rotation from the settings, each entry checked; a fallback's if +nothing can be played (server.rotation_fallback) */ +static void server_read_rotation( + struct server_rotation *rotation, + char const *text) +{ + long index; + + server_rotation_parse(text, rotation); + for (index = 0; index < rotation->count; index++) + { + server_rotation_resolve(&rotation->entries[index]); + server_gametype_check_entry(&rotation->entries[index]); + } + if (!server_rotation_playable(rotation) && config_boolean("server.rotation_fallback") && + rotation->count < SERVER_ROTATION_MAXIMUM_ENTRIES) + { + struct server_entry *entry = &rotation->entries[rotation->count]; + + if (server_rotation_parse_entry("bloodgulch:slayer", 17, entry)) + { + server_rotation_resolve(entry); + server_gametype_check_entry(entry); + rotation->count++; + } + } +} + +/* the entry after the one at index that can be played (index itself +last), NONE if none can */ +static long server_rotation_after( + long index) +{ + long step; + + for (step = 1; step <= server.rotation.count; step++) + { + long candidate = (index + step) % server.rotation.count; + + if (!server.rotation.entries[candidate].rejected[0]) + return candidate; + } + return NONE; +} + +char const *server_director_first_level( + void) +{ + struct server_entry const *entry; + + if (server.current_set && strcmp(server.current.gametype, "coop")) + return server.current.level; + entry = server_director_next(); + return entry && strcmp(entry->gametype, "coop") ? entry->level : NULL; +} + +struct server_entry const *server_director_next( + void) +{ + long next; + + if (server.has_interposed && !server.current_is_interposed) + return &server.interposed; + if (!server.rotation.count) + return NULL; + next = server_rotation_after(server.index); + return next != NONE ? &server.rotation.entries[next] : NULL; +} + +/* ---------- the settings */ + +/* the game's name (15 characters of printable ASCII) and its most players, +for this game and the next */ +static void server_apply_game_settings( + void) +{ + char const *name = config_string("server.name"); + wchar_t wide[NETWORK_GAME_NAME_LENGTH]; + long maximum = config_integer("server.max_players"); + long index, length = 0; + + for (index = 0; name[index] && length < NETWORK_GAME_NAME_LENGTH - 1; index++) + { + if ((unsigned char)name[index] >= 0x20 && (unsigned char)name[index] < 0x7F) + wide[length++] = (wchar_t)(unsigned char)name[index]; + } + wide[length] = 0; + /* (server.name's default: 13 characters, as the game's name holds) */ + if (!length) + ustrncpy(wide, L"OpenCE Server", NETWORK_GAME_NAME_LENGTH - 1); + network_game_server_port_set_settings(wide, PIN(maximum, 2, HALO_PORT_MAXIMUM_NETWORK_PLAYERS - 1)); +} + +/* the listing: internet play (network.online), public or by invite, and +its password */ +static void server_apply_listing( + void) +{ + char const *password = config_string("server.password"); + + p2p_set_hosting_allowed(config_boolean("network.online")); + p2p_set_hosting_password(password[0] ? password : NULL); + /* (withdrawn while a stop waits, whatever server.public says) */ + p2p_set_hosting_public(config_boolean("server.public") && !server.drain_active); +} + +void server_director_apply_settings( + void) +{ + server_apply_game_settings(); + if (global_network_game_server_get()) + server_apply_listing(); +} + +/* ---------- setting a game up */ + +/* the lobby's map and gametype set to the entry's (the rotation's at index; +NONE: the interposed one): TRUE, else FALSE (said) */ +static boolean server_apply_entry( + struct server_entry const *entry, + long index) +{ + struct network_game_server *network_server = global_network_game_server_get(); + struct game_variant variant; + struct game_variant_options options; + char reason[SERVER_REASON_SIZE]; + char variant_name[32] = "Co-op"; + + if (!network_server) + return FALSE; + csmemset(&variant, 0, sizeof(variant)); + if (!strcmp(entry->gametype, "coop")) + { + static char const *const difficulties[] = { "easy", "normal", "hard", "impossible" }; + char const *setting = config_string("server.coop_difficulty"); + short difficulty; + + for (difficulty = 0; difficulty < (short)NUMBEROF(difficulties) && strcmp(setting, difficulties[difficulty]); + difficulty++) + { + } + if (difficulty == (short)NUMBEROF(difficulties)) + difficulty = 1; + if (!ui_widget_port_cooperative_level_choose(entry->level, difficulty)) + { + server_log(_server_log_warning, "lobby", "%s cannot be played as co-op", entry->level); + return FALSE; + } + } + else + { + if (!server_gametype_build(entry->gametype, entry->rules, entry->rule_count, &variant, &options, reason, + sizeof(reason))) + { + server_log(_server_log_warning, "lobby", "%s cannot be played: %s", entry->text, reason); + return FALSE; + } + main_set_multiplayer_map_name(entry->level); + network_game_server_change_map_name(network_server, entry->level); + player_ui_set_game_variant(&variant); + player_ui_set_game_variant_options(&options); + network_game_server_change_game_variant(network_server, &variant); + server_variant_name(&variant, variant_name, sizeof(variant_name)); + } + server.current = *entry; + server.current_set = TRUE; + server.current_index = index; + server.current_is_interposed = index == NONE; + server_log(_server_log_info, "lobby", "next game: %s on %s", entry->gametype, entry->map_name); + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "lobby_opened"); + server_json_string(&json, "map", entry->map_name); + server_json_string(&json, "gametype", entry->gametype); + server_json_string(&json, "variant", variant_name); + server_json_boolean(&json, "teams", variant.universal_variant.teams); + server_json_integer(&json, "index", index); + server_json_object_end(&json); + server_event_add("game", server_json_text(&json)); + server_json_free(&json); + } + return TRUE; +} + +/* the entry for this lobby: an interposed one (index NONE), else the +rotation's after the one played last (its index) */ +static struct server_entry const *server_lobby_entry( + long *index) +{ + *index = NONE; + if (server.has_interposed) + return &server.interposed; + if (!server.rotation.count) + return NULL; + *index = server_rotation_after(server.index); + return *index != NONE ? &server.rotation.entries[*index] : NULL; +} + +/* a game over: an interposed one was played (the rotation goes on after +the entry played before it) */ +static void server_advance( + void) +{ + if (server.current_is_interposed) + server.has_interposed = FALSE; + server.current_is_interposed = FALSE; +} + +/* ---------- ending a game */ + +static boolean server_cooperative( + void) +{ + return server.current_set && !strcmp(server.current.gametype, "coop"); +} + +/* the game over, said: how it ended, the scores' seconds, and the next game +on every machine */ +static void server_game_over( + void) +{ + struct server_json json; + long postgame_seconds = server.skip_postgame ? 0 : PIN(config_integer("server.postgame_seconds"), 0, 300); + + server.game_over_said = TRUE; + server_log(_server_log_info, "game", "ended (%s) after %ld s", server_end_names[server.end_reason], + (long)server.game_seconds); + server_game_event("game_ended", server_end_names[server.end_reason]); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "postgame"); + server_json_integer(&json, "seconds", postgame_seconds); + server_json_object_end(&json); + server_event_add("game", server_json_text(&json)); + server_json_free(&json); + if (!server.skip_postgame && server.current_set && server_director_next()) + { + char notice[128]; + struct server_entry const *next = server_director_next(); + + snprintf(notice, sizeof(notice), "Next game in %ld s: %s, %s", postgame_seconds, next->map_name, next->gametype); + distributed_send_notice(notice); + } +} + +/* a co-op level ended for everyone, as a won one is +(network_game_server_port_cooperative_won): the server's postgame, and at +once the lobby (co-op has no scores to show); the lobby tried again from +the postgame if its machines are still joining (server_postgame) */ +static void server_end_cooperative( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + server.skip_postgame = TRUE; + server_game_over(); + server_set_state(_server_state_postgame); + if (network_server && server_network_state() == SERVER_INGAME) + { + network_game_server_switch_to_postgame(network_server); + if (network_game_server_reset_to_pregame(network_server)) + server.back_to_lobby = TRUE; + } +} + +boolean server_director_end_game( + short reason, + boolean skip_postgame) +{ + boolean cooperative = server_cooperative(); + + /* (a co-op level is played once loaded: it has no game engine) */ + if (server.state != _server_state_playing || + (cooperative ? !game_in_progress() : !game_engine_running() || !game_engine_can_score())) + { + return FALSE; + } + if (!server.end_asked) + { + server.end_asked = TRUE; + server.end_reason = reason; + server.skip_postgame = skip_postgame; + server_log(_server_log_info, "game", "the game ends (%s)", server_end_names[reason]); + if (cooperative) + server_end_cooperative(); + else + game_engine_end_game(); + } + return TRUE; +} + +boolean server_director_interpose( + char const *text, + char *reason, + long reason_size) +{ + struct server_entry entry; + + if (!server_rotation_parse_entry(text, (int)strlen(text), &entry)) + { + snprintf(reason, (size_t)reason_size, "give a map, and a gametype: sv_map bloodgulch ctf"); + return FALSE; + } + server_rotation_resolve(&entry); + server_gametype_check_entry(&entry); + if (entry.rejected[0]) + { + snprintf(reason, (size_t)reason_size, "%s", entry.rejected); + return FALSE; + } + server.interposed = entry; + server.has_interposed = TRUE; + /* (a new one: not the one being played, if that was interposed too) */ + server.current_is_interposed = FALSE; + /* (now: in the lobby the entry is set again, a game ends at once; once + the lobby's start was sent, its machines load the game, and this one is + the next) */ + if (server.state == _server_state_lobby && + !network_game_server_port_start_sent(global_network_game_server_get())) + { + /* (the rotation's entry it takes the place of played after it) */ + if (server.entry_applied && server.current_set && server.current_index != NONE && server.rotation.count) + { + server.index = (server.current_index + server.rotation.count - 1) % server.rotation.count; + server.index_anchored = TRUE; + } + server.entry_applied = FALSE; + snprintf(reason, (size_t)reason_size, "now"); + } + else if (server.state == _server_state_playing && server_director_end_game(_server_end_next, TRUE)) + { + snprintf(reason, (size_t)reason_size, "now: this game ends"); + } + else + { + snprintf(reason, (size_t)reason_size, "once this game is over"); + } + return TRUE; +} + +/* the entry of the rotation with the text that is nearest index (an entry +may be in it more than once), NONE if none has it */ +static long server_rotation_find( + struct server_rotation const *rotation, + char const *text, + long index) +{ + long found = NONE, candidate; + + for (candidate = 0; candidate < rotation->count; candidate++) + { + if (!strcmp(rotation->entries[candidate].text, text) && + (found == NONE || labs(candidate - index) < labs(found - index))) + { + found = candidate; + } + } + return found; +} + +/* a rotation in place of the one played: the game being played goes on, +and the next is what comes after the entry played last in the new one (by +its text, else by the text of the one that came after it, else by its +place); the first entry if none has been played yet */ +static void server_adopt_rotation( + struct server_rotation const *rotation) +{ + long anchor = rotation->count - 1; + + if (server.index_anchored && server.index >= 0 && server.index < server.rotation.count && rotation->count) + { + long after = server_rotation_after(server.index); + + anchor = server_rotation_find(rotation, server.rotation.entries[server.index].text, server.index); + /* (it was taken out: the one that came after it now comes next) */ + if (anchor == NONE && after != NONE && after != server.index) + { + anchor = server_rotation_find(rotation, server.rotation.entries[after].text, server.index); + if (anchor != NONE) + anchor = (anchor + rotation->count - 1) % rotation->count; + } + if (anchor == NONE) + anchor = (MIN(server.index, rotation->count) + rotation->count - 1) % rotation->count; + } + if (server.current_set && !server.current_is_interposed && server.current_index != NONE) + server.current_index = server_rotation_find(rotation, server.current.text, server.current_index); + server.rotation = *rotation; + server.index = anchor; +} + +boolean server_director_set_rotation( + char const *text, + boolean write, + struct server_json *rejected) +{ + static struct server_rotation rotation; + long index; + boolean playable; + + server_read_rotation(&rotation, text); + playable = server_rotation_playable(&rotation) == rotation.count && rotation.count > 0 && !rotation.truncated; + if (rejected) + { + server_json_array_begin(rejected, "rejected"); + for (index = 0; index < rotation.count; index++) + { + if (rotation.entries[index].rejected[0]) + { + server_json_object_begin(rejected, NULL); + server_json_integer(rejected, "index", index); + server_json_string(rejected, "entry", rotation.entries[index].text); + server_json_string(rejected, "reason", rotation.entries[index].rejected); + server_json_object_end(rejected); + } + } + server_json_array_end(rejected); + } + if (!playable) + return FALSE; + server_adopt_rotation(&rotation); + if (write) + { + char joined[SERVER_ROTATION_MAXIMUM_TEXT + 1]; + + server_rotation_text(&server.rotation, joined, sizeof(joined)); + if (!config_write("server.rotation", joined)) + server_log(_server_log_warning, "server", "config.toml cannot be written: the rotation holds for this run"); + } + server_log(_server_log_info, "server", "the rotation now has %d entries", rotation.count); + return TRUE; +} + +/* (the web's: server_http.c) */ +int server_director_set_rotation_text( + char const *text, + struct server_json *rejected) +{ + return server_director_set_rotation(text, TRUE, rejected); +} + +boolean server_director_rotation_begin( + long index) +{ + if (index < 0 || index >= server.rotation.count || server.rotation.entries[index].rejected[0]) + return FALSE; + /* (the entry is the next: the one before it is "played") */ + server.index = (index + server.rotation.count - 1) % server.rotation.count; + server.index_anchored = TRUE; + server.has_interposed = FALSE; + return TRUE; +} + +void server_director_reload( + void) +{ + static struct server_rotation rotation; + char before[SERVER_ROTATION_MAXIMUM_TEXT + 1]; + char after[SERVER_ROTATION_MAXIMUM_TEXT + 1]; + struct server_json json; + long index; + boolean rejections_changed; + + server_rotation_text(&server.rotation, before, sizeof(before)); + if (!config_reload("server.")) + { + server_log(_server_log_warning, "server", "config.toml cannot be read again: nothing changed"); + return; + } + server_read_rotation(&rotation, config_string("server.rotation")); + server_rotation_text(&rotation, after, sizeof(after)); + /* (the same entries, checked again: a map or gametype file added or + taken out since) */ + rejections_changed = rotation.count != server.rotation.count; + for (index = 0; !rejections_changed && index < rotation.count; index++) + rejections_changed = strcmp(rotation.entries[index].rejected, server.rotation.entries[index].rejected) != 0; + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "reloaded"); + server_json_boolean(&json, "rotation_changed", strcmp(before, after) != 0); + server_json_array_begin(&json, "rejected"); + for (index = 0; index < rotation.count; index++) + { + if (rotation.entries[index].rejected[0]) + { + server_json_object_begin(&json, NULL); + server_json_string(&json, "entry", rotation.entries[index].text); + server_json_string(&json, "reason", rotation.entries[index].rejected); + server_json_object_end(&json); + server_log(_server_log_warning, "server", "rotation entry %ld (%s) left out: %s", index + 1, + rotation.entries[index].text, rotation.entries[index].rejected); + } + } + server_json_array_end(&json); + server_json_object_end(&json); + server_event_add("server", server_json_text(&json)); + server_json_free(&json); + if (strcmp(before, after) || rejections_changed) + { + if (server_rotation_playable(&rotation)) + { + server_adopt_rotation(&rotation); + server_log(_server_log_info, "server", "reloaded: the rotation goes on after the entry played last"); + } + else + { + server_log(_server_log_warning, "server", "reloaded: the new rotation has nothing playable, the old one stays"); + } + } + else + { + server_log(_server_log_info, "server", "reloaded"); + } + server_director_apply_settings(); +} + +/* ---------- stopping */ + +static void server_withdraw( + void) +{ + /* (the brokers' tombstone and the masters', once) */ + static boolean withdrawn; + + if (withdrawn) + return; + withdrawn = TRUE; + p2p_set_hosting_public(FALSE); + server_master_stop(); +} + +void server_director_quit( + int exit_code, + char const *why) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + if (server.quitting) + return; + server.quitting = TRUE; + server.exit_code = exit_code; + server_set_state(_server_state_stopping); + server_log(_server_log_info, "server", "stopping (%s)", why); + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "shutdown"); + server_json_string(&json, "reason", why); + server_json_integer(&json, "code", exit_code); + server_json_object_end(&json); + server_event_add("server", server_json_text(&json)); + server_json_free(&json); + } + /* (a shutdown that hangs ends the process anyway) */ + posix_server_quit_deadline(QUIT_DEADLINE_SECONDS, exit_code); + if (network_server) + { + /* the game's end shown, then every machine told the server goes */ + if (server_network_state() == SERVER_INGAME) + network_game_server_switch_to_postgame(network_server); + network_game_server_graceful_shutdown(network_server); + } + server_withdraw(); +} + +void server_fatal( + char const *text) +{ + if (!halo_server_mode()) + return; + server_withdraw(); + server_mode_fatal(text); +} + +/* ---------- a stop that waits (a drain) */ + +static char const *const server_drain_names[NUMBER_OF_SERVER_DRAINS] = { "now", "game", "empty", "cancel" }; + +static void server_drain_event( + short after, + boolean restart, + char const *notice) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "drain"); + server_json_string(&json, "after", server_drain_names[after]); + server_json_boolean(&json, "restart", restart); + server_json_string(&json, "notice", notice ? notice : ""); + server_json_object_end(&json); + server_event_add("server", server_json_text(&json)); + server_json_free(&json); +} + +void server_director_drain( + short after, + boolean restart, + char const *notice) +{ + if (server.quitting || after < _server_drain_now || after > _server_drain_empty) + return; + server_drain_event(after, restart, notice); + if (after == _server_drain_now) + { + server_director_quit(restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED, restart ? "sv_restart" : "sv_stop"); + return; + } + if (!server.drain_active) + server.drain_since = posix_server_unix_seconds(); + server.drain_active = TRUE; + server.drain_after = after; + server.drain_restart = restart; + /* (the listings withdrawn as sv_public 0 withdraws them, the setting + kept: new players come by the invite or the LAN alone, and the game + refuses them, server_director_refuses_joins) */ + p2p_set_hosting_public(FALSE); + server_log(_server_log_info, "server", "stopping (exit code %d) once %s: the listings withdrawn, new players refused", + restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED, after == _server_drain_game ? "the game is over" : + "nobody plays"); +} + +boolean server_director_drain_cancel( + char const *notice) +{ + if (!server.drain_active || server.quitting) + return FALSE; + server.drain_active = FALSE; + server_drain_event(_server_drain_cancel, server.drain_restart, notice); + p2p_set_hosting_public(config_boolean("server.public")); + server_log(_server_log_info, "server", "the stop is cancelled: listed (as server.public says) and open again"); + return TRUE; +} + +boolean server_director_refuses_joins( + void) +{ + return server.drain_active || server.quitting; +} + +void server_director_drain_json( + struct server_json *json) +{ + if (!server.drain_active) + { + server_json_null(json, "drain"); + return; + } + server_json_object_begin(json, "drain"); + server_json_string(json, "after", server_drain_names[server.drain_after]); + server_json_boolean(json, "restart", server.drain_restart); + server_json_integer(json, "since", server.drain_since); + server_json_object_end(json); +} + +/* a drain's stop, when its moment comes: for "empty", nobody on the server; +for "game", no game being played or about to be (not hosting yet, or a +lobby whose start was not sent, with nobody in it or no countdown running: +a game over goes back to such a lobby) */ +static void server_drain_update( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + boolean stop; + + if (!server.drain_active || server.quitting) + return; + if (server.drain_after == _server_drain_empty) + { + stop = server_player_count() == 0; + } + else if (!network_server || server.state == _server_state_waiting || server.state == _server_state_hosting) + { + stop = TRUE; + } + else + { + long countdown = network_game_server_port_countdown_milliseconds(network_server); + + stop = server.state == _server_state_lobby && !network_game_server_port_start_sent(network_server) && + (server_player_count() == 0 || countdown == NONE || network_game_server_port_countdown_paused(network_server)); + } + if (stop) + { + server.drain_active = FALSE; + server_director_quit(server.drain_restart ? SERVER_EXIT_RESTART : SERVER_EXIT_STOPPED, "drained"); + } +} + +/* ---------- stopping by itself, and updates */ + +/* debug.exit_after (the tests'): the server stopped as quit stops it once +it has run that long */ +static void server_exit_after( + void) +{ + double seconds = config_real("debug.exit_after"); + + if (seconds > 0.0 && server_mode_uptime() >= seconds) + server_director_quit(SERVER_EXIT_STOPPED, "debug.exit_after"); +} + +/* whether nobody plays on the server, nor is about to */ +static boolean server_empty( + void) +{ + return server_player_count() == 0 && server.state != _server_state_playing && server.state != _server_state_loading; +} + +/* server.auto_update: "check" looks for a new release every +UPDATE_CHECK_SECONDS and says when one is out (a warning event); "install" +also installs it once nobody has played for UPDATE_EMPTY_SECONDS (on a +thread of its own: the server goes on meanwhile), and stops with +SERVER_EXIT_RESTART for what runs the server to start the new build */ +static void server_auto_update( + real seconds) +{ + char const *mode = config_string("server.auto_update"); + boolean install = !strcmp(mode, "install"); + long available; + + /* (the install under way, or done: nothing else looked for) */ + if (server.update_installing) + { + char error[512]; + int installed = updater_server_install_poll(error, sizeof(error)); + + if (installed == 0) + return; + server.update_installing = FALSE; + if (installed > 0) + { + server.update_installed = TRUE; + server_log(_server_log_info, "update", "build %ld installed: stopping to be started again%s", + server.update_installing_build, server_empty() ? "" : " once nobody plays"); + } + else + { + /* (the next look finds it again) */ + server.update_check_seconds = UPDATE_CHECK_SECONDS; + server.update_empty_seconds = 0.0f; + server_log(_server_log_error, "update", "build %ld could not be installed: %s (tried again in %d hours)", + server.update_installing_build, error, UPDATE_CHECK_SECONDS / (60 * 60)); + return; + } + } + if (server.update_installed) + { + /* (a player who joined while it installed plays on: the server + stops once empty) */ + if (server_empty()) + server_director_quit(SERVER_EXIT_RESTART, "update installed"); + return; + } + if (!install && strcmp(mode, "check")) + return; + server.update_check_seconds -= seconds; + if (server.update_check_seconds <= 0.0f) + { + server.update_check_seconds = UPDATE_CHECK_SECONDS; + if (!updater_server_check()) + { + /* (never again: this build has no number to compare) */ + server.update_check_seconds = 1e30f; + server_log(_server_log_warning, "update", "server.auto_update is %s, but this build has no number: " + "only the builds CI numbers update themselves", mode); + return; + } + } + available = updater_server_available(); + if (!available) + return; + if (available != server.update_told) + { + struct server_json json; + char text[160]; + + server.update_told = available; + snprintf(text, sizeof(text), "build %ld is out (this is build %s)%s", available, server_mode_build(), + install ? ": installed once the server has been empty for 10 minutes" : ""); + server_log(_server_log_warning, "update", "%s", text); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "warning"); + server_json_string(&json, "text", text); + server_json_object_end(&json); + server_event_add("server", server_json_text(&json)); + server_json_free(&json); + } + if (!install) + return; + if (!server_empty()) + { + server.update_empty_seconds = 0.0f; + return; + } + server.update_empty_seconds += seconds; + if (server.update_empty_seconds >= UPDATE_EMPTY_SECONDS) + { + server.update_empty_seconds = 0.0f; + server.update_installing_build = available; + if (updater_server_install_start()) + { + server.update_installing = TRUE; + server_log(_server_log_info, "update", "installing build %ld", available); + } + else + { + char error[512]; + + updater_server_install_poll(error, sizeof(error)); + server.update_check_seconds = UPDATE_CHECK_SECONDS; + server_log(_server_log_error, "update", "build %ld could not be installed: %s (tried again in %d hours)", + available, error, UPDATE_CHECK_SECONDS / (60 * 60)); + } + } +} + +/* ---------- the players */ + +void server_director_player_removed( + long machine_index, + char const *reason) +{ + if (machine_index >= 0 && machine_index < MAXIMUM_MACHINES) + { + server.leave_reasons[machine_index] = reason; + server.leave_reason_seconds[machine_index] = 0.0f; + } +} + +/* a player's joining or leaving (player_joined: its team and machine; +player_left: its reason), as every role sees it, and with its address and +hardware id as it joined for the readers who see them (the owner, or every +role with server.log_addresses) */ +static void server_player_event( + char const *event, + long slot, + long team, + long machine, + char const *reason) +{ + struct server_json json[2]; + long pass; + + for (pass = 0; pass < 2; pass++) + { + server_json_begin(&json[pass]); + server_json_object_begin(&json[pass], NULL); + server_json_string(&json[pass], "event", event); + server_json_integer(&json[pass], "slot", slot); + server_json_string(&json[pass], "name", server.seen_names[slot]); + if (reason) + { + server_json_string(&json[pass], "reason", reason); + } + else + { + server_json_integer(&json[pass], "team", team); + server_json_integer(&json[pass], "machine", machine); + } + if (pass) + { + server_json_string(&json[pass], "address", server.seen_addresses[slot]); + server_json_string(&json[pass], "hwid", server.seen_hardware_ids[slot]); + } + server_json_object_end(&json[pass]); + } + server_event_add_addressed("player", server_json_text(&json[0]), server_json_text(&json[1])); + server_json_free(&json[0]); + server_json_free(&json[1]); +} + +static void server_note_players( + real seconds) +{ + struct network_game *game = server_game(); + long slot; + long machine_index; + + /* (a command's reason for a machine's leaving, for a moment) */ + for (machine_index = 0; machine_index < MAXIMUM_MACHINES; machine_index++) + { + if (server.leave_reasons[machine_index]) + { + server.leave_reason_seconds[machine_index] += seconds; + if (server.leave_reason_seconds[machine_index] >= LEAVE_REASON_SECONDS) + server.leave_reasons[machine_index] = NULL; + } + } + + for (slot = 0; slot < MAXIMUM_SLOTS; slot++) + { + char name[16] = ""; + long machine = NONE; + + if (game && network_player_is_valid(&game->players[slot])) + { + server_status_player_name(slot, name, sizeof(name)); + machine = game->players[slot].machine_index; + } + if (strcmp(name, server.seen_names[slot]) || machine != server.seen_machines[slot]) + { + if (server.seen_names[slot][0]) + { + long seen = server.seen_machines[slot]; + char const *reason = seen >= 0 && seen < MAXIMUM_MACHINES && server.leave_reasons[seen] ? + server.leave_reasons[seen] : "left"; + + server_log(_server_log_info, "player", "%s %s (slot %ld)", server.seen_names[slot], reason, slot); + server_player_event("player_left", slot, NONE, NONE, reason); + } + snprintf(server.seen_names[slot], sizeof(server.seen_names[slot]), "%s", name); + server.seen_machines[slot] = machine; + server.seen_addresses[slot][0] = 0; + server.seen_hardware_ids[slot][0] = 0; + if (name[0]) + { + unsigned long address = distributed_machine_real_address(machine); + char const *hardware_id = network_game_server_machine_hardware_id(machine); + + server_log(_server_log_info, "player", "%s joined (slot %ld, machine %ld)", name, slot, machine); + server.seen_joined[slot] = posix_server_unix_milliseconds(); + if (address) + { + snprintf(server.seen_addresses[slot], sizeof(server.seen_addresses[slot]), "%lu.%lu.%lu.%lu", + (address >> 24) & 255, (address >> 16) & 255, (address >> 8) & 255, address & 255); + } + snprintf(server.seen_hardware_ids[slot], sizeof(server.seen_hardware_ids[slot]), "%s", + hardware_id ? hardware_id : ""); + server_player_event("player_joined", slot, game->variant.universal_variant.teams ? + game->players[slot].team_index : -1, machine, NULL); + } + server.motd_sent[slot] = FALSE; + server.motd_waits[slot] = 0.0f; + } + /* the message of the day, once the player's machine plays */ + if (name[0] && !server.motd_sent[slot] && server.state == _server_state_playing && config_string("server.motd")[0]) + { + server.motd_waits[slot] += seconds; + if (server.motd_waits[slot] >= MOTD_DELAY_SECONDS) + { + long other; + + /* (a machine's players see it once) */ + for (other = 0; other < MAXIMUM_SLOTS; other++) + { + if (server.seen_machines[other] == machine) + server.motd_sent[other] = TRUE; + } + distributed_send_notice_to_machine(machine, config_string("server.motd")); + } + } + } +} + +long long server_director_joined_time( + long slot) +{ + return slot >= 0 && slot < MAXIMUM_SLOTS ? server.seen_joined[slot] : 0; +} + +/* the invite, said when it changes: whole on the standard output alone +(the owner's: a private server's invite lets anyone in), a word of it in +the log the web's viewers read */ +static void server_note_invite( + void) +{ + char invite[P2P_LISTING_INVITE_SIZE]; + + if (!p2p_invite_link(invite, sizeof(invite))) + invite[0] = 0; + if (strcmp(invite, server.invite)) + { + snprintf(server.invite, sizeof(server.invite), "%s", invite); + if (invite[0]) + { + server_log(_server_log_info, "net", "a new invite (on the standard output; /api/status gives it to admins)"); + server_log_private(_server_log_info, "net", "invite: %s", invite); + } + } +} + +/* ---------- the lobby's rule (network_server_manager.c) */ + +boolean server_director_ok_to_countdown( + long player_count, + boolean needs_more_teams) +{ + long minimum = PIN(config_integer("server.min_players"), 1, 16); + char const *team_start = config_string("server.team_start"); + + if (server.quitting || server.state != _server_state_lobby || !server.entry_applied || player_count < minimum) + return FALSE; + /* (a team game: with one team's players, unless "both") */ + if (needs_more_teams && !strcmp(team_start, "both")) + return FALSE; + return TRUE; +} + +/* ---------- each state */ + +static void server_hosting( + real seconds) +{ + server.menu_seconds += seconds; + if (server.menu_seconds < MENU_SETTLE_SECONDS) + return; + server_apply_game_settings(); + player_ui_fast_setup_network_server(); + if (!global_network_game_server_get() || !global_network_game_client_get()) + { + server_log(_server_log_warning, "server", "could not host (the game's ports taken by another program? " + "network.address gives each server on a machine its own): trying again in %d s", HOSTING_RETRY_SECONDS); + server.menu_seconds = MENU_SETTLE_SECONDS - HOSTING_RETRY_SECONDS; + return; + } + server_apply_game_settings(); + server_apply_listing(); + server.entry_applied = FALSE; + server.lobby_seconds = 0.0f; + server.back_to_lobby = FALSE; + server_set_state(_server_state_lobby); + server_log(_server_log_info, "lobby", "hosting"); +} + +/* whether the entry is a team game's */ +static boolean server_entry_teams( + struct server_entry const *entry) +{ + struct game_variant variant; + struct game_variant_options options; + char reason[SERVER_REASON_SIZE]; + + return strcmp(entry->gametype, "coop") && server_gametype_build(entry->gametype, entry->rules, entry->rule_count, + &variant, &options, reason, sizeof(reason)) && variant.universal_variant.teams; +} + +/* whether the rotation has a game one player can play (no team game's) */ +static boolean server_rotation_has_solo_entry( + void) +{ + long index; + + for (index = 0; index < server.rotation.count; index++) + { + if (!server.rotation.entries[index].rejected[0] && !server_entry_teams(&server.rotation.entries[index])) + return TRUE; + } + return FALSE; +} + +static void server_lobby( + real seconds) +{ + struct network_game_server *network_server = global_network_game_server_get(); + long players = server_player_count(); + long minimum = PIN(config_integer("server.min_players"), 1, 16); + boolean skip_teams = !strcmp(config_string("server.team_start"), "skip"); + long countdown; + + server.lobby_seconds += seconds; + if (!server.entry_applied) + { + struct server_entry const *entry; + long index; + + if (server.lobby_seconds < LOBBY_SETUP_SECONDS) + return; + /* (a team entry skipped while one player waits: server.team_start skip) */ + entry = server_lobby_entry(&index); + if (entry && index != NONE && skip_teams && players == 1) + { + long tries; + + for (tries = 0; tries < server.rotation.count && entry && server_entry_teams(entry); tries++) + { + server.index = index; + server.index_anchored = TRUE; + entry = server_lobby_entry(&index); + } + } + if (!entry) + { + server_log(_server_log_error, "server", "nothing in the rotation can be played"); + server_director_quit(SERVER_EXIT_CONFIGURATION, "nothing to play"); + return; + } + if (!server_apply_entry(entry, index)) + { + /* (its map gone since: the next) */ + if (index == NONE) + server.has_interposed = FALSE; + else + snprintf(server.rotation.entries[index].rejected, sizeof(server.rotation.entries[index].rejected), + "could not be set up"); + server.lobby_seconds = 0.0f; + return; + } + if (index != NONE) + { + server.index = index; + server.index_anchored = TRUE; + } + server.entry_applied = TRUE; + server.map_checked_seconds = server.lobby_seconds; + network_game_server_pause_countdown(network_server, TRUE); + return; + } + /* (the lobby's widgets may put their map back: the entry's again, said) */ + if (server.lobby_seconds - server.map_checked_seconds >= 1.0f && strcmp(server.current.gametype, "coop")) + { + struct network_game *game = server_game(); + + server.map_checked_seconds = server.lobby_seconds; + if (game && csstrcasecmp(game->map.name, server.current.level) && !network_game_server_port_start_sent(network_server)) + { + server_log(_server_log_warning, "lobby", "the lobby's map became %s: %s set again", game->map.name, + server.current.level); + network_game_server_change_map_name(network_server, server.current.level); + } + } + /* the countdown: held below the players wanted, started with them in */ + countdown = network_game_server_port_countdown_milliseconds(network_server); + if (players < minimum) + { + if (!network_game_server_port_countdown_paused(network_server)) + { + network_game_server_pause_countdown(network_server, TRUE); + if (countdown != NONE) + server_simple_event("game", "countdown", "seconds", -1); + } + } + else if (countdown == NONE || network_game_server_port_countdown_paused(network_server)) + { + long lobby_seconds = PIN(config_integer("server.lobby_seconds"), 0, 300); + + /* (as the game's server checks it each tick, its teams as + server.team_start says: a countdown it does not allow it stops at + once) */ + if (network_game_server_port_ok_to_countdown(network_server)) + { + /* (a team game, and one player waits: the next entry for him, if + the rotation has one he can play: server.team_start skip) */ + if (skip_teams && players == 1 && !server.current_is_interposed && server_entry_teams(&server.current) && + server_rotation_has_solo_entry()) + { + server_log(_server_log_info, "lobby", "one player waits: %s skipped (server.team_start)", + server.current.text); + server.entry_applied = FALSE; + server.lobby_seconds = LOBBY_SETUP_SECONDS; + return; + } + network_game_server_port_countdown_begin(network_server, lobby_seconds ? lobby_seconds * 1000 - 1 : 0); + server_log(_server_log_info, "lobby", "%ld player%s in: the game starts in %ld s", players, + players == 1 ? "" : "s", lobby_seconds); + server_simple_event("game", "countdown", "seconds", lobby_seconds); + } + } + if (network_game_server_port_start_sent(network_server)) + { + server_set_state(_server_state_loading); + server_log(_server_log_info, "game", "loading %s (%s)", server.current.map_name, server.current.gametype); + } +} + +static void server_playing( + real seconds) +{ + long players; + + if (!game_engine_running() && !server_cooperative()) + return; + if (game_engine_running() && !game_engine_can_score()) + { + /* the game over: its scores (or, skipped, at once the lobby) */ + struct network_game *game = server_game(); + + /* (a game the engine ended as its last player left (whose quit, a + tick or two on, may not have come yet: its machine has gone), at its + time limit, else at its score) */ + if (!server.end_asked) + { + server.end_reason = server_players_in_game() == 0 || + network_game_server_port_remote_machine_count(global_network_game_server_get(), TRUE) == 0 ? + _server_end_empty : + game && game->variant_options.time_limit > 0 && + server.game_seconds >= 60.0f * game->variant_options.time_limit - 2.0f ? _server_end_time : + _server_end_score; + } + server_game_over(); + server_set_state(_server_state_postgame); + return; + } + /* (a co-op level not loaded yet: nothing to count) */ + if (server_cooperative() && !game_in_progress()) + return; + server.game_seconds += seconds; + /* nobody in it (and nobody joining): it ends */ + players = server_players_in_game(); + if (players == 0 && network_game_server_port_remote_machine_count(global_network_game_server_get(), FALSE) == 0) + { + server.empty_seconds += seconds; + if (config_integer("server.empty_seconds") > 0 && server.empty_seconds >= (real)config_integer("server.empty_seconds")) + server_director_end_game(_server_end_empty, TRUE); + } + else + { + server.empty_seconds = 0.0f; + } + /* no score for so long: it ends */ + { + long red, blue, sum = 0; + struct data_iterator iterator; + struct player_datum *player; + + if (server_status_team_scores(&red, &blue)) + sum = red + blue; + if (player_data) + { + data_iterator_new(&iterator, player_data); + while ((player = (struct player_datum *)data_iterator_next(&iterator)) != NULL) + sum += player->statistics.kills[0] + player->statistics.deaths; + } + if (sum != server.score_sum) + { + server.score_sum = sum; + server.idle_seconds = 0.0f; + } + else + { + server.idle_seconds += seconds; + } + if (config_integer("server.idle_minutes") > 0 && players > 0 && + server.idle_seconds >= 60.0f * (real)config_integer("server.idle_minutes")) + { + server_director_end_game(_server_end_idle, FALSE); + } + } + /* scores, once a second at most, as they change (but for a co-op level + ended just now) */ + if (server.state != _server_state_playing) + return; + server.score_event_seconds += seconds; + if (server.score_event_seconds >= 1.0f && server_status_scores_changed()) + { + server.score_event_seconds = 0.0f; + server_game_event("score", NULL); + } +} + +static void server_postgame( + real seconds) +{ + struct network_game_server *network_server = global_network_game_server_get(); + real wanted = server.skip_postgame ? 0.0f : (real)PIN(config_integer("server.postgame_seconds"), 0, 300); + + /* (the engine's end of the game first: it switches the server to its + postgame a few seconds on; a game whose scores are skipped switches at + once. The lobby only takes the machines from the server's postgame) */ + if (server_network_state() != SERVER_POSTGAME) + { + if (server.skip_postgame && server_network_state() == SERVER_INGAME) + network_game_server_switch_to_postgame(network_server); + server.postgame_wait = 0.0f; + return; + } + /* (a game nobody saw: its scores a moment only) */ + if (!server.skip_postgame && server_players_in_game() == 0) + wanted = MIN(wanted, (real)EMPTY_POSTGAME_SECONDS); + server.postgame_wait += seconds; + if (server.postgame_wait >= wanted && !server.back_to_lobby) + { + /* (the lobby's machines may still be joining: tried each second until + it takes) */ + if (network_game_server_reset_to_pregame(network_server)) + server.back_to_lobby = TRUE; + else + server.postgame_wait = wanted - 1.0f; + } +} + +/* back in the lobby after a game: the next entry */ +static void server_back_in_lobby( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + server.back_to_lobby = FALSE; + server_advance(); + server.entry_applied = FALSE; + server.lobby_seconds = 0.0f; + server.end_asked = FALSE; + server.skip_postgame = FALSE; + server.game_over_said = FALSE; + server.game_seconds = server.empty_seconds = server.idle_seconds = 0.0f; + server.score_sum = 0; + /* (a join queued as the game ended would hold every later one) */ + network_game_server_port_clear_queued_players(network_server); + network_game_server_pause_countdown(network_server, TRUE); + server_set_state(_server_state_lobby); +} + +/* ---------- each frame */ + +static void server_run_line( + char const *line, + int role, + int origin) +{ + boolean succeeded; + char *output; + + /* (the owner's console's lines on the standard output alone: what the + owner sees, addresses and all, is not the web's viewers' to read) */ + if (origin == _server_origin_standard_input) + server_log_private(_server_log_info, "console", "> %s", server_console_line_is_secret(line) ? + "(a password's command)" : line); + output = server_commands_run(line, role, origin, &succeeded); + if (output) + { + char const *cursor = output; + + while (*cursor) + { + char const *end = strchr(cursor, '\n'); + long length = end ? (long)(end - cursor) : (long)strlen(cursor); + + if (length > 0) + { + server_log_private(succeeded ? _server_log_info : _server_log_warning, "console", "%.*s", (int)length, + cursor); + } + cursor += length + (end != NULL); + } + server_console_free(output); + } +} + +static void server_signals( + void) +{ + int quit_signal = 0; + int signals = server_mode_signals(&quit_signal); + + if (signals & _posix_server_signal_quit) + { + char why[32]; + + snprintf(why, sizeof(why), "signal %d", quit_signal); + server_director_quit(SERVER_EXIT_STOPPED, why); + } + if (signals & _posix_server_signal_reload) + server_director_reload(); + if (signals & _posix_server_signal_status) + { + struct server_json json; + + server_json_begin(&json); + server_status_write(&json, _server_role_owner); + fprintf(stdout, "%s\n", server_json_text(&json)); + fflush(stdout); + server_json_free(&json); + } +} + +/* the status file (server.status_file), written whole and put in place */ +static void server_write_status_file( + void) +{ + char const *path = config_string("server.status_file"); + struct server_json json; + + if (!path[0]) + return; + server_json_begin(&json); + server_status_write(&json, _server_role_viewer); + /* (a path of the host's: not the game's fopen, which takes the Xbox's) */ + posix_server_write_file(path, server_json_text(&json), (unsigned long)strlen(server_json_text(&json))); + server_json_free(&json); +} + +static void server_health_line( + void) +{ + static unsigned long last_cpu; + static double last_uptime; + unsigned long cpu = posix_server_cpu_milliseconds(); + double uptime = server_mode_uptime(); + double longest, mean; + double share = uptime > last_uptime ? (double)(cpu - last_cpu) / ((uptime - last_uptime) * 10.0) : 0.0; + + server_mode_frame_times(&longest, &mean); + server_log(_server_log_info, "health", "players=%ld frame_ms_max=%.1f frame_ms_avg=%.2f cpu=%.1f%% rss=%luMB", + server_player_count(), longest, mean, share, posix_server_resident_bytes() / (1024UL * 1024UL)); + last_cpu = cpu; + last_uptime = uptime; +} + +/* the state published to the master servers: what the listing lacks */ +static void server_publish_state( + void) +{ + struct server_json json; + struct network_game *game = server_game(); + char variant[16] = ""; + + if (game) + { + long index; + + for (index = 0; index < 11 && game->variant.human_readable_game_description[index]; index++) + { + wchar_t character = game->variant.human_readable_game_description[index]; + + variant[index] = character >= 0x20 && character < 0x7F ? (char)character : '?'; + } + variant[index] = 0; + } + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "variant", variant); + server_json_string(&json, "state", server_status_state_name(server.state)); + server_json_integer(&json, "players", server_player_count()); + server_json_string(&json, "map", server.current_set ? server.current.map_name : ""); + server_json_string(&json, "gametype", server.current_set ? server.current.gametype : ""); + server_json_object_end(&json); + if (strcmp(server_json_text(&json), server.master_state)) + { + snprintf(server.master_state, sizeof(server.master_state), "%s", server_json_text(&json)); + server_master_set_state(server.master_state, TRUE); + } + server_json_free(&json); +} + +void server_update( + boolean main_menu_loaded, + real seconds) +{ + struct network_game_server *network_server; + short network_state; + char line[SERVER_COMMAND_LINE_SIZE]; + int origin; + + if (!halo_server_mode()) + return; + if (!server.started) + { + long index; + + server.started = TRUE; + /* (nothing is drawn: nobody sees it) */ + debug_no_drawing = TRUE; + for (index = 0; index < MAXIMUM_SLOTS; index++) + server.seen_machines[index] = NONE; + server_read_rotation(&server.rotation, config_string("server.rotation")); + server.index = server.rotation.count - 1; + server.current_index = NONE; + server.last_countdown_seconds = NONE; + { + struct server_json json; + long entry; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "server_started"); + server_json_string(&json, "name", config_string("server.name")); + server_json_string(&json, "build", server_mode_build()); + server_json_integer(&json, "network_version", HALO_PORT_NETWORK_VERSION); + server_status_identity(&json); + server_json_array_begin(&json, "rotation"); + for (entry = 0; entry < server.rotation.count; entry++) + server_json_string(&json, NULL, server.rotation.entries[entry].text); + server_json_array_end(&json); + server_json_object_end(&json); + server_event_add("server", server_json_text(&json)); + server_json_free(&json); + } + } + server_signals(); + server_console_frame(); + while (server_console_next(line, sizeof(line), &origin)) + { + /* (a line left out, too long, comes empty) */ + if (line[0]) + server_run_line(line, _server_role_owner, origin); + } + server_http_main_thread(); + if (!server.quitting) + { + server_exit_after(); + server_auto_update(seconds); + } + + /* stopping: the machines given a moment to hear it, then the end */ + if (server.quitting) + { + server.quit_seconds += seconds; + if (server.quit_seconds >= QUIT_SECONDS) + { + dispose_global_network_game_server(); + server_log(_server_log_info, "server", "stopped (exit code %d)", server.exit_code); + server_mode_exit(server.exit_code); + } + return; + } + /* a stop that waited, when its moment comes (stopping from the next + frame) */ + server_drain_update(); + if (server.quitting) + return; + + network_server = global_network_game_server_get(); + network_state = server_network_state(); + server.state_seconds += seconds; + server_mode_set_idle(!network_server || + (network_state == SERVER_PREGAME && !network_game_server_port_remote_machine_count(network_server, FALSE))); + + if (!network_server) + { + /* the intro movie skipped, as a button press does (it holds the + texture cache's spare memory, which the map takes) */ + if (main_menu_loaded && bink_playback_in_progress()) + { + bink_playback_stop(); + return; + } + if (server.state != _server_state_waiting && server.state != _server_state_hosting) + { + server_log(_server_log_warning, "server", "the game's server stopped: hosting again"); + server.current_set = FALSE; + server.current_is_interposed = FALSE; + server.invite[0] = 0; + } + if (!main_menu_loaded) + { + server_set_state(_server_state_waiting); + return; + } + if (server.state != _server_state_hosting) + { + server_set_state(_server_state_hosting); + server.menu_seconds = 0.0f; + } + server_hosting(seconds); + return; + } + + server_note_invite(); + server_note_players(seconds); + switch (network_state) + { + case SERVER_PREGAME: + if (!main_menu_loaded) + break; + if (server.state == _server_state_postgame || server.state == _server_state_playing || + server.state == _server_state_loading) + { + /* (a co-op level won: network_game_server_port_cooperative_won + took every machine to the lobby at once) */ + if (server.state == _server_state_playing && server_cooperative() && !server.game_over_said) + { + if (!server.end_asked) + server.end_reason = _server_end_score; + server.skip_postgame = TRUE; + server_game_over(); + } + if (server.state != _server_state_postgame) + server_log(_server_log_info, "game", "back in the lobby"); + server_back_in_lobby(); + } + if (server.state == _server_state_hosting || server.state == _server_state_waiting) + server_set_state(_server_state_lobby); + server_lobby(seconds); + break; + case SERVER_INGAME: + if (server.state == _server_state_lobby || server.state == _server_state_loading) + { + if (game_engine_running() || server_cooperative()) + { + server.games_hosted++; + server.game_seconds = server.empty_seconds = server.idle_seconds = 0.0f; + server.end_asked = FALSE; + server.skip_postgame = FALSE; + server.game_over_said = FALSE; + server_set_state(_server_state_playing); + server_log(_server_log_info, "game", "started: %s on %s, %ld player%s", server.current.gametype, + server.current.map_name, server_player_count(), server_player_count() == 1 ? "" : "s"); + server_game_event("game_started", NULL); + } + else if (server.state == _server_state_lobby) + { + server_set_state(_server_state_loading); + } + } + if (server.state == _server_state_playing) + server_playing(seconds); + else if (server.state == _server_state_postgame) + server_postgame(seconds); + break; + case SERVER_POSTGAME: + if (server.state != _server_state_postgame) + server_set_state(_server_state_postgame); + server_postgame(seconds); + break; + } + + /* the ready line: the lobby open (and listed, or 10 s on) */ + if (!server.ready_said && server.state == _server_state_lobby && server.entry_applied) + { + server.ready_wait += seconds; + if (!config_boolean("server.public") || !config_boolean("network.online") || server.invite[0] || + server.ready_wait >= 10.0f) + { + char web[64] = "off"; + + server.ready_said = TRUE; + if (config_integer("server.web_port") > 0) + snprintf(web, sizeof(web), "%s:%ld", config_string("server.web_bind"), config_integer("server.web_port")); + server_log(_server_log_info, "server", "ready (tunnel %ld/udp, web %s, listing %s)", + config_boolean("network.online") ? config_integer("network.tunnel_port") : 0L, web, + !config_boolean("network.online") ? "LAN only" : config_boolean("server.public") ? "public" : "by invite"); + } + } + server.status_file_seconds += seconds; + if (server.status_file_seconds >= STATUS_FILE_SECONDS) + { + server.status_file_seconds = 0.0f; + server_write_status_file(); + server_publish_state(); + } + server.health_seconds += seconds; + if (server.health_seconds >= HEALTH_SECONDS) + { + server.health_seconds = 0.0f; + server_health_line(); + } +} + +#else + +void server_update(boolean main_menu_loaded, real seconds) +{ +} + +char const *server_director_first_level(void) +{ + return NULL; +} + +boolean server_director_ok_to_countdown(long player_count, boolean needs_more_teams) +{ + return FALSE; +} + +void server_fatal(char const *text) +{ +} + +boolean server_director_refuses_joins(void) +{ + return FALSE; +} + +#endif diff --git a/port/linux/game/server_director.h b/port/linux/game/server_director.h new file mode 100644 index 00000000..ba22c900 --- /dev/null +++ b/port/linux/game/server_director.h @@ -0,0 +1,168 @@ +/* +SERVER_DIRECTOR.H + +A dedicated server's director (server_director.c), its commands +(server_commands.c) and its status (server_status.c): what the game's +sources call of them, and what they share. halo_server_mode() says whether +this run is a dedicated server (port/linux/src/server_mode.c). +*/ + +#ifndef __SERVER_DIRECTOR_H +#define __SERVER_DIRECTOR_H + +#include "../src/server_mode.h" /* port: port/linux/src/server_mode.c */ + +struct server_json; +struct server_entry; +struct server_rotation; + +/* the director's states (server_status.c names them) */ +enum +{ + _server_state_waiting, + _server_state_hosting, + _server_state_lobby, + _server_state_loading, + _server_state_playing, + _server_state_postgame, + _server_state_stopping, + NUMBER_OF_SERVER_STATES +}; + +/* why a game ended (game_ended's reason) */ +enum +{ + _server_end_score, + _server_end_time, + _server_end_idle, + _server_end_empty, + _server_end_command, + _server_end_next, + NUMBER_OF_SERVER_ENDS +}; + +/* when a stop asked for comes (sv_stop, sv_restart: drain's after) */ +enum +{ + _server_drain_now, + /* once the game being played is over (at once with none) */ + _server_drain_game, + /* once nobody plays on the server */ + _server_drain_empty, + /* (no stop: the one waiting cancelled) */ + _server_drain_cancel, + NUMBER_OF_SERVER_DRAINS +}; + +/* ---------- the director (server_director.c) */ + +/* each frame, from the main loop (main.c), beside network_test_update */ +void server_update(boolean main_menu_loaded, real seconds); +/* (network_server_manager.c) whether the lobby may count down: its entry +set, enough players, and teams as server.team_start says */ +boolean server_director_ok_to_countdown(long player_count, boolean needs_more_teams); +/* the game's fatal error (main.c's halt_and_catch_fire, main_loop_of_death) */ +void server_fatal(char const *text); + +/* the director's state, and the seconds in it */ +short server_director_state(void); +/* the rotation, the entry being played (its index; NONE for one sv_map +interposed, or before the first is set), and the next */ +struct server_rotation const *server_director_rotation(void); +long server_director_index(void); +struct server_entry const *server_director_current(void); +struct server_entry const *server_director_next(void); +/* games hosted since the start, and this game's seconds */ +long server_director_games_hosted(void); +real server_director_game_seconds(void); + +/* the commands' (server_commands.c) */ +/* the game ended now: with its scores (end_game), or without (skip) */ +boolean server_director_end_game(short reason, boolean skip_postgame); +/* the entry played next (sv_map; now: the lobby's set again, or the game +ended at once if one runs; once the lobby's start was sent, after the game +it starts): TRUE and when in reason, else FALSE and why */ +boolean server_director_interpose(char const *text, char *reason, long reason_size); +/* the rotation replaced (sv_rotation_set, PUT /api/rotation): FALSE with +the rejected entries said, and nothing changed */ +boolean server_director_set_rotation(char const *text, boolean write, struct server_json *rejected); +/* the rotation's next game is the entry at index (sv_rotation_begin) */ +boolean server_director_rotation_begin(long index); +/* the settings read again (sv_reload, SIGHUP) */ +void server_director_reload(void); +/* the server stops: its game ended, every machine told, the listings +withdrawn; then the process ends with the code */ +void server_director_quit(int exit_code, char const *why); +/* the settings the game takes from server.* applied now (the name, the +most players, the listing) */ +void server_director_apply_settings(void); +/* a command dropped the machine's players: their leaving is said so +(player_left's reason: "kicked", "banned"; a static text) */ +void server_director_player_removed(long machine_index, char const *reason); +/* the server stops (exit code 0, or 5 to be restarted) now, or once the +game is over or the server is empty (a drain): until then its listings are +withdrawn and new players refused. The notice was said already */ +void server_director_drain(short after, boolean restart, char const *notice); +/* the drain waiting cancelled: listed and open again; FALSE if none was */ +boolean server_director_drain_cancel(char const *notice); +/* (network_server_manager.c) whether new machines are refused: a drain */ +boolean server_director_refuses_joins(void); +/* the status's "drain": null, or {after, restart, since} */ +void server_director_drain_json(struct server_json *json); + +/* ---------- the commands (server_commands.c) */ + +/* (hs.c) a command of the server's, run if the line is one, with the role +the line came with (server_commands_run's; the owner's otherwise): TRUE +with its result in result */ +boolean server_command_try(char const *expression, boolean *result); +/* a line run with a role (standard input's, the web's): the console's +output it made, lines separated by newlines (free it), and whether it +succeeded */ +char *server_commands_run(char const *line, int role, int origin, boolean *succeeded); +/* the role of the command being run (the owner's when none runs) */ +int server_commands_role(void); + +/* ---------- the game's events (server_status.c; nothing but on a dedicated +server) */ + +/* a player killed (network_distributed_player_killed's, on the host) */ +void server_event_player_killed(long killing_player_index, long killing_object_index, long dead_player_index, + boolean friendly_fire); +/* a line of chat the host sends on (network_chat.c), by its sender's +absolute player index */ +void server_event_chat(short player_index, short channel, char const *text); +/* a machine found running its game faster than the host's (action "kicked" +or "unverified") */ +void server_event_speed_hack(long machine_index, char const *names, real rate, long ahead_ticks, + char const *action); +/* a machine's download of the game's map (map_share.c): state "asked", +"downloading", "downloaded" or "failed" */ +void server_event_map_share(long machine_index, char const *map, char const *state, unsigned long done, + unsigned long total); + +/* ---------- the status (server_status.c) */ + +/* the status (docs/dedicated_server.md, "The status"), as the role sees it */ +void server_status_write(struct server_json *json, int role); +/* the server's identity, for the status and server_started: its process, +start, instance, region, ports and publisher key's id */ +void server_status_identity(struct server_json *json); +/* the players, as the role sees them */ +void server_status_players(struct server_json *json, int role); +/* one player, as the role sees him (a players[] element) */ +void server_status_player(struct server_json *json, long slot, int role); +/* the maps there are */ +void server_status_maps(struct server_json *json); +/* the gametypes there are, and their rules' schema */ +void server_status_gametypes(struct server_json *json); +/* the state's name ("lobby"...) */ +char const *server_status_state_name(short state); +/* the players' scores changed since this was last asked (score events) */ +boolean server_status_scores_changed(void); +/* the network game's player at slot (its name in ASCII into name): TRUE */ +boolean server_status_player_name(long slot, char *name, long size); +/* the team scores of a team game (two), else FALSE */ +boolean server_status_team_scores(long *red, long *blue); + +#endif diff --git a/port/linux/game/server_gametypes.c b/port/linux/game/server_gametypes.c new file mode 100644 index 00000000..7d649b89 --- /dev/null +++ b/port/linux/game/server_gametypes.c @@ -0,0 +1,508 @@ +/* +SERVER_GAMETYPES.C + +A dedicated server's gametypes (server_gametypes.h; docs/dedicated_server.md): +the variant of a built-in gametype by its name (the Xbox's default gametypes' +builders, game_engine_playlist.h), of OpenCE's (game_modes.c), of a gametype +file (gametypes/.toml: its base, then its rules) or of a gametype a +player's game saved (gametypes/.gametype), with a rotation entry's own +rules over it. Each rule is checked to be of the variant's game engine: a +bad gametype never reaches the wire, where the clients play by it. +*/ + +#include "cseries.h" +#include "game/game_engine.h" +#include "game/game_engine_playlist.h" +#include "saved games/playlist_profile.h" +#include "saved games/saved_game_files.h" +#include "text/unicode.h" +#include "game_modes.h" +#include "server_gametypes.h" + +#include "../src/server_mode.h" /* port: port/linux/src/server_mode.c */ +#include "../src/server_toml.h" /* port: port/linux/src/server_toml.c */ +#include "../src/server_json.h" /* port: port/linux/src/server_json.c */ +#include "../src/posix.h" /* port: port/linux/src/posix_files.c */ + +#include +#include + +#if HALO_SERVER_BUILD + +/* the built-in gametypes, by name, and what the lobby and the server +browser call them (11 characters) */ +static struct +{ + char const *name; + struct game_variant *(*build)(struct game_variant *variant); + wchar_t const *shown; +} const server_builtin_gametypes[] = +{ + { "slayer", build_game_variant_slayer, L"Slayer" }, + { "team_slayer", build_game_variant_team_slayer, L"Team Slayer" }, + { "slayer_pro", build_game_variant_slayer_pro, L"Slayer Pro" }, + { "elimination", build_game_variant_elimination, L"Elimination" }, + { "phantoms", build_game_variant_phantoms, L"Phantoms" }, + { "endurance", build_game_variant_endurance, L"Endurance" }, + { "rockets", build_game_variant_rockets, L"Rockets" }, + { "snipers", build_game_variant_snipers, L"Snipers" }, + { "stalker", build_game_variant_stalker, L"Stalker" }, + { "ctf", build_game_variant_ctf, L"CTF" }, + { "ironctf", build_game_variant_iron_ctf, L"Iron CTF" }, + { "ctf_pro", build_game_variant_ctf_pro, L"CTF Pro" }, + { "invasion", build_game_variant_invasion, L"Invasion" }, + { "oddball", build_game_variant_oddball, L"Oddball" }, + { "team_oddball", build_game_variant_team_oddball, L"Team Ball" }, + { "reverse_tag", build_game_variant_reverse_tag, L"Reverse Tag" }, + { "accumulation", build_game_variant_accumulation, L"Accumulate" }, + { "juggernaut", build_game_variant_juggernaut, L"Juggernaut" }, + { "king", build_game_variant_king, L"King" }, + { "team_king", build_game_variant_team_king, L"Team King" }, + { "king_pro", build_game_variant_king_pro, L"King Pro" }, + { "crazy_king", build_game_variant_crazy_king, L"Crazy King" }, + { "race", build_game_variant_race, L"Race" }, + { "team_race", build_game_variant_team_race, L"Team Race" }, + { "rally", build_game_variant_rally, L"Rally" }, + { "team_rally", build_game_variant_team_rally, L"Team Rally" }, +}; + +/* Halo PC's dedicated server's names for them */ +static struct +{ + char const *alias; + char const *name; +} const server_gametype_aliases[] = +{ + { "ffa", "slayer" }, + { "ffa_slayer", "slayer" }, + { "teamslayer", "team_slayer" }, + { "tslayer", "team_slayer" }, + { "koth", "king" }, + { "team_koth", "team_king" }, + { "iron_ctf", "ironctf" }, + { "teamrace", "team_race" }, + { "teamoddball", "team_oddball" }, +}; + +static char const *const server_opence_gametypes[] = +{ + "swat", "infection", "gun_game", "hardcore", "hardcore_ctf", "hardcore_oddball", "hardcore_king", +}; + +char const *server_gametype_builtin_name( + long index) +{ + if (index >= 0 && index < (long)NUMBEROF(server_builtin_gametypes)) + return server_builtin_gametypes[index].name; + index -= (long)NUMBEROF(server_builtin_gametypes); + if (index >= 0 && index < (long)NUMBEROF(server_opence_gametypes)) + return server_opence_gametypes[index]; + return NULL; +} + +char const *server_gametype_engine_name( + long game_engine_index) +{ + switch (game_engine_index) + { + case game_engine_ctf: return "ctf"; + case game_engine_slayer: return "slayer"; + case game_engine_oddball: return "oddball"; + case game_engine_king: return "king"; + case game_engine_race: return "race"; + default: return "none"; + } +} + +/* a built-in's or OpenCE's gametype by its name (or alias): TRUE */ +static boolean server_gametype_base( + char const *name, + struct game_variant *variant, + struct game_variant_options *options) +{ + struct game_variant temporary; + long index; + + for (index = 0; index < (long)NUMBEROF(server_gametype_aliases); index++) + { + if (!strcmp(name, server_gametype_aliases[index].alias)) + name = server_gametype_aliases[index].name; + } + for (index = 0; index < (long)NUMBEROF(server_builtin_gametypes); index++) + { + if (!strcmp(name, server_builtin_gametypes[index].name)) + { + *variant = *server_builtin_gametypes[index].build(&temporary); + game_variant_options_default(variant, options); + if (!variant->human_readable_game_description[0]) + { + ustrncpy(variant->human_readable_game_description, server_builtin_gametypes[index].shown, + NUMBEROF(variant->human_readable_game_description) - 1); + } + return TRUE; + } + } + return game_modes_gametype_by_name(name, variant, options); +} + +static void server_gametype_flag( + struct game_variant *variant, + long bit, + long value) +{ + SET_FLAG(variant->universal_variant.flags, bit, value != 0); +} + +/* a rule over the variant and options (its engine already checked) */ +static void server_gametype_rule( + struct server_rule const *rule, + struct game_variant *variant, + struct game_variant_options *options, + boolean *team_vehicles_given, + boolean *vehicle_counts_given) +{ + char const *key = rule->key; + long value = rule->value; + struct universal_variant *universal = &variant->universal_variant; + static char const *const vehicles[NUMBER_OF_VARIANT_VEHICLES] = + { "warthogs", "ghosts", "scorpions", "rocket_warthogs", "banshees", "gun_turrets" }; + long index; + + if (!strcmp(key, "score_to_win")) universal->score_to_win = value; + else if (!strcmp(key, "lives")) universal->lives = value; + else if (!strcmp(key, "respawn_time")) universal->respawn_time = value * TICKS_PER_SECOND; + else if (!strcmp(key, "respawn_time_growth")) universal->respawn_time_growth = value * TICKS_PER_SECOND; + else if (!strcmp(key, "suicide_penalty")) universal->suicide_penalty = value * TICKS_PER_SECOND; + else if (!strcmp(key, "health")) universal->health = (real)value / 100.0f; + else if (!strcmp(key, "teams")) universal->teams = (boolean)(value != 0); + else if (!strcmp(key, "odd_man_out")) universal->odd_man_out = (boolean)(value != 0); + else if (!strcmp(key, "radar")) server_gametype_flag(variant, _game_variant_draw_object_in_motion_sensor_bit, value); + else if (!strcmp(key, "friend_indicators")) server_gametype_flag(variant, _game_variant_allow_friendly_navpoints_bit, value); + else if (!strcmp(key, "infinite_grenades")) server_gametype_flag(variant, _game_variant_infinite_grenades_bit, value); + else if (!strcmp(key, "no_shields")) server_gametype_flag(variant, _game_variant_no_shields_bit, value); + else if (!strcmp(key, "invisible")) server_gametype_flag(variant, _game_variant_always_invisible_bit, value); + else if (!strcmp(key, "generic_equipment")) server_gametype_flag(variant, _game_variant_generic_starting_equipment_bit, value); + else if (!strcmp(key, "weapon_set")) universal->weapon_set = value; + else if (!strcmp(key, "vehicle_set")) universal->vehicle_set = value; + else if (!strcmp(key, "goal_radar")) universal->goal_radar = value; + else if (!strcmp(key, "time_limit")) options->time_limit = (short)value; + else if (!strcmp(key, "friendly_fire")) options->friendly_fire = (short)value; + else if (!strcmp(key, "friendly_fire_penalty")) options->friendly_fire_penalty = (short)value; + else if (!strcmp(key, "vehicle_respawn_time")) options->vehicle_respawn_time = (short)value; + else if (!strcmp(key, "auto_team_balance")) options->auto_team_balance = (boolean)(value != 0); + else if (!strcmp(key, "radar_players")) options->radar_players = (byte)value; + else if (!strcmp(key, "loadout")) options->loadout = (byte)value; + else if (!strcmp(key, "primary_weapon")) options->primary_weapon = (byte)value; + else if (!strcmp(key, "secondary_weapon")) options->secondary_weapon = (byte)value; + else if (!strcmp(key, "no_map_weapons")) options->no_map_weapons = (boolean)(value != 0); + else if (!strcmp(key, "hardcore")) options->hardcore = (boolean)(value != 0); + else if (!strcmp(key, "team_vehicles")) + { + byte set = value == 1 ? VARIANT_VEHICLE_SET_PC : value == 2 ? VARIANT_VEHICLE_SET_CUSTOM : + (byte)universal->vehicle_set; + + options->vehicle_set[0] = options->vehicle_set[1] = set; + *team_vehicles_given = TRUE; + } + else if (!strcmp(key, "assault")) variant->game_engine_variant.ctf.assault = (boolean)(value != 0); + else if (!strcmp(key, "reset_on_capture")) variant->game_engine_variant.ctf.reset_on_capture = (boolean)(value != 0); + else if (!strcmp(key, "flag_must_reset")) variant->game_engine_variant.ctf.flag_must_reset = (boolean)(value != 0); + else if (!strcmp(key, "flag_at_home_to_score")) variant->game_engine_variant.ctf.flag_at_home_to_score = (boolean)(value != 0); + else if (!strcmp(key, "single_flag_time")) variant->game_engine_variant.ctf.single_flag_time = value * TICKS_PER_SECOND; + else if (!strcmp(key, "no_death_bonus")) variant->game_engine_variant.slayer.no_death_bonus = (boolean)(value != 0); + else if (!strcmp(key, "no_kill_penalty")) variant->game_engine_variant.slayer.no_kill_penalty = (boolean)(value != 0); + else if (!strcmp(key, "kill_in_order")) variant->game_engine_variant.slayer.kill_in_order = (boolean)(value != 0); + else if (!strcmp(key, "moving_hill")) variant->game_engine_variant.king.moving_hill = (boolean)(value != 0); + else if (!strcmp(key, "random_start")) variant->game_engine_variant.oddball.random_start = (boolean)(value != 0); + else if (!strcmp(key, "ball_spawn_delay")) variant->game_engine_variant.oddball.ball_spawn_delay = (boolean)(value != 0); + else if (!strcmp(key, "speed_with_ball")) variant->game_engine_variant.oddball.speed_with_ball = value; + else if (!strcmp(key, "trait_with_ball")) variant->game_engine_variant.oddball.trait_with_ball = value; + else if (!strcmp(key, "trait_without_ball")) variant->game_engine_variant.oddball.trait_without_ball = value; + else if (!strcmp(key, "ball_type")) variant->game_engine_variant.oddball.oddball_ball_type = value; + else if (!strcmp(key, "ball_spawn_count")) variant->game_engine_variant.oddball.ball_spawn_count = value; + else if (!strcmp(key, "race_type")) variant->game_engine_variant.race.race_type = value; + else if (!strcmp(key, "team_scoring")) variant->game_engine_variant.race.team_scoring = value; + else + { + for (index = 0; index < NUMBER_OF_VARIANT_VEHICLES; index++) + { + if (!strcmp(key, vehicles[index])) + { + options->vehicle_counts[0][index] = options->vehicle_counts[1][index] = (byte)value; + *vehicle_counts_given = TRUE; + } + } + } +} + +/* the rules over the variant, each of its engine: FALSE and why if one is +another's */ +static boolean server_gametype_apply_rules( + struct server_rule const *rules, + long rule_count, + struct game_variant *variant, + struct game_variant_options *options, + boolean *team_vehicles_given, + boolean *vehicle_counts_given, + char *reason, + long reason_size) +{ + long engine_bit = variant->game_engine_index >= game_engine_ctf && variant->game_engine_index <= game_engine_race ? + 1L << (variant->game_engine_index - game_engine_ctf) : 0; + long index; + + for (index = 0; index < rule_count; index++) + { + struct server_rule_definition const *definition = server_rule_find(rules[index].key); + + if (!definition) + { + snprintf(reason, (size_t)reason_size, "no rule is named %s", rules[index].key); + return FALSE; + } + if (!(definition->engines & engine_bit)) + { + snprintf(reason, (size_t)reason_size, "%s is a %s gametype's rule, and this one is %s", definition->key, + definition->table, server_gametype_engine_name(variant->game_engine_index)); + return FALSE; + } + server_gametype_rule(&rules[index], variant, options, team_vehicles_given, vehicle_counts_given); + } + return TRUE; +} + +boolean server_gametype_build( + char const *gametype, + struct server_rule const *rules, + long rule_count, + struct game_variant *variant, + struct game_variant_options *options, + char *reason, + long reason_size) +{ + boolean team_vehicles_given = FALSE; + boolean vehicle_counts_given = FALSE; + struct universal_variant before; + + csmemset(variant, 0, sizeof(*variant)); + csmemset(options, 0, sizeof(*options)); + reason[0] = 0; + if (!strncmp(gametype, "file=", 5)) + { + /* a gametype a player's game saved, copied into the gametypes folder + (the .toml files' own: beside config.toml first, server_toml.c's + server_gametypes_folder) */ + byte block[SAVED_GAME_FILE_BLOCK_SIZE]; + int size; + + if (!server_gametype_file_name_valid(gametype + 5)) + { + snprintf(reason, (size_t)reason_size, "\"%s\" is not a gametype file's name", gametype + 5); + return FALSE; + } + size = server_gametype_saved_read(gametype + 5, block, sizeof(block)); + if (size < 0 || !playlist_profile_get_from_data_with_options(block, (unsigned long)size, variant, options)) + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.gametype is missing, or not a saved gametype", + gametype + 5); + return FALSE; + } + if (variant->game_engine_index < game_engine_ctf || variant->game_engine_index > game_engine_race) + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.gametype has no game engine", gametype + 5); + return FALSE; + } + } + else if (!server_gametype_base(gametype, variant, options)) + { + struct server_gametype_file file; + int absent; + + if (!server_gametype_file_read(gametype, &file, &absent)) + { + if (absent) + { + snprintf(reason, (size_t)reason_size, + "no gametype is named %s (a built-in's, OpenCE's, or gametypes/%s.toml)", gametype, gametype); + } + else if (file.error_line > 0) + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.toml line %d: %s", gametype, file.error_line, + file.error); + } + else + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: %s", gametype, file.error); + } + return FALSE; + } + if (!server_gametype_base(file.base, variant, options)) + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: its base, %s, is no built-in gametype", gametype, + file.base); + return FALSE; + } + if (file.engine_table[0] && strcmp(file.engine_table, server_gametype_engine_name(variant->game_engine_index))) + { + snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: [%s] is not its base's game engine (%s)", + gametype, file.engine_table, server_gametype_engine_name(variant->game_engine_index)); + return FALSE; + } + if (!server_gametype_apply_rules(file.rules, file.rule_count, variant, options, &team_vehicles_given, + &vehicle_counts_given, reason, reason_size)) + { + char why[SERVER_REASON_SIZE]; + + snprintf(why, sizeof(why), "%s", reason); + snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: %s", gametype, why); + return FALSE; + } + if (file.name[0]) + { + long index; + + csmemset(variant->human_readable_game_description, 0, sizeof(variant->human_readable_game_description)); + for (index = 0; file.name[index] && index < (long)NUMBEROF(variant->human_readable_game_description) - 1; + index++) + { + variant->human_readable_game_description[index] = (wchar_t)(unsigned char)file.name[index]; + } + } + } + before = variant->universal_variant; + if (!server_gametype_apply_rules(rules, rule_count, variant, options, &team_vehicles_given, &vehicle_counts_given, + reason, reason_size)) + { + return FALSE; + } + /* (each team's vehicles: the vehicle set's unless said, a count's + making them custom) */ + if (vehicle_counts_given && !team_vehicles_given) + options->vehicle_set[0] = options->vehicle_set[1] = VARIANT_VEHICLE_SET_CUSTOM; + else if (!team_vehicles_given && before.vehicle_set != variant->universal_variant.vehicle_set) + options->vehicle_set[0] = options->vehicle_set[1] = (byte)variant->universal_variant.vehicle_set; + /* (CTF always has teams) */ + if (variant->game_engine_index == game_engine_ctf) + variant->universal_variant.teams = TRUE; + game_modes_options_sanitize(options); + return TRUE; +} + +/* (the web's: server_http.c) a gametype file's text checked as the +rotation would take it: TRUE, else why and its line */ +int server_gametype_check_text( + char const *text, + int length, + char *reason, + long reason_size, + int *line) +{ + struct server_gametype_file file; + struct game_variant variant; + struct game_variant_options options; + boolean team_vehicles_given = FALSE, vehicle_counts_given = FALSE; + + *line = 0; + if (!server_gametype_file_parse(text, length, &file)) + { + snprintf(reason, (size_t)reason_size, "%s", file.error); + *line = file.error_line; + return FALSE; + } + if (!server_gametype_base(file.base, &variant, &options)) + { + snprintf(reason, (size_t)reason_size, "base = \"%s\" is no built-in gametype", file.base); + return FALSE; + } + if (file.engine_table[0] && strcmp(file.engine_table, server_gametype_engine_name(variant.game_engine_index))) + { + snprintf(reason, (size_t)reason_size, "[%s] is not its base's game engine (%s)", file.engine_table, + server_gametype_engine_name(variant.game_engine_index)); + return FALSE; + } + return server_gametype_apply_rules(file.rules, file.rule_count, &variant, &options, &team_vehicles_given, + &vehicle_counts_given, reason, reason_size); +} + +/* (the web's) the gametype files there are: "files":[{name, base, +description}] */ +void server_gametype_files_json( + struct server_json *json) +{ + char folder[1024]; + char name[256]; + void *directory; + + server_gametypes_folder(folder, sizeof(folder)); + server_json_array_begin(json, "files"); + directory = posix_directory_open(folder); + while (directory && posix_directory_next(directory, name, sizeof(name))) + { + size_t length = strlen(name); + struct server_gametype_file file; + char base_name[64]; + int absent; + + if (length < 6 || length - 5 >= sizeof(base_name) || strcmp(name + length - 5, ".toml")) + continue; + snprintf(base_name, sizeof(base_name), "%.*s", (int)(length - 5), name); + server_json_object_begin(json, NULL); + server_json_string(json, "name", base_name); + if (server_gametype_file_read(base_name, &file, &absent)) + { + server_json_string(json, "base", file.base); + server_json_string(json, "description", file.name); + server_json_null(json, "error"); + } + else + { + server_json_string(json, "base", ""); + server_json_string(json, "description", ""); + server_json_string(json, "error", file.error); + } + server_json_object_end(json); + } + if (directory) + posix_directory_close(directory); + server_json_array_end(json); +} + +void server_gametype_check_entry( + struct server_entry *entry) +{ + struct game_variant variant; + struct game_variant_options options; + char reason[SERVER_REASON_SIZE]; + + if (entry->rejected[0] || !strcmp(entry->gametype, "coop")) + return; + if (!server_gametype_build(entry->gametype, entry->rules, entry->rule_count, &variant, &options, reason, + sizeof(reason))) + { + snprintf(entry->rejected, sizeof(entry->rejected), "%s", reason); + } +} + +#else + +boolean server_gametype_build(char const *gametype, struct server_rule const *rules, long rule_count, + struct game_variant *variant, struct game_variant_options *options, char *reason, long reason_size) +{ + return FALSE; +} + +void server_gametype_check_entry(struct server_entry *entry) +{ +} + +char const *server_gametype_builtin_name(long index) +{ + return NULL; +} + +char const *server_gametype_engine_name(long game_engine_index) +{ + return "none"; +} + +#endif diff --git a/port/linux/game/server_gametypes.h b/port/linux/game/server_gametypes.h new file mode 100644 index 00000000..c09c5e6a --- /dev/null +++ b/port/linux/game/server_gametypes.h @@ -0,0 +1,31 @@ +/* +SERVER_GAMETYPES.H + +A dedicated server's gametypes (server_gametypes.c): a rotation entry's +gametype (a built-in's name, an OpenCE gametype's, a gametype file's, a saved +gametype's) and its rules made into the game's variant and PC options. +*/ + +#ifndef __SERVER_GAMETYPES_H +#define __SERVER_GAMETYPES_H + +#include "../src/server_rotation.h" /* port: port/linux/src/server_rotation.c */ + +struct game_variant; +struct game_variant_options; + +/* the variant and options of the gametype, the rules over it: TRUE, else +FALSE and why */ +boolean server_gametype_build(char const *gametype, struct server_rule const *rules, long rule_count, + struct game_variant *variant, struct game_variant_options *options, char *reason, long reason_size); +/* the entry rejected if its gametype and rules cannot be built (or it is a +co-op entry: those need nothing more) */ +void server_gametype_check_entry(struct server_entry *entry); +/* the built-in gametypes' names (game_engine_get_variant_by_name's and the +Xbox's other default gametypes'), and OpenCE's; NULL past the last */ +char const *server_gametype_builtin_name(long index); +/* the name of a game engine (game_engine_index): "ctf", "slayer", "oddball", +"king", "race" */ +char const *server_gametype_engine_name(long game_engine_index); + +#endif diff --git a/port/linux/game/server_status.c b/port/linux/game/server_status.c new file mode 100644 index 00000000..4b58e726 --- /dev/null +++ b/port/linux/game/server_status.c @@ -0,0 +1,677 @@ +/* +SERVER_STATUS.C + +A dedicated server's status (server_director.h; docs/dedicated_server.md, +"The status"): the server, its game and its players as JSON, for the API, +the remote console's status events, the status file, sv_status --json and +the master servers. Addresses and hardware ids are written only for the +owner, or with server.log_addresses; the invite only for admins. +*/ + +#include "cseries.h" +#include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "networking/network_server_manager.h" +#include "networking/network_server_manager_internal.h" +#include "game/game.h" +#include "game/game_engine.h" +#include "game/players.h" +#include "objects/objects.h" +#include "tag_files/tag_files.h" +#include "units/units.h" +#include "network_chat.h" +#include "network_distributed.h" +#include "server_director.h" +#include "server_gametypes.h" + +#include "../src/p2p.h" /* port: port/linux/src/p2p.c */ +#include "../src/server_console.h" /* port: port/linux/src/server_console.c */ +#include "../src/server_events.h" /* port: port/linux/src/server_events.c */ +#include "../src/server_http.h" /* port: port/linux/src/server_http.c */ +#include "../src/server_json.h" /* port: port/linux/src/server_json.c */ +#include "../src/server_master.h" /* port: port/linux/src/server_master.c */ +#include "../src/posix_server.h" /* port: port/linux/src/posix_server.c */ +#include "../src/port_config.h" /* port: port/linux/src/port_config.c */ + +#include +#include + +#if HALO_SERVER_BUILD + +/* server_director.c's */ +long long server_director_joined_time(long slot); +/* port/linux/src/updater.c's */ +long updater_server_available(void); + +enum +{ + MAXIMUM_SLOTS = HALO_PORT_MAXIMUM_NETWORK_PLAYERS, +}; + +static char const *const server_state_names[NUMBER_OF_SERVER_STATES] = + { "waiting", "hosting", "lobby", "loading", "playing", "postgame", "stopping" }; + +/* the scores last seen (server_status_scores_changed) */ +static long server_status_last_scores[MAXIMUM_SLOTS + 2]; + +char const *server_status_state_name( + short state) +{ + return state >= 0 && state < NUMBER_OF_SERVER_STATES ? server_state_names[state] : "waiting"; +} + +static struct network_game *server_game( + void) +{ + struct network_game_server *network_server = global_network_game_server_get(); + + return network_server ? network_game_server_get_game(network_server) : NULL; +} + +boolean server_status_player_name( + long slot, + char *name, + long size) +{ + struct network_game *game = server_game(); + long index; + + if (!game || slot < 0 || slot >= MAXIMUM_SLOTS || !network_player_is_valid(&game->players[slot])) + { + if (size > 0) + name[0] = 0; + return FALSE; + } + for (index = 0; index < (long)NUMBEROF(game->players[slot].name) && game->players[slot].name[index] && + index < size - 1; index++) + { + name[index] = player_name_character_ascii(game->players[slot].name[index]); + } + name[index] = 0; + return TRUE; +} + +/* (the web's: server_http.c) whether the slot holds the player of that name +(the page's view may be old); the slot's player's name in current either +way (empty for none) */ +int server_status_player_matches( + long slot, + char const *name, + char *current, + long size) +{ + if (!server_status_player_name(slot, current, size)) + return FALSE; + return !strcmp(current, name); +} + +boolean server_status_team_scores( + long *red, + long *blue) +{ + struct network_game *game = server_game(); + + if (!game || !game->variant.universal_variant.teams || !game_engine_running()) + return FALSE; + *red = game_engine_get_team_score(0); + *blue = game_engine_get_team_score(1); + return TRUE; +} + +/* the player in the running game of the network game's slot, NULL if none */ +static struct player_datum *server_status_datum( + long slot) +{ + struct network_game *game = server_game(); + + if (!game || !game_engine_running() || !network_player_is_valid(&game->players[slot])) + return NULL; + return distributed_player(game->players[slot].player_list_index); +} + +static long server_status_score( + long slot) +{ + struct network_game *game = server_game(); + + if (!server_status_datum(slot) || !game_engine || !game_engine->get_player_score) + return 0; + return game_engine->get_player_score(DATUM_INDEX_TO_ABSOLUTE_INDEX(game->players[slot].player_list_index), + _get_score_individual); +} + +boolean server_status_scores_changed( + void) +{ + boolean changed = FALSE; + long slot, red = 0, blue = 0; + + for (slot = 0; slot < MAXIMUM_SLOTS; slot++) + { + long score = server_status_score(slot); + + if (score != server_status_last_scores[slot]) + { + server_status_last_scores[slot] = score; + changed = TRUE; + } + } + server_status_team_scores(&red, &blue); + if (red != server_status_last_scores[MAXIMUM_SLOTS] || blue != server_status_last_scores[MAXIMUM_SLOTS + 1]) + { + server_status_last_scores[MAXIMUM_SLOTS] = red; + server_status_last_scores[MAXIMUM_SLOTS + 1] = blue; + changed = TRUE; + } + return changed; +} + +void server_status_player( + struct server_json *json, + long slot, + int role) +{ + struct network_game *game = server_game(); + struct network_player const *player; + struct player_datum *datum = server_status_datum(slot); + char name[16]; + + if (!server_status_player_name(slot, name, sizeof(name))) + return; + player = &game->players[slot]; + server_json_object_begin(json, NULL); + server_json_integer(json, "slot", slot); + server_json_string(json, "name", name); + server_json_integer(json, "team", game->variant.universal_variant.teams ? (long long)player->team_index : -1); + server_json_integer(json, "score", server_status_score(slot)); + server_json_integer(json, "kills", datum ? datum->statistics.kills[0] : 0); + server_json_integer(json, "deaths", datum ? datum->statistics.deaths : 0); + server_json_integer(json, "assists", datum ? datum->statistics.assists[0] : 0); + { + long ping = datum ? distributed_player_ping(player->player_list_index) : NONE; + + if (ping == NONE) + server_json_null(json, "ping"); + else + server_json_integer(json, "ping", ping); + } + server_json_integer(json, "machine", player->machine_index); + server_json_integer(json, "joined", server_director_joined_time(slot) / 1000); + { + wchar_t wide[12]; + + csmemcpy(wide, player->name, sizeof(wide)); + server_json_boolean(json, "muted", network_chat_host_name_muted(wide)); + } + if (role >= _server_role_owner || config_boolean("server.log_addresses")) + { + unsigned long address = distributed_machine_real_address(player->machine_index); + char text[32]; + char const *hardware_id = network_game_server_machine_hardware_id(player->machine_index); + + snprintf(text, sizeof(text), "%lu.%lu.%lu.%lu", (address >> 24) & 255, (address >> 16) & 255, + (address >> 8) & 255, address & 255); + server_json_string(json, "address", address ? text : ""); + server_json_string(json, "hwid", hardware_id ? hardware_id : ""); + } + server_json_object_end(json); +} + +void server_status_players( + struct server_json *json, + int role) +{ + struct network_game *game = server_game(); + long slot; + + server_json_array_begin(json, "players"); + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + { + if (network_player_is_valid(&game->players[slot])) + server_status_player(json, slot, role); + } + server_json_array_end(json); +} + +static void server_status_entry( + struct server_json *json, + char const *key, + struct server_entry const *entry) +{ + long index; + + if (!entry) + { + server_json_null(json, key); + return; + } + server_json_object_begin(json, key); + server_json_string(json, "text", entry->text); + server_json_string(json, "map", entry->map_name); + server_json_string(json, "gametype", entry->gametype); + server_json_object_begin(json, "rules"); + for (index = 0; index < entry->rule_count; index++) + server_json_integer(json, entry->rules[index].key, entry->rules[index].value); + server_json_object_end(json); + if (entry->rejected[0]) + server_json_string(json, "rejected", entry->rejected); + else + server_json_null(json, "rejected"); + server_json_object_end(json); +} + +/* the rotation: its entries, the one played and the next */ +void server_status_rotation( + struct server_json *json) +{ + struct server_rotation const *rotation = server_director_rotation(); + long index; + + server_json_integer(json, "index", server_director_index()); + server_json_array_begin(json, "entries"); + for (index = 0; index < rotation->count; index++) + server_status_entry(json, NULL, &rotation->entries[index]); + server_json_array_end(json); + server_status_entry(json, "next", server_director_next()); +} + +void server_status_identity( + struct server_json *json) +{ + char instance[64]; + char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + + server_master_identity(instance, sizeof(instance), key_id, sizeof(key_id)); + server_json_integer(json, "pid", (long long)server_mode_process_id()); + server_json_integer(json, "started", (long long)server_mode_started()); + server_json_string(json, "instance", instance); + server_json_string(json, "region", config_string("server.region")); + server_json_integer(json, "tunnel_port", config_boolean("network.online") ? config_integer("network.tunnel_port") : 0); + server_json_string(json, "publisher_key_id", key_id); + server_json_integer(json, "web_port", config_integer("server.web_port")); +} + +void server_status_write( + struct server_json *json, + int role) +{ + struct network_game *game = server_game(); + struct network_game_server *network_server = global_network_game_server_get(); + struct server_entry const *current = server_director_current(); + char variant[16] = ""; + long players = 0, slot, red, blue; + long countdown = NONE; + double longest, mean; + + for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++) + players += network_player_is_valid(&game->players[slot]) != 0; + if (game) + { + long index; + + for (index = 0; index < 11 && game->variant.human_readable_game_description[index]; index++) + variant[index] = player_name_character_ascii(game->variant.human_readable_game_description[index]); + variant[index] = 0; + } + if (network_server && server_director_state() == _server_state_lobby) + countdown = network_game_server_port_countdown_milliseconds(network_server); + server_json_object_begin(json, NULL); + server_json_boolean(json, "ok", TRUE); + server_json_integer(json, "time", posix_server_unix_milliseconds()); + server_json_string(json, "name", config_string("server.name")); + server_json_string(json, "state", server_status_state_name(server_director_state())); + server_json_string(json, "build", server_mode_build()); + server_json_integer(json, "network_version", HALO_PORT_NETWORK_VERSION); + server_json_integer(json, "uptime", (long long)server_mode_uptime()); + server_json_integer(json, "games_hosted", server_director_games_hosted()); + server_json_string(json, "map", current ? current->map_name : ""); + server_json_string(json, "level", current ? current->level : ""); + server_json_string(json, "gametype", current ? current->gametype : ""); + server_json_string(json, "variant", variant); + server_json_boolean(json, "teams", game ? game->variant.universal_variant.teams : FALSE); + server_json_integer(json, "players", players); + server_json_integer(json, "max_players", game ? game->maximum_players : config_integer("server.max_players")); + server_json_integer(json, "machines", network_server ? + network_game_server_port_remote_machine_count(network_server, TRUE) : 0); + server_json_integer(json, "countdown", countdown == NONE ? -1 : (countdown + 999) / 1000); + server_json_integer(json, "game_seconds", (long long)server_director_game_seconds()); + if (server_status_team_scores(&red, &blue)) + { + server_json_array_begin(json, "team_scores"); + server_json_integer(json, NULL, red); + server_json_integer(json, NULL, blue); + server_json_array_end(json); + } + else + { + server_json_null(json, "team_scores"); + } + server_json_object_begin(json, "listing"); + server_json_boolean(json, "online", config_boolean("network.online")); + server_json_boolean(json, "public", config_boolean("server.public")); + server_json_boolean(json, "password", config_string("server.password")[0] != 0); + { + char invite[P2P_LISTING_INVITE_SIZE] = ""; + + if (role >= _server_role_admin) + p2p_invite_link(invite, sizeof(invite)); + server_json_string(json, "invite", invite); + } + server_json_object_end(json); + server_json_object_begin(json, "rotation"); + { + struct server_rotation const *rotation = server_director_rotation(); + + server_json_integer(json, "index", server_director_index()); + server_json_integer(json, "count", rotation->count); + server_status_entry(json, "next", server_director_next()); + } + server_json_object_end(json); + server_mode_frame_times(&longest, &mean); + server_json_real(json, "frame_ms_max", longest); + server_json_real(json, "frame_ms_avg", mean); + server_json_real(json, "tick_ms_avg", mean); + server_master_status(json); + server_json_object_begin(json, "update"); + { + /* (server.auto_update's last look: port/linux/src/updater.c) */ + long available = updater_server_available(); + char build[32] = ""; + + if (available) + snprintf(build, sizeof(build), "build-%ld", available); + server_json_boolean(json, "available", available != 0); + server_json_string(json, "build", build); + } + server_json_object_end(json); + server_director_drain_json(json); + server_status_identity(json); + server_http_capabilities(json, "capabilities"); + server_json_object_end(json); +} + +/* ---------- the maps and the gametypes */ + +/* the multiplayer maps' names as the game shows them (the Xbox's, and Halo +PC's own); another map's is its file's */ +static struct +{ + char const *name; + char const *title; +} const server_map_titles[] = +{ + { "beavercreek", "Battle Creek" }, + { "bloodgulch", "Blood Gulch" }, + { "boardingaction", "Boarding Action" }, + { "carousel", "Derelict" }, + { "chillout", "Chill Out" }, + { "damnation", "Damnation" }, + { "dangercanyon", "Danger Canyon" }, + { "deathisland", "Death Island" }, + { "gephyrophobia", "Gephyrophobia" }, + { "hangemhigh", "Hang 'Em High" }, + { "icefields", "Ice Fields" }, + { "infinity", "Infinity" }, + { "longest", "Longest" }, + { "prisoner", "Prisoner" }, + { "putput", "Chiron TL-34" }, + { "ratrace", "Rat Race" }, + { "sidewinder", "Sidewinder" }, + { "timberland", "Timberland" }, + { "wizard", "Wizard" }, +}; + +static void server_status_map( + void *context, + char const *name, + char const *level, + char const *kind) +{ + struct server_json *json = context; + char const *title = name; + long index; + + /* (a Custom Edition map of a stock map's name is its own) */ + for (index = 0; !strcmp(kind, "xbox") && index < (long)NUMBEROF(server_map_titles); index++) + { + if (!strcmp(name, server_map_titles[index].name)) + title = server_map_titles[index].title; + } + server_json_object_begin(json, NULL); + server_json_string(json, "name", name); + server_json_string(json, "level", level); + server_json_string(json, "kind", kind); + server_json_string(json, "title", title); + server_json_object_end(json); +} + +void server_status_maps( + struct server_json *json) +{ + server_json_array_begin(json, "maps"); + server_maps_each(server_status_map, json); + server_json_array_end(json); +} + +void server_status_gametypes( + struct server_json *json) +{ + char const *name; + long index; + + server_json_array_begin(json, "builtin"); + for (index = 0; (name = server_gametype_builtin_name(index)) != NULL; index++) + server_json_string(json, NULL, name); + server_json_array_end(json); + server_json_object_begin(json, "schema"); + server_json_array_begin(json, "fields"); + for (index = 0; index < server_rule_definition_count(); index++) + { + struct server_rule_definition const *rule = &server_rule_definitions[index]; + + server_json_object_begin(json, NULL); + server_json_string(json, "key", rule->key); + server_json_string(json, "alias", rule->alias); + server_json_string(json, "table", rule->table); + server_json_string(json, "engine", rule->engines == _server_engine_all ? "all" : rule->table); + server_json_string(json, "type", rule->type == _server_rule_integer ? "integer" : + rule->type == _server_rule_boolean ? "boolean" : "choice"); + if (rule->type == _server_rule_integer) + { + server_json_integer(json, "min", rule->minimum); + server_json_integer(json, "max", rule->maximum); + server_json_null(json, "choices"); + } + else + { + long choice; + + server_json_null(json, "min"); + server_json_null(json, "max"); + if (rule->type == _server_rule_choice) + { + server_json_array_begin(json, "choices"); + for (choice = 0; rule->choices[choice]; choice++) + server_json_string(json, NULL, rule->choices[choice]); + server_json_array_end(json); + } + else + { + server_json_null(json, "choices"); + } + } + server_json_null(json, "default"); + server_json_string(json, "description", rule->description); + server_json_object_end(json); + } + server_json_array_end(json); + server_json_object_end(json); +} + +/* ---------- the game's events (server_director.h) */ + +/* the slot of the network game's player whose player is at the absolute +index; NONE if none */ +static long server_status_slot_of( + long absolute_index) +{ + struct network_game *game = server_game(); + long slot; + + for (slot = 0; game && absolute_index != NONE && slot < MAXIMUM_SLOTS; slot++) + { + if (network_player_is_valid(&game->players[slot]) && + DATUM_INDEX_TO_ABSOLUTE_INDEX(game->players[slot].player_list_index) == absolute_index) + { + return slot; + } + } + return NONE; +} + +/* the tag name of what killed: a unit's weapon in hand, else the object */ +static char const *server_status_killing_tag( + long object_index) +{ + struct object_datum *object = object_index != NONE ? object_try_and_get(object_index) : NULL; + + if (!object) + return NULL; + if (TEST_FLAG(_object_mask_unit, object->object.type)) + { + struct unit_datum *unit = unit_get(object_index); + long weapon_index = unit->unit.current_weapon_index != NONE ? + unit_inventory_get_weapon(object_index, unit->unit.current_weapon_index) : NONE; + struct object_datum *weapon = weapon_index != NONE ? object_try_and_get(weapon_index) : NULL; + + return weapon ? tag_get_name(weapon->definition_index) : NULL; + } + return tag_get_name(object->definition_index); +} + +void server_event_player_killed( + long killing_player_index, + long killing_object_index, + long dead_player_index, + boolean friendly_fire) +{ + struct server_json json; + char const *weapon; + + if (!halo_server_mode() || dead_player_index == NONE) + return; + weapon = server_status_killing_tag(killing_object_index); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "kill"); + server_json_integer(&json, "killer", killing_player_index != NONE ? + server_status_slot_of(DATUM_INDEX_TO_ABSOLUTE_INDEX(killing_player_index)) : NONE); + server_json_integer(&json, "victim", server_status_slot_of(DATUM_INDEX_TO_ABSOLUTE_INDEX(dead_player_index))); + if (weapon) + server_json_string(&json, "weapon", weapon); + else + server_json_null(&json, "weapon"); + server_json_boolean(&json, "friendly_fire", friendly_fire); + server_json_object_end(&json); + server_event_add("game", server_json_text(&json)); + server_json_free(&json); +} + +void server_event_chat( + short player_index, + short channel, + char const *text) +{ + static char const *const channel_names[] = { "everyone", "team", "vehicle" }; + struct server_json json; + long slot; + char name[16]; + + if (!halo_server_mode()) + return; + slot = server_status_slot_of(player_index); + server_status_player_name(slot, name, sizeof(name)); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "chat"); + server_json_string(&json, "channel", channel >= 0 && channel < (short)NUMBEROF(channel_names) ? + channel_names[channel] : "everyone"); + server_json_integer(&json, "slot", slot); + server_json_string(&json, "name", name); + server_json_string(&json, "text", text); + server_json_object_end(&json); + server_event_add("chat", server_json_text(&json)); + server_json_free(&json); +} + +void server_event_speed_hack( + long machine_index, + char const *names, + real rate, + long ahead_ticks, + char const *action) +{ + struct server_json json; + + if (!halo_server_mode()) + return; + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "speed_hack"); + server_json_integer(&json, "machine", machine_index); + server_json_string(&json, "names", names); + server_json_real(&json, "rate", rate); + server_json_integer(&json, "ahead_ticks", ahead_ticks); + server_json_string(&json, "action", action); + server_json_object_end(&json); + server_event_add("moderation", server_json_text(&json)); + server_json_free(&json); + /* (one dropped is kept out, its line in bans.txt: its players banned) */ + if (!strcmp(action, "kicked")) + server_director_player_removed(machine_index, "banned"); +} + +void server_event_map_share( + long machine_index, + char const *map, + char const *state, + unsigned long done, + unsigned long total) +{ + struct server_json json; + + if (!halo_server_mode()) + return; + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "map_share"); + server_json_integer(&json, "machine", machine_index); + server_json_string(&json, "map", map); + server_json_string(&json, "state", state); + server_json_integer(&json, "progress", total ? (long long)((double)done * 100.0 / (double)total) : 0); + server_json_object_end(&json); + server_event_add("player", server_json_text(&json)); + server_json_free(&json); +} + +#else + +void server_event_player_killed(long killing_player_index, long killing_object_index, long dead_player_index, + boolean friendly_fire) +{ +} + +void server_event_chat(short player_index, short channel, char const *text) +{ +} + +void server_event_speed_hack(long machine_index, char const *names, real rate, long ahead_ticks, char const *action) +{ +} + +void server_event_map_share(long machine_index, char const *map, char const *state, unsigned long done, + unsigned long total) +{ +} + +#endif diff --git a/port/linux/include/halo_linux_source_fixups.h b/port/linux/include/halo_linux_source_fixups.h index 9c00c7c5..8a464513 100644 --- a/port/linux/include/halo_linux_source_fixups.h +++ b/port/linux/include/halo_linux_source_fixups.h @@ -88,6 +88,23 @@ void halo_screen_anti_alias(short x0, short y0, short x1, short y1); /* display.hdr's tone mapping of a window's 3D view, before its anti-aliasing (source/render/render.c) */ void halo_screen_tone_map(short x0, short y0, short x1, short y1); +/* network.client_timeout_seconds, in milliseconds (port/linux/src/port_config.c) */ +unsigned long network_client_timeout_milliseconds(void); +/* a dedicated server (halo --server: port/linux/src/server_mode.c, +port/linux/game/server_director.c; docs/dedicated_server.md): whether this +run is one; its start, before the game's (shell_xbox.c); its frames' pace +(main.c); the game's fatal error ended with (main.c); and the console's +output, which it logs and captures for its commands' replies (terminal.c) */ +int halo_server_mode(void); +void server_mode_start(void); +void server_mode_pace_frame(void); +void server_fatal(char const *text); +void server_console_output(const char *text); +/* ... whether debug.txt's lines go to the standard error too +(server.log_debug_echo: errors.c) */ +int halo_server_debug_echo(void); +/* ... the level its rotation plays next, NULL if none (game_engine.c) */ +char const *server_director_first_level(void); /* the mouse in the menus (source/interface/ui_widget.c) */ #include "halo_ui_pointer.h" diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 3726ca00..133d18c2 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 38 +#define HALO_PORT_NETWORK_VERSION 39 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags, then the game's kind (an OpenCE gametype's mode, and Hardcore: port/linux/game/game_modes.h's game_modes_kind), for the server browser */ @@ -79,6 +79,9 @@ netcode was removed; hosts of version 4 built before then may not) */ /* ... the game is under way (loading, playing or over), not in its lobby: the menus show it before joining it (hosts built before then never set it) */ #define HALO_PORT_ADVERTISED_IN_PROGRESS_FLAG 0x02 +/* ... the host is a dedicated server, with no player of its own (halo +--server: port/linux/src/server_mode.c): the menus say so (from version 39) */ +#define HALO_PORT_ADVERTISED_DEDICATED_FLAG 0x04 /* a message header's 12-bit length allows messages of up to 0xFFF bytes, header included; the per-tick update of 128 players is 3,857 */ diff --git a/port/linux/network_format.txt b/port/linux/network_format.txt index a0245285..4866e959 100644 --- a/port/linux/network_format.txt +++ b/port/linux/network_format.txt @@ -1,4 +1,4 @@ -version 38 +version 39 bound CHAT_TEXT_SIZE 64 bound DISCORD_ID_SIZE 24 bound DISCORD_NAME_SIZE 40 diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 03a69561..349be2f5 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -929,6 +929,11 @@ static struct static void vertex_array_cache_invalidate(void); #endif +int xgpu_gl_ready(void) +{ + return device.gl_ready; +} + void xgpu_gl_state_invalidate(void) { memset(&gl_state, 0xff, sizeof(gl_state)); diff --git a/port/linux/src/hud_hires.c b/port/linux/src/hud_hires.c index ee6f5c9b..d3424225 100644 --- a/port/linux/src/hud_hires.c +++ b/port/linux/src/hud_hires.c @@ -428,11 +428,13 @@ static void upscale_outline(unsigned char *pixels, const unsigned char *bgra, unsigned int hud_hires_upscale_texture(const unsigned char *bgra, unsigned long width, unsigned long height, long kind, unsigned long *levels) { - unsigned char *pixels = kind == 4 ? upscale_pixels(bgra, width, height, 512) : - hud_hires_upscale_pixels(bgra, width, height); + unsigned char *pixels; unsigned long largest; GLuint texture; + if (!xgpu_gl_ready()) + return 0; + pixels = kind == 4 ? upscale_pixels(bgra, width, height, 512) : hud_hires_upscale_pixels(bgra, width, height); if (!pixels) return 0; upscale_outline(pixels, bgra, width, height, kind); @@ -584,8 +586,12 @@ static unsigned int png_pixels_texture(unsigned char *pixels, unsigned long widt unsigned long largest; GLuint texture; - if (!pixels) + /* (no OpenGL: a null renderer's postgame scoreboard asks for its icons) */ + if (!pixels || !xgpu_gl_ready()) + { + free(pixels); return 0; + } *levels = 1; for (largest = width > height ? width : height; largest > 1; largest >>= 1) (*levels)++; @@ -614,7 +620,7 @@ unsigned int hud_hires_override_texture(long asset, unsigned long *levels) { const struct hud_hires_embedded *embedded; - if (asset < 0 || asset >= hud_hires_asset_count() || textures[asset].failed) + if (asset < 0 || asset >= hud_hires_asset_count() || textures[asset].failed || !xgpu_gl_ready()) return 0; if (textures[asset].texture) { diff --git a/port/linux/src/master_list.c b/port/linux/src/master_list.c new file mode 100644 index 00000000..f3fe342d --- /dev/null +++ b/port/linux/src/master_list.c @@ -0,0 +1,704 @@ +/* +MASTER_LIST.C + +The server browser's master servers (master_list.h). +*/ + +#include "platform.h" +#include "port_config.h" +#include "master_list.h" +#include "p2p.h" +#include "p2p_internal.h" +#include "server_json.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) +#include +#include "update.h" +#endif + +enum +{ + /* seconds between rounds while browsing; a list older than this is + refused (a replayed one), and one dated further ahead of this machine's + clock (one made to be replayed later) */ + ROUND_SECONDS = 30, + FRESH_SECONDS = 300, + MAXIMUM_ENTRIES = 512, + MAXIMUM_ANSWER = 1024 * 1024, + MAXIMUM_LISTING = 400, +}; + +static struct +{ + pthread_mutex_t lock; + pthread_cond_t wake; + /* each opening of the browser's: a thread of an earlier one (still + asking a master when the browser was closed and opened again) ends at + its next look, without being waited for */ + unsigned long generation; + int browsing; + int refresh; + int count; + int answered; + int asked; + char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; +} list = { PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER }; + +/* waits on the condition for seconds at most */ +static void wait_seconds(int seconds) +{ + struct timespec until; + + clock_gettime(CLOCK_REALTIME, &until); + until.tv_sec += seconds; + pthread_cond_timedwait(&list.wake, &list.lock, &until); +} + +static int hex_digit(char character) +{ + if (character >= '0' && character <= '9') + return character - '0'; + if (character >= 'a' && character <= 'f') + return character - 'a' + 10; + if (character >= 'A' && character <= 'F') + return character - 'A' + 10; + return -1; +} + +/* ---------- masters.txt */ + +/* whether a master's URL may be asked: https, or plain http to this machine +alone (a test's master): http://127.0.0.1 or http://localhost, then a port, +a path or nothing (not http://127.0.0.1.example.net) */ +static int master_url_allowed(const char *url) +{ + static const char *const loopbacks[] = { "http://127.0.0.1", "http://localhost" }; + size_t index; + + if (!strncmp(url, "https://", 8)) + return 1; + for (index = 0; index < sizeof(loopbacks) / sizeof(loopbacks[0]); index++) + { + size_t length = strlen(loopbacks[index]); + + if (!strncmp(url, loopbacks[index], length) && (!url[length] || url[length] == ':' || url[length] == '/')) + return 1; + } + return 0; +} + +static char *masters_file(const char *setting, char *path, int size) +{ + char folder[1024]; + size_t length; + char *text; + const char *base; + + if (setting[0] == '/' || (setting[0] && setting[1] == ':')) + { + snprintf(path, (size_t)size, "%s", setting); + return config_file_read(path, &length); + } + config_folder(folder, sizeof(folder)); + snprintf(path, (size_t)size, "%s%s", folder, setting); + text = config_file_read(path, &length); +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) + base = SDL_GetBasePath(); + if (!text && base) + { + snprintf(path, (size_t)size, "%s%s", base, setting); + text = config_file_read(path, &length); + } +#else + (void)base; +#endif + return text; +} + +int master_list_read_file(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum) +{ + char path[1200]; + char *text = masters_file(setting, path, sizeof(path)); + const char *line; + int count = 0; + + if (!text) + return 0; + for (line = text; *line && count < maximum;) + { + const char *end = line + strcspn(line, "\n"); + char copy[512]; + char url[MASTER_LIST_URL_SIZE], key[96]; + int index, ok = 1; + + snprintf(copy, sizeof(copy), "%.*s", (int)(end - line), line); + copy[strcspn(copy, "#\r")] = 0; + line = *end ? end + 1 : end; + if (sscanf(copy, "%255s %95s", url, key) != 2) + continue; + /* (https, or plain http to this machine: a test's master) */ + if (!master_url_allowed(url) || + strncmp(key, "ed25519:", 8) || strlen(key) != 8 + 2 * MASTER_LIST_KEY_SIZE) + { + continue; + } + for (index = 0; index < MASTER_LIST_KEY_SIZE && ok; index++) + { + int high = hex_digit(key[8 + 2 * index]), low = hex_digit(key[8 + 2 * index + 1]); + + ok = high >= 0 && low >= 0; + keys[count][index] = (unsigned char)(high << 4 | low); + } + if (!ok) + continue; + while (strlen(url) > 8 && url[strlen(url) - 1] == '/') + url[strlen(url) - 1] = 0; + snprintf(urls[count], MASTER_LIST_URL_SIZE, "%s", url); + count++; + } + free(text); + return count; +} + +/* one master (a URL and its "ed25519:" key) checked and kept: 1 */ +static int master_keep(const char *url, const char *key, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int index) +{ + int digit; + + if (!master_url_allowed(url) || + strlen(url) >= MASTER_LIST_URL_SIZE || strncmp(key, "ed25519:", 8) || strlen(key) != 8 + 2 * MASTER_LIST_KEY_SIZE) + { + return 0; + } + for (digit = 0; digit < MASTER_LIST_KEY_SIZE; digit++) + { + int high = hex_digit(key[8 + 2 * digit]), low = hex_digit(key[8 + 2 * digit + 1]); + + if (high < 0 || low < 0) + return 0; + keys[index][digit] = (unsigned char)(high << 4 | low); + } + snprintf(urls[index], MASTER_LIST_URL_SIZE, "%s", url); + while (strlen(urls[index]) > 8 && urls[index][strlen(urls[index]) - 1] == '/') + urls[index][strlen(urls[index]) - 1] = 0; + return 1; +} + +/* the official master servers, asked when masters.txt names none (it is +missing, empty or has comments alone) */ +int master_list_read_defaults(char (*urls)[MASTER_LIST_URL_SIZE], unsigned char (*keys)[MASTER_LIST_KEY_SIZE], + int maximum) +{ + static const struct + { + const char *url; + const char *key; + } masters[] = + { + { "https://master.opencommunityedition.com", + "ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa" }, + }; + int index, count = 0; + + for (index = 0; index < (int)(sizeof(masters) / sizeof(masters[0])) && count < maximum; index++) + count += master_keep(masters[index].url, masters[index].key, urls, keys, count); + return count; +} + +int master_list_read(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum) +{ + int count = master_list_read_file(setting, urls, keys, maximum); + + return count ? count : master_list_read_defaults(urls, keys, maximum); +} + +int master_list_read_json(const char *text, unsigned long size, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum) +{ + struct server_json_document document; + int masters, index, count = 0; + + if (!server_json_parse(&document, text, (int)size, 8)) + return 0; + masters = server_json_member(&document, 0, "masters"); + for (index = 0; count < maximum && server_json_element(&document, masters, index) >= 0; index++) + { + int entry = server_json_element(&document, masters, index); + char url[MASTER_LIST_URL_SIZE], key[96]; + + if (server_json_get_string(&document, server_json_member(&document, entry, "url"), url, sizeof(url)) && + server_json_get_string(&document, server_json_member(&document, entry, "key"), key, sizeof(key))) + { + count += master_keep(url, key, urls, keys, count); + } + } + server_json_document_free(&document); + return count; +} + +int master_list_policy(void) +{ + const char *setting = config_string("network.listings"); + + if (!strcmp(setting, "masters")) + return _master_listings_masters; + if (!strcmp(setting, "brokers")) + return _master_listings_brokers; + return _master_listings_both; +} + +/* ---------- a list's checks */ + +int master_list_verify(const char *body, unsigned long size, const char *signature_header, const char *key_header, + const unsigned char *key, unsigned long now, char *why, int why_size) +{ + unsigned char signature[P2P_SIGNATURE_SIZE + 4]; + unsigned char digest[P2P_SHA256_SIZE]; + char key_id[2 * 8 + 1]; + char text[96]; + struct server_json_document document; + double protocol, time_value; + int master, ok; + + p2p_sha256(key, MASTER_LIST_KEY_SIZE, digest); + p2p_hex(digest, 8, key_id); + if (strncmp(signature_header, "ed25519=", 8) || + server_base64_decode(signature_header + 8, signature, sizeof(signature)) != P2P_SIGNATURE_SIZE) + { + snprintf(why, (size_t)why_size, "no signature"); + return 0; + } + /* (the key the master says it signed with, which must be the one + pinned: a list without it is refused too) */ + if (!key_header || strcmp(key_header, key_id)) + { + snprintf(why, (size_t)why_size, "signed by another key (%.16s)", key_header ? key_header : "none said"); + return 0; + } + if (!p2p_ed25519_verify(key, body, (int)size, signature)) + { + snprintf(why, (size_t)why_size, "its signature does not check"); + return 0; + } + if (!server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS) || + document.tokens[0].type != _server_json_object) + { + snprintf(why, (size_t)why_size, "not a list"); + return 0; + } + master = server_json_member(&document, 0, "master"); + ok = server_json_get_number(&document, server_json_member(&document, 0, "protocol"), &protocol) && protocol == 1 && + server_json_get_string(&document, server_json_member(&document, master, "key_id"), text, sizeof(text)) && + !strcmp(text, key_id) && + server_json_get_number(&document, server_json_member(&document, master, "time"), &time_value); + server_json_document_free(&document); + if (!ok) + { + snprintf(why, (size_t)why_size, "not a list of protocol 1 by this master"); + return 0; + } + if ((double)now - time_value > FRESH_SECONDS) + { + snprintf(why, (size_t)why_size, "a list %.0f seconds old", (double)now - time_value); + return 0; + } + if (time_value - (double)now > FRESH_SECONDS) + { + snprintf(why, (size_t)why_size, "a list dated %.0f seconds ahead (this machine's clock is wrong, or the " + "master's)", time_value - (double)now); + return 0; + } + return 1; +} + +/* ---------- fetching */ + +struct fetched +{ + int status; + char *body; + unsigned long size; + char signature[128]; + char key[32]; +}; + +/* whether the text begins with the name, in either case */ +static int begins_with_name(const char *text, const char *name, size_t length) +{ + size_t index; + + for (index = 0; index < length; index++) + { + if (!text[index] || tolower((unsigned char)text[index]) != tolower((unsigned char)name[index])) + return 0; + } + return 1; +} + +/* a response's header's value, from its "Name: value" lines */ +static void header_value(const char *headers, const char *name, char *value, int size) +{ + size_t length = strlen(name); + const char *line = headers; + + value[0] = 0; + while (line && *line) + { + if (begins_with_name(line, name, length) && line[length] == ':') + { + const char *start = line + length + 1; + int count; + + while (*start == ' ' || *start == '\t') + start++; + count = (int)strcspn(start, "\r\n"); + snprintf(value, (size_t)size, "%.*s", count, start); + return; + } + line = strchr(line, '\n'); + if (line) + line++; + } +} + +#if defined(HALO_WEB) +/* the page's fetch (port/web/src/web_library.js's web_js_fetch), which this +thread waits for: its answer in the slot. The page claims the slot for its +answer by changing its id to -id (Atomics.compareExchange), writes it, then +sets done; the thread gives up on it by changing the id to 0 the same way, +so an answer and a give-up never cross */ +struct web_fetch_slot +{ + int id; + int done; + int status; + char *body; + int size; + char signature[128]; + char key[32]; +}; +/* (url: a copy the page frees once read) */ +void web_js_fetch(char *url, struct web_fetch_slot *slot, int id); + +static int fetch(const char *url, struct fetched *fetched) +{ + /* (one slot, which the page writes into: one fetch at a time, a thread + of an earlier opening of the browser's included) */ + static pthread_mutex_t one_at_a_time = PTHREAD_MUTEX_INITIALIZER; + static struct web_fetch_slot slot; + static int next_id; + char *url_copy = strdup(url); + int id, waited, fetched_one; + + if (!url_copy) + return 0; + pthread_mutex_lock(&one_at_a_time); + /* (no answer of an earlier fetch writes to it now: it was taken, or + given up on before the page claimed it) */ + memset(&slot, 0, sizeof(slot)); + if (++next_id <= 0) + next_id = 1; + id = next_id; + __atomic_store_n(&slot.id, id, __ATOMIC_SEQ_CST); + web_js_fetch(url_copy, &slot, id); + for (waited = 0; !__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE) && waited < 500; waited++) + { + struct timespec pause = { 0, 20 * 1000 * 1000 }; + + nanosleep(&pause, NULL); + } + if (!__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE)) + { + int expected = id; + + /* (given up on: an answer coming later finds another id, and the + page drops it) */ + if (__atomic_compare_exchange_n(&slot.id, &expected, 0, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) + { + pthread_mutex_unlock(&one_at_a_time); + return 0; + } + /* (the page claimed it as the time ran out: its answer is being + written, at once) */ + while (!__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE)) + { + struct timespec pause = { 0, 1000 * 1000 }; + + nanosleep(&pause, NULL); + } + } + fetched->status = slot.status; + fetched->body = slot.body; + fetched->size = (unsigned long)slot.size; + snprintf(fetched->signature, sizeof(fetched->signature), "%s", slot.signature); + snprintf(fetched->key, sizeof(fetched->key), "%s", slot.key); + fetched_one = slot.status > 0; + pthread_mutex_unlock(&one_at_a_time); + return fetched_one; +} +#elif defined(HALO_ANDROID) +/* the app's host (port/android/host/host_https.c) */ +int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity, + int *body_size, char *error, int error_size); + +static int fetch(const char *url, struct fetched *fetched) +{ + enum { HEADERS_SIZE = 4096 }; + /* (each call's own: a thread of an earlier opening of the browser's may + be fetching too) */ + char *headers = malloc(HEADERS_SIZE); + char error[256]; + int body_size = 0; + + fetched->body = malloc(MAXIMUM_ANSWER + 1); + if (!headers || !fetched->body || !host_https_get(url, &fetched->status, headers, HEADERS_SIZE, fetched->body, + MAXIMUM_ANSWER, &body_size, error, sizeof(error))) + { + free(headers); + free(fetched->body); + fetched->body = NULL; + return 0; + } + fetched->body[body_size] = 0; + fetched->size = (unsigned long)body_size; + header_value(headers, "Signature", fetched->signature, sizeof(fetched->signature)); + header_value(headers, "Signature-Key", fetched->key, sizeof(fetched->key)); + free(headers); + return 1; +} +#else +static int fetch(const char *url, struct fetched *fetched) +{ + struct update_response response; + char error[256]; + + if (!update_https_request("GET", url, NULL, NULL, 0, MAXIMUM_ANSWER, &response, error, sizeof(error))) + { + platform_log("server browser: %s: %s", url, error); + return 0; + } + fetched->status = response.status; + fetched->body = response.body; + fetched->size = response.body_size; + header_value(response.headers, "Signature", fetched->signature, sizeof(fetched->signature)); + header_value(response.headers, "Signature-Key", fetched->key, sizeof(fetched->key)); + return 1; +} +#endif + +/* the masters to ask (the list thread's): masters.txt's; else, in the web +build, its site's masters.json (the page's list of them, which its Servers +panel reads too); else the official ones */ +static void read_masters(void) +{ + char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; + int count = master_list_read_file(config_string("network.masters_file"), urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + +#if defined(HALO_WEB) + if (!count) + { + struct fetched fetched; + + memset(&fetched, 0, sizeof(fetched)); + if (fetch("masters.json", &fetched) && fetched.status == 200) + count = master_list_read_json(fetched.body, fetched.size, urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + free(fetched.body); + } +#endif + if (!count) + count = master_list_read_defaults(urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + pthread_mutex_lock(&list.lock); + memcpy(list.urls, urls, sizeof(urls)); + memcpy(list.keys, keys, sizeof(keys)); + list.count = count; + pthread_mutex_unlock(&list.lock); +} + +/* a list's servers given to the browser: how many were taken */ +static int take_list(const char *body, unsigned long size) +{ + struct server_json_document document; + int servers, index, taken = 0; + + if (!server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS)) + return 0; + servers = server_json_member(&document, 0, "servers"); + for (index = 0; index < MAXIMUM_ENTRIES && server_json_element(&document, servers, index) >= 0; index++) + { + int entry = server_json_element(&document, servers, index); + char listing_text[600]; + unsigned char listing[MAXIMUM_LISTING]; + char publisher[64] = "", region[32] = ""; + double version = 0; + int listing_size, character, dedicated; + + /* (an entry that is not one is skipped, not the list) */ + if (!server_json_get_string(&document, server_json_member(&document, entry, "listing"), listing_text, + sizeof(listing_text))) + { + continue; + } + listing_size = server_base64_decode(listing_text, listing, sizeof(listing)); + if (listing_size <= 0) + continue; + server_json_get_string(&document, server_json_member(&document, server_json_member(&document, entry, "publisher"), + "name"), publisher, sizeof(publisher)); + server_json_get_string(&document, server_json_member(&document, entry, "region"), region, sizeof(region)); + server_json_get_number(&document, server_json_member(&document, entry, "network_version"), &version); + /* (a dedicated server's, as the entry says: a player's public game, + published with a publisher key, is not) */ + dedicated = 0; + server_json_get_boolean(&document, server_json_member(&document, entry, "dedicated"), &dedicated); + /* (the publisher's name and the region kept to printable ASCII, cut + to their sizes: shown in the ticker) */ + for (character = 0; publisher[character]; character++) + { + if ((unsigned char)publisher[character] < 0x20 || (unsigned char)publisher[character] >= 0x7F) + publisher[character] = '?'; + } + publisher[P2P_LISTING_PUBLISHER_SIZE] = 0; + for (character = 0; region[character]; character++) + { + if (!(isalnum((unsigned char)region[character]) || region[character] == '-')) + region[character] = '?'; + } + region[P2P_LISTING_REGION_SIZE] = 0; + taken += p2p_lobby_take_master_listing(listing, listing_size, publisher, region, (int)version, dedicated); + } + server_json_document_free(&document); + return taken; +} + +/* under list.lock: whether the thread of that opening goes on */ +static int list_current(unsigned long generation) +{ + return list.browsing && list.generation == generation; +} + +static void *list_thread_main(void *argument) +{ + unsigned long generation = (unsigned long)(uintptr_t)argument; + char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; + int count; + + read_masters(); + pthread_mutex_lock(&list.lock); + while (list_current(generation)) + { + int index, answered = 0; + + list.refresh = 0; + list.asked = 0; + /* (the masters as read, this thread's own copy) */ + memcpy(urls, list.urls, sizeof(urls)); + memcpy(keys, list.keys, sizeof(keys)); + count = list.count; + pthread_mutex_unlock(&list.lock); + p2p_lobby_masters_heard(); + for (index = 0; index < count; index++) + { + struct fetched fetched; + char url[MASTER_LIST_URL_SIZE + 64]; + char why[128]; + + memset(&fetched, 0, sizeof(fetched)); + snprintf(url, sizeof(url), "%s/v1/servers", urls[index]); + if (!fetch(url, &fetched)) + { + platform_log("server browser: no answer from %s", urls[index]); + } + else if (fetched.status != 200) + { + platform_log("server browser: %s answered %d", urls[index], fetched.status); + } + else if (!master_list_verify(fetched.body, fetched.size, fetched.signature, fetched.key, keys[index], + (unsigned long)time(NULL), why, sizeof(why))) + { + platform_log("server browser: %s's list refused: %s", urls[index], why); + } + else + { + int taken = take_list(fetched.body, fetched.size); + + answered++; + platform_log("server browser: %s lists %d server%s", urls[index], taken, taken == 1 ? "" : "s"); + } + free(fetched.body); + pthread_mutex_lock(&list.lock); + list.asked++; + list.answered = answered; + if (!list_current(generation)) + { + pthread_mutex_unlock(&list.lock); + break; + } + pthread_mutex_unlock(&list.lock); + } + pthread_mutex_lock(&list.lock); + list.answered = answered; + if (list_current(generation) && !list.refresh) + wait_seconds(ROUND_SECONDS); + } + pthread_mutex_unlock(&list.lock); + return NULL; +} + +void master_list_browse(int on) +{ + if (on && (master_list_policy() == _master_listings_brokers || !config_boolean("network.online"))) + on = 0; + pthread_mutex_lock(&list.lock); + if (on && !list.browsing) + { + pthread_t thread; + + list.answered = 0; + list.asked = 0; + list.browsing = 1; + list.generation++; + if (pthread_create(&thread, NULL, list_thread_main, (void *)(uintptr_t)list.generation) == 0) + pthread_detach(thread); + else + list.browsing = 0; + pthread_mutex_unlock(&list.lock); + return; + } + if (!on && list.browsing) + { + list.browsing = 0; + pthread_cond_broadcast(&list.wake); + } + pthread_mutex_unlock(&list.lock); +} + +void master_list_refresh(void) +{ + pthread_mutex_lock(&list.lock); + list.refresh = 1; + pthread_cond_broadcast(&list.wake); + pthread_mutex_unlock(&list.lock); +} + +void master_list_counts(int *count, int *answered, int *asked) +{ + pthread_mutex_lock(&list.lock); + *count = list.count; + *answered = list.answered; + *asked = list.asked; + pthread_mutex_unlock(&list.lock); +} diff --git a/port/linux/src/master_list.h b/port/linux/src/master_list.h new file mode 100644 index 00000000..931fd57b --- /dev/null +++ b/port/linux/src/master_list.h @@ -0,0 +1,68 @@ +/* +MASTER_LIST.H + +The server browser's master servers (master_list.c; docs/master_protocol.md, +"The list"): while the browser is open, each master of masters.txt is asked +for its list every 30 seconds (and on Refresh), on a thread of its own (the +official masters, built in, when masters.txt names none); a list whose +signature by the master's key (pinned in masters.txt, or built in) checks, and +that is fresh, gives its dedicated servers' host-signed listings to the +browser's games (p2p_lobby.c), each checked again there. network.listings +says whether the masters are asked at all. +*/ + +#ifndef __HALO_LINUX_MASTER_LIST_H +#define __HALO_LINUX_MASTER_LIST_H + +enum +{ + MASTER_LIST_MAXIMUM_MASTERS = 4, + MASTER_LIST_KEY_SIZE = 32, + MASTER_LIST_URL_SIZE = 256, +}; + +/* network.listings: what the server browser lists */ +enum +{ + _master_listings_both, + _master_listings_masters, + _master_listings_brokers, +}; + +/* the masters of a masters.txt (the setting's file: beside config.toml +unless a full path, else beside the executable): their URLs and Ed25519 +keys, up to maximum; how many (none: no file, or one with no master) */ +int master_list_read_file(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum); +/* ... the official master servers (built in): how many */ +int master_list_read_defaults(char (*urls)[MASTER_LIST_URL_SIZE], unsigned char (*keys)[MASTER_LIST_KEY_SIZE], + int maximum); +/* ... masters.txt's masters, else (it names none) the official ones. The +server browser and a dedicated server's publisher ask these */ +int master_list_read(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum); + +/* ... the same of a masters.json ({"masters":[{"url":…,"key":"ed25519:…"}]}, +the web site's) */ +int master_list_read_json(const char *text, unsigned long size, char (*urls)[MASTER_LIST_URL_SIZE], + unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum); + +/* the server browser open (asked every 30 seconds) or closed */ +void master_list_browse(int on); +/* Refresh: asked again now */ +void master_list_refresh(void); +/* network.listings' choice */ +int master_list_policy(void); +/* the masters in masters.txt, how many answered with a list that checked +last time, and how many were asked yet (all: a round is done) */ +void master_list_counts(int *count, int *answered, int *asked); + +/* a signed list's checks (pure: the harness tests them): the body's +signature header's value ("ed25519=") over the body with the key, +the Signature-Key header's (the key's id: none is refused), and the body's +master and time (300 seconds old at most, and ahead of now at most); 1 if +it is a list to take */ +int master_list_verify(const char *body, unsigned long size, const char *signature_header, const char *key_header, + const unsigned char *key, unsigned long now, char *why, int why_size); + +#endif diff --git a/port/linux/src/p2p.c b/port/linux/src/p2p.c index 3bccf8ef..8a4c8888 100644 --- a/port/linux/src/p2p.c +++ b/port/linux/src/p2p.c @@ -60,6 +60,7 @@ only look up and create stand-ins. #include "platform.h" #include "posix.h" +#include "server_master.h" #include "port_config.h" #include "p2p_internal.h" #include "log_address.h" @@ -420,6 +421,13 @@ static struct unsigned char join_host_hash[P2P_KEY_HASH_SIZE]; unsigned char join_token[P2P_TOKEN_SIZE]; unsigned long join_time; + /* the join's progress (p2p_join_progress): one was asked for this run; + its host answered; it was given up (JOIN_TIMEOUT); the host's game + refused it (its rejection code, -1 if not) */ + int join_started; + int join_answered; + int join_gave_up; + int join_refused; char clipboard[P2P_LINK_SIZE]; int has_clipboard; @@ -433,6 +441,14 @@ static struct int upnp_released; struct p2p_candidate upnp_candidate; unsigned long upnp_time; + /* network.public_address, looked up (an address and port joiners reach + this machine at, behind a NAT with one forwarded port): offered first; + the text it was looked up from, and when */ + struct p2p_candidate public_candidate; + int has_public; + char public_text[256]; + unsigned long public_time; + int public_logged; } p2p = { 0, 0, -1, 0, -1, .hosting_socket = -1 }; /* the proxy (its index + 1) with each local port (all of theirs are on @@ -1119,6 +1135,12 @@ int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char * p2p_key_hash(public_key, peer->key_hash); peer->connection = -1; peer->offered_time = p2p_now(); + /* (the host of the join under way answered it: p2p_join_progress) */ + if (offered == _p2p_offered_host && (p2p.joining || p2p.join_requested) && + !memcmp(peer_identifier, p2p.join_host, P2P_IDENTIFIER_SIZE)) + { + p2p.join_answered = 1; + } if (!peer->probe) { platform_log("Internet play: reaching %s %s%s", offered == _p2p_offered_host ? "host" : "player", @@ -1327,6 +1349,98 @@ static void stun_setup(void) } } +/* network.public_address ("host[:port]"; the port the router forwards to +the tunnel's, else the tunnel's own), looked up as it changes and every +PUBLIC_ADDRESS_SECONDS: a host name's address may change. Under p2p_lock, +on the p2p thread */ +static void public_address_update(void) +{ + enum { PUBLIC_ADDRESS_SECONDS = 300 }; + const char *setting = config_string("network.public_address"); + char host[256]; + const char *colon; + unsigned long port = 0, address; + + if (!setting[0]) + { + p2p.has_public = 0; + p2p.public_text[0] = 0; + return; + } + if (!strcmp(setting, p2p.public_text) && p2p.public_time && + !elapsed(p2p.public_time, PUBLIC_ADDRESS_SECONDS * 1000UL)) + { + return; + } + snprintf(p2p.public_text, sizeof(p2p.public_text), "%s", setting); + p2p.public_time = p2p_now() | 1; + snprintf(host, sizeof(host), "%s", setting); + /* (an IPv6 address, bracketed or not, is no candidate: the tunnel is + IPv4's) */ + colon = strchr(host, ':'); + if (host[0] == '[' || (colon && strchr(colon + 1, ':'))) + { + if (!p2p.public_logged) + platform_log("Internet play: network.public_address %s is IPv6, which the tunnel does not use: left " + "out", setting); + p2p.public_logged = 1; + p2p.has_public = 0; + return; + } + if (colon) + { + char *end; + + port = strtoul(colon + 1, &end, 10); + host[colon - host] = 0; + if (*end || port == 0 || port > 65535) + { + if (!p2p.public_logged) + platform_log("Internet play: network.public_address %s has no port 1 to 65535: left out", setting); + p2p.public_logged = 1; + p2p.has_public = 0; + return; + } + } + address = p2p_resolve(host); + if (!address) + { + platform_log("Internet play: cannot look up network.public_address %s", host); + p2p.has_public = 0; + return; + } + p2p.public_candidate.address = address; + p2p.public_candidate.port = port ? network_short((unsigned short)port) : p2p.tunnel_port; + if (!p2p.has_public || !p2p.public_logged) + { + char text[64]; + + platform_log("Internet play: joiners are offered network.public_address, %s", + log_address_ipv4(p2p.public_candidate.address, p2p.public_candidate.port, text, sizeof(text))); + } + p2p.has_public = 1; + p2p.public_logged = 1; +} + +/* (p2p.h) network.public_address as looked up, "a.b.c.d:port"; 0 if none */ +int p2p_public_address(char *text, int size) +{ + int has; + + pthread_mutex_lock(&p2p_lock); + has = p2p.has_public; + if (has) + { + unsigned long address = network_long(p2p.public_candidate.address); + unsigned short port = network_short(p2p.public_candidate.port); + + snprintf(text, (size_t)size, "%lu.%lu.%lu.%lu:%u", (address >> 24) & 255, (address >> 16) & 255, + (address >> 8) & 255, address & 255, (unsigned)port); + } + pthread_mutex_unlock(&p2p_lock); + return has; +} + static void stun_update(void) { int index; @@ -1453,6 +1567,9 @@ int p2p_local_candidates(struct p2p_candidate *candidates, int maximum_count) int count = 0; int index; + /* (network.public_address first: the operator's word for it) */ + if (p2p.has_public && count < maximum_count) + candidates[count++] = p2p.public_candidate; if (lan && count < maximum_count) { candidates[count].address = lan; @@ -2370,7 +2487,14 @@ static void tunnel_received(const unsigned char *packet, int size, const struct its number, can be STUN's magic cookie) */ if (size < 1 || packet[0] != TUNNEL_MAGIC) { - if (size >= 20 && packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42) + unsigned char answer[128]; + int answer_size; + + /* (a master server's probe of a dedicated server's port, answered + to where it came from: server_master.c) */ + if (server_master_probe(packet, size, from->sin_addr.s_addr, answer, &answer_size)) + posix_socket_sendto(p2p.tunnel_socket, answer, answer_size, 0, from, sizeof(*from)); + else if (size >= 20 && packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42) stun_received(packet, size, from); return; } @@ -2647,6 +2771,19 @@ static int parse_invite(const char *text, unsigned char *host_hash, unsigned cha (p2p_join_listing), till another invite is joined: its identifier */ static unsigned char listed_host[P2P_IDENTIFIER_SIZE]; static int listed_host_known; +/* the listing joined is a dedicated server's that a master server lists (an +approved operator's, by its key: a player's host cannot say it is one) */ +static int listed_host_dedicated; + +/* a new join's progress (p2p_join_progress): answered, its host reached +already (a probe's tunnel) */ +static void join_progress_begin(int answered) +{ + p2p.join_started = 1; + p2p.join_answered = answered; + p2p.join_gave_up = 0; + p2p.join_refused = -1; +} /* under p2p_lock: parse_invite's result */ static int join_invite(const char *text) @@ -2662,6 +2799,7 @@ static int join_invite(const char *text) return parsed; /* (any invite's game is no listed one, unless p2p_join_listing says) */ listed_host_known = 0; + listed_host_dedicated = 0; p2p_identifier_from_hash(hash, host); if (!memcmp(host, identifier, P2P_IDENTIFIER_SIZE)) return 1; @@ -2683,6 +2821,7 @@ static int join_invite(const char *text) memcpy(p2p.join_host, host, sizeof(host)); memcpy(p2p.join_host_hash, hash, sizeof(hash)); memcpy(p2p.join_token, token, sizeof(token)); + join_progress_begin(1); platform_log("Internet play: joining %s's game, reached already; it is listed under Multiplayer, " "System Link", find_peer(host)->name); return 1; @@ -2700,6 +2839,7 @@ static int join_invite(const char *text) memcpy(p2p.join_host_hash, hash, sizeof(hash)); memcpy(p2p.join_token, token, sizeof(token)); p2p.join_requested = 1; + join_progress_begin(0); return 1; } @@ -2755,6 +2895,7 @@ int p2p_join_listing(const struct p2p_listing *listing) { p2p_identifier_from_hash(hash, listed_host); listed_host_known = 1; + listed_host_dedicated = listing->dedicated && listing->master; } pthread_mutex_unlock(&p2p_lock); return 1; @@ -2774,6 +2915,58 @@ int p2p_joined_listed_host(void) return reached; } +int p2p_joined_listed_dedicated(void) +{ + int dedicated; + + if (!p2p_joined_listed_host()) + return 0; + pthread_mutex_lock(&p2p_lock); + dedicated = listed_host_dedicated; + pthread_mutex_unlock(&p2p_lock); + return dedicated; +} + +int p2p_join_progress(int *stage, char *reason, int size) +{ + /* (the game's rejection codes in their order: network_game_protocol.h's + version too old, too new, bad join token, bad password, full, closed, + blacklisted machine) */ + static const char *const refusals[] = { "version", "version", "token", "password", "full", "closed", "banned" }; + struct peer *peer; + const char *why = ""; + + pthread_mutex_lock(&p2p_lock); + peer = p2p.join_started ? find_peer(p2p.join_host) : NULL; + *stage = P2P_JOIN_NONE; + if (p2p.join_started && p2p.join_refused >= 0) + { + *stage = P2P_JOIN_FAILED; + why = p2p.join_refused < (int)(sizeof(refusals) / sizeof(*refusals)) ? refusals[p2p.join_refused] : "closed"; + } + else if (peer && peer->connected && peer->is_host && !peer->is_player) + *stage = P2P_JOIN_CONNECTED; + else if (p2p.joining || p2p.join_requested) + *stage = peer || p2p.join_answered ? P2P_JOIN_ANSWERED : P2P_JOIN_ASKING; + else if (p2p.join_started && p2p.join_gave_up) + { + *stage = P2P_JOIN_FAILED; + why = p2p.join_answered ? "no_route" : "no_answer"; + } + pthread_mutex_unlock(&p2p_lock); + if (reason && size > 0) + snprintf(reason, (size_t)size, "%s", why); + return *stage; +} + +void p2p_join_refused(int rejection_code) +{ + pthread_mutex_lock(&p2p_lock); + if (p2p.join_started && rejection_code >= 0) + p2p.join_refused = rejection_code; + pthread_mutex_unlock(&p2p_lock); +} + void p2p_invite_received(const char *text) { /* (an older version's is logged as such) */ @@ -2805,6 +2998,7 @@ static void update_joining(void) else if (p2p.joining && elapsed(p2p.join_time, JOIN_TIMEOUT)) { p2p.joining = 0; + p2p.join_gave_up = 1; p2p_signal_stop_joining(); platform_log("Internet play: no answer from the invite's host; it may have stopped hosting or quit"); } @@ -2899,6 +3093,12 @@ static void make_invite(void) snprintf(p2p.invite, sizeof(p2p.invite), "halo://join/%s", text); } +void p2p_token_listed(const unsigned char *token) +{ + if (p2p.has_token && !memcmp(token, p2p.token, P2P_TOKEN_SIZE)) + p2p.token_listed = 1; +} + void p2p_new_invite_if_listed(void) { if (!p2p.has_token || !p2p.token_listed) @@ -3626,6 +3826,7 @@ static void *p2p_thread(void *unused) update_probes(); webrtc_busy = p2p_webrtc_update(); expire_proxies(); + public_address_update(); stun_update(); update_hosting(); /* the server browser (p2p_lobby.c): signalling while browsing too */ diff --git a/port/linux/src/p2p.h b/port/linux/src/p2p.h index e860210e..582d4705 100644 --- a/port/linux/src/p2p.h +++ b/port/linux/src/p2p.h @@ -100,7 +100,8 @@ reached only by its invite. Going private makes a new invite, if the old one was listed */ void p2p_set_hosting_public(int public); /* whether the game this machine hosts is listed in everyone's server -browser (PUBLIC, and network.public_lobby): the text chat is off in one */ +browser (PUBLIC, and network.public_lobby): the text chat is off in one a +player hosts (a dedicated server's follows network.text_chat) */ int p2p_hosting_listed(void); /* the password of the game hosted for the internet (Server Setup's PASSWORD; NULL or empty: none): its listing lets only those who know it @@ -130,6 +131,9 @@ enum P2P_LISTING_SIGNING_KEY_SIZE = 32, P2P_LISTING_KEY_HASH_SIZE = 16, P2P_LISTING_SEALED_TOKEN_SIZE = 56, + /* a master server's word on a listed server (master_list.c) */ + P2P_LISTING_PUBLISHER_SIZE = 32, + P2P_LISTING_REGION_SIZE = 16, }; struct p2p_listing @@ -155,6 +159,16 @@ struct p2p_listing unsigned char key_hash[P2P_LISTING_KEY_HASH_SIZE]; unsigned char signing_key[P2P_LISTING_SIGNING_KEY_SIZE]; unsigned char sealed_token[P2P_LISTING_SEALED_TOKEN_SIZE]; + /* a dedicated server's (its listing's flag, or a master's word); listed + by the master servers (master_list.c): by how many, its publisher's + name and its region (empty: none said) */ + unsigned char dedicated; + unsigned char master; + unsigned char master_count; + char publisher[P2P_LISTING_PUBLISHER_SIZE + 1]; + char region[P2P_LISTING_REGION_SIZE + 1]; + /* the network version its host plays (another's cannot be joined) */ + unsigned short network_version; }; /* joins a public game the server browser found (p2p_join_invite with its invite), remembering its host: nonzero if it held an invite */ @@ -162,16 +176,123 @@ int p2p_join_listing(const struct p2p_listing *listing); /* whether this machine is in the public game it joined from the server browser: the tunnel still reaches its host, and no other invite was joined since. Such a game has no text chat, whatever its host says -(port/linux/game/network_chat.c) */ +(port/linux/game/network_chat.c), unless it is a dedicated server's: */ int p2p_joined_listed_host(void); +/* whether the public game joined (p2p_joined_listed_host) is a dedicated +server's that a master server lists: an approved operator's, whose chat +follows its network.text_chat (a listing's own dedicated flag, which any +host may set, is not enough) */ +int p2p_joined_listed_dedicated(void); /* a copy of the public games found, in the order shown (the most players first; then those not failed, the open ones, by name); returns their count */ int p2p_lobby_games(struct p2p_listing *games, int maximum_count); +/* the master servers' (port/linux/src/master_list.c, server_master.c): a +fresh signed listing of the game this machine hosts for the internet, as +the brokers get it (0 if it hosts none, or none public); a master's entry's +listing taken into the server browser's games (its signature checked) with +the master's word on it, 1 if it was; and the masters' lists fetched again +(each game's count of them starts over) */ +int p2p_lobby_host_listing(unsigned char *bytes, int size); +/* network.public_address as last looked up (p2p.c), "a.b.c.d:port": 0 if it +is unset or cannot be looked up (an IPv6 one, which the tunnel does not use) */ +int p2p_public_address(char *text, int size); +int p2p_lobby_take_master_listing(const unsigned char *payload, int size, const char *publisher, const char *region, + int network_version_shown, int dedicated); +void p2p_lobby_masters_heard(void); /* how many hosts list a game of a newer network version, which is not shown (an old build sees none of the games) */ int p2p_lobby_newer_games(void); /* a game the browser could not join: kept, marked failed, for this run */ void p2p_lobby_mark_failed(const unsigned char *identifier); +/* whether a signalling broker answers (the player-hosted games' lists come +through them) */ +int p2p_lobby_brokers_reached(void); + +/* ---- what the server browser shows of the games found, and in what order: +its source (its top-left tab), Filters and the column titles (the menus' +network.browser_* settings). Pure, over a copy of the games (the harness +tests them) */ + +/* network.browser_source */ +enum +{ + P2P_LOBBY_SOURCE_ALL, + P2P_LOBBY_SOURCE_DEDICATED, + P2P_LOBBY_SOURCE_PLAYERS, +}; +/* network.browser_maps: games on maps this machine lacks shown, not, or +those it could download as it joins */ +enum +{ + P2P_LOBBY_MAPS_YES, + P2P_LOBBY_MAPS_NO, + P2P_LOBBY_MAPS_DOWNLOADABLE, +}; +/* what a game's map is to this machine */ +enum +{ + P2P_LOBBY_MAP_INSTALLED, + P2P_LOBBY_MAP_DOWNLOADABLE, + P2P_LOBBY_MAP_MISSING, +}; +/* network.browser_sort's orders (a column title's), each in its own first +direction: dedicated servers first, then the most players, then by name +(servers); the most players; by name, map or type (A to Z); those with a +password first */ +enum +{ + P2P_LOBBY_SORT_SERVERS, + P2P_LOBBY_SORT_PLAYERS, + P2P_LOBBY_SORT_NAME, + P2P_LOBBY_SORT_MAP, + P2P_LOBBY_SORT_TYPE, + P2P_LOBBY_SORT_LOCKED, + NUMBER_OF_P2P_LOBBY_SORTS, +}; +struct p2p_lobby_choices +{ + int source; + int show_empty, show_full; + /* one game engine's only (1 CTF, 2 Slayer, 3 Oddball, 4 King, 5 Race), + 0 any */ + int engine_type; + int maps; +}; +/* the games the choices show, kept at the front in their order; returns +how many. map_state tells what a game's map is to this machine (NULL: every +map installed). A game of another network version is kept: shown, greyed, +not joinable */ +int p2p_lobby_filter(struct p2p_listing *games, int count, const struct p2p_lobby_choices *choices, + int (*map_state)(const char *map)); +/* the games in the order's (reverse: the other way), those of another +network version last; equal ones keep their order */ +void p2p_lobby_sort(struct p2p_listing *games, int count, int order, int reverse); +/* network.browser_sort's text: an order's name, "-" before it reversed +("-players"); an unknown one is servers' */ +int p2p_lobby_sort_parse(const char *text, int *reverse); +void p2p_lobby_sort_text(int order, int reverse, char *text, int size); + +/* the join of an invite (or a listing) under way: its stage, and once it +failed why, as a word ("" while it goes on): "no_answer" (the host never +answered), "no_route" (it answered, and the tunnel never reached it); or +the host's game refused it (p2p_join_refused): "full", "closed", "banned", +"password", "version" or "token" (its invite was replaced). Returns the +stage */ +enum +{ + P2P_JOIN_NONE, + /* asking the brokers for the host */ + P2P_JOIN_ASKING, + /* the host answered: the tunnel being made (hole punching) */ + P2P_JOIN_ANSWERED, + /* the tunnel reaches the host: the game's own join follows */ + P2P_JOIN_CONNECTED, + P2P_JOIN_FAILED, +}; +int p2p_join_progress(int *stage, char *reason, int size); +/* the host of the game joined refused it (the game's rejection code: +network_game_protocol.h's _rejection_code_*), the game's client says */ +void p2p_join_refused(int rejection_code); /* a locked game's invite (into its invite), if the password is its own: FALSE if not; TRUE for a game with none. It takes a few milliseconds */ int p2p_listing_unlock(struct p2p_listing *listing, const char *password); diff --git a/port/linux/src/p2p_internal.h b/port/linux/src/p2p_internal.h index 85b56a87..36d0a4af 100644 --- a/port/linux/src/p2p_internal.h +++ b/port/linux/src/p2p_internal.h @@ -132,6 +132,10 @@ void p2p_invite_received(const char *text); server browser (going private: those who saw it must not get in); under p2p_lock */ void p2p_new_invite_if_listed(void); +/* the token of the game hosted was listed elsewhere than the brokers (a +master server's listing: p2p_lobby_host_listing), so going private makes a +new invite too; under p2p_lock */ +void p2p_token_listed(const unsigned char *token); /* ---------- p2p_signal.c: signalling through public MQTT brokers */ diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c index 8502fedb..1bc3c2fd 100644 --- a/port/linux/src/p2p_lobby.c +++ b/port/linux/src/p2p_lobby.c @@ -66,6 +66,9 @@ game with a password is not: its token is sealed. #include "port_config.h" #include "p2p_internal.h" #include "halo_port_limits.h" +#include "server_mode.h" +#include "server_master.h" +#include "master_list.h" #include #include @@ -82,6 +85,8 @@ enum _listing_closed = 8, /* its token sealed with a password's key */ _listing_password = 16, + /* a dedicated server's (halo --server: server_mode.c) */ + _listing_dedicated = 32, STAMP_SIZE = 8, /* the signed part's end: everything but the signature */ MAXIMUM_LISTING_SIZE = 2 + 1 + 2 + 1 + 4 + 4 + P2P_KEY_SIZE + P2P_SEALED_TOKEN_SIZE + 3 + @@ -149,6 +154,13 @@ struct game int ping; int probing; unsigned long probed_time; + /* listed by a master server (master_list.c) lately: not probed (the + player's address is not told to every server listed), and when */ + int master; + /* (a master's entry's word that the game is a dedicated server's: a + player's public game a publisher key publishes is not) */ + int master_dedicated; + unsigned long master_time; }; struct tombstone @@ -178,9 +190,15 @@ static struct int kind; int flags; int player_count, maximum_player_count; + /* the counts the last publish was made with (a change publishes again) */ + int published_player_count, published_maximum_player_count; /* the password's key (p2p_set_hosting_password), if it has one */ int has_password; unsigned char password_key[P2P_PASSWORD_KEY_SIZE]; + /* hosting for the internet: its invite's token, for the master servers' + listing too (p2p_lobby_host_listing) */ + int hosting_token_valid; + unsigned char hosting_token[P2P_TOKEN_SIZE]; /* listed: with this token; its listing as last published */ int listed; unsigned char token[P2P_TOKEN_SIZE]; @@ -280,8 +298,8 @@ static int put_text(unsigned char *bytes, const char *text, int maximum) } /* the listing of the game hosted, signed: flags (_listing_closed for a -tombstone) */ -static int listing_make(unsigned char *bytes, int flags) +tombstone), with the invite's token */ +static int listing_make_token(unsigned char *bytes, int flags, const unsigned char *token) { unsigned char *signed_part = bytes; unsigned char data[sizeof(signature_label) - 1 + MAXIMUM_LISTING_SIZE]; @@ -289,6 +307,8 @@ static int listing_make(unsigned char *bytes, int flags) if (lobby.has_password && !(flags & _listing_closed)) flags |= _listing_password; + if (halo_server_mode()) + flags |= _listing_dedicated; bytes[size++] = 'H'; bytes[size++] = 'L'; bytes[size++] = LISTING_FORMAT; @@ -303,7 +323,7 @@ static int listing_make(unsigned char *bytes, int flags) size += P2P_KEY_SIZE; if (flags & _listing_password) { - p2p_seal_token(lobby.password_key, p2p_signing_key(), lobby.token, bytes + size); + p2p_seal_token(lobby.password_key, p2p_signing_key(), token, bytes + size); size += P2P_SEALED_TOKEN_SIZE; } else @@ -312,7 +332,7 @@ static int listing_make(unsigned char *bytes, int flags) if (flags & _listing_closed) memset(bytes + size, 0, P2P_TOKEN_SIZE); else - memcpy(bytes + size, lobby.token, P2P_TOKEN_SIZE); + memcpy(bytes + size, token, P2P_TOKEN_SIZE); size += P2P_TOKEN_SIZE; } bytes[size++] = (unsigned char)(lobby.player_count > 255 ? 255 : lobby.player_count); @@ -330,6 +350,11 @@ static int listing_make(unsigned char *bytes, int flags) return size + P2P_SIGNATURE_SIZE; } +static int listing_make(unsigned char *bytes, int flags) +{ + return listing_make_token(bytes, flags, lobby.token); +} + static int get_text(const unsigned char *bytes, int size, int *offset, char *text, int maximum) { int length; @@ -445,18 +470,33 @@ static void stop_listing(void) static void update_hosting(const unsigned char *token, int player_count, int maximum_player_count) { - int want = token && lobby.public && config_boolean("network.public_lobby"); + /* (a dedicated server is listed by the master servers, not among the + games players host: server_master.c; server.list_on_brokers, until + there are masters, lists it here too) */ + int want = token && lobby.public && config_boolean("network.public_lobby") && + (!halo_server_mode() || config_boolean("server.list_on_brokers")); + + lobby.hosting_token_valid = token != NULL; + if (token) + memcpy(lobby.hosting_token, token, P2P_TOKEN_SIZE); + if (token) + { + lobby.player_count = player_count; + lobby.maximum_player_count = maximum_player_count; + } if (lobby.listed && (!want || memcmp(token, lobby.token, P2P_TOKEN_SIZE))) stop_listing(); if (!want) return; - if (player_count != lobby.player_count || maximum_player_count != lobby.maximum_player_count) + if (player_count != lobby.published_player_count || maximum_player_count != lobby.published_maximum_player_count) { - lobby.player_count = player_count; - lobby.maximum_player_count = maximum_player_count; + lobby.published_player_count = player_count; + lobby.published_maximum_player_count = maximum_player_count; if (lobby.listed) republish_soon(0); + /* (and to the master servers, if they list it) */ + server_master_changed(); } if (!lobby.listed) { @@ -676,6 +716,8 @@ static void listing_take(const struct queued *queued, const struct listing *list shown->open = (listing->flags & _listing_open) != 0; shown->in_progress = (listing->flags & _listing_in_progress) != 0; shown->has_teams = (listing->flags & _listing_has_teams) != 0; + shown->dedicated = (listing->flags & _listing_dedicated) != 0 || (game->master && game->master_dedicated); + shown->network_version = (unsigned short)listing->version; } /* the hosts' pings: those not known lately probed, while there is room (a @@ -689,7 +731,7 @@ static void update_probes(void) { struct game *game = &lobby.games[index]; - if (!game->used || !game->has_token || game->probing || + if (!game->used || !game->has_token || game->probing || game->master || (game->probed_time && !elapsed(game->probed_time, PROBE_REFRESH))) { continue; @@ -784,6 +826,11 @@ static void update_browsing(void) { if (lobby.games[index].used && elapsed(lobby.games[index].heard_time, GAME_EXPIRY)) lobby.games[index].used = 0; + if (lobby.games[index].used && lobby.games[index].master && elapsed(lobby.games[index].master_time, GAME_EXPIRY)) + { + lobby.games[index].master = 0; + lobby.games[index].listing.master = 0; + } if (lobby.newer[index].used && elapsed(lobby.newer[index].heard_time, GAME_EXPIRY)) lobby.newer[index].used = 0; } @@ -810,6 +857,90 @@ void p2p_lobby_probed(const unsigned char *identifier, int ping) } } +/* ---------- the master servers' (master_list.c, server_master.c) */ + +int p2p_lobby_host_listing(unsigned char *bytes, int size) +{ + int made = 0; + + if (size < MAXIMUM_LISTING_SIZE) + return 0; + pthread_mutex_lock(&p2p_lock); + /* (public, as the brokers' listing is: network.public_lobby too) */ + if (lobby.hosting_token_valid && lobby.public && config_boolean("network.public_lobby")) + { + made = listing_make_token(bytes, lobby.flags, lobby.hosting_token); + /* (its token listed: going private makes a new invite, p2p.c) */ + p2p_token_listed(lobby.hosting_token); + } + pthread_mutex_unlock(&p2p_lock); + return made; +} + +int p2p_lobby_take_master_listing(const unsigned char *payload, int size, const char *publisher, const char *region, + int network_version_shown, int dedicated) +{ + struct listing listing; + struct queued queued; + struct game *game; + int taken = 0; + + (void)network_version_shown; + if (size < MINIMUM_LISTING_SIZE || size > MAXIMUM_LISTING_SIZE || !listing_read(payload, size, &listing) || + (listing.flags & _listing_closed) || !signing_key_hash(listing.key, queued.key_hash) || + !listing_signed(payload, &listing)) + { + return 0; + } + memcpy(queued.payload, payload, (size_t)size); + queued.size = size; + queued.retained = 0; + pthread_mutex_lock(&p2p_lock); + if (lobby.browsing) + { + game = find_game(queued.key_hash); + /* (the same listing again: heard; a newer one: taken; another + version's is shown, greyed, by the menus: network_version) */ + if (game && game->payload_size == size && !memcmp(game->payload, payload, (size_t)size)) + { + game->heard_time = p2p_now(); + } + else + { + if (game) + game->master = 1; + listing_take(&queued, &listing); + game = find_game(queued.key_hash); + } + if (game) + { + game->master = 1; + game->master_dedicated = dedicated != 0; + game->master_time = p2p_now(); + game->listing.dedicated = (listing.flags & _listing_dedicated) != 0 || dedicated; + game->listing.master = 1; + snprintf(game->listing.publisher, sizeof(game->listing.publisher), "%s", publisher ? publisher : ""); + snprintf(game->listing.region, sizeof(game->listing.region), "%s", region ? region : ""); + if (game->listing.master_count < 255) + game->listing.master_count++; + taken = 1; + } + } + pthread_mutex_unlock(&p2p_lock); + return taken; +} + +void p2p_lobby_masters_heard(void) +{ + int index; + + /* (each game's count of the masters listing it, counted again) */ + pthread_mutex_lock(&p2p_lock); + for (index = 0; index < MAXIMUM_GAMES; index++) + lobby.games[index].listing.master_count = 0; + pthread_mutex_unlock(&p2p_lock); +} + /* ---------- p2p.c's side */ void p2p_lobby_update(const unsigned char *token, int player_count, int maximum_player_count) @@ -836,12 +967,18 @@ void p2p_lobby_quit(void) void p2p_set_hosting_public(int public) { + int changed; + pthread_mutex_lock(&p2p_lock); /* (a new invite going private: p2p.c) */ if (lobby.public && !public) p2p_new_invite_if_listed(); + changed = lobby.public != (public ? 1 : 0); lobby.public = public ? 1 : 0; pthread_mutex_unlock(&p2p_lock); + /* (the master servers told at once: listed, or withdrawn) */ + if (changed) + server_master_changed(); } int p2p_hosting_listed(void) @@ -911,6 +1048,7 @@ void p2p_set_game_listing(const char *name, const char *map, const char *gametyp if (lobby.listed) republish_soon(0); pthread_mutex_unlock(&p2p_lock); + server_master_changed(); } void p2p_lobby_browse(int on) @@ -930,6 +1068,8 @@ void p2p_lobby_browse(int on) p2p_signal_lobby_topics(lobby.listed, on); } pthread_mutex_unlock(&p2p_lock); + /* (the master servers' lists too, while the browser is open) */ + master_list_browse(on); } void p2p_lobby_refresh(void) @@ -946,6 +1086,7 @@ void p2p_lobby_refresh(void) lobby.games[index].probed_time = 0; } pthread_mutex_unlock(&p2p_lock); + master_list_refresh(); } int p2p_lobby_browsing(void) @@ -959,6 +1100,8 @@ static int listing_order(const void *first, const void *second) { const struct p2p_listing *a = first, *b = second; + if (a->dedicated != b->dedicated) + return b->dedicated - a->dedicated; if (a->player_count != b->player_count) return b->player_count - a->player_count; if (a->failed != b->failed) @@ -1029,3 +1172,148 @@ void p2p_lobby_mark_failed(const unsigned char *identifier) memcpy(lobby.failed[lobby.failed_count++], identifier, P2P_IDENTIFIER_SIZE); pthread_mutex_unlock(&p2p_lock); } + +int p2p_lobby_brokers_reached(void) +{ + int reached; + + pthread_mutex_lock(&p2p_lock); + reached = p2p_signal_connected(); + pthread_mutex_unlock(&p2p_lock); + return reached; +} + +/* ---------- what the server browser shows (pure: the harness tests these) */ + +static const char *const sort_names[NUMBER_OF_P2P_LOBBY_SORTS] = +{ + "servers", "players", "name", "map", "type", "locked", +}; + +int p2p_lobby_filter(struct p2p_listing *games, int count, const struct p2p_lobby_choices *choices, + int (*map_state)(const char *map)) +{ + int read, written = 0; + + for (read = 0; read < count; read++) + { + const struct p2p_listing *game = &games[read]; + int state = map_state ? map_state(game->map) : P2P_LOBBY_MAP_INSTALLED; + + if ((choices->source == P2P_LOBBY_SOURCE_DEDICATED && !game->dedicated) || + (choices->source == P2P_LOBBY_SOURCE_PLAYERS && game->dedicated)) + { + continue; + } + if (!choices->show_empty && game->player_count == 0) + continue; + if (!choices->show_full && game->maximum_player_count && game->player_count >= game->maximum_player_count) + continue; + if (choices->engine_type && game->engine_type != choices->engine_type) + continue; + if ((choices->maps == P2P_LOBBY_MAPS_NO && state != P2P_LOBBY_MAP_INSTALLED) || + (choices->maps == P2P_LOBBY_MAPS_DOWNLOADABLE && state == P2P_LOBBY_MAP_MISSING)) + { + continue; + } + if (written != read) + games[written] = games[read]; + written++; + } + return written; +} + +/* names A to Z, whatever their letters' case */ +static int text_order(const char *first, const char *second) +{ + for (;; first++, second++) + { + int a = *first >= 'A' && *first <= 'Z' ? *first + 'a' - 'A' : (unsigned char)*first; + int b = *second >= 'A' && *second <= 'Z' ? *second + 'a' - 'A' : (unsigned char)*second; + + if (a != b || !a) + return a - b; + } +} + +/* the order's: below 0 if first comes first */ +static int sort_order(const struct p2p_listing *first, const struct p2p_listing *second, int order) +{ + int difference = 0; + + switch (order) + { + case P2P_LOBBY_SORT_PLAYERS: + difference = second->player_count - first->player_count; + break; + case P2P_LOBBY_SORT_MAP: + difference = text_order(first->map, second->map); + break; + case P2P_LOBBY_SORT_TYPE: + difference = first->engine_type != second->engine_type ? first->engine_type - second->engine_type : + first->kind != second->kind ? first->kind - second->kind : text_order(first->gametype, second->gametype); + break; + case P2P_LOBBY_SORT_LOCKED: + difference = second->locked - first->locked; + break; + case P2P_LOBBY_SORT_SERVERS: + /* (as p2p_lobby_games has them: then those joining has not failed, + and the open ones) */ + difference = second->dedicated - first->dedicated; + if (!difference) + difference = second->player_count - first->player_count; + if (!difference) + difference = first->failed - second->failed; + if (!difference) + difference = second->open - first->open; + break; + } + return difference ? difference : text_order(first->name, second->name); +} + +void p2p_lobby_sort(struct p2p_listing *games, int count, int order, int reverse) +{ + int index; + + /* (an insertion sort: stable, and the games are a few hundred at most) */ + for (index = 1; index < count; index++) + { + struct p2p_listing game = games[index]; + int other_version = game.network_version != HALO_PORT_NETWORK_VERSION; + int place = index; + + while (place > 0) + { + const struct p2p_listing *before = &games[place - 1]; + int before_other = before->network_version != HALO_PORT_NETWORK_VERSION; + int difference = sort_order(before, &game, order); + + if (before_other != other_version ? !before_other : (reverse ? -difference : difference) <= 0) + break; + games[place] = games[place - 1]; + place--; + } + games[place] = game; + } +} + +int p2p_lobby_sort_parse(const char *text, int *reverse) +{ + int order; + + *reverse = text && *text == '-'; + for (order = 0; text && order < NUMBER_OF_P2P_LOBBY_SORTS; order++) + { + if (!strcmp(text + *reverse, sort_names[order])) + return order; + } + *reverse = 0; + return P2P_LOBBY_SORT_SERVERS; +} + +void p2p_lobby_sort_text(int order, int reverse, char *text, int size) +{ + if (order < 0 || order >= NUMBER_OF_P2P_LOBBY_SORTS) + order = P2P_LOBBY_SORT_SERVERS; + snprintf(text, (size_t)size, "%s%s", reverse ? "-" : "", sort_names[order]); +} diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index f2e8afc7..c8759238 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -12,6 +12,11 @@ it exists, so that the player's edits and comments stay. #include "platform.h" #include "port_config.h" +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) +/* (the command line: --config, --server, --probe...) */ +#include "posix.h" +#include "posix_server.h" +#endif #include "tomlc17.h" #include @@ -56,6 +61,9 @@ enum _platform_windows = 4, /* (the web build, which has the desktop's settings, and these) */ _platform_web = 8, + /* (a dedicated server's, of the Linux and Windows builds: written into + config.toml only in server mode) */ + _platform_server = 16, }; struct config_setting @@ -471,6 +479,13 @@ static const struct config_setting config_settings[] = "A Halo Custom Edition install whose maps folder is looked in after the\n" "custom_maps folder for Custom Edition maps and their bitmaps.map,\n" "sounds.map and loc.map (game.custom_edition); empty for none." }, + { "paths.logs", _config_string, "\"\"", "HALO_LOG_ROOT", _environment_value, _platform_desktop, + "Where debug.txt, bans.txt and cheaters.txt go; empty for the data\n" + "folder. Lets the data folder be read-only (a dedicated server's maps\n" + "mounted so)." }, + { "paths.custom_maps", _config_string, "\"\"", "HALO_CUSTOM_MAPS_ROOT", _environment_value, _platform_desktop, + "The custom_maps folder (Custom Edition maps, and their bitmaps.map,\n" + "sounds.map and loc.map); empty for the one in the data folder." }, { "network.address", _config_string, "\"\"", "HALO_NET_ADDRESS", _environment_value, _platform_all, "This machine's IPv4 address for system link, for a machine on several\n" @@ -491,6 +506,12 @@ static const struct config_setting config_settings[] = { "network.tunnel_port", _config_integer, "0", "HALO_NET_TUNNEL_PORT", _environment_value, _platform_all, "The UDP port internet play uses; 0 picks one. A fixed one can be\n" "forwarded on the router, for networks whose NAT stops connections." }, + { "network.client_timeout_seconds", _config_integer, "15", "HALO_NET_CLIENT_TIMEOUT_SECONDS", _environment_value, + _platform_all, + "Seconds a host waits for a machine it hears nothing from before it\n" + "drops it (5 to 300; the stock game's 15). Longer keeps players whose\n" + "connection stalls (a browser tab in the background) at the cost of\n" + "frozen players for longer when one really goes." }, { "network.allow_upnp", _config_boolean, "true", "HALO_NET_ALLOW_UPNP", _environment_value, _platform_all, "Let internet play ask the router (UPnP) to forward its port, for\n" "networks whose NAT stops connections: when a player joins this\n" @@ -524,8 +545,9 @@ static const struct config_setting config_settings[] = "to be heard by proximity voice chat (5 to 100)." }, { "network.text_chat", _config_string, "\"on\"", "HALO_NET_TEXT_CHAT", _environment_value, _platform_all, "Hosting: the text chat: \"on\" (links in lines are refused), \"links\"\n" - "(links allowed) or \"off\", in either case. Always off in a game listed\n" - "in the server browser (a public internet game)." }, + "(links allowed) or \"off\", in either case. Always off in a game a\n" + "player hosts that is listed in the server browser (a public internet\n" + "game); a dedicated server's follows this setting, listed or not." }, { "network.votekick", _config_boolean, "true", "HALO_NET_VOTEKICK", _environment_value, _platform_all, "Hosting: let the players vote to kick a player (the scoreboard's\n" "right-click, or the console's votekick). More than half of the players\n" @@ -626,6 +648,48 @@ static const struct config_setting config_settings[] = "invite find each other (its messages are encrypted), beside this file\n" "unless a full path: one host:port on each line, up to 4. Updates\n" "replace brokers.txt: keep a list of your own under another name." }, + { "network.public_address", _config_string, "\"\"", "HALO_NET_PUBLIC_ADDRESS", _environment_value, + _platform_desktop, + "Hosting: the internet address joiners reach this machine at, as\n" + "host[:port] (the port: the one forwarded to network.tunnel_port), for a\n" + "server behind a NAT with one forwarded port; offered first, its name\n" + "looked up again every 5 minutes. Empty: what STUN and UPnP find." }, + { "network.listings", _config_string, "\"both\"", "HALO_NET_LISTINGS", _environment_value, _platform_all, + "What the server browser lists: \"both\" (dedicated servers from the\n" + "master servers first, then the games players host, from the brokers),\n" + "\"masters\" (the master servers' alone) or \"brokers\" (the brokers'\n" + "alone)." }, + { "network.masters_file", _config_string, "\"masters.txt\"", "HALO_NET_MASTERS_FILE", _environment_value, + _platform_all, + "The file of the master servers the server browser asks for dedicated\n" + "servers, beside this file unless a full path: an https:// URL and its\n" + "key (ed25519:) on each line, up to 4. None named (no file, or\n" + "comments alone): the official master, built in." }, + { "network.browser_source", _config_string, "\"all\"", "HALO_NET_BROWSER_SOURCE", _environment_value, + _platform_all, + "The server browser's games: \"all\" (ALL GAMES: the dedicated\n" + "servers first, then the games players host), \"dedicated\" (the\n" + "master servers' dedicated servers) or \"players\" (P2P GAMES: games\n" + "players host). Its top-left tab changes it." }, + { "network.browser_show_empty", _config_boolean, "true", "HALO_NET_BROWSER_SHOW_EMPTY", _environment_value, + _platform_all, + "The server browser lists games with no player (its Filters screen)." }, + { "network.browser_show_full", _config_boolean, "true", "HALO_NET_BROWSER_SHOW_FULL", _environment_value, + _platform_all, + "The server browser lists full games (its Filters screen)." }, + { "network.browser_maps", _config_string, "\"yes\"", "HALO_NET_BROWSER_MAPS", _environment_value, _platform_all, + "The server browser lists games on maps this machine lacks: \"yes\", \"no\",\n" + "or \"downloadable\" (those of a Custom Edition map its host shares)." }, + { "network.browser_gametype", _config_string, "\"any\"", "HALO_NET_BROWSER_GAMETYPE", _environment_value, + _platform_all, + "The server browser lists games of one kind only: \"any\", \"ctf\",\n" + "\"slayer\", \"oddball\", \"king\" or \"race\" (its Filters screen)." }, + { "network.browser_sort", _config_string, "\"servers\"", "HALO_NET_BROWSER_SORT", _environment_value, + _platform_all, + "The server browser's order: \"servers\" (dedicated servers first, then by\n" + "players), \"players\", \"name\", \"map\", \"type\" or \"locked\" (a password's\n" + "first); \"-\" before one reverses it (\"-players\"). A column title sorts by\n" + "it, and again the other way." }, { "network.stun_servers", _config_string, "\"stun.l.google.com:19302,stun.cloudflare.com:3478\"", "HALO_NET_STUN", _environment_value, _platform_all, "Public STUN servers that tell this machine its internet address;\n" @@ -638,6 +702,156 @@ static const struct config_setting config_settings[] = { "update.auto", _config_boolean, "true", "HALO_UPDATE_AUTO", _environment_value, _platform_all, "Look for a new version when the game starts, and offer to update to it;\n" "false never looks (the game's \"Do not ask again\" writes false here)." }, + /* (a dedicated server's: halo --server, port/linux/src/server_mode.c; + written into the file only by one) */ + { "server.enabled", _config_boolean, "false", "HALO_SERVER", _environment_set_is_true, _platform_server, + "Run as a dedicated server (as halo --server does): host games with no\n" + "window, sound or player of its own, one after another from\n" + "server.rotation (docs/dedicated_server.md)." }, + { "server.name", _config_string, "\"OpenCE Server\"", "HALO_SERVER_NAME", _environment_value, _platform_server, + "The game's name in the lobby and the server browser: 15 characters of\n" + "printable ASCII." }, + { "server.max_players", _config_integer, "16", "HALO_SERVER_MAX_PLAYERS", _environment_value, _platform_server, + "The most players (2 to 127)." }, + { "server.min_players", _config_integer, "1", "HALO_SERVER_MIN_PLAYERS", _environment_value, _platform_server, + "Players in the lobby before it counts down to a game (1 to 16)." }, + { "server.password", _config_string, "\"\"", "HALO_SERVER_PASSWORD", _environment_value, _platform_server, + "The password the server browser asks for (the invite link still joins);\n" + "empty for none. Never logged." }, + { "server.public", _config_boolean, "true", "HALO_SERVER_PUBLIC", _environment_value, _platform_server, + "List the server in everyone's server browser (on the master servers,\n" + "with network.online); false: joined by its invite link and on the LAN." }, + { "server.rotation", _config_string, "\"bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer\"", + "HALO_SERVER_ROTATION", _environment_value, _platform_server, + "The games played one after another: map[:gametype[+rule=value...]],\n" + "separated by commas, 64 at most. A map is a multiplayer map's name\n" + "(bloodgulch), custom_maps\\ or a level's path; a gametype a\n" + "built-in's name (slayer, team_slayer, ctf...), an OpenCE gametype's\n" + "(swat...), a gametypes/.toml or file=." }, + { "server.rotation_fallback", _config_boolean, "false", "HALO_SERVER_ROTATION_FALLBACK", _environment_value, + _platform_server, + "With no playable rotation entry, play bloodgulch:slayer rather than\n" + "stop (exit code 3)." }, + { "server.lobby_seconds", _config_integer, "15", "HALO_SERVER_LOBBY_SECONDS", _environment_value, + _platform_server, + "Seconds the lobby counts down once min_players are in (0 to 300; 0\n" + "starts at once)." }, + { "server.postgame_seconds", _config_integer, "12", "HALO_SERVER_POSTGAME_SECONDS", _environment_value, + _platform_server, + "Seconds the scores show after a game (0 to 300)." }, + { "server.empty_seconds", _config_integer, "30", "HALO_SERVER_EMPTY_SECONDS", _environment_value, + _platform_server, + "Seconds a game nobody is in goes on before it ends (0: never). A game\n" + "whose last player leaves ends at once, as the game ends it." }, + { "server.idle_minutes", _config_integer, "0", "HALO_SERVER_IDLE_MINUTES", _environment_value, _platform_server, + "Minutes a game goes on with no score before it ends (0: never)." }, + { "server.team_start", _config_string, "\"one\"", "HALO_SERVER_TEAM_START", _environment_value, _platform_server, + "A team game: \"one\" starts with one team's players (the next joiners\n" + "balance), \"both\" waits for a player on each team, \"skip\" skips team\n" + "games while one player waits." }, + { "server.motd", _config_string, "\"\"", "HALO_SERVER_MOTD", _environment_value, _platform_server, + "A notice each machine is shown as it joins (127 characters); empty for\n" + "none." }, + { "server.frame_rate", _config_integer, "60", "HALO_SERVER_FRAME_RATE", _environment_value, _platform_server, + "Frames a second (30 to 120); the game's ticks stay 30 a second. A\n" + "server with no other machine runs 10." }, + { "server.console", _config_boolean, "true", "HALO_SERVER_CONSOLE", _environment_value, _platform_server, + "Take commands from the standard input." }, + { "server.console_exit_on_eof", _config_boolean, "false", "HALO_SERVER_CONSOLE_EXIT_ON_EOF", _environment_value, + _platform_server, + "Stop when the standard input ends; false goes on (a panel may close\n" + "it)." }, + { "server.init_file", _config_string, "\"init.txt\"", "HALO_SERVER_INIT_FILE", _environment_value, + _platform_server, + "Commands run once at the start, one a line: in the data folder unless a\n" + "full path; empty for none." }, + { "server.web_port", _config_integer, "8080", "HALO_SERVER_WEB_PORT", _environment_value, _platform_server, + "The TCP port of the web page, its API and the remote console (RCON);\n" + "0 for none." }, + { "server.web_bind", _config_string, "\"127.0.0.1\"", "HALO_SERVER_WEB_BIND", _environment_value, + _platform_server, + "The address the web port listens on. Any but 127.0.0.1 needs\n" + "web_password (behind a reverse proxy with TLS)." }, + { "server.web_password", _config_string, "\"\"", "HALO_SERVER_WEB_PASSWORD", _environment_value, + _platform_server, + "The web page's admin password (8 characters or more). Empty: one is\n" + "made at the first start, printed once and written here." }, + { "server.web_owner_password", _config_string, "\"\"", "HALO_SERVER_WEB_OWNER_PASSWORD", _environment_value, + _platform_server, + "A password with the owner role (quit, sv_restart, sv_stop, the owner's\n" + "settings, the game's console), for a supervisor on this machine: it\n" + "signs in from 127.0.0.0/8 alone. 8 characters or more; empty for none." }, + { "server.web_moderator_password", _config_string, "\"\"", "HALO_SERVER_WEB_MODERATOR_PASSWORD", + _environment_value, _platform_server, + "A second password, with the moderator role (kick, mute, say, the next\n" + "map, bans of a day at most); empty for none." }, + { "server.web_viewer_password", _config_string, "\"\"", "HALO_SERVER_WEB_VIEWER_PASSWORD", _environment_value, + _platform_server, + "A password with the viewer role (the status, the players, the log: no\n" + "changes); empty for none." }, + { "server.web_ui", _config_boolean, "true", "HALO_SERVER_WEB_UI", _environment_value, _platform_server, + "Serve the admin page on the web port; false: its API and RCON only." }, + { "server.web_trusted_proxy", _config_string, "\"\"", "HALO_SERVER_WEB_TRUSTED_PROXY", _environment_value, + _platform_server, + "The address of a reverse proxy in front of the web port, whose\n" + "X-Forwarded-For (its last entry, the proxy's), -Host and -Proto are\n" + "believed; empty for none." }, + { "server.metrics", _config_boolean, "true", "HALO_SERVER_METRICS", _environment_value, _platform_server, + "Serve /metrics (Prometheus) on the web port without a password." }, + { "server.log_format", _config_string, "\"text\"", "HALO_SERVER_LOG_FORMAT", _environment_value, + _platform_server, + "The standard output's lines: \"text\" or \"json\"." }, + { "server.log_addresses", _config_boolean, "false", "HALO_SERVER_LOG_ADDRESSES", _environment_value, + _platform_server, + "Players' internet addresses and hardware ids in the log, the events and\n" + "the API for every role (the owner's console has them always)." }, + { "server.log_debug_echo", _config_boolean, "false", "HALO_SERVER_LOG_DEBUG_ECHO", _environment_value, + _platform_server, + "Also write debug.txt's lines to the standard error." }, + { "server.status_file", _config_string, "\"\"", "HALO_SERVER_STATUS_FILE", _environment_value, + _platform_server, + "A file the status (as /api/status) is written to every 2 seconds, for a\n" + "panel without HTTP; empty for none." }, + { "server.auto_update", _config_string, "\"off\"", "HALO_SERVER_AUTO_UPDATE", _environment_value, + _platform_server, + "\"off\"; \"check\" logs a newer release; \"install\" installs it once the\n" + "server has been empty for 10 minutes, and stops (exit code 5) to be\n" + "started again." }, + { "server.upnp", _config_boolean, "false", "HALO_SERVER_UPNP", _environment_value, _platform_server, + "Ask the router (UPnP) to forward the tunnel's port (network.allow_upnp,\n" + "for a server at home)." }, + { "server.coop_difficulty", _config_string, "\"normal\"", "HALO_SERVER_COOP_DIFFICULTY", _environment_value, + _platform_server, + "Co-op rotation entries' (:coop) difficulty: \"easy\", \"normal\",\n" + "\"hard\" or \"impossible\"." }, + { "server.list_on_brokers", _config_boolean, "false", "HALO_SERVER_LIST_ON_BROKERS", _environment_value, + _platform_server, + "Also list the server among the games players host (through the\n" + "signalling brokers), for server browsers with no master server." }, + { "server.master_publish", _config_boolean, "true", "HALO_SERVER_MASTER_PUBLISH", _environment_value, + _platform_server, + "Publish a public server to the official master servers, signed with\n" + "the publisher key (publisher_key_file)." }, + { "server.masters_file", _config_string, "\"masters.txt\"", "HALO_SERVER_MASTERS_FILE", _environment_value, + _platform_server, + "The master servers published to, beside this file unless a full path:\n" + "an https:// URL and its key (ed25519:) on each line. None named:\n" + "the official master, built in." }, + { "server.publisher_key_file", _config_string, "\"publisher.key\"", "HALO_SERVER_PUBLISHER_KEY_FILE", + _environment_value, _platform_server, + "The operator's Ed25519 key for the master servers (halo --server\n" + "--keygen makes it), beside this file unless a full path.\n" + "HALO_SERVER_PUBLISHER_KEY holds the key itself for containers." }, + { "server.instance", _config_string, "\"\"", "HALO_SERVER_INSTANCE", _environment_value, _platform_server, + "A short name telling this server from the operator's others on the\n" + "master servers (letters, digits, - and _); empty: from server.name." }, + { "server.region", _config_string, "\"\"", "HALO_SERVER_REGION", _environment_value, _platform_server, + "A region shown in the server browser (eu, na-east...): 16 characters;\n" + "empty for none." }, + { "server.master_publish_address", _config_boolean, "false", "HALO_SERVER_MASTER_PUBLISH_ADDRESS", + _environment_value, _platform_server, + "Let the master servers give this server's address to players; false:\n" + "only their reachability probe uses it." }, { "streamer.enabled", _config_boolean, "false", "HALO_STREAMER_MODE", _environment_value, _platform_all, "Hide offensive words in names and text from other players, maps and mods\n" "as stars, at the levels below (Settings > Network Setup > Streamer Mode)." }, @@ -829,15 +1043,25 @@ static const struct config_setting config_settings[] = #define NUMBER_OF_CONFIG_SETTINGS (sizeof(config_settings) / sizeof(config_settings[0])) #ifdef HALO_ANDROID -#define CONFIG_PLATFORM _platform_android +#define CONFIG_BUILD_PLATFORM _platform_android #elif defined(HALO_WEB) -#define CONFIG_PLATFORM (_platform_desktop | _platform_web) +#define CONFIG_BUILD_PLATFORM (_platform_desktop | _platform_web) #elif defined(_WIN32) -#define CONFIG_PLATFORM (_platform_desktop | _platform_windows) +#define CONFIG_BUILD_PLATFORM (_platform_desktop | _platform_windows) #else -#define CONFIG_PLATFORM _platform_desktop +#define CONFIG_BUILD_PLATFORM _platform_desktop #endif +/* a dedicated server (server.enabled, --server, HALO_SERVER: decided as the +file is read): its settings are written into the file too */ +static int config_server_mode; +/* a run that only answers its command line (--version...): nothing written */ +static int config_answer_only; +/* halo --probe []: a list site's look at a game (network_test.c's +"probe"), headless */ +static int config_probe_mode; +#define CONFIG_PLATFORM (CONFIG_BUILD_PLATFORM | (config_server_mode ? _platform_server : 0)) + struct config_value { int boolean; @@ -850,6 +1074,31 @@ static struct config_value config_values[NUMBER_OF_CONFIG_SETTINGS]; static int config_loaded = 0; static pthread_mutex_t config_lock = PTHREAD_MUTEX_INITIALIZER; +/* ---------- the command line */ + +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) +/* whether the command line has the argument name ("--server"), and with +value, the one after it (or after "=": --config=) */ +static int config_argument(const char *name, char *value, size_t size) +{ + char text[1024]; + size_t length = strlen(name); + int index; + + for (index = 1; posix_command_line_argument(index, text, (posix_ulong)sizeof(text)); index++) + { + if (!strcmp(text, name)) + return !value || posix_command_line_argument(index + 1, value, (posix_ulong)size); + if (value && !strncmp(text, name, length) && text[length] == '=') + { + snprintf(value, size, "%s", text + length + 1); + return 1; + } + } + return 0; +} +#endif + /* ---------- the file */ static void config_path(char *path, size_t size) @@ -861,9 +1110,19 @@ static void config_path(char *path, size_t size) snprintf(path, size, "%s/config.toml", root && *root ? root : "."); #else - /* the executable's folder, with its separator */ - const char *base = SDL_GetBasePath(); + /* HALO_CONFIG_FILE or --config (a dedicated server's instance's + own), else the executable's folder's, with its separator */ + const char *file = getenv("HALO_CONFIG_FILE"); + const char *base; + if (file && *file) + { + snprintf(path, size, "%s", file); + return; + } + if (config_argument("--config", path, size) && *path) + return; + base = SDL_GetBasePath(); snprintf(path, size, "%sconfig.toml", base ? base : ""); #endif } @@ -922,6 +1181,13 @@ static int config_write_file(const char *path, const char *text) return 0; written = fwrite(text, 1, strlen(text), file) == strlen(text); return fclose(file) == 0 && written; +#elif !defined(HALO_ANDROID) && !defined(HALO_WEB) + /* (a dedicated server's holds its passwords: its user's to read alone) */ + if (!SDL_SaveFile(path, text, strlen(text))) + return 0; + if (config_server_mode) + posix_server_private_file(path); + return 1; #else return SDL_SaveFile(path, text, strlen(text)); #endif @@ -1605,6 +1871,167 @@ static void config_check_words(void) } } +/* the passwords a file may hold instead of their variable (a container's +secrets): _FILE names it, and its first line is the value */ +static const char *const config_secret_files[] = +{ + "server.password", "server.web_password", "server.web_owner_password", "server.web_moderator_password", + "server.web_viewer_password", +}; +#define NUMBER_OF_CONFIG_SECRET_FILES (sizeof(config_secret_files) / sizeof(config_secret_files[0])) + +/* the value of the setting's _FILE (read once, at the first +question: a file changed after the start is not read again), NULL if +there is none, it cannot be read or its first line is empty */ +static const char *config_environment_file(const struct config_setting *setting) +{ + static char values[NUMBER_OF_CONFIG_SECRET_FILES][512]; + static signed char read[NUMBER_OF_CONFIG_SECRET_FILES]; + size_t index; + + for (index = 0; index < NUMBER_OF_CONFIG_SECRET_FILES && strcmp(config_secret_files[index], setting->name); index++) + { + } + if (index == NUMBER_OF_CONFIG_SECRET_FILES || !setting->environment) + return NULL; + if (!read[index]) + { + char variable[96]; + const char *path; + char *text; + size_t size = 0, length; + + read[index] = -1; + snprintf(variable, sizeof(variable), "%s_FILE", setting->environment); + path = getenv(variable); + if (!path || !path[0]) + return NULL; + text = config_read_file(path, &size); + if (!text) + { + platform_log("settings: %s names %s, which cannot be read: %s is not set by it", variable, path, + setting->name); + return NULL; + } + /* (the first line, its CR and LF off) */ + length = strcspn(text, "\n"); + if (length && text[length - 1] == '\r') + length--; + if (length >= sizeof(values[index])) + { + platform_log("settings: the first line of %s (%s) is longer than %d characters: %s is not set by it", + path, variable, (int)sizeof(values[index]) - 1, setting->name); + length = 0; + } + memcpy(values[index], text, length); + values[index][length] = 0; + memset(text, 0, size); + free(text); + read[index] = length ? 1 : -1; + } + return read[index] > 0 ? values[index] : NULL; +} + +/* a setting's environment variable, NULL if it is not set; a dedicated +server's settings' empty ones are not set either (a panel passes every +variable it has, empty ones too: an empty password must not stand for one). +A password's variable that is not set may be read from its file +(_FILE: config_environment_file) */ +static const char *config_environment(const struct config_setting *setting) +{ + const char *environment = setting->environment ? getenv(setting->environment) : NULL; + + if (environment && !environment[0] && + (setting->platforms == _platform_server || !strcmp(setting->name, "network.public_address"))) + { + environment = NULL; + } + if (!environment) + environment = config_environment_file(setting); + return environment; +} + +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) +/* a dedicated server (port/linux/src/server_mode.c) has no window, sound, +microphone or player, never asks anything, and is reached only as its +settings say, whatever the file and the environment say of these (for this +run: nothing of it is written) */ +/* halo --probe: no window, sound, update, Discord or menus' test; the +network test's probe, and a deadline (internet play as set: on by default, +which an invite needs) */ +static void config_apply_probe_mode(void) +{ + static const char *const forced[][2] = + { + { "debug.null_renderer", "true" }, + { "debug.hidden_window", "true" }, + { "display.fullscreen", "false" }, + { "audio.enabled", "false" }, + { "audio.voice_chat", "off" }, + { "network.join_from_clipboard", "false" }, + { "update.auto", "false" }, + { "crash_reports.upload", "no" }, + { "discord.application_id", "" }, + { "debug.network_test", "probe" }, + { "server.enabled", "false" }, + { "debug.test_input", "" }, + { "debug.menu_open", "" }, + { "debug.exit_after", "45" }, + }; + size_t index; + + for (index = 0; index < sizeof(forced) / sizeof(forced[0]); index++) + { + long found = config_setting_index(forced[index][0]); + + if (found >= 0) + config_set_from_text(&config_values[found], config_settings[found].type, forced[index][1]); + } +} + +static void config_apply_server_mode(void) +{ + static const char *const forced[][2] = + { + { "server.enabled", "true" }, + { "debug.null_renderer", "true" }, + { "debug.hidden_window", "true" }, + { "display.fullscreen", "false" }, + { "display.vsync", "false" }, + { "display.interpolation", "false" }, + { "display.high_res_hud", "false" }, + { "display.high_res_text", "false" }, + { "display.anti_aliasing", "off" }, + { "audio.enabled", "false" }, + { "audio.voice_chat", "off" }, + { "network.join_from_clipboard", "false" }, + { "update.auto", "false" }, + { "crash_reports.upload", "no" }, + { "discord.application_id", "" }, + { "debug.network_test", "" }, + { "debug.test_input", "" }, + { "debug.menu_open", "" }, + }; + const char *tunnel_port = getenv("HALO_NET_TUNNEL_PORT"); + size_t index; + + for (index = 0; index < sizeof(forced) / sizeof(forced[0]); index++) + { + long found = config_setting_index(forced[index][0]); + + if (found >= 0) + config_set_from_text(&config_values[found], config_settings[found].type, forced[index][1]); + } + /* (the tunnel's port fixed, Halo PC's 2302, unless one is given: a + server's players and firewall need to know it; a router asked to + forward it only as server.upnp says) */ + if ((!tunnel_port || !*tunnel_port) && !config_values[config_setting_index("network.tunnel_port")].integer) + config_values[config_setting_index("network.tunnel_port")].integer = 2302; + config_values[config_setting_index("network.allow_upnp")].boolean = + config_values[config_setting_index("server.upnp")].boolean; +} +#endif + static void config_load(void) { char path[1024]; @@ -1632,6 +2059,20 @@ static void config_load(void) config_path(path, sizeof(path)); #if !defined(HALO_ANDROID) && !defined(HALO_WEB) config_report_unread(path); + /* (a dedicated server's file has its settings too: asked for by the + command line or the environment, or by the file's server.enabled; a + probe is never one, whatever they say) */ + config_probe_mode = config_argument("--probe", NULL, 0); + { + const char *environment = getenv("HALO_SERVER"); + + config_server_mode = !config_probe_mode && (config_argument("--server", NULL, 0) || + (environment && config_environment_set(environment))); + } + /* (a run that only answers its command line, halo --version, writes no + settings: the file read is left as it is) */ + config_answer_only = config_argument("--help", NULL, 0) || config_argument("--version", NULL, 0) || + config_argument("--keygen", NULL, 0) || config_argument("--sign", NULL, 0) || config_probe_mode; #endif text = config_read_file(path, &size); if (text) @@ -1660,7 +2101,7 @@ static void config_load(void) { char *completed; - if (joined && !skipped) + if (joined && !skipped && !config_answer_only) { if (config_write_file(path, text)) platform_log("settings: joined the sections %s had twice", path); @@ -1669,6 +2110,10 @@ static void config_load(void) } for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++) config_set_from_file(&config_values[index], &config_settings[index], result.toptab); +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) + if (config_values[config_setting_index("server.enabled")].boolean && !config_probe_mode) + config_server_mode = 1; +#endif config_report_unknown_keys(result.toptab); platform_log("settings: %s", path); /* (a file with lines skipped is left as the player wrote it, to @@ -1678,7 +2123,7 @@ static void config_load(void) platform_log("settings: %d line%s of config.toml skipped; every other setting in it is used", skipped, skipped == 1 ? "" : "s"); } - else + else if (!config_answer_only) { completed = config_add_missing(text, result.toptab); if (completed && !config_write_file(path, completed)) @@ -1694,7 +2139,7 @@ static void config_load(void) toml_free(result); free(text); } - else + else if (!config_answer_only) { char *defaults = config_default_text(); @@ -1708,7 +2153,7 @@ static void config_load(void) for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++) { const struct config_setting *setting = &config_settings[index]; - const char *environment = setting->environment ? getenv(setting->environment) : NULL; + const char *environment = config_environment(setting); if (!environment || (setting->environment_style != _environment_value && !config_environment_set(environment))) continue; @@ -1725,14 +2170,18 @@ static void config_load(void) break; } } +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) + if (config_server_mode) + config_apply_server_mode(); + else if (config_probe_mode) + config_apply_probe_mode(); +#endif config_check_words(); } -static const struct config_value *config_value(const char *name, enum config_type type) +/* the file read, the first time a setting is asked for */ +static void config_ensure_loaded(void) { - static const struct config_value none = { 0, 0, 0.0, "" }; - long index; - pthread_mutex_lock(&config_lock); if (!config_loaded) { @@ -1740,6 +2189,14 @@ static const struct config_value *config_value(const char *name, enum config_typ config_loaded = 1; } pthread_mutex_unlock(&config_lock); +} + +static const struct config_value *config_value(const char *name, enum config_type type) +{ + static const struct config_value none = { 0, 0, 0.0, "" }; + long index; + + config_ensure_loaded(); index = config_setting_index(name); if (index < 0 || config_settings[index].type != type) { @@ -1783,6 +2240,9 @@ static int config_line_section(const char *line, const char *end, char *section, return 1; } +/* the longest string config_write takes */ +#define CONFIG_MAXIMUM_WRITTEN_STRING 4096 + /* sets a setting, for now and in config.toml, from its value as text ("true", "60", "1.5", "all"): its line there is changed (or added), the rest of the file kept as it is */ @@ -1790,43 +2250,70 @@ int config_write(const char *name, const char *value) { const char *dot = strchr(name, '.'); long index = config_setting_index(name); - char section[64], key[64], wanted[80], current[64] = "", line_text[600], path[1024]; + char section[64], key[64], wanted[80], current[64] = "", path[1024]; struct config_text out = { 0 }; - size_t size = 0; - char *text; + size_t size = 0, line_size; + char *text, *line_text; const char *line; int written = 0, in_section = 0, succeeded; - if (index < 0 || !dot || (size_t)(dot - name) >= sizeof(section) || strlen(value) > 256) + /* (a string as long as a server's rotation, server_rotation.h's + SERVER_ROTATION_MAXIMUM_TEXT; any other value is a word or a number) */ + if (index < 0 || !dot || (size_t)(dot - name) >= sizeof(section) || + strlen(value) > (config_settings[index].type == _config_string ? CONFIG_MAXIMUM_WRITTEN_STRING : 256)) + { + return 0; + } + /* (the line: its key, and its value with each character escaped as + \uXXXX at most) */ + line_size = strlen(dot + 1) + 6 * strlen(value) + 64; + line_text = malloc(line_size); + if (!line_text) return 0; /* (the file read first, as the other settings are) */ config_value(name, config_settings[index].type); pthread_mutex_lock(&config_lock); config_set_from_text(&config_values[index], config_settings[index].type, value); + /* (a run that only answers its command line, a probe's too, writes + nothing: the value is this run's alone) */ + if (config_answer_only) + { + config_change_count++; + pthread_mutex_unlock(&config_lock); + free(line_text); + return 1; + } snprintf(section, sizeof(section), "%.*s", (int)(dot - name), name); snprintf(key, sizeof(key), "%s", dot + 1); switch (config_settings[index].type) { case _config_boolean: - snprintf(line_text, sizeof(line_text), "%s = %s\n", key, config_values[index].boolean ? "true" : "false"); + snprintf(line_text, line_size, "%s = %s\n", key, config_values[index].boolean ? "true" : "false"); break; case _config_integer: - snprintf(line_text, sizeof(line_text), "%s = %ld\n", key, config_values[index].integer); + snprintf(line_text, line_size, "%s = %ld\n", key, config_values[index].integer); break; case _config_real: /* (with its point: TOML reads 1 as an integer) */ - snprintf(line_text, sizeof(line_text), "%s = %.15g", key, config_values[index].real); + snprintf(line_text, line_size, "%s = %.15g", key, config_values[index].real); if (!strpbrk(line_text + strlen(key) + 3, ".en")) strcat(line_text, ".0"); strcat(line_text, "\n"); break; case _config_string: { - char *end = line_text + snprintf(line_text, sizeof(line_text), "%s = \"", key); + char *end = line_text + snprintf(line_text, line_size, "%s = \"", key); const char *character; + /* (a TOML basic string: quotes and backslashes escaped, and control + characters, so that a value cannot end its line) */ for (character = config_values[index].string; *character; character++) { + if ((unsigned char)*character < 0x20 || *character == 0x7F) + { + end += sprintf(end, "\\u%04x", (unsigned)(unsigned char)*character); + continue; + } if (*character == '"' || *character == '\\') *end++ = '\\'; *end++ = *character; @@ -1889,6 +2376,7 @@ int config_write(const char *name, const char *value) pthread_mutex_unlock(&config_lock); free(out.buffer); free(text); + free(line_text); return succeeded; } @@ -1957,6 +2445,100 @@ unsigned long config_changes(void) return config_change_count; } +int config_server_mode_enabled(void) +{ + config_value("server.enabled", _config_boolean); + return config_server_mode; +} + +int config_setting_info(int index, struct config_setting_info *info) +{ + static const char *const types[] = { "boolean", "integer", "real", "string" }; + const struct config_setting *setting; + const char *environment; + + if (index < 0 || (size_t)index >= NUMBER_OF_CONFIG_SETTINGS) + return 0; + setting = &config_settings[index]; + memset(info, 0, sizeof(*info)); + info->name = setting->name; + info->type = types[setting->type]; + info->environment = setting->environment; + info->comment = setting->comment; + config_default(setting->name, info->default_text, sizeof(info->default_text)); + environment = config_environment(setting); + info->pinned = environment && (setting->environment_style == _environment_value || config_environment_set(environment)); + info->written = (setting->platforms & CONFIG_PLATFORM) != 0; + return 1; +} + +int config_reload(const char *prefix) +{ + char path[1024]; + size_t size = 0; + size_t length = strlen(prefix); + char *text; + toml_result_t result; + size_t index; + + config_ensure_loaded(); + config_path(path, sizeof(path)); + text = config_read_file(path, &size); + if (!text) + return 0; + result = toml_parse(text, (int)size); + if (!result.ok) + { + platform_log("settings: %s cannot be read again (%s); the settings stay as they were", path, result.errmsg); + toml_free(result); + free(text); + return 0; + } + pthread_mutex_lock(&config_lock); + for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++) + { + const struct config_setting *setting = &config_settings[index]; + const char *environment = config_environment(setting); + struct config_value value; + + /* (the setting's own: those of the environment stay its) */ + if (strncmp(setting->name, prefix, length) || + (environment && (setting->environment_style == _environment_value || config_environment_set(environment)))) + { + continue; + } + memset(&value, 0, sizeof(value)); + if (setting->type == _config_string) + { + size_t default_length = strlen(setting->default_value); + + value.string = default_length >= 2 ? config_copy(setting->default_value + 1, default_length - 2) : strdup(""); + } + else + { + config_set_from_text(&value, setting->type, setting->default_value); + } + config_set_from_file(&value, setting, result.toptab); + /* (the old string kept, as config_set_from_text keeps it) */ + config_values[index] = value; + } +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) + /* (what the run forces, forced again: a server started by --server + stays one whatever the file's server.enabled says, and its router asked + as its server.upnp now says) */ + if (config_server_mode) + config_apply_server_mode(); + else if (config_probe_mode) + config_apply_probe_mode(); +#endif + config_change_count++; + pthread_mutex_unlock(&config_lock); + toml_free(result); + free(text); + config_check_words(); + return 1; +} + int config_default(const char *name, char *text, size_t size) { long index = config_setting_index(name); @@ -1996,6 +2578,17 @@ long config_integer(const char *name) return config_value(name, _config_integer)->integer; } +unsigned long network_client_timeout_milliseconds(void) +{ + long seconds = config_integer("network.client_timeout_seconds"); + + if (seconds < 5) + seconds = 5; + if (seconds > 300) + seconds = 300; + return (unsigned long)seconds * 1000UL; +} + double config_real(const char *name) { return config_value(name, _config_real)->real; diff --git a/port/linux/src/port_config.h b/port/linux/src/port_config.h index e1dcb372..4999e585 100644 --- a/port/linux/src/port_config.h +++ b/port/linux/src/port_config.h @@ -38,4 +38,32 @@ int config_default(const char *name, char *text, size_t size); it again when this moves */ unsigned long config_changes(void); +/* whether this is a dedicated server (server.enabled, --server, +HALO_SERVER: port/linux/src/server_mode.c), whose settings the file holds */ +int config_server_mode_enabled(void); + +/* a setting, for a list of them (a dedicated server's Settings page): the +one at index; 0 past the last */ +struct config_setting_info +{ + const char *name; + /* "boolean", "integer", "real" or "string" */ + const char *type; + /* NULL: none */ + const char *environment; + const char *comment; + /* as text, a string's without its quotes */ + char default_text[300]; + /* its environment variable sets it for this run */ + int pinned; + /* this build writes it into config.toml */ + int written; +}; +int config_setting_info(int index, struct config_setting_info *info); + +/* the settings whose names begin with prefix ("server.") read from +config.toml again, but for those their environment variables set; 1 on +success (a file that cannot be read leaves them as they were) */ +int config_reload(const char *prefix); + #endif diff --git a/port/linux/src/posix_server.c b/port/linux/src/posix_server.c new file mode 100644 index 00000000..dd6a4d72 --- /dev/null +++ b/port/linux/src/posix_server.c @@ -0,0 +1,238 @@ +/* +POSIX_SERVER.C + +A dedicated server's signals, standard input and output, and the process's +use of memory and processor (posix_server.h), on Linux. None on Android and +the web, which have no dedicated server. +*/ + +#include "posix_server.h" + +#if !defined(__ANDROID__) && !defined(__EMSCRIPTEN__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* (the bits set by the handler and taken by the game's thread, each +atomically: a bit set between the game's read and its clear is kept) */ +static volatile sig_atomic_t server_signals; +static volatile sig_atomic_t server_quit_signal; +static volatile sig_atomic_t server_quit_count; +static volatile sig_atomic_t server_deadline_code; + +static void server_signal_handler(int number) +{ + switch (number) + { + case SIGTERM: + case SIGINT: + /* (a second one: at once) */ + if (server_quit_count++) + _exit(128 + number); + server_quit_signal = number; + __atomic_fetch_or(&server_signals, _posix_server_signal_quit, __ATOMIC_SEQ_CST); + break; + case SIGHUP: + __atomic_fetch_or(&server_signals, _posix_server_signal_reload, __ATOMIC_SEQ_CST); + break; + case SIGUSR1: + __atomic_fetch_or(&server_signals, _posix_server_signal_status, __ATOMIC_SEQ_CST); + break; + case SIGALRM: + _exit(server_quit_signal ? 128 + server_quit_signal : server_deadline_code); + break; + } +} + +void posix_server_signals_install(void) +{ + struct sigaction action; + + memset(&action, 0, sizeof(action)); + action.sa_handler = server_signal_handler; + /* (the others held while one is handled: its counts and bits are the + handler's alone at a time) */ + sigfillset(&action.sa_mask); + sigaction(SIGTERM, &action, NULL); + sigaction(SIGINT, &action, NULL); + sigaction(SIGHUP, &action, NULL); + sigaction(SIGUSR1, &action, NULL); + sigaction(SIGALRM, &action, NULL); + signal(SIGPIPE, SIG_IGN); + signal(SIGTTIN, SIG_IGN); + signal(SIGTTOU, SIG_IGN); +} + +int posix_server_signals_take(int *quit_signal) +{ + int taken = __atomic_exchange_n(&server_signals, 0, __ATOMIC_SEQ_CST); + + if (quit_signal) + *quit_signal = server_quit_signal; + return taken; +} + +void posix_server_quit_deadline(int seconds, int exit_code) +{ + server_deadline_code = exit_code; + alarm(seconds > 0 ? (unsigned)seconds : 1); +} + +void posix_server_stdout_line_buffered(void) +{ + setvbuf(stdout, NULL, _IOLBF, 0); +} + +int posix_server_stdin_is_terminal(void) +{ + return isatty(STDIN_FILENO); +} + +int posix_server_stdin_read(char *buffer, int size) +{ + struct pollfd descriptor; + ssize_t count; + + descriptor.fd = STDIN_FILENO; + descriptor.events = POLLIN; + descriptor.revents = 0; + if (poll(&descriptor, 1, 0) <= 0) + return 0; + if (descriptor.revents & POLLNVAL) + return -1; + if (!(descriptor.revents & (POLLIN | POLLHUP | POLLERR))) + return 0; + /* (read(2), not stdio: stdio's lock on stdin would hold exit()'s flush) */ + count = read(STDIN_FILENO, buffer, (size_t)size); + if (count < 0) + return errno == EAGAIN || errno == EINTR ? 0 : -1; + return count == 0 ? -1 : (int)count; +} + +unsigned long posix_server_resident_bytes(void) +{ + FILE *file = fopen("/proc/self/statm", "r"); + unsigned long pages = 0, resident = 0; + + if (!file) + return 0; + if (fscanf(file, "%lu %lu", &pages, &resident) != 2) + resident = 0; + fclose(file); + return resident * (unsigned long)sysconf(_SC_PAGESIZE); +} + +unsigned long posix_server_cpu_milliseconds(void) +{ + struct rusage usage; + + if (getrusage(RUSAGE_SELF, &usage) != 0) + return 0; + return (unsigned long)(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) * 1000UL + + (unsigned long)(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000UL; +} + +int posix_server_rename(const char *from, const char *to) +{ + return rename(from, to) == 0; +} + +int posix_server_write_file(const char *path, const char *text, unsigned long size) +{ + char temporary[4200]; + FILE *file; + int written; + + if (snprintf(temporary, sizeof(temporary), "%s.tmp", path) >= (int)sizeof(temporary)) + return 0; + file = fopen(temporary, "wb"); + if (!file) + return 0; + written = fwrite(text, 1, size, file) == size; + if (fclose(file) != 0 || !written) + { + unlink(temporary); + return 0; + } + return posix_server_rename(temporary, path); +} + +int posix_server_private_file(const char *path) +{ + return chmod(path, S_IRUSR | S_IWUSR) == 0; +} + +int posix_server_write_private_file(const char *path, const void *data, unsigned long size) +{ + const char *bytes = data; + unsigned long written = 0; + int descriptor = open(path, O_CREAT | O_TRUNC | O_WRONLY | O_CLOEXEC, S_IRUSR | S_IWUSR); + + if (descriptor < 0) + return 0; + /* (a file that was there keeps its mode: made its owner's too) */ + if (fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) + { + close(descriptor); + return 0; + } + while (written < size) + { + ssize_t count = write(descriptor, bytes + written, (size_t)(size - written)); + + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + written += (unsigned long)count; + } + return close(descriptor) == 0 && written == size; +} + +int posix_server_remove(const char *path) +{ + return unlink(path) == 0; +} + +unsigned long posix_server_unix_seconds(void) +{ + return (unsigned long)time(NULL); +} + +long long posix_server_unix_milliseconds(void) +{ + struct timespec now; + + clock_gettime(CLOCK_REALTIME, &now); + return (long long)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +void posix_server_utc_text(unsigned long seconds, char *text, int size) +{ + time_t when = (time_t)seconds; + struct tm utc; + + if (!gmtime_r(&when, &utc) || !strftime(text, (size_t)size, "%Y-%m-%dT%H:%M:%SZ", &utc)) + snprintf(text, (size_t)size, "%lu", seconds); +} + +void posix_server_local_text(unsigned long seconds, char *text, int size) +{ + time_t when = (time_t)seconds; + struct tm local; + + if (!localtime_r(&when, &local) || !strftime(text, (size_t)size, "%Y-%m-%d %H:%M:%S", &local)) + snprintf(text, (size_t)size, "%lu", seconds); +} + +#endif diff --git a/port/linux/src/posix_server.h b/port/linux/src/posix_server.h new file mode 100644 index 00000000..54dc569a --- /dev/null +++ b/port/linux/src/posix_server.h @@ -0,0 +1,73 @@ +/* +POSIX_SERVER.H + +What a dedicated server (server_mode.c) needs of the system beyond posix.h: +its signals, its standard input and output, and what the process uses. +posix_server.c does it on Linux, port/windows/src/win32_server.c on Windows +(Ctrl+C and the console's close for the signals). The Android and web builds +have no dedicated server, and none of these. + +Only integers and strings cross here (the two sides are compiled with +different structure layouts), as in posix.h. +*/ + +#ifndef __HALO_LINUX_POSIX_SERVER_H +#define __HALO_LINUX_POSIX_SERVER_H + +enum +{ + /* posix_server_signals_take's bits */ + _posix_server_signal_quit = 1 << 0, + _posix_server_signal_reload = 1 << 1, + _posix_server_signal_status = 1 << 2, +}; + +/* SIGTERM and SIGINT ask to quit (a second one ends the process at once, +with 128 + its number), SIGHUP to reload, SIGUSR1 for the status; SIGPIPE +and SIGTTIN are ignored */ +void posix_server_signals_install(void); +/* the signals that came since the last call (the bits above), and the +number of the last quitting one (0 if none) */ +int posix_server_signals_take(int *quit_signal); +/* ends the process (128 + the quitting signal, else exit_code) unless it +has ended within seconds: a shutdown that hangs (a map loading) */ +void posix_server_quit_deadline(int seconds, int exit_code); + +/* standard output written a line at a time (a panel's pipe shows each at +once) */ +void posix_server_stdout_line_buffered(void); +/* whether standard input is a terminal */ +int posix_server_stdin_is_terminal(void); +/* what standard input has without waiting: the bytes read (up to size), 0 +for none yet, -1 at its end (closed, or there is none) */ +int posix_server_stdin_read(char *buffer, int size); + +/* the process's resident memory, in bytes, and its processor time (user +and system), in milliseconds */ +unsigned long posix_server_resident_bytes(void); +unsigned long posix_server_cpu_milliseconds(void); + +/* renames from to to, replacing to (a file written whole, then put in +place); 1 on success */ +int posix_server_rename(const char *from, const char *to); +/* the file at path (the host's, not the game's Xbox path) written whole: +size bytes of text into path.tmp, put in place of path; 1 if it was */ +int posix_server_write_file(const char *path, const char *text, unsigned long size); +/* makes the file readable and writable by its owner alone (a key); 1 on +success */ +int posix_server_private_file(const char *path); +/* the file at path written whole (size bytes of data), readable and +writable by its owner alone from its creation (a key): created so, or made +so if it was there; 1 if it was */ +int posix_server_write_private_file(const char *path, const void *data, unsigned long size); +/* removes a file; 1 on success */ +int posix_server_remove(const char *path); +/* the seconds since the Unix epoch, and the milliseconds */ +unsigned long posix_server_unix_seconds(void); +long long posix_server_unix_milliseconds(void); +/* the time as UTC text: "2026-10-19T18:03:11Z" */ +void posix_server_utc_text(unsigned long seconds, char *text, int size); +/* local time as "2026-10-19 18:03:11" (bans.txt's) */ +void posix_server_local_text(unsigned long seconds, char *text, int size); + +#endif diff --git a/port/linux/src/posix_update.c b/port/linux/src/posix_update.c index 5fde2a9c..561b0ff6 100644 --- a/port/linux/src/posix_update.c +++ b/port/linux/src/posix_update.c @@ -51,6 +51,13 @@ static const char *const certificate_bundles[] = "/etc/ssl/cert.pem", /* Alpine, Arch, Void */ }; +/* Android's folders of them, the first found used */ +static const char *const certificate_folders[] = +{ + "/apex/com.android.conscrypt/cacerts", /* Android 14 and later */ + "/system/etc/security/cacerts", +}; + static pthread_once_t certificates_once = PTHREAD_ONCE_INIT; static mbedtls_x509_crt certificates; static int certificates_loaded; @@ -79,6 +86,17 @@ static void load_certificates(void) return; } } + /* Android's: a folder of one certificate a file, updated with the +Conscrypt module from Android 14 (the system's own is the older one) */ + for (index = 0; index < sizeof(certificate_folders) / sizeof(*certificate_folders); index++) + { + if (access(certificate_folders[index], R_OK) == 0 && + mbedtls_x509_crt_parse_path(&certificates, certificate_folders[index]) >= 0 && certificates.version) + { + certificates_loaded = 1; + return; + } + } /* no bundle of the system's: the environment's, said loudly, since it decides which servers updates are taken from */ if (environment && *environment && mbedtls_x509_crt_parse_file(&certificates, environment) >= 0 && @@ -137,11 +155,14 @@ struct connection unsigned char buffer[16384]; size_t start, end; int closed; + /* (an http:// address on this machine: no TLS) */ + int plain; }; static void connection_free(struct connection *connection) { - mbedtls_ssl_close_notify(&connection->ssl); + if (!connection->plain) + mbedtls_ssl_close_notify(&connection->ssl); mbedtls_ssl_free(&connection->ssl); mbedtls_ssl_config_free(&connection->config); mbedtls_net_free(&connection->net); @@ -157,6 +178,7 @@ static int connection_open(struct connection *connection, const char *host, cons mbedtls_ssl_config_init(&connection->config); connection->start = connection->end = 0; connection->closed = 0; + connection->plain = 0; if ((result = mbedtls_net_connect(&connection->net, host, port, MBEDTLS_NET_PROTO_TCP)) != 0) { set_error(error, error_size, "could not connect", result); @@ -208,7 +230,8 @@ static int connection_write(struct connection *connection, const char *data, siz { while (size) { - int result = mbedtls_ssl_write(&connection->ssl, (const unsigned char *)data, size); + int result = connection->plain ? mbedtls_net_send(&connection->net, (const unsigned char *)data, size) : + mbedtls_ssl_write(&connection->ssl, (const unsigned char *)data, size); if (result == MBEDTLS_ERR_SSL_WANT_READ || result == MBEDTLS_ERR_SSL_WANT_WRITE) continue; @@ -239,8 +262,10 @@ static int connection_fill(struct connection *connection) } for (;;) { - result = mbedtls_ssl_read(&connection->ssl, connection->buffer + connection->end, - sizeof(connection->buffer) - connection->end); + result = connection->plain ? mbedtls_net_recv_timeout(&connection->net, connection->buffer + connection->end, + sizeof(connection->buffer) - connection->end, TIMEOUT_MILLISECONDS) : + mbedtls_ssl_read(&connection->ssl, connection->buffer + connection->end, + sizeof(connection->buffer) - connection->end); if (result > 0) { connection->end += (size_t)result; @@ -251,6 +276,8 @@ static int connection_fill(struct connection *connection) { continue; } + if (connection->plain && result == MBEDTLS_ERR_SSL_TIMEOUT) + return -1; if (result == 0 || result == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY || result == MBEDTLS_ERR_NET_CONN_RESET) { connection->closed = 1; @@ -547,6 +574,234 @@ int update_download(const char *url, const char *path, unsigned long long maximu return 0; } +/* ---------- any request */ + +/* the body of an answer, into memory */ +static int read_memory_body(struct connection *connection, unsigned char **data, unsigned long long *size, + unsigned long long maximum, int chunked, unsigned long long length, int have_length) +{ + unsigned char buffer[16384]; + unsigned long long capacity = 0; + + *data = NULL; + *size = 0; + for (;;) + { + unsigned long long remaining = 0; + int count; + + if (chunked) + { + char line[128]; + char *after; + + if (!connection_read_line(connection, line, sizeof(line))) + return 0; + remaining = strtoull(line, &after, 16); + if (after == line) + return 0; + if (!remaining) + return 1; + } + else if (have_length) + { + if (*size >= length) + return 1; + remaining = length - *size; + } + else + { + remaining = sizeof(buffer); + } + while (remaining) + { + count = connection_read(connection, buffer, remaining < sizeof(buffer) ? (size_t)remaining : sizeof(buffer)); + if (count < 0) + return 0; + if (count == 0) + return !have_length && !chunked; + if (*size + (unsigned long long)count > maximum) + return 0; + if (*size + (unsigned long long)count + 1 > capacity) + { + unsigned long long grown_capacity = capacity ? capacity * 2 : 16384; + unsigned char *grown; + + while (grown_capacity < *size + (unsigned long long)count + 1) + grown_capacity *= 2; + grown = realloc(*data, (size_t)grown_capacity); + if (!grown) + return 0; + *data = grown; + capacity = grown_capacity; + } + memcpy(*data + *size, buffer, (size_t)count); + *size += (unsigned long long)count; + (*data)[*size] = 0; + if (chunked || have_length) + remaining -= (unsigned long long)count; + } + if (chunked) + { + char line[128]; + + if (!connection_read_line(connection, line, sizeof(line))) + return 0; + } + } +} + +int update_https_request(const char *method, const char *url, const char *headers, const void *body, + unsigned long body_size, unsigned long maximum, struct update_response *response, char *error, int error_size) +{ + struct connection *connection; + char host[256], port[16], path[2048]; + char *request; + char line[MAXIMUM_HEADER_SIZE]; + unsigned long long length = 0, received = 0; + unsigned char *data = NULL; + int have_length = 0, chunked = 0, plain = 0, result, status = 0; + size_t request_size, head_length, headers_length = 0; + + memset(response, 0, sizeof(*response)); + if (!strncasecmp(url, "http://", 7)) + { + char https[2100]; + const char *slash = strchr(url + 7, '/'); + const char *colon = strchr(url + 7, ':'); + + /* (plain HTTP only to this machine: a test's master) */ + snprintf(https, sizeof(https), "https://%s", url + 7); + if (!parse_url(https, host, sizeof(host), port, sizeof(port), path, sizeof(path)) || + (strcmp(host, "127.0.0.1") && strcasecmp(host, "localhost"))) + { + snprintf(error, (size_t)error_size, "plain http:// only to this machine: %s", url); + return 0; + } + if (!colon || (slash && colon > slash)) + snprintf(port, sizeof(port), "80"); + plain = 1; + } + else if (!parse_url(url, host, sizeof(host), port, sizeof(port), path, sizeof(path))) + { + snprintf(error, (size_t)error_size, "not an https:// address: %s", url); + return 0; + } + if (!plain) + { + pthread_once(&certificates_once, load_certificates); + if (!crypto_ready || !certificates_loaded) + { + snprintf(error, (size_t)error_size, "the system's certificate authorities were not found " + "(install ca-certificates, or set SSL_CERT_FILE)"); + return 0; + } + } + connection = calloc(1, sizeof(*connection)); + request_size = 4096 + (headers ? strlen(headers) : 0) + (body ? body_size : 0); + request = malloc(request_size); + if (!connection || !request) + { + free(connection); + free(request); + snprintf(error, (size_t)error_size, "out of memory"); + return 0; + } + if (plain) + { + mbedtls_net_init(&connection->net); + mbedtls_ssl_init(&connection->ssl); + mbedtls_ssl_config_init(&connection->config); + connection->plain = 1; + if ((result = mbedtls_net_connect(&connection->net, host, port, MBEDTLS_NET_PROTO_TCP)) != 0) + { + set_error(error, error_size, "could not connect", result); + connection_free(connection); + free(connection); + free(request); + return 0; + } + } + else if (!connection_open(connection, host, port, error, error_size)) + { + connection_free(connection); + free(connection); + free(request); + return 0; + } + snprintf(request, request_size, + "%s %s HTTP/1.1\r\nHost: %s%s%s\r\nUser-Agent: opence\r\nAccept: application/json\r\n" + "Connection: close\r\nContent-Length: %lu\r\n%s\r\n", + method, path, host, strcmp(port, plain ? "80" : "443") ? ":" : "", strcmp(port, plain ? "80" : "443") ? port : "", + body ? body_size : 0UL, headers ? headers : ""); + head_length = strlen(request); + if (body && body_size) + { + memcpy(request + head_length, body, body_size); + head_length += body_size; + } + result = connection_write(connection, request, head_length); + free(request); + if (!result || !connection_read_line(connection, line, sizeof(line)) || + sscanf(line, "HTTP/%*d.%*d %d", &status) != 1) + { + snprintf(error, (size_t)error_size, "no answer from %s", host); + connection_free(connection); + free(connection); + return 0; + } + response->status = status; + for (;;) + { + char *value; + + if (!connection_read_line(connection, line, sizeof(line))) + { + snprintf(error, (size_t)error_size, "a broken answer from %s", host); + connection_free(connection); + free(connection); + return 0; + } + if (!line[0]) + break; + if (headers_length + strlen(line) + 2 < sizeof(response->headers)) + { + memcpy(response->headers + headers_length, line, strlen(line)); + headers_length += strlen(line); + response->headers[headers_length++] = '\n'; + response->headers[headers_length] = 0; + } + value = strchr(line, ':'); + if (!value) + continue; + *value++ = 0; + value += strspn(value, " \t"); + if (!strcasecmp(line, "Content-Length")) + { + length = strtoull(value, NULL, 10); + have_length = 1; + } + else if (!strcasecmp(line, "Transfer-Encoding") && strcasestr(value, "chunked")) + { + chunked = 1; + } + } + if ((have_length && !chunked && length > maximum) || (status != 204 && status != 304 && strcmp(method, "HEAD") && + !read_memory_body(connection, &data, &received, maximum, chunked, length, have_length && !chunked))) + { + free(data); + snprintf(error, (size_t)error_size, "the answer from %s broke off, or is larger than %lu bytes", host, maximum); + connection_free(connection); + free(connection); + return 0; + } + response->body = data ? (char *)data : calloc(1, 1); + response->body_size = (unsigned long)received; + connection_free(connection); + free(connection); + return 1; +} + /* ---------- files and processes */ int update_executable_path(char *path, int size) diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c index 3299a3ee..ae1d96a6 100644 --- a/port/linux/src/sdl_platform.c +++ b/port/linux/src/sdl_platform.c @@ -15,6 +15,8 @@ and the debug keyboard that the game's console reads. #include "port_config.h" #include "p2p.h" #include "port_mods.h" +#include "server_mode.h" +#include "server_master.h" #include "xiso.h" #include "touch_input.h" #include "../game/text_filter.h" @@ -209,6 +211,8 @@ static void platform_choose_video_driver(void) BOOL platform_sdl_initialize(void) { + SDL_InitFlags flags = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD | SDL_INIT_EVENTS; + if (platform_sdl_started) return TRUE; #if !defined(_WIN32) && !defined(HALO_ANDROID) @@ -249,21 +253,51 @@ BOOL platform_sdl_initialize(void) SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "0"); } #endif - if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD | SDL_INIT_EVENTS)) +#if !defined(HALO_ANDROID) && !defined(HALO_WEB) + /* a dedicated server (server_mode.c) runs where there is no display, + sound or controller (a service, a container): SDL's drivers that need + none, and its events alone */ + if (halo_server_mode()) { - platform_log("SDL_Init failed: %s", SDL_GetError()); + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + flags = SDL_INIT_EVENTS; + } +#endif + if (!SDL_Init(flags)) + { + /* (tried again by each caller that needs SDL, some of them every + frame: a failure is logged once, and again only when its reason + changes, not tens of thousands of times without a display) */ + static char last_failure[256]; + const char *error = SDL_GetError(); + + if (strncmp(error, last_failure, sizeof(last_failure) - 1)) + { + snprintf(last_failure, sizeof(last_failure), "%s", error); + platform_log("SDL_Init failed: %s", error); + } return FALSE; } platform_sdl_started = TRUE; #ifndef HALO_ANDROID /* found (or offered to the player, platform_offer_game_data) before the - game's window opens, and checked to be the Xbox maps */ + game's window opens, and checked to be the Xbox maps (a dedicated + server's were, by server_mode.c) */ platform_data_root(); #ifndef HALO_WEB - data_check_maps(); + if (!halo_server_mode()) + data_check_maps(); #endif - /* (a new version looked for meanwhile, updater_poll asking about it) */ - updater_start(); + /* (a new version looked for meanwhile, updater_poll asking about it; a + dedicated server's is server.auto_update's) */ + if (!halo_server_mode()) + { + updater_start(); + /* (a player's public game on the master servers too, with a + publisher key: server_master.c) */ + server_master_start_host(); + } #endif /* (the mods, and whether to play those that say they cannot be played together, before the window opens: port_mods.c) */ @@ -1957,7 +1991,9 @@ static void platform_pump_events_headless(void) thread = SDL_GetCurrentThreadID(); if (SDL_GetCurrentThreadID() != thread) return; - exit_after_check(); + /* (a dedicated server stops itself: server_director.c) */ + if (!halo_server_mode()) + exit_after_check(); #ifndef HALO_ANDROID if (config_string("debug.update_answer")[0]) updater_poll(NULL); diff --git a/port/linux/src/server_console.c b/port/linux/src/server_console.c new file mode 100644 index 00000000..40b5e9b5 --- /dev/null +++ b/port/linux/src/server_console.c @@ -0,0 +1,478 @@ +/* +SERVER_CONSOLE.C + +A dedicated server's commands from its owner, and the console's output +(server_console.h). +*/ + +#include "platform.h" +#include "port_config.h" +#include "server_console.h" +#include "server_mode.h" + +#include + +#if HALO_SERVER_BUILD + +#include "posix_server.h" +#include "server_log.h" + +#include +#include +#include + +enum +{ + /* the init file's most lines */ + MAXIMUM_INIT_LINES = 512, + /* standard input's lines taken a frame */ + MAXIMUM_INPUT_LINES_PER_FRAME = 32, + INPUT_BUFFER_SIZE = 4096, + MAXIMUM_CAPTURE_DEPTH = 4, +}; + +static struct +{ + /* the init file's lines, and the next */ + char (*init_lines)[SERVER_COMMAND_LINE_SIZE]; + int init_count; + int init_next; + /* standard input: what is read and not yet a whole line, and its end */ + char input[INPUT_BUFFER_SIZE]; + int input_length; + int input_ended; + /* the "quit" its end asks for (server.console_exit_on_eof), not yet + taken: its last line came first */ + int quit_owed; + int input_overlong; + int lines_this_frame; + /* the captures: each one's text */ + char *captures[MAXIMUM_CAPTURE_DEPTH]; + int capture_lengths[MAXIMUM_CAPTURE_DEPTH]; + int capture_depth; + int capture_overflow; +} server_console; + +static const char *const role_names[] = { "viewer", "moderator", "admin", "owner" }; + +/* whether a word is the text, in either case */ +static int word_is(const char *word, int length, const char *text) +{ + int index; + + for (index = 0; index < length && text[index]; index++) + { + if (tolower((unsigned char)word[index]) != tolower((unsigned char)text[index])) + return 0; + } + return index == length && !text[index]; +} + +/* whether the text holds the part, in either case */ +static int holds_word(const char *text, int length, const char *part) +{ + int start, size = (int)strlen(part); + + for (start = 0; start + size <= length; start++) + { + if (word_is(text + start, size, part)) + return 1; + } + return 0; +} + +/* the word at the cursor, as the commands split a line (server_commands.c's +split_words: a quoted word whole, without its quotes): its start and +length, the cursor after it; 0 at the line's end */ +static int next_word(const char **cursor, const char **word, int *length) +{ + const char *at = *cursor; + + while (*at == ' ' || *at == '\t') + at++; + if (!*at) + return 0; + if (*at == '"') + { + *word = ++at; + while (*at && *at != '"') + at++; + } + else + { + *word = at; + while (*at && *at != ' ' && *at != '\t') + at++; + } + *length = (int)(at - *word); + *cursor = *at ? at + 1 : at; + return 1; +} + +int server_console_line_is_secret(const char *line) +{ + const char *cursor = line, *word, *setting; + int length, setting_length; + + if (!next_word(&cursor, &word, &length)) + return 0; + if (word_is(word, length, "sv_password")) + return 1; + if (!word_is(word, length, "sv_set")) + return 0; + /* (sv_set's setting: a password's, or the publisher key's) */ + if (!next_word(&cursor, &setting, &setting_length)) + return 0; + return holds_word(setting, setting_length, "password") || holds_word(setting, setting_length, "publisher_key"); +} + +int server_role_named(const char *name) +{ + int index; + + for (index = 0; index < (int)(sizeof(role_names) / sizeof(role_names[0])); index++) + { + if (!strcmp(name, role_names[index])) + return index; + } + return _server_role_none; +} + +const char *server_role_name(int role) +{ + return role >= 0 && role < (int)(sizeof(role_names) / sizeof(role_names[0])) ? role_names[role] : "none"; +} + +/* a line kept to printable ASCII, its ends' spaces and a comment's line +dropped: empty if nothing is left to run */ +static void clean_line(char *line) +{ + int read, written = 0; + + for (read = 0; line[read]; read++) + { + unsigned char character = (unsigned char)line[read]; + + if (character == '\t') + character = ' '; + if (character >= 0x20 && character < 0x7F) + line[written++] = (char)character; + } + line[written] = 0; + while (written > 0 && line[written - 1] == ' ') + line[--written] = 0; + for (read = 0; line[read] == ' '; read++) + { + } + if (read) + memmove(line, line + read, strlen(line + read) + 1); + if (line[0] == '#' || line[0] == ';' || (line[0] == '/' && line[1] == '/')) + line[0] = 0; +} + +void server_console_start(void) +{ + const char *setting = config_string("server.init_file"); + char path[1024]; + FILE *file; + char line[SERVER_COMMAND_LINE_SIZE + 2]; + + if (!setting[0]) + return; + /* (a full path, else the data root's) */ + if (setting[0] == '/' || (setting[0] && setting[1] == ':')) + snprintf(path, sizeof(path), "%s", setting); + else + snprintf(path, sizeof(path), "%s/%s", platform_data_root(), setting); + file = fopen(path, "r"); + if (!file) + { + server_log(_server_log_info, "server", "no init file (%s): the settings alone", path); + return; + } + server_console.init_lines = calloc(MAXIMUM_INIT_LINES, SERVER_COMMAND_LINE_SIZE); + while (server_console.init_lines && fgets(line, sizeof(line), file)) + { + int whole = strchr(line, '\n') != NULL || feof(file); + + line[strcspn(line, "\r\n")] = 0; + if (!whole) + { + int character; + + /* (the rest of a line too long to run, left out) */ + while ((character = fgetc(file)) != EOF && character != '\n') + { + } + server_log(_server_log_warning, "server", "%s: a line longer than %d characters left out", setting, + SERVER_COMMAND_LINE_SIZE - 1); + continue; + } + clean_line(line); + if (!line[0]) + continue; + /* (a line as long as the buffer: left out, not run cut short) */ + if (strlen(line) >= SERVER_COMMAND_LINE_SIZE) + { + server_log(_server_log_warning, "server", "%s: a line longer than %d characters left out", setting, + SERVER_COMMAND_LINE_SIZE - 1); + continue; + } + if (server_console.init_count >= MAXIMUM_INIT_LINES) + { + server_log(_server_log_warning, "server", "%s: lines past %d left out", setting, MAXIMUM_INIT_LINES); + break; + } + snprintf(server_console.init_lines[server_console.init_count++], SERVER_COMMAND_LINE_SIZE, "%s", line); + } + fclose(file); + server_log(_server_log_info, "server", "%d command%s from %s", server_console.init_count, + server_console.init_count == 1 ? "" : "s", path); +} + +/* a whole line of standard input's, taken out of the buffer: 1 */ +static int take_input_line(char *line, int size) +{ + char *end = memchr(server_console.input, '\n', (size_t)server_console.input_length); + int length; + + if (!end) + return 0; + length = (int)(end - server_console.input); + if (server_console.input_overlong) + { + /* (the end of a line too long, dropped with its start) */ + server_console.input_overlong = 0; + line[0] = 0; + } + else if (length >= size) + { + /* (a line longer than a command's: left out, not run cut short) */ + server_log(_server_log_warning, "console", "a line longer than %d characters left out", size - 1); + line[0] = 0; + } + else + { + snprintf(line, (size_t)size, "%.*s", length, server_console.input); + } + memmove(server_console.input, end + 1, (size_t)(server_console.input_length - length - 1)); + server_console.input_length -= length + 1; + return 1; +} + +int server_console_next(char *line, int size, int *origin) +{ + if (server_console.init_lines && server_console.init_next < server_console.init_count) + { + const char *taken = server_console.init_lines[server_console.init_next++]; + + /* (a line longer than a command's: left out, not run cut short) */ + if (strlen(taken) >= (size_t)size) + { + server_log(_server_log_warning, "console", "a line of the init file longer than %d characters left out", + size - 1); + taken = ""; + } + snprintf(line, (size_t)size, "%s", taken); + *origin = _server_origin_init_file; + if (server_console.init_next == server_console.init_count) + { + free(server_console.init_lines); + server_console.init_lines = NULL; + } + return 1; + } + *origin = _server_origin_standard_input; + if (!config_boolean("server.console")) + return 0; + /* (so many a frame: a pasted file runs over a few frames, and the game + goes on) */ + if (server_console.lines_this_frame >= MAXIMUM_INPUT_LINES_PER_FRAME) + return 0; + for (;;) + { + int count; + + if (take_input_line(line, size)) + { + clean_line(line); + if (!line[0]) + continue; + server_console.lines_this_frame++; + return 1; + } + if (server_console.input_ended) + { + if (!server_console.quit_owed) + return 0; + server_console.quit_owed = 0; + snprintf(line, (size_t)size, "quit"); + return 1; + } + if (server_console.input_length >= INPUT_BUFFER_SIZE - 1) + { + /* a line longer than the buffer: dropped, to its end */ + server_console.input_length = 0; + server_console.input_overlong = 1; + server_log(_server_log_warning, "console", "a line longer than %d characters left out", INPUT_BUFFER_SIZE - 1); + } + count = posix_server_stdin_read(server_console.input + server_console.input_length, + INPUT_BUFFER_SIZE - 1 - server_console.input_length); + if (count == 0) + return 0; + if (count < 0) + { + int last = server_console.input_length > 0 && !server_console.input_overlong; + + server_console.input_ended = 1; + if (config_boolean("server.console_exit_on_eof")) + { + server_log(_server_log_info, "console", "the standard input ended: stopping " + "(server.console_exit_on_eof)"); + server_console.quit_owed = 1; + } + /* (its last line, with no newline, before the quit) */ + if (last && server_console.input_length >= size) + { + server_log(_server_log_warning, "console", "a line longer than %d characters left out", size - 1); + last = 0; + } + if (last) + { + snprintf(line, (size_t)size, "%.*s", server_console.input_length, server_console.input); + clean_line(line); + } + server_console.input_length = 0; + if (last && line[0]) + { + server_console.lines_this_frame++; + return 1; + } + continue; + } + server_console.input_length += count; + } +} + +void server_console_frame(void) +{ + server_console.lines_this_frame = 0; +} + +/* ---------- output */ + +void server_console_output(const char *text) +{ + int depth = server_console.capture_depth; + + if (!halo_server_mode() || !text || !text[0]) + return; + if (depth > 0) + { + int index = depth - 1; + int length = (int)strlen(text); + + if (!server_console.captures[index] || + server_console.capture_lengths[index] + length + 2 > SERVER_COMMAND_OUTPUT_SIZE) + { + server_console.capture_overflow = 1; + return; + } + memcpy(server_console.captures[index] + server_console.capture_lengths[index], text, (size_t)length); + server_console.capture_lengths[index] += length; + server_console.captures[index][server_console.capture_lengths[index]++] = '\n'; + server_console.captures[index][server_console.capture_lengths[index]] = 0; + return; + } + /* (the game's own lines: its warnings, the host's notices) */ + server_log(_server_log_info, "console", "%s", text); +} + +int server_console_capture_begin(void) +{ + int index = server_console.capture_depth; + + if (index >= MAXIMUM_CAPTURE_DEPTH) + return 0; + server_console.captures[index] = malloc(SERVER_COMMAND_OUTPUT_SIZE); + if (server_console.captures[index]) + server_console.captures[index][0] = 0; + server_console.capture_lengths[index] = 0; + server_console.capture_depth++; + return 1; +} + +void server_console_free(char *text) +{ + free(text); +} + +char *server_console_capture_end(void) +{ + char *text; + + if (server_console.capture_depth <= 0) + return NULL; + server_console.capture_depth--; + text = server_console.captures[server_console.capture_depth]; + server_console.captures[server_console.capture_depth] = NULL; + return text; +} + +#else + +void server_console_start(void) +{ +} + +void server_console_frame(void) +{ +} + +int server_console_next(char *line, int size, int *origin) +{ + (void)line; + (void)size; + *origin = 0; + return 0; +} + +void server_console_output(const char *text) +{ + (void)text; +} + +int server_console_capture_begin(void) +{ + return 0; +} + +char *server_console_capture_end(void) +{ + return NULL; +} + +void server_console_free(char *text) +{ + (void)text; +} + +int server_console_line_is_secret(const char *line) +{ + (void)line; + return 0; +} + +int server_role_named(const char *name) +{ + (void)name; + return _server_role_none; +} + +const char *server_role_name(int role) +{ + (void)role; + return "none"; +} + +#endif diff --git a/port/linux/src/server_console.h b/port/linux/src/server_console.h new file mode 100644 index 00000000..4e496096 --- /dev/null +++ b/port/linux/src/server_console.h @@ -0,0 +1,72 @@ +/* +SERVER_CONSOLE.H + +A dedicated server's commands from its owner (server_console.c): the lines +of server.init_file, run once at the start, and of the standard input, read +without waiting each frame; and the console's output captured while a +command runs, for its reply. The commands themselves are the game's +(port/linux/game/server_commands.c), whatever the line came from: the HTTP +API and the remote console (server_http.c) run them too. +*/ + +#ifndef __HALO_LINUX_SERVER_CONSOLE_H +#define __HALO_LINUX_SERVER_CONSOLE_H + +enum +{ + SERVER_COMMAND_LINE_SIZE = 512, + /* the most of a command's output kept for its reply */ + SERVER_COMMAND_OUTPUT_SIZE = 64 * 1024, +}; + +/* who may run a command: each role may run its own and those below it */ +enum +{ + _server_role_none = -1, + _server_role_viewer = 0, + _server_role_moderator, + _server_role_admin, + /* the standard input, the init file, the telnet console: whoever holds + the process */ + _server_role_owner, +}; + +/* where a command came from */ +enum +{ + _server_origin_init_file, + _server_origin_standard_input, + _server_origin_web, + _server_origin_telnet, +}; + +/* reads the init file's lines (the first to be taken) */ +void server_console_start(void); +/* a frame begins: so many lines of standard input are taken a frame */ +void server_console_frame(void); +/* the next line to run (the init file's, then standard input's): 1 with it +in line, 0 if none now. A standard input that ends asks to quit as "quit" +when server.console_exit_on_eof says so */ +int server_console_next(char *line, int size, int *origin); + +/* the console's output (each line the terminal prints: terminal.c) */ +void server_console_output(const char *text); +/* the output captured from now (nested captures nest: the outer one sees +nothing of the inner's) until server_console_capture_end, which copies it, +lines separated by newlines, into text (the output's to free: NULL if +none); 1 if it began */ +int server_console_capture_begin(void); +char *server_console_capture_end(void); +/* frees what server_console_capture_end (and server_commands_run) gave: the +game's sources, whose free is the game's allocator's, call this */ +void server_console_free(char *text); + +/* the role a role's name ("viewer"...) is, _server_role_none if none */ +int server_role_named(const char *name); +/* whether a command line holds a secret, so that its text is not logged: +sv_password, or sv_set of a password's setting or the publisher key's, in +any case */ +int server_console_line_is_secret(const char *line); +const char *server_role_name(int role); + +#endif diff --git a/port/linux/src/server_events.c b/port/linux/src/server_events.c new file mode 100644 index 00000000..e50cea0d --- /dev/null +++ b/port/linux/src/server_events.c @@ -0,0 +1,194 @@ +/* +SERVER_EVENTS.C + +A dedicated server's events (server_events.h). +*/ + +#include "server_events.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "posix_server.h" + +#include +#include +#include +#include + +struct server_event +{ + unsigned long sequence; + char group[SERVER_EVENT_GROUP_SIZE]; + char *text; + /* the text with the players' addresses (NULL: the same as text) */ + char *addressed_text; +}; + +static struct +{ + pthread_mutex_t lock; + struct server_event events[SERVER_EVENTS_RING_SIZE]; + unsigned long latest; + void (*listener)(unsigned long sequence, const char *group, const char *text, const char *addressed_text); +} server_events = { PTHREAD_MUTEX_INITIALIZER }; + +/* an event's object with its type, number and time put first: NULL if it +is not an object, or out of memory */ +static char *event_text(const char *object, unsigned long sequence, long long time) +{ + char head[96]; + size_t length, head_length; + char *text; + + if (!object || object[0] != '{') + return NULL; + length = strlen(object); + snprintf(head, sizeof(head), "{\"type\":\"event\",\"seq\":%lu,\"time\":%lld%s", sequence, time, + object[1] == '}' ? "" : ","); + head_length = strlen(head); + text = malloc(head_length + length); + if (!text) + return NULL; + memcpy(text, head, head_length); + memcpy(text + head_length, object + 1, length); + return text; +} + +unsigned long server_event_add_addressed(const char *group, const char *object, const char *addressed_object) +{ + struct server_event *event; + char *text, *addressed_text = NULL; + unsigned long sequence; + long long time = posix_server_unix_milliseconds(); + void (*listener)(unsigned long sequence, const char *group, const char *text, const char *addressed_text); + + if (!object || object[0] != '{') + return 0; + pthread_mutex_lock(&server_events.lock); + sequence = server_events.latest + 1; + text = event_text(object, sequence, time); + if (addressed_object) + addressed_text = event_text(addressed_object, sequence, time); + if (!text || (addressed_object && !addressed_text)) + { + pthread_mutex_unlock(&server_events.lock); + free(text); + free(addressed_text); + return 0; + } + server_events.latest = sequence; + event = &server_events.events[sequence % SERVER_EVENTS_RING_SIZE]; + free(event->text); + free(event->addressed_text); + event->text = text; + event->addressed_text = addressed_text; + event->sequence = sequence; + snprintf(event->group, sizeof(event->group), "%s", group ? group : "server"); + listener = server_events.listener; + /* (the listener given the texts while the ring still holds them: under + the lock, as it copies what it keeps) */ + if (listener) + listener(sequence, event->group, text, addressed_text); + pthread_mutex_unlock(&server_events.lock); + return sequence; +} + +unsigned long server_event_add(const char *group, const char *object) +{ + return server_event_add_addressed(group, object, NULL); +} + +int server_events_read(unsigned long after, int maximum, int addresses, + void (*each)(void *context, unsigned long sequence, const char *group, const char *text), void *context, + unsigned long *next, unsigned long *missed) +{ + unsigned long oldest, sequence; + int count = 0; + + pthread_mutex_lock(&server_events.lock); + oldest = server_events.latest >= SERVER_EVENTS_RING_SIZE ? server_events.latest - SERVER_EVENTS_RING_SIZE + 1 : 1; + *missed = after + 1 < oldest ? oldest - (after + 1) : 0; + sequence = after + 1 < oldest ? oldest : after + 1; + for (; sequence <= server_events.latest && count < maximum; sequence++) + { + struct server_event const *event = &server_events.events[sequence % SERVER_EVENTS_RING_SIZE]; + + if (event->text && event->sequence == sequence) + { + each(context, sequence, event->group, addresses && event->addressed_text ? event->addressed_text : + event->text); + count++; + } + } + *next = sequence - 1; + if (after > server_events.latest) + { + *missed = 0; + *next = server_events.latest; + } + pthread_mutex_unlock(&server_events.lock); + return count; +} + +void server_events_set_listener(void (*listener)(unsigned long sequence, const char *group, const char *text, + const char *addressed_text)) +{ + pthread_mutex_lock(&server_events.lock); + server_events.listener = listener; + pthread_mutex_unlock(&server_events.lock); +} + +unsigned long server_events_latest(void) +{ + unsigned long latest; + + pthread_mutex_lock(&server_events.lock); + latest = server_events.latest; + pthread_mutex_unlock(&server_events.lock); + return latest; +} + +#else + +unsigned long server_event_add(const char *group, const char *object) +{ + (void)group; + (void)object; + return 0; +} + +unsigned long server_event_add_addressed(const char *group, const char *object, const char *addressed_object) +{ + (void)group; + (void)object; + (void)addressed_object; + return 0; +} + +int server_events_read(unsigned long after, int maximum, int addresses, + void (*each)(void *context, unsigned long sequence, const char *group, const char *text), void *context, + unsigned long *next, unsigned long *missed) +{ + (void)after; + (void)addresses; + (void)maximum; + (void)each; + (void)context; + *next = 0; + *missed = 0; + return 0; +} + +void server_events_set_listener(void (*listener)(unsigned long sequence, const char *group, const char *text, + const char *addressed_text)) +{ + (void)listener; +} + +unsigned long server_events_latest(void) +{ + return 0; +} + +#endif diff --git a/port/linux/src/server_events.h b/port/linux/src/server_events.h new file mode 100644 index 00000000..e12d92c1 --- /dev/null +++ b/port/linux/src/server_events.h @@ -0,0 +1,44 @@ +/* +SERVER_EVENTS.H + +A dedicated server's events (server_events.c; docs/dedicated_server.md, +"Events"): what happens to the game, its players and the server, each a +JSON object numbered in order, kept in a ring of the last +SERVER_EVENTS_RING_SIZE for the remote console's subscribers and +/api/events. Any thread may add one. +*/ + +#ifndef __HALO_LINUX_SERVER_EVENTS_H +#define __HALO_LINUX_SERVER_EVENTS_H + +enum +{ + SERVER_EVENTS_RING_SIZE = 512, + SERVER_EVENT_GROUP_SIZE = 16, +}; + +/* adds an event of a group ("player", "chat", "game", "moderation", +"status", "log", "master", "server") from its object's text ({"event": +"player_joined",...}): its sequence number and time are put in it. The +event's sequence number (0 if it was not kept) */ +unsigned long server_event_add(const char *group, const char *object); +/* the same, with a second text of it, addressed_object, for the readers who +see players' addresses (the owner, or every role with +server.log_addresses): the same event, under the same number, with its +address and hardware id */ +unsigned long server_event_add_addressed(const char *group, const char *object, const char *addressed_object); +/* each event after the sequence number after (0: every one kept), in order: +the callback gets its number, group and text (its addressed text, if it +has one, for a reader with addresses); how many. next is the number to ask +after next time, missed how many had left the ring */ +int server_events_read(unsigned long after, int maximum, int addresses, + void (*each)(void *context, unsigned long sequence, const char *group, const char *text), void *context, + unsigned long *next, unsigned long *missed); +/* each event as it is added (the remote console's), on the adder's thread: +its text and its addressed text (NULL for none) */ +void server_events_set_listener(void (*listener)(unsigned long sequence, const char *group, const char *text, + const char *addressed_text)); +/* the latest event's number */ +unsigned long server_events_latest(void); + +#endif diff --git a/port/linux/src/server_http.c b/port/linux/src/server_http.c new file mode 100644 index 00000000..637ca04c --- /dev/null +++ b/port/linux/src/server_http.c @@ -0,0 +1,3950 @@ +/* +SERVER_HTTP.C + +A dedicated server's web port (server_http.h; docs/dedicated_server.md, +"HTTP" and "The RCON"): HTTP/1.1 and WebSocket on non-blocking sockets, +served by a thread of its own. What only reads the game is answered from +the snapshots the game's thread leaves every half second (the status, the +players, the health); what needs the game is a ticket the game's thread +runs between frames (server_http_main_thread), its answer sent when it is +done, or "503 loading" after 20 seconds (a map loading holds the game's +thread). Every request is untrusted: its head, body, frames and messages +are bounded, its session, role, CSRF token and Origin checked, its address +rate limited, and its password guesses locked out. +*/ + +#include "platform.h" +#include "port_config.h" +#include "server_http.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "p2p_internal.h" +#include "posix.h" +#include "posix_server.h" +#include "server_console.h" +#include "server_events.h" +#include "server_json.h" +#include "server_log.h" +#include "server_master.h" +#include "server_toml.h" + +#include +#include +#include +#include +#include + +/* ---------- the game side's (port/linux/game/server_*.c) */ + +void server_status_write(struct server_json *json, int role); +void server_status_players(struct server_json *json, int role); +void server_status_maps(struct server_json *json); +void server_status_gametypes(struct server_json *json); +void server_status_rotation(struct server_json *json); +short server_director_state(void); +char const *server_status_state_name(short state); +char *server_commands_run(char const *line, int role, int origin, unsigned char *succeeded); +int server_director_set_rotation_text(char const *text, struct server_json *rejected); +int server_commands_set_setting_text(char const *name, char const *value, int role, char *reason, long reason_size, + int *code); +int server_commands_ban_address_text(char const *ip, char const *hwid, char const *names, char const *duration, + char const *reason, int role, char *message, long message_size, long *number); +void server_commands_settings_json(struct server_json *json); +void server_commands_bans_json(struct server_json *json); +int server_status_player_matches(long slot, char const *name, char *current, long size); +int server_gametype_check_text(char const *text, int length, char *reason, long reason_size, int *line); +void server_gametype_files_json(struct server_json *json); + +/* the admin page's files (tools/server_ui_embed.py: port/assets/server_ui) */ +struct server_ui_asset +{ + const char *path; + const char *content_type; + const unsigned char *data; + unsigned long size; + const char *etag; +}; +extern const struct server_ui_asset server_ui_assets[]; +extern const unsigned long server_ui_asset_count; + +/* ---------- limits */ + +enum +{ + MAXIMUM_CONNECTIONS = 24, + MAXIMUM_WEBSOCKETS = 8, + MAXIMUM_HEAD = 8 * 1024, + MAXIMUM_BODY = 64 * 1024, + /* a connection's answers waiting for its client: past the first, its + requests are not read; past the second, it is closed (one answer, + however long, is taken onto output below the first) */ + OUTPUT_PAUSE = 256 * 1024, + OUTPUT_MOST = 2 * 1024 * 1024, + HEAD_SECONDS = 10, + IDLE_SECONDS = 60, + TICKET_SECONDS = 20, + AUTH_SECONDS = 10, + WS_FRAME = 64 * 1024, + WS_MESSAGE = 256 * 1024, + WS_QUEUE = 1024, + MAXIMUM_SESSIONS = 16, + SESSION_SECONDS = 12 * 60 * 60, + SESSION_IDLE_SECONDS = 30 * 60, + TOKEN_BYTES = 32, + CSRF_BYTES = 16, + /* five wrong passwords from an address in ten minutes lock it for ten */ + LOGIN_FAILURES = 5, + LOGIN_WINDOW_SECONDS = 10 * 60, + LOGIN_LOCK_SECONDS = 10 * 60, + /* passwords checked a minute, in all */ + LOGIN_BUDGET = 30, + MAXIMUM_ADDRESSES = 128, + REQUESTS_UNAUTHENTICATED = 60, + REQUESTS_AUTHENTICATED = 600, + COMMANDS_PER_SECOND = 32, + COMMANDS_BURST = 64, + SNAPSHOT_MILLISECONDS = 500, + STATUS_EVENT_MILLISECONDS = 2000, + TICKETS_PER_FRAME = 16, + NAME_SIZE = 33, +}; + +/* the event groups (subscribe's) */ +static const char *const event_groups[] = { "player", "chat", "game", "moderation", "status", "log", "master", "server" }; +#define NUMBER_OF_EVENT_GROUPS ((int)(sizeof(event_groups) / sizeof(event_groups[0]))) + +/* ---------- SHA-1 (FIPS 180-4), for the WebSocket handshake alone */ + +static unsigned long sha1_rotate(unsigned long value, int bits) +{ + return ((value << bits) | (value >> (32 - bits))) & 0xFFFFFFFFUL; +} + +static void sha1(const unsigned char *data, unsigned long size, unsigned char digest[20]) +{ + unsigned long state[5] = { 0x67452301UL, 0xEFCDAB89UL, 0x98BADCFEUL, 0x10325476UL, 0xC3D2E1F0UL }; + unsigned long total = size + 9 + 63; + unsigned long blocks = total / 64; + unsigned long block; + + for (block = 0; block < blocks; block++) + { + unsigned char chunk[64]; + unsigned long words[80]; + unsigned long a, b, c, d, e; + int index; + + for (index = 0; index < 64; index++) + { + unsigned long position = block * 64 + (unsigned long)index; + + if (position < size) + chunk[index] = data[position]; + else if (position == size) + chunk[index] = 0x80; + else if (position >= blocks * 64 - 8) + { + unsigned long long bits = (unsigned long long)size * 8; + + chunk[index] = (unsigned char)(bits >> (8 * (blocks * 64 - 1 - position))); + } + else + chunk[index] = 0; + } + for (index = 0; index < 16; index++) + { + words[index] = (unsigned long)chunk[4 * index] << 24 | (unsigned long)chunk[4 * index + 1] << 16 | + (unsigned long)chunk[4 * index + 2] << 8 | chunk[4 * index + 3]; + } + for (index = 16; index < 80; index++) + words[index] = sha1_rotate(words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16], 1); + a = state[0]; b = state[1]; c = state[2]; d = state[3]; e = state[4]; + for (index = 0; index < 80; index++) + { + unsigned long f, k, temporary; + + if (index < 20) { f = (b & c) | (~b & d); k = 0x5A827999UL; } + else if (index < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1UL; } + else if (index < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDCUL; } + else { f = b ^ c ^ d; k = 0xCA62C1D6UL; } + temporary = (sha1_rotate(a, 5) + (f & 0xFFFFFFFFUL) + e + k + words[index]) & 0xFFFFFFFFUL; + e = d; d = c; c = sha1_rotate(b, 30); b = a; a = temporary; + } + state[0] = (state[0] + a) & 0xFFFFFFFFUL; + state[1] = (state[1] + b) & 0xFFFFFFFFUL; + state[2] = (state[2] + c) & 0xFFFFFFFFUL; + state[3] = (state[3] + d) & 0xFFFFFFFFUL; + state[4] = (state[4] + e) & 0xFFFFFFFFUL; + } + for (block = 0; block < 5; block++) + { + digest[4 * block] = (unsigned char)(state[block] >> 24); + digest[4 * block + 1] = (unsigned char)(state[block] >> 16); + digest[4 * block + 2] = (unsigned char)(state[block] >> 8); + digest[4 * block + 3] = (unsigned char)state[block]; + } +} + +/* ---------- structures */ + +struct http_request +{ + char method[8]; + char path[256]; + char query[512]; + int minor_version; + char host[256]; + char origin[256]; + /* (the browser's word on the request's site: same-origin...) */ + char fetch_site[32]; + char session_cookie[2 * TOKEN_BYTES + 1]; + char authorization[160]; + char csrf[2 * CSRF_BYTES + 1]; + char upgrade[32]; + char connection[64]; + char websocket_key[64]; + char websocket_version[8]; + char websocket_protocol[128]; + char forwarded_for[64]; + char forwarded_proto[16]; + char forwarded_host[256]; + char if_none_match[64]; + long content_length; + int has_transfer_encoding; + int has_expect; + int keep_alive; +}; + +/* a message queued for a WebSocket */ +struct ws_message +{ + struct ws_message *next; + int length; + char text[1]; +}; + +struct server_ticket; + +struct connection +{ + /* -1: the slot is free */ + int socket; + /* host byte order: the peer's, and the client's (behind the trusted + proxy, the address it forwarded: an IPv6 one's key, forwarded_address), + with its text for the log */ + unsigned long address; + unsigned long real_address; + char real_text[64]; + /* the request came from the trusted proxy: its X-Forwarded-Host and + X-Forwarded-Proto are its word */ + int forwarded; + int websocket; + unsigned char *in; + int in_length; + unsigned char *out; + int out_length; + int out_sent; + int out_capacity; + /* its output passed OUTPUT_MOST: closed at once */ + int out_overflowed; + Uint64 head_started; + Uint64 last_activity; + int close_after_write; + /* a request whose head is read, its body coming */ + int have_head; + int head_length; + struct http_request request; + struct server_ticket *ticket; + /* a WebSocket's */ + int role; + char by[NAME_SIZE]; + unsigned long groups; + unsigned char *message; + int message_length; + int message_open; + struct ws_message *queue_head, *queue_tail; + int queued; + int overflowed; + double bucket; + Uint64 bucket_time; + Uint64 auth_deadline; + int closing; +}; + +enum +{ + _ticket_api, + _ticket_ws_command, +}; + +struct server_ticket +{ + struct server_ticket *next; + int kind; + int role; + char by[NAME_SIZE]; + char method[8]; + char path[256]; + char *body; + int body_length; + /* the WebSocket command's id, as the client gave it (JSON text) */ + char id[32]; + /* the answer */ + int status; + char *response; + int retry_after; + int done; + Uint64 deadline; + /* its connection gave it up (it closed, or was answered "loading" when + the deadline passed): from then the ticket is the game's thread's, which + frees it once it has run it, and the HTTP thread never reads it again */ + int abandoned; +}; + +struct session +{ + int used; + unsigned char hash[P2P_SHA256_SIZE]; + char csrf[2 * CSRF_BYTES + 1]; + int role; + char by[NAME_SIZE]; + unsigned long created; + unsigned long used_at; +}; + +struct address_record +{ + unsigned long address; + unsigned long window_start; + int requests; + unsigned long failures_start; + int failures; + unsigned long locked_until; + unsigned long last_seen; +}; + +/* an event or log line for the WebSockets, from another thread: its text, +and after it (at addressed, 0 for none) its text with the players' +addresses */ +struct push +{ + struct push *next; + char group[SERVER_EVENT_GROUP_SIZE]; + int length; + int addressed; + char text[1]; +}; + +static struct +{ + int started; + int listening; + SDL_Thread *thread; + SDL_Mutex *lock; + struct connection connections[MAXIMUM_CONNECTIONS]; + struct session sessions[MAXIMUM_SESSIONS]; + struct address_record addresses[MAXIMUM_ADDRESSES]; + unsigned char session_key[32]; + unsigned long budget_minute; + int budget_used; + /* the web passwords' hashes (keyed by session_key), a role's each, as + config_changes() last read them: a change signs that role out */ + unsigned char password_hashes[_server_role_owner + 1][P2P_SHA256_SIZE]; + unsigned long password_changes; + int passwords_known; + /* under lock: the tickets waiting for the game's thread, and those it + ran */ + struct server_ticket *pending; + /* under lock: the snapshots the game's thread leaves */ + char *status_viewer; + char *status_admin; + char *players_viewer; + char *players_admin; + char *players_owner; + char state[16]; + Uint64 snapshot_time; + long metrics_players, metrics_max_players, metrics_machines, metrics_games; + double metrics_frame_longest, metrics_frame_mean; + /* under lock: what is to go to the WebSockets */ + struct push *pushes_head, *pushes_tail; + int pushes; + unsigned long pushes_dropped; + /* the HTTP thread's */ + Uint64 last_status_event; + unsigned long events_dropped; + unsigned long requests_total; + unsigned long logins_failed; + /* the game's thread's */ + Uint64 last_snapshot; +} http; + +/* ---------- small things */ + +static unsigned long now_seconds(void) +{ + return posix_server_unix_seconds(); +} + +static int text_equal_case(const char *a, const char *b) +{ + for (; *a && *b; a++, b++) + { + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) + return 0; + } + return !*a && !*b; +} + +static int constant_time_equal(const char *a, const char *b) +{ + size_t length_a = strlen(a), length_b = strlen(b), index; + unsigned char difference = (unsigned char)(length_a != length_b); + + for (index = 0; index < length_a; index++) + difference |= (unsigned char)(a[index] ^ b[index % (length_b ? length_b : 1)]); + return difference == 0; +} + +static void address_text(unsigned long address, char *text, int size) +{ + snprintf(text, (size_t)size, "%lu.%lu.%lu.%lu", (address >> 24) & 255, (address >> 16) & 255, (address >> 8) & 255, + address & 255); +} + +/* whether a connection is the server's own machine's: its socket's peer in +127.0.0.0/8, and not the trusted proxy's (a proxy on this machine passes +others' requests on: the owner's role is never given through it) */ +static int connection_is_local(struct connection *connection) +{ + return (connection->address >> 24) == 127 && !connection->forwarded; +} + +/* whether a reader of the role sees the players' addresses in the events: +the owner, or every role with server.log_addresses (as /api/players) */ +static int role_sees_addresses(int role) +{ + return role >= _server_role_owner || config_boolean("server.log_addresses"); +} + +static int parse_address(const char *text, unsigned long *address) +{ + unsigned long parts[4]; + int index; + + for (index = 0; index < 4; index++) + { + int digits = 0; + + parts[index] = 0; + while (*text >= '0' && *text <= '9' && digits < 3) + { + parts[index] = parts[index] * 10 + (unsigned long)(*text++ - '0'); + digits++; + } + if (!digits || parts[index] > 255 || (index < 3 && *text++ != '.')) + return 0; + } + *address = parts[0] << 24 | parts[1] << 16 | parts[2] << 8 | parts[3]; + return *text == 0 || *text == ',' || *text == ' '; +} + +static int hex_digit_value(char character) +{ + if (character >= '0' && character <= '9') + return character - '0'; + if (character >= 'a' && character <= 'f') + return character - 'a' + 10; + if (character >= 'A' && character <= 'F') + return character - 'A' + 10; + return -1; +} + +/* an IPv6 address's text, length characters (RFC 4291 2.2: up to eight +groups of up to four hex digits, one "::" for a run of zero groups, and a +dotted IPv4 address for the last two), into its 16 bytes: 0 if it is none +(a zone is none) */ +static int parse_address6(const char *text, int length, unsigned char *bytes) +{ + unsigned char parsed[16]; + int count = 0, gap = -1, at = 0; + + if (length >= 2 && text[0] == ':' && text[1] == ':') + { + gap = 0; + at = 2; + } + else if (length < 1 || text[0] == ':') + { + return 0; + } + while (at < length) + { + unsigned long group = 0; + int digits = 0; + + /* (the last 32 bits as a dotted IPv4 address) */ + if (memchr(text + at, '.', (size_t)(length - at)) && !memchr(text + at, ':', (size_t)(length - at))) + { + char dotted[16]; + unsigned long ipv4; + int rest = length - at; + + if (count > 12 || rest >= (int)sizeof(dotted)) + return 0; + memcpy(dotted, text + at, (size_t)rest); + dotted[rest] = 0; + if ((int)strspn(dotted, "0123456789.") != rest || !parse_address(dotted, &ipv4)) + return 0; + parsed[count++] = (unsigned char)(ipv4 >> 24); + parsed[count++] = (unsigned char)(ipv4 >> 16); + parsed[count++] = (unsigned char)(ipv4 >> 8); + parsed[count++] = (unsigned char)ipv4; + break; + } + while (at < length && digits < 4 && hex_digit_value(text[at]) >= 0) + { + group = group << 4 | (unsigned long)hex_digit_value(text[at++]); + digits++; + } + if (!digits || count >= 16) + return 0; + parsed[count++] = (unsigned char)(group >> 8); + parsed[count++] = (unsigned char)group; + if (at == length) + break; + if (text[at++] != ':' || at == length) + return 0; + if (text[at] == ':') + { + if (gap >= 0) + return 0; + gap = count; + at++; + } + } + if (gap < 0 ? count != 16 : count > 14) + return 0; + memset(bytes, 0, 16); + if (gap < 0) + gap = count; + memcpy(bytes, parsed, (size_t)gap); + memcpy(bytes + 16 - (count - gap), parsed + gap, (size_t)(count - gap)); + return 1; +} + +/* ":", a port of 0 to 65535 */ +static int port_text(const char *text) +{ + size_t digits = strspn(text + 1, "0123456789"); + + return text[0] == ':' && digits >= 1 && digits <= 5 && !text[1 + digits] && strtol(text + 1, NULL, 10) <= 65535; +} + +/* the address the trusted proxy put in X-Forwarded-For: its last entry, +the one it appended (those before it are the client's word, which anyone +may forge), as the addresses' table's key, and its text: an IPv4 address +("a.b.c.d", or "a.b.c.d:port") itself, an IPv6 one ("v6", "[v6]" or +"[v6]:port") a keyed hash of its /64 (one host's addresses, as many as it +likes, share its limits and lockouts), an IPv4-mapped one its IPv4 +address; 0 if the entry is none of these */ +static int forwarded_address(const char *list, unsigned long *address, char *text, int size) +{ + const char *last = strrchr(list, ','); + unsigned char bytes[16]; + char entry[64]; + char *colon; + int length; + + last = last ? last + 1 : list; + while (*last == ' ' || *last == '\t') + last++; + length = (int)strlen(last); + while (length && (last[length - 1] == ' ' || last[length - 1] == '\t')) + length--; + if (!length || length >= (int)sizeof(entry)) + return 0; + memcpy(entry, last, (size_t)length); + entry[length] = 0; + colon = strchr(entry, ':'); + if (entry[0] == '[') + { + char *close = strchr(entry, ']'); + + if (!close || !parse_address6(entry + 1, (int)(close - entry - 1), bytes) || (close[1] && !port_text(close + 1))) + return 0; + *close = 0; + snprintf(text, (size_t)size, "%s", entry + 1); + } + else if (colon && strchr(colon + 1, ':')) + { + if (!parse_address6(entry, length, bytes)) + return 0; + snprintf(text, (size_t)size, "%s", entry); + } + else + { + if (colon) + { + if (!port_text(colon)) + return 0; + *colon = 0; + } + if (strspn(entry, "0123456789.") != strlen(entry) || !parse_address(entry, address)) + return 0; + address_text(*address, text, size); + return 1; + } + if (!memcmp(bytes, "\0\0\0\0\0\0\0\0\0\0\xff\xff", 12)) + { + *address = (unsigned long)bytes[12] << 24 | (unsigned long)bytes[13] << 16 | (unsigned long)bytes[14] << 8 | + bytes[15]; + address_text(*address, text, size); + return 1; + } + { + unsigned char digest[P2P_SHA256_SIZE]; + + p2p_hmac_sha256(http.session_key, sizeof(http.session_key), bytes, 8, digest); + *address = (unsigned long)digest[0] << 24 | (unsigned long)digest[1] << 16 | (unsigned long)digest[2] << 8 | + digest[3]; + } + return 1; +} + +/* ---------- output */ + +/* the bytes written for the client and not yet sent */ +static int out_pending(struct connection *connection) +{ + return connection->out_length - connection->out_sent; +} + +static void out_append(struct connection *connection, const void *data, int size) +{ + if (size <= 0 || connection->out_overflowed) + return; + /* (a client that does not read its answers: past OUTPUT_PAUSE nothing + more of its is read, so only it fills its output this far) */ + if (out_pending(connection) > OUTPUT_PAUSE && out_pending(connection) + size > OUTPUT_MOST) + { + connection->out_overflowed = 1; + connection->out_length = connection->out_sent = 0; + connection->close_after_write = 1; + connection->closing = connection->websocket; + return; + } + if (connection->out_length + size > connection->out_capacity && connection->out_sent > 0) + { + /* (what was sent makes room first) */ + memmove(connection->out, connection->out + connection->out_sent, (size_t)out_pending(connection)); + connection->out_length -= connection->out_sent; + connection->out_sent = 0; + } + if (connection->out_length + size > connection->out_capacity) + { + int capacity = connection->out_capacity ? connection->out_capacity : 4096; + unsigned char *grown; + + while (capacity < connection->out_length + size) + capacity *= 2; + grown = realloc(connection->out, (size_t)capacity); + if (!grown) + { + connection->close_after_write = 1; + return; + } + connection->out = grown; + connection->out_capacity = capacity; + } + memcpy(connection->out + connection->out_length, data, (size_t)size); + connection->out_length += size; +} + +static const char *status_text(int status) +{ + switch (status) + { + case 101: return "Switching Protocols"; + case 200: return "OK"; + case 304: return "Not Modified"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 409: return "Conflict"; + case 413: return "Payload Too Large"; + case 426: return "Upgrade Required"; + case 429: return "Too Many Requests"; + case 500: return "Internal Server Error"; + case 503: return "Service Unavailable"; + default: return "OK"; + } +} + +/* a whole response: its status, type and body, and extra header lines +(each ending "\r\n") */ +static void respond(struct connection *connection, int status, const char *content_type, const void *body, int size, + const char *extra_headers, int no_store) +{ + char head[2048]; + int length; + + length = snprintf(head, sizeof(head), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %d\r\n" + "Cache-Control: %s\r\n" + "X-Content-Type-Options: nosniff\r\n" + /* (same-origin, not no-referrer: under no-referrer a browser sends + the page's own posts with Origin: null) */ + "Referrer-Policy: same-origin\r\n" + "Content-Security-Policy: default-src 'self'; frame-ancestors 'none'\r\n" + "X-Frame-Options: DENY\r\n" + "Connection: %s\r\n" + "%s\r\n", + status, status_text(status), content_type, size, no_store ? "no-store" : "no-cache", + connection->close_after_write || !connection->request.keep_alive ? "close" : "keep-alive", + extra_headers ? extra_headers : ""); + if (!connection->request.keep_alive) + connection->close_after_write = 1; + out_append(connection, head, length); + if (strcmp(connection->request.method, "HEAD")) + out_append(connection, body, size); +} + +static void respond_json(struct connection *connection, int status, const char *json, const char *extra_headers) +{ + respond(connection, status, "application/json", json, (int)strlen(json), extra_headers, 1); +} + +/* {"ok":false,"error":{"code","message"}} */ +static void respond_error(struct connection *connection, int status, const char *code, const char *message, + int retry_after) +{ + struct server_json json; + char headers[64] = ""; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 0); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message); + if (retry_after > 0) + server_json_integer(&json, "retry_after", retry_after); + server_json_object_end(&json); + server_json_object_end(&json); + if (retry_after > 0) + snprintf(headers, sizeof(headers), "Retry-After: %d\r\n", retry_after); + respond_json(connection, status, server_json_text(&json), headers); + server_json_free(&json); +} + +/* ---------- the request's head (a pure parser: the harness tests it) */ + +/* the head's lines read into request: 0 if it is not a request this +server takes, with the status and code to answer */ +int server_http_parse_head(const char *head, int length, struct http_request *request, int *status, const char **code) +{ + const char *line = head, *end = head + length; + int line_number = 0; + int has_content_length = 0; + + memset(request, 0, sizeof(*request)); + request->content_length = 0; + *status = 400; + *code = "bad_request"; + while (line < end) + { + const char *line_end = memchr(line, '\n', (size_t)(end - line)); + int line_length; + char text[MAXIMUM_HEAD]; + + /* (lines end with CRLF: a bare LF, or a CR inside a line, which a proxy + in front may read otherwise, is refused) */ + if (!line_end) + line_end = end; + else if (line_end == line || line_end[-1] != '\r') + return 0; + line_length = (int)(line_end - line); + if (line_length > 0 && line[line_length - 1] == '\r') + line_length--; + if (memchr(line, '\r', (size_t)line_length)) + return 0; + memcpy(text, line, (size_t)line_length); + text[line_length] = 0; + line = line_end + 1; + if (!line_number++) + { + char target[1024]; + char version[16]; + char *question; + int index; + + if (sscanf(text, "%7s %1023s %15s", request->method, target, version) != 3 || + strncmp(version, "HTTP/1.", 7) || (version[7] != '0' && version[7] != '1') || version[8]) + { + return 0; + } + request->minor_version = version[7] - '0'; + request->keep_alive = request->minor_version == 1; + if (strcmp(request->method, "GET") && strcmp(request->method, "POST") && strcmp(request->method, "PUT") && + strcmp(request->method, "DELETE") && strcmp(request->method, "HEAD")) + { + *status = 405; + *code = "method"; + return 0; + } + question = strchr(target, '?'); + if (question) + { + *question = 0; + snprintf(request->query, sizeof(request->query), "%s", question + 1); + } + if (target[0] != '/' || strlen(target) >= sizeof(request->path) || strstr(target, "..") || + strstr(target, "//") || strchr(target, '\\') || strchr(target, '%')) + { + return 0; + } + for (index = 0; target[index]; index++) + { + if ((unsigned char)target[index] <= 0x20 || (unsigned char)target[index] >= 0x7F) + return 0; + } + snprintf(request->path, sizeof(request->path), "%s", target); + continue; + } + /* (the empty line ends the head: nothing may follow it) */ + if (!line_length) + { + if (line < end) + return 0; + break; + } + { + char *colon = strchr(text, ':'); + char *value; + size_t value_length; + + if (!colon || colon == text) + return 0; + *colon = 0; + value = colon + 1; + while (*value == ' ' || *value == '\t') + value++; + value_length = strlen(value); + while (value_length && (value[value_length - 1] == ' ' || value[value_length - 1] == '\t')) + value[--value_length] = 0; +#define HEADER(name, field) else if (text_equal_case(text, name)) snprintf(request->field, sizeof(request->field), "%s", value) + if (text_equal_case(text, "Content-Length")) + { + char *number_end; + long number; + + /* (digits alone, and once: two lengths, which a proxy in front may + read otherwise, are refused: RFC 9112, 6.3) */ + if (!value[0] || !isdigit((unsigned char)value[0]) || has_content_length) + return 0; + has_content_length = 1; + number = strtol(value, &number_end, 10); + if (*number_end) + return 0; + if (number < 0 || value_length > 9) + { + *status = 413; + *code = "too_large"; + return 0; + } + request->content_length = number; + } + else if (text_equal_case(text, "Transfer-Encoding")) + request->has_transfer_encoding = 1; + else if (text_equal_case(text, "Expect")) + request->has_expect = 1; + else if (text_equal_case(text, "Cookie")) + { + const char *found = strstr(value, "opence_session="); + + while (found && found != value && found[-1] != ' ' && found[-1] != ';') + found = strstr(found + 1, "opence_session="); + if (found) + { + size_t taken = strcspn(found + 15, "; "); + + if (taken < sizeof(request->session_cookie)) + snprintf(request->session_cookie, sizeof(request->session_cookie), "%.*s", (int)taken, found + 15); + } + } + else if (text_equal_case(text, "Connection")) + { + snprintf(request->connection, sizeof(request->connection), "%s", value); + if (strstr(value, "close") || strstr(value, "Close")) + request->keep_alive = 0; + else if (strstr(value, "keep-alive") || strstr(value, "Keep-Alive")) + request->keep_alive = 1; + } + HEADER("Host", host); + HEADER("Origin", origin); + HEADER("Sec-Fetch-Site", fetch_site); + HEADER("Authorization", authorization); + HEADER("X-CSRF-Token", csrf); + HEADER("Upgrade", upgrade); + HEADER("Sec-WebSocket-Key", websocket_key); + HEADER("Sec-WebSocket-Version", websocket_version); + HEADER("Sec-WebSocket-Protocol", websocket_protocol); + else if (text_equal_case(text, "X-Forwarded-For")) + { + /* (its end, if it is longer than the field: the trusted proxy's + entry is the last) */ + snprintf(request->forwarded_for, sizeof(request->forwarded_for), "%s", + value_length < sizeof(request->forwarded_for) ? value : + value + value_length - (sizeof(request->forwarded_for) - 1)); + } + HEADER("X-Forwarded-Proto", forwarded_proto); + HEADER("X-Forwarded-Host", forwarded_host); + HEADER("If-None-Match", if_none_match); +#undef HEADER + } + } + if (!request->method[0]) + return 0; + if (request->has_transfer_encoding || request->has_expect) + return 0; + if (request->content_length > MAXIMUM_BODY) + { + *status = 413; + *code = "too_large"; + return 0; + } + return 1; +} + +/* a query's parameter's value: the default if it is not there or no number */ +static long query_number(const char *query, const char *name, long fallback) +{ + size_t length = strlen(name); + const char *at = query; + + while (at && *at) + { + if (!strncmp(at, name, length) && at[length] == '=') + { + char *end; + long value = strtol(at + length + 1, &end, 10); + + return end != at + length + 1 ? value : fallback; + } + at = strchr(at, '&'); + if (at) + at++; + } + return fallback; +} + +/* ---------- addresses: rate limits and lockouts */ + +/* the address's record, seen now; made if it has none, in an unused one, +else the least lately seen one that is not locked, else (every one locked) +the one whose lock ends soonest: no address is refused for want of room */ +static struct address_record *address_record(unsigned long address) +{ + struct address_record *unused = NULL, *unlocked = NULL, *locked = NULL, *record; + unsigned long now = now_seconds(); + int index; + + for (index = 0; index < MAXIMUM_ADDRESSES; index++) + { + record = &http.addresses[index]; + if (!record->last_seen) + { + if (!unused) + unused = record; + } + else if (record->address == address) + { + record->last_seen = now; + return record; + } + else if (record->locked_until > now) + { + if (!locked || record->locked_until < locked->locked_until) + locked = record; + } + else if (!unlocked || record->last_seen < unlocked->last_seen) + { + unlocked = record; + } + } + record = unused ? unused : unlocked ? unlocked : locked; + memset(record, 0, sizeof(*record)); + record->address = address; + record->last_seen = now; + return record; +} + +/* a request from the address: 0 if it is past its allowance a minute */ +static int address_allows_request(unsigned long address, int authenticated) +{ + struct address_record *record = address_record(address); + unsigned long now = now_seconds(); + + if (now - record->window_start >= 60) + { + record->window_start = now; + record->requests = 0; + } + record->requests++; + return record->requests <= (authenticated ? REQUESTS_AUTHENTICATED : REQUESTS_UNAUTHENTICATED); +} + +/* the seconds the address is locked out for (0: not) */ +static int address_locked(unsigned long address) +{ + struct address_record *record = address_record(address); + unsigned long now = now_seconds(); + + return record->locked_until > now ? (int)(record->locked_until - now) : 0; +} + +static void address_login_failed(unsigned long address, const char *text) +{ + struct address_record *record = address_record(address); + unsigned long now = now_seconds(); + + http.logins_failed++; + if (now - record->failures_start >= LOGIN_WINDOW_SECONDS) + { + record->failures_start = now; + record->failures = 0; + } + if (++record->failures >= LOGIN_FAILURES) + { + record->locked_until = now + LOGIN_LOCK_SECONDS; + record->failures = 0; + server_log(_server_log_warning, "server", "web: %s locked out for %d minutes after %d wrong passwords", + config_boolean("server.log_addresses") ? text : "an address", LOGIN_LOCK_SECONDS / 60, LOGIN_FAILURES); + } +} + +/* ---------- sessions */ + +static void session_hash(const char *token, unsigned char *hash) +{ + p2p_hmac_sha256(http.session_key, sizeof(http.session_key), token, (int)strlen(token), hash); +} + +/* the session of a token, refreshed; NULL if there is none (or it lapsed) */ +static struct session *session_find(const char *token) +{ + unsigned char hash[P2P_SHA256_SIZE]; + unsigned long now = now_seconds(); + int index; + + if (strlen(token) != 2 * TOKEN_BYTES) + return NULL; + session_hash(token, hash); + for (index = 0; index < MAXIMUM_SESSIONS; index++) + { + struct session *session = &http.sessions[index]; + + if (!session->used || !p2p_equal(session->hash, hash, P2P_SHA256_SIZE)) + continue; + if (now - session->created > SESSION_SECONDS || now - session->used_at > SESSION_IDLE_SECONDS) + { + session->used = 0; + return NULL; + } + session->used_at = now; + return session; + } + return NULL; +} + +/* a new session (the oldest one gives way), its token in token */ +static struct session *session_new(int role, const char *by, char *token) +{ + struct session *oldest = &http.sessions[0]; + unsigned char bytes[TOKEN_BYTES]; + int index; + + for (index = 0; index < MAXIMUM_SESSIONS; index++) + { + if (!http.sessions[index].used) + { + oldest = &http.sessions[index]; + break; + } + if (http.sessions[index].used_at < oldest->used_at) + oldest = &http.sessions[index]; + } + memset(oldest, 0, sizeof(*oldest)); + posix_random_bytes(bytes, sizeof(bytes)); + p2p_hex(bytes, TOKEN_BYTES, token); + session_hash(token, oldest->hash); + posix_random_bytes(bytes, CSRF_BYTES); + p2p_hex(bytes, CSRF_BYTES, oldest->csrf); + oldest->role = role; + snprintf(oldest->by, sizeof(oldest->by), "%s", by); + oldest->created = oldest->used_at = now_seconds(); + oldest->used = 1; + return oldest; +} + +static void ws_close(struct connection *connection, int code, const char *reason); + +/* the web passwords, checked for changes (sv_set, the settings' page, +sv_reload): the sessions of a role whose password changed end, and its +remote consoles close */ +static void passwords_check(void) +{ + static const char *const settings[_server_role_owner + 1] = { + "server.web_viewer_password", "server.web_moderator_password", "server.web_password", + "server.web_owner_password" }; + unsigned long changes = config_changes(); + unsigned long changed = 0; + int role, index; + + if (http.passwords_known && changes == http.password_changes) + return; + http.password_changes = changes; + for (role = 0; role <= _server_role_owner; role++) + { + const char *password = config_string(settings[role]); + unsigned char hash[P2P_SHA256_SIZE]; + + p2p_hmac_sha256(http.session_key, sizeof(http.session_key), password, (int)strlen(password), hash); + if (http.passwords_known && !p2p_equal(hash, http.password_hashes[role], P2P_SHA256_SIZE)) + changed |= 1UL << role; + memcpy(http.password_hashes[role], hash, P2P_SHA256_SIZE); + } + http.passwords_known = 1; + if (!changed) + return; + for (index = 0; index < MAXIMUM_SESSIONS; index++) + { + if (http.sessions[index].used && http.sessions[index].role >= 0 && + (changed & (1UL << http.sessions[index].role))) + { + http.sessions[index].used = 0; + } + } + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &http.connections[index]; + + if (connection->socket >= 0 && connection->websocket && connection->role >= 0 && + connection->role <= _server_role_owner && (changed & (1UL << connection->role))) + { + connection->role = _server_role_none; + ws_close(connection, 1008, "the password changed"); + } + } + for (role = 0; role <= _server_role_owner; role++) + { + if (changed & (1UL << role)) + server_log(_server_log_info, "server", "web: the %s password changed: its sessions are signed out", + server_role_name(role)); + } +} + +/* the role a password gives (checked against the budget and the address's +lockout; the address's text for the log): _server_role_none, and the +status and code why. The owner's password gives the owner's role to a +local connection (connection_is_local) alone: from anywhere else it is +refused "forbidden", and counted as a wrong password is (no cheaper to +guess than another) */ +static int password_role(unsigned long address, const char *address_shown, const char *password, int local, + int *status, const char **code, int *retry_after) +{ + unsigned long minute = now_seconds() / 60; + const char *admin = config_string("server.web_password"); + const char *moderator = config_string("server.web_moderator_password"); + const char *viewer = config_string("server.web_viewer_password"); + const char *owner = config_string("server.web_owner_password"); + int locked = address_locked(address); + int owner_elsewhere = 0; + + /* (a password changed since the thread's loop began: its sessions end + before one is begun with the new one) */ + passwords_check(); + *retry_after = 0; + if (locked) + { + *status = 429; + *code = "locked"; + *retry_after = locked; + return _server_role_none; + } + if (minute != http.budget_minute) + { + http.budget_minute = minute; + http.budget_used = 0; + } + if (++http.budget_used > LOGIN_BUDGET) + { + *status = 429; + *code = "rate_limited"; + *retry_after = 60 - (int)(now_seconds() % 60); + return _server_role_none; + } + if (password && password[0] && owner[0] && constant_time_equal(password, owner)) + { + if (local) + return _server_role_owner; + owner_elsewhere = 1; + } + if (password && password[0]) + { + if (admin[0] && constant_time_equal(password, admin)) + return _server_role_admin; + if (moderator[0] && constant_time_equal(password, moderator)) + return _server_role_moderator; + if (viewer[0] && constant_time_equal(password, viewer)) + return _server_role_viewer; + } + address_login_failed(address, address_shown); + if (owner_elsewhere) + { + server_log(_server_log_warning, "server", "web: the owner's password was given from %s: refused (it signs in " + "from this machine alone)", config_boolean("server.log_addresses") ? address_shown : "another address"); + *status = 403; + *code = "forbidden"; + return _server_role_none; + } + *status = 401; + *code = "denied"; + return _server_role_none; +} + +/* ---------- origins */ + +/* the host the request was made to (a trusted proxy's word for it) */ +static const char *request_host(struct connection *connection) +{ + if (connection->request.forwarded_host[0] && connection->forwarded) + return connection->request.forwarded_host; + return connection->request.host; +} + +/* whether the request's Origin is the server's own (scheme ignored: the +proxy's) */ +static int origin_matches(struct connection *connection) +{ + const char *origin = connection->request.origin; + const char *host = request_host(connection); + const char *after_scheme = strstr(origin, "://"); + + /* (an Origin of "null", a browser's for a page whose referrer policy + hides it, taken only when the browser itself says the request is + same-origin: Sec-Fetch-Site, which no page can set) */ + if (!strcmp(origin, "null")) + return !strcmp(connection->request.fetch_site, "same-origin"); + if (!origin[0] || !host[0] || !after_scheme) + return 0; + return text_equal_case(after_scheme + 3, host); +} + +/* ---------- tickets */ + +static struct server_ticket *ticket_new(int kind, int role, const char *by) +{ + struct server_ticket *ticket = calloc(1, sizeof(*ticket)); + + if (!ticket) + return NULL; + ticket->kind = kind; + ticket->role = role; + snprintf(ticket->by, sizeof(ticket->by), "%s", by ? by : ""); + ticket->deadline = SDL_GetTicksNS() + (Uint64)TICKET_SECONDS * 1000000000ULL; + return ticket; +} + +static void ticket_free(struct server_ticket *ticket) +{ + if (!ticket) + return; + free(ticket->body); + free(ticket->response); + free(ticket); +} + +static void ticket_submit(struct server_ticket *ticket) +{ + SDL_LockMutex(http.lock); + ticket->next = http.pending; + http.pending = ticket; + SDL_UnlockMutex(http.lock); +} + +/* ---------- WebSocket messages */ + +static void ws_frame(struct connection *connection, int opcode, const void *payload, int size) +{ + unsigned char head[10]; + int length = 0; + + head[length++] = (unsigned char)(0x80 | opcode); + if (size < 126) + { + head[length++] = (unsigned char)size; + } + else if (size < 65536) + { + head[length++] = 126; + head[length++] = (unsigned char)(size >> 8); + head[length++] = (unsigned char)size; + } + else + { + int index; + + head[length++] = 127; + for (index = 7; index >= 0; index--) + head[length++] = index >= 4 ? 0 : (unsigned char)((unsigned long)size >> (8 * index)); + } + out_append(connection, head, length); + out_append(connection, payload, size); +} + +static void ws_close(struct connection *connection, int code, const char *reason) +{ + unsigned char payload[128]; + int length = (int)strlen(reason); + + if (connection->closing) + return; + if (length > 120) + length = 120; + /* (the messages queued before it go before it) */ + while (connection->queue_head) + { + struct ws_message *message = connection->queue_head; + + connection->queue_head = message->next; + ws_frame(connection, 0x1, message->text, message->length); + free(message); + } + connection->queue_tail = NULL; + connection->queued = 0; + payload[0] = (unsigned char)(code >> 8); + payload[1] = (unsigned char)code; + memcpy(payload + 2, reason, (size_t)length); + ws_frame(connection, 0x8, payload, length + 2); + connection->closing = 1; + connection->close_after_write = 1; +} + +/* a message for the client, queued (sent as the socket takes it); past +WS_QUEUE, the queue goes and the client is told it overflowed */ +static void ws_send(struct connection *connection, const char *text) +{ + int length = (int)strlen(text); + struct ws_message *message; + + if (connection->closing || connection->overflowed) + return; + if (connection->queued >= WS_QUEUE) + { + struct ws_message *each = connection->queue_head; + + while (each) + { + struct ws_message *next = each->next; + + free(each); + each = next; + } + connection->queue_head = connection->queue_tail = NULL; + connection->queued = 0; + connection->overflowed = 1; + http.events_dropped++; + ws_frame(connection, 0x1, "{\"type\":\"overflow\"}", 19); + ws_close(connection, 1013, "overflow"); + return; + } + message = malloc(sizeof(*message) + (size_t)length); + if (!message) + return; + message->next = NULL; + message->length = length; + memcpy(message->text, text, (size_t)length + 1); + if (connection->queue_tail) + connection->queue_tail->next = message; + else + connection->queue_head = message; + connection->queue_tail = message; + connection->queued++; +} + +/* the queue onto the socket's buffer, as it drains */ +static void ws_flush_queue(struct connection *connection) +{ + while (connection->queue_head && out_pending(connection) < OUTPUT_PAUSE) + { + struct ws_message *message = connection->queue_head; + + connection->queue_head = message->next; + if (!connection->queue_head) + connection->queue_tail = NULL; + connection->queued--; + ws_frame(connection, 0x1, message->text, message->length); + free(message); + } +} + +static void ws_error(struct connection *connection, const char *id, const char *code, const char *message) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "type", "error"); + if (id && id[0]) + server_json_raw(&json, "id", id); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message); + server_json_object_end(&json); + server_json_object_end(&json); + ws_send(connection, server_json_text(&json)); + server_json_free(&json); +} + +static void ws_reply_refused(struct connection *connection, const char *id, const char *code, const char *message) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "type", "reply"); + server_json_raw(&json, "id", id && id[0] ? id : "null"); + server_json_boolean(&json, "ok", 0); + server_json_array_begin(&json, "lines"); + server_json_array_end(&json); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message); + server_json_object_end(&json); + server_json_object_end(&json); + ws_send(connection, server_json_text(&json)); + server_json_free(&json); +} + +static void ws_auth_answer(struct connection *connection, int ok, const char *code, const char *message, int retry_after) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "type", "auth"); + server_json_boolean(&json, "ok", ok); + if (ok) + { + server_json_string(&json, "role", server_role_name(connection->role)); + } + else + { + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message); + if (retry_after > 0) + server_json_integer(&json, "retry_after", retry_after); + server_json_object_end(&json); + } + server_json_object_end(&json); + ws_send(connection, server_json_text(&json)); + server_json_free(&json); + if (!ok) + ws_close(connection, 1008, code); +} + +static int group_index(const char *name) +{ + int index; + + for (index = 0; index < NUMBER_OF_EVENT_GROUPS; index++) + { + if (!strcmp(name, event_groups[index])) + return index; + } + return -1; +} + +/* whether the connection takes an event of the group */ +static int ws_wants(struct connection *connection, const char *group) +{ + int index = group_index(group); + + if (!connection->websocket || connection->role == _server_role_none || connection->closing) + return 0; + if (!strcmp(group, "chat") && connection->role < _server_role_admin) + return 0; + return !strcmp(group, "server") || (index >= 0 && (connection->groups & (1UL << index))); +} + +struct replay_context +{ + struct connection *connection; +}; + +/* whether the connection's events carry the players' addresses */ +static int ws_addresses(struct connection *connection) +{ + return role_sees_addresses(connection->role); +} + +static void ws_replay_one(void *context, unsigned long sequence, const char *group, const char *text) +{ + struct replay_context *replay = context; + size_t length = strlen(text); + char *marked; + + (void)sequence; + if (!ws_wants(replay->connection, group) || length < 2) + return; + marked = malloc(length + 16); + if (!marked) + return; + /* (,"replay":true before its closing brace) */ + memcpy(marked, text, length - 1); + strcpy(marked + length - 1, ",\"replay\":true}"); + ws_send(replay->connection, marked); + free(marked); +} + +/* the status event, as the connection's role sees it */ +static void ws_send_status(struct connection *connection) +{ + char *text; + const char *status; + size_t length; + /* (before the lock: the events' lock is taken before this one, as an + event is added) */ + unsigned long latest = server_events_latest(); + + SDL_LockMutex(http.lock); + status = connection->role >= _server_role_admin ? http.status_admin : http.status_viewer; + if (!status) + { + SDL_UnlockMutex(http.lock); + return; + } + length = strlen(status) + 128; + text = malloc(length); + if (text) + { + snprintf(text, length, "{\"type\":\"event\",\"seq\":%lu,\"time\":%lld,\"event\":\"status\",\"status\":%s}", + latest, posix_server_unix_milliseconds(), status); + } + SDL_UnlockMutex(http.lock); + if (text) + { + ws_send(connection, text); + free(text); + } +} + +/* a WebSocket command's line from its message: "line", or "command" and +"args" */ +static int ws_command_line(struct server_json_document const *document, char *line, int size) +{ + int token = server_json_member(document, 0, "line"); + int command, arguments, index, length; + + if (token >= 0) + return server_json_get_string(document, token, line, size) && line[0]; + command = server_json_member(document, 0, "command"); + if (command < 0 || !server_json_get_string(document, command, line, size) || !line[0]) + return 0; + arguments = server_json_member(document, 0, "args"); + length = (int)strlen(line); + for (index = 0; server_json_element(document, arguments, index) >= 0; index++) + { + char word[256]; + int quote; + + if (!server_json_get_string(document, server_json_element(document, arguments, index), word, sizeof(word))) + return 0; + quote = strchr(word, ' ') != NULL || !word[0]; + length += snprintf(line + length, (size_t)(size - length), quote ? " \"%s\"" : " %s", word); + if (length >= size) + return 0; + } + return 1; +} + +static void ws_message(struct connection *connection, const char *text, int length) +{ + struct server_json_document document; + char type[32]; + char id[32] = ""; + int token; + + if (!server_json_parse(&document, text, length, 16) || document.tokens[0].type != _server_json_object) + { + if (document.tokens) + server_json_document_free(&document); + ws_error(connection, NULL, "bad_request", "not a JSON object"); + return; + } + token = server_json_member(&document, 0, "id"); + if (token >= 0 && document.tokens[token].end - document.tokens[token].start < (int)sizeof(id) - 2) + { + if (document.tokens[token].type == _server_json_string_token) + snprintf(id, sizeof(id), "\"%.*s\"", document.tokens[token].end - document.tokens[token].start, + text + document.tokens[token].start); + else + snprintf(id, sizeof(id), "%.*s", document.tokens[token].end - document.tokens[token].start, + text + document.tokens[token].start); + /* (an id holds no quote or backslash of its own: it is echoed raw) */ + if (strchr(id + 1, '\\') || (id[0] == '"' && strchr(id + 1, '"') != id + strlen(id) - 1)) + id[0] = 0; + } + if (!server_json_get_string(&document, server_json_member(&document, 0, "type"), type, sizeof(type))) + { + ws_error(connection, id, "bad_request", "a message has a type"); + server_json_document_free(&document); + return; + } + if (!strcmp(type, "ping")) + { + ws_send(connection, "{\"type\":\"pong\"}"); + } + else if (!strcmp(type, "auth")) + { + char secret[160] = ""; + char by[NAME_SIZE] = ""; + + if (connection->role != _server_role_none) + { + ws_error(connection, id, "bad_request", "signed in already"); + } + else if (server_json_get_string(&document, server_json_member(&document, 0, "token"), secret, sizeof(secret))) + { + struct session *session = session_find(secret); + + /* (an owner's session is used from this machine alone, where it + was begun) */ + if (!session || (session->role >= _server_role_owner && !connection_is_local(connection))) + { + ws_auth_answer(connection, 0, "denied", "no such session", 0); + } + else + { + connection->role = session->role; + snprintf(connection->by, sizeof(connection->by), "%s", session->by); + ws_auth_answer(connection, 1, NULL, NULL, 0); + } + } + else + { + int status, retry_after, role; + const char *code; + + server_json_get_string(&document, server_json_member(&document, 0, "password"), secret, sizeof(secret)); + server_json_get_string(&document, server_json_member(&document, 0, "by"), by, sizeof(by)); + role = password_role(connection->real_address, connection->real_text, secret, connection_is_local(connection), + &status, &code, &retry_after); + if (role == _server_role_none) + { + ws_auth_answer(connection, 0, code, status == 401 ? "wrong password" : status == 403 ? + "the owner's password signs in from the server's own machine alone" : "too many tries: wait", + retry_after); + } + else + { + connection->role = role; + if (!by[0]) + snprintf(by, sizeof(by), "rcon %s", server_role_name(role)); + snprintf(connection->by, sizeof(connection->by), "%s", by); + server_log(_server_log_info, "server", "rcon: %s signed in%s%s (%s)", connection->by, + config_boolean("server.log_addresses") ? " from " : "", config_boolean("server.log_addresses") ? + connection->real_text : "", server_role_name(role)); + ws_auth_answer(connection, 1, NULL, NULL, 0); + } + } + memset(secret, 0, sizeof(secret)); + } + else if (!strcmp(type, "command")) + { + char line[SERVER_COMMAND_LINE_SIZE]; + Uint64 now = SDL_GetTicksNS(); + + if (connection->role == _server_role_none) + { + ws_reply_refused(connection, id, "denied", "sign in first"); + } + else + { + connection->bucket += (double)(now - connection->bucket_time) / 1e9 * COMMANDS_PER_SECOND; + if (connection->bucket > COMMANDS_BURST) + connection->bucket = COMMANDS_BURST; + connection->bucket_time = now; + if (connection->bucket < 1.0) + { + ws_reply_refused(connection, id, "rate_limited", "32 commands a second at most"); + } + else if (!ws_command_line(&document, line, sizeof(line))) + { + connection->bucket -= 1.0; + ws_reply_refused(connection, id, "bad_request", "line is a command (511 characters at most)"); + } + else if (connection->ticket) + { + ws_reply_refused(connection, id, "busy", "one command at a time: wait for its reply"); + } + else + { + struct server_ticket *ticket = ticket_new(_ticket_ws_command, connection->role, connection->by); + + connection->bucket -= 1.0; + if (ticket) + { + ticket->body = strdup(line); + ticket->body_length = ticket->body ? (int)strlen(line) : 0; + snprintf(ticket->id, sizeof(ticket->id), "%s", id[0] ? id : "null"); + connection->ticket = ticket; + ticket_submit(ticket); + } + } + } + } + else if (connection->role == _server_role_none) + { + ws_error(connection, id, "denied", "sign in first"); + } + else if (!strcmp(type, "subscribe")) + { + int events = server_json_member(&document, 0, "events"); + int index; + unsigned long groups = 0; + double after; + int ok = events >= 0 && document.tokens[events].type == _server_json_array; + + for (index = 0; ok && server_json_element(&document, events, index) >= 0; index++) + { + char group[SERVER_EVENT_GROUP_SIZE]; + int found; + + if (!server_json_get_string(&document, server_json_element(&document, events, index), group, sizeof(group)) || + (found = group_index(group)) < 0) + { + ok = 0; + break; + } + groups |= 1UL << found; + } + if (!ok) + { + ws_error(connection, id, "bad_request", "events is a list of event groups"); + } + else + { + connection->groups = groups; + if (server_json_get_number(&document, server_json_member(&document, 0, "after"), &after) && after >= 0) + { + struct replay_context replay; + unsigned long next, missed; + + replay.connection = connection; + server_events_read((unsigned long)after, SERVER_EVENTS_RING_SIZE, ws_addresses(connection), ws_replay_one, + &replay, &next, &missed); + } + if (groups & (1UL << group_index("status"))) + ws_send_status(connection); + } + } + else + { + char message[64]; + + snprintf(message, sizeof(message), "no message type %.32s", type); + ws_error(connection, id, "bad_request", message); + } + server_json_document_free(&document); +} + +/* the frames read so far: each one taken; 0 if the connection is to close */ +static int ws_read_frames(struct connection *connection) +{ + while (connection->in_length >= 2 && !connection->closing && out_pending(connection) < OUTPUT_PAUSE) + { + unsigned char *data = connection->in; + int fin = data[0] & 0x80, opcode = data[0] & 0x0F; + unsigned long long size = data[1] & 0x7F; + int head = 2, index; + unsigned char mask[4]; + + if (data[0] & 0x70) + { + ws_close(connection, 1002, "reserved bits"); + return 1; + } + if (!(data[1] & 0x80)) + { + ws_close(connection, 1002, "a client's frames are masked"); + return 1; + } + if (size == 126) + { + if (connection->in_length < 4) + return 1; + size = (unsigned long long)data[2] << 8 | data[3]; + head = 4; + } + else if (size == 127) + { + if (connection->in_length < 10) + return 1; + for (size = 0, index = 2; index < 10; index++) + size = size << 8 | data[index]; + head = 10; + } + if (opcode >= 0x8 && (size > 125 || !fin)) + { + ws_close(connection, 1002, "a bad control frame"); + return 1; + } + if (size > WS_FRAME) + { + ws_close(connection, 1009, "64 KB a frame at most"); + return 1; + } + if ((unsigned long long)connection->in_length < (unsigned long long)head + 4 + size) + return 1; + memcpy(mask, data + head, 4); + head += 4; + for (index = 0; index < (int)size; index++) + data[head + index] ^= mask[index & 3]; + switch (opcode) + { + case 0x8: + ws_close(connection, 1000, "goodbye"); + break; + case 0x9: + ws_frame(connection, 0xA, data + head, (int)size); + break; + case 0xA: + break; + case 0x1: + case 0x0: + if ((opcode == 0x1) == (connection->message_open != 0)) + { + ws_close(connection, 1002, opcode == 0x1 ? "a new message inside a fragmented one" : + "a continuation without a message"); + break; + } + if (connection->message_length + (int)size > WS_MESSAGE) + { + ws_close(connection, 1009, "256 KB a message at most"); + break; + } + { + unsigned char *grown = realloc(connection->message, (size_t)connection->message_length + (size_t)size + 1); + + if (!grown) + { + ws_close(connection, 1011, "out of memory"); + break; + } + connection->message = grown; + memcpy(connection->message + connection->message_length, data + head, (size_t)size); + connection->message_length += (int)size; + connection->message[connection->message_length] = 0; + } + connection->message_open = !fin; + if (fin) + { + ws_message(connection, (const char *)connection->message, connection->message_length); + connection->message_length = 0; + } + break; + case 0x2: + ws_close(connection, 1003, "text frames only"); + break; + default: + ws_close(connection, 1002, "an unknown opcode"); + break; + } + memmove(connection->in, connection->in + head + size, (size_t)(connection->in_length - head - (int)size)); + connection->in_length -= head + (int)size; + } + return 1; +} + +/* ---------- the routes */ + +/* the session the request carries (Bearer, else the cookie): NULL if none; +by_cookie says which */ +static struct session *request_session(struct connection *connection, int *by_cookie) +{ + const char *authorization = connection->request.authorization; + + *by_cookie = 0; + if (!strncmp(authorization, "Bearer ", 7)) + return session_find(authorization + 7); + *by_cookie = 1; + return connection->request.session_cookie[0] ? session_find(connection->request.session_cookie) : NULL; +} + +static int body_string(const char *body, int length, const char *key, char *value, int size) +{ + struct server_json_document document; + int found; + + value[0] = 0; + if (!server_json_parse(&document, body, length, 8)) + return 0; + found = document.tokens[0].type == _server_json_object && + server_json_get_string(&document, server_json_member(&document, 0, key), value, size); + server_json_document_free(&document); + return found; +} + +static void serve_asset(struct connection *connection) +{ + const char *path = connection->request.path; + unsigned long index; + char headers[128]; + + if (!strcmp(path, "/")) + path = "/index.html"; + if (!config_boolean("server.web_ui")) + { + respond_error(connection, 404, "not_found", "the page is off (server.web_ui)", 0); + return; + } + for (index = 0; index < server_ui_asset_count; index++) + { + const struct server_ui_asset *asset = &server_ui_assets[index]; + + if (strcmp(asset->path, path + 1)) + continue; + snprintf(headers, sizeof(headers), "ETag: \"%s\"\r\n", asset->etag); + if (connection->request.if_none_match[0] && strstr(connection->request.if_none_match, asset->etag)) + { + respond(connection, 304, asset->content_type, "", 0, headers, 0); + return; + } + respond(connection, 200, asset->content_type, asset->data, (int)asset->size, headers, 0); + return; + } + respond_error(connection, 404, "not_found", "nothing is there", 0); +} + +static void serve_healthz(struct connection *connection) +{ + char state[16]; + char body[64]; + Uint64 age; + int hosting; + + SDL_LockMutex(http.lock); + snprintf(state, sizeof(state), "%s", http.state[0] ? http.state : "starting"); + age = http.snapshot_time ? SDL_GetTicksNS() - http.snapshot_time : 0; + SDL_UnlockMutex(http.lock); + hosting = !strcmp(state, "lobby") || !strcmp(state, "loading") || !strcmp(state, "playing") || + !strcmp(state, "postgame"); + /* (healthy: hosting, its frames running; a map loading holds them some + seconds, a minute without one is a game stuck) */ + if (!http.snapshot_time) + respond_json(connection, 503, "{\"state\":\"starting\"}", NULL); + else if (age > 60ULL * 1000000000ULL) + respond_json(connection, 503, "{\"state\":\"stalled\"}", NULL); + else + { + snprintf(body, sizeof(body), "{\"state\":\"%s\"}", state); + respond_json(connection, hosting ? 200 : 503, body, NULL); + } +} + +static void serve_metrics(struct connection *connection) +{ + char text[4096]; + int length; + + if (!config_boolean("server.metrics")) + { + respond_error(connection, 404, "not_found", "the metrics are off (server.metrics)", 0); + return; + } + SDL_LockMutex(http.lock); + length = snprintf(text, sizeof(text), + "# HELP opence_up Whether the server hosts.\n# TYPE opence_up gauge\nopence_up %d\n" + "# TYPE opence_state gauge\nopence_state{state=\"%s\"} 1\n" + "# HELP opence_players Players in the game.\n# TYPE opence_players gauge\nopence_players %ld\n" + "# TYPE opence_players_max gauge\nopence_players_max %ld\n" + "# TYPE opence_machines gauge\nopence_machines %ld\n" + "# TYPE opence_games_total counter\nopence_games_total %ld\n" + "# HELP opence_frame_seconds_max The longest frame of the last minute.\n" + "# TYPE opence_frame_seconds_max gauge\nopence_frame_seconds_max %.6f\n" + "# TYPE opence_frame_seconds_mean gauge\nopence_frame_seconds_mean %.6f\n" + "# TYPE opence_events_dropped_total counter\nopence_events_dropped_total %lu\n" + "# TYPE opence_http_requests_total counter\nopence_http_requests_total %lu\n" + "# TYPE opence_logins_failed_total counter\nopence_logins_failed_total %lu\n" + "# TYPE opence_process_resident_bytes gauge\nopence_process_resident_bytes %lu\n" + "# TYPE opence_process_cpu_seconds_total counter\nopence_process_cpu_seconds_total %.3f\n" + "# TYPE opence_uptime_seconds counter\nopence_uptime_seconds %.0f\n" + "# TYPE opence_build_info gauge\nopence_build_info{build=\"%s\",network_version=\"%d\"} 1\n", + strcmp(http.state, "lobby") && strcmp(http.state, "playing") && strcmp(http.state, "loading") && + strcmp(http.state, "postgame") ? 0 : 1, + http.state[0] ? http.state : "starting", http.metrics_players, http.metrics_max_players, http.metrics_machines, + http.metrics_games, http.metrics_frame_longest / 1000.0, http.metrics_frame_mean / 1000.0, + http.events_dropped + http.pushes_dropped, http.requests_total, http.logins_failed, + posix_server_resident_bytes(), posix_server_cpu_milliseconds() / 1000.0, server_mode_uptime(), + server_mode_build(), HALO_PORT_NETWORK_VERSION); + SDL_UnlockMutex(http.lock); + respond(connection, 200, "text/plain; version=0.0.4; charset=utf-8", text, length, NULL, 1); +} + +struct log_context +{ + struct server_json *json; + int role; +}; + +static void serve_log(struct connection *connection, int role) +{ + static struct server_log_line lines[1024]; + unsigned long after = (unsigned long)(query_number(connection->request.query, "after", 0) < 0 ? 0 : + query_number(connection->request.query, "after", 0)); + long limit = query_number(connection->request.query, "limit", 256); + unsigned long next, missed; + struct server_json json; + int count, index; + + if (limit < 1) + limit = 1; + if (limit > 1024) + limit = 1024; + /* (a cursor past the latest, as after a restart: from the start) */ + if (after > server_log_latest()) + after = 0; + count = server_log_read(after, lines, (int)limit, &next, &missed); + if (!after) + missed = 0; + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_array_begin(&json, "lines"); + for (index = 0; index < count; index++) + { + if (!strcmp(lines[index].category, "chat") && role < _server_role_admin) + continue; + server_json_object_begin(&json, NULL); + server_json_integer(&json, "n", (long long)lines[index].number); + server_json_integer(&json, "time", lines[index].time); + server_json_string(&json, "level", server_log_level_name(lines[index].level)); + server_json_string(&json, "cat", lines[index].category); + server_json_string(&json, "text", lines[index].text); + server_json_object_end(&json); + } + server_json_array_end(&json); + server_json_integer(&json, "next", (long long)next); + server_json_integer(&json, "missed", (long long)missed); + server_json_object_end(&json); + respond_json(connection, 200, server_json_text(&json), NULL); + server_json_free(&json); +} + +static void events_collect(void *context, unsigned long sequence, const char *group, const char *text) +{ + struct log_context *collected = context; + + (void)sequence; + if (!strcmp(group, "chat") && collected->role < _server_role_admin) + return; + server_json_raw(collected->json, NULL, text); +} + +static void serve_events(struct connection *connection, int role) +{ + unsigned long after = (unsigned long)(query_number(connection->request.query, "after", 0) < 0 ? 0 : + query_number(connection->request.query, "after", 0)); + long limit = query_number(connection->request.query, "limit", 256); + unsigned long next, missed; + struct server_json json; + struct log_context collected; + + if (limit < 1) + limit = 1; + if (limit > SERVER_EVENTS_RING_SIZE) + limit = SERVER_EVENTS_RING_SIZE; + if (after > server_events_latest()) + after = 0; + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_array_begin(&json, "events"); + collected.json = &json; + collected.role = role; + server_events_read(after, (int)limit, role_sees_addresses(role), events_collect, &collected, &next, &missed); + server_json_array_end(&json); + server_json_integer(&json, "next", (long long)next); + server_json_integer(&json, "missed", after ? (long long)missed : 0); + server_json_object_end(&json); + respond_json(connection, 200, server_json_text(&json), NULL); + server_json_free(&json); +} + +static void serve_snapshot(struct connection *connection, const char *text) +{ + if (!text) + { + respond_error(connection, 503, "starting", "the server is starting", 5); + return; + } + respond_json(connection, 200, text, NULL); +} + +static void serve_login(struct connection *connection, const char *body, int length) +{ + char password[256], by[NAME_SIZE], token[2 * TOKEN_BYTES + 1]; + char headers[256]; + int status, retry_after, role; + const char *code; + struct session *session; + struct server_json json; + + if (!body_string(body, length, "password", password, sizeof(password))) + { + respond_error(connection, 400, "schema", "password is needed", 0); + return; + } + body_string(body, length, "by", by, sizeof(by)); + role = password_role(connection->real_address, connection->real_text, password, connection_is_local(connection), + &status, &code, &retry_after); + memset(password, 0, sizeof(password)); + if (role == _server_role_none) + { + respond_error(connection, status, code, status == 401 ? "wrong password" : status == 403 ? + "the owner's password signs in from the server's own machine alone" : "too many tries: wait", retry_after); + return; + } + if (!by[0]) + snprintf(by, sizeof(by), "web %s", server_role_name(role)); + session = session_new(role, by, token); + server_log(_server_log_info, "server", "web: %s signed in (%s)", session->by, server_role_name(role)); + snprintf(headers, sizeof(headers), "Set-Cookie: opence_session=%s; HttpOnly; SameSite=Strict; Path=/%s\r\n", token, + text_equal_case(connection->request.forwarded_proto, "https") && connection->forwarded ? + "; Secure" : ""); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_string(&json, "token", token); + server_json_string(&json, "role", server_role_name(role)); + server_json_string(&json, "csrf", session->csrf); + server_json_integer(&json, "expires", session->created + SESSION_SECONDS); + server_json_object_end(&json); + respond_json(connection, 200, server_json_text(&json), headers); + server_json_free(&json); + memset(token, 0, sizeof(token)); +} + +/* the routes that need the game's thread: a ticket of the request */ +static int route_needs_game(const char *method, const char *path) +{ + return !strncmp(path, "/api/players/", 13) || !strcmp(path, "/api/say") || !strncmp(path, "/api/rotation", 13) || + !strncmp(path, "/api/gametypes", 14) || !strcmp(path, "/api/maps") || !strncmp(path, "/api/bans", 9) || + !strcmp(path, "/api/settings") || !strcmp(path, "/api/command") || !strcmp(path, "/api/stop"); + (void)method; +} + +/* the role a route needs */ +static int route_role(const char *method, const char *path) +{ + int mutation = strcmp(method, "GET") && strcmp(method, "HEAD"); + + if (!strcmp(path, "/api/stop")) + return _server_role_owner; + if (!strncmp(path, "/api/players/", 13) || !strcmp(path, "/api/say") || !strcmp(path, "/api/rotation/next") || + !strcmp(path, "/api/rotation/play")) + { + return _server_role_moderator; + } + if ((!strncmp(path, "/api/rotation", 13) || !strncmp(path, "/api/gametypes", 14)) && mutation) + return _server_role_admin; + if (!strncmp(path, "/api/bans", 9) || !strcmp(path, "/api/settings")) + return _server_role_admin; + return _server_role_viewer; +} + +static void handle_request(struct connection *connection, const char *body, int length) +{ + struct http_request *request = &connection->request; + const char *path = request->path; + int mutation = strcmp(request->method, "GET") && strcmp(request->method, "HEAD"); + int by_cookie = 0; + struct session *session = NULL; + + http.requests_total++; + if (request->origin[0] && !origin_matches(connection)) + { + respond_error(connection, 403, "origin", "a request from another site", 0); + return; + } + if (!strcmp(path, "/healthz")) + { + serve_healthz(connection); + return; + } + if (!strcmp(path, "/api/login")) + { + if (strcmp(request->method, "POST")) + respond_error(connection, 405, "method", "/api/login takes POST", 0); + else if (!address_allows_request(connection->real_address, 0)) + respond_error(connection, 429, "rate_limited", "too many requests: wait a minute", 60); + else + serve_login(connection, body, length); + return; + } + /* (every request but the health's counts against its address's + allowance, the page's files and the metrics too; a sign in's, above, as + one without a session) */ + session = request_session(connection, &by_cookie); + /* (an owner's session is used from this machine alone, where it was + begun) */ + if (session && session->role >= _server_role_owner && !connection_is_local(connection)) + session = NULL; + if (!address_allows_request(connection->real_address, session != NULL)) + { + respond_error(connection, 429, "rate_limited", "too many requests: wait a minute", 60); + return; + } + if (!strcmp(path, "/metrics")) + { + serve_metrics(connection); + return; + } + if (strncmp(path, "/api/", 5)) + { + if (mutation) + respond_error(connection, 405, "method", "the page takes GET", 0); + else + serve_asset(connection); + return; + } + if (!strcmp(path, "/api/logout") && strcmp(request->method, "POST")) + { + /* (a change: a POST, with the CSRF token if by the cookie) */ + respond_error(connection, 405, "method", "/api/logout takes POST", 0); + return; + } + if (!session) + { + respond_error(connection, 401, "denied", "sign in first", 0); + return; + } + if (mutation && by_cookie) + { + if (!request->origin[0]) + { + respond_error(connection, 403, "origin", "a change from the page needs its Origin", 0); + return; + } + if (!constant_time_equal(request->csrf, session->csrf)) + { + respond_error(connection, 403, "csrf", "the X-CSRF-Token header is missing or wrong", 0); + return; + } + } + if (session->role < route_role(request->method, path)) + { + char message[64]; + + snprintf(message, sizeof(message), "needs the %s role", server_role_name(route_role(request->method, path))); + respond_error(connection, 403, "forbidden", message, 0); + return; + } + if (!strcmp(path, "/api/logout")) + { + session->used = 0; + respond_json(connection, 200, "{\"ok\":true}", + "Set-Cookie: opence_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0\r\n"); + return; + } + if (!strcmp(path, "/api/session")) + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_string(&json, "role", server_role_name(session->role)); + server_json_string(&json, "csrf", session->csrf); + server_json_integer(&json, "expires", session->created + SESSION_SECONDS); + server_json_object_end(&json); + respond_json(connection, 200, server_json_text(&json), NULL); + server_json_free(&json); + return; + } + if (!strcmp(path, "/api/status") && !mutation) + { + SDL_LockMutex(http.lock); + serve_snapshot(connection, session->role >= _server_role_admin ? http.status_admin : http.status_viewer); + SDL_UnlockMutex(http.lock); + return; + } + if (!strcmp(path, "/api/players") && !mutation) + { + SDL_LockMutex(http.lock); + serve_snapshot(connection, session->role >= _server_role_owner ? http.players_owner : + session->role >= _server_role_admin ? http.players_admin : http.players_viewer); + SDL_UnlockMutex(http.lock); + return; + } + if (!strcmp(path, "/api/log") && !mutation) + { + serve_log(connection, session->role); + return; + } + if (!strcmp(path, "/api/events") && !mutation) + { + serve_events(connection, session->role); + return; + } + if (route_needs_game(request->method, path)) + { + struct server_ticket *ticket = ticket_new(_ticket_api, session->role, session->by); + + if (!ticket) + { + respond_error(connection, 500, "internal", "out of memory", 0); + return; + } + snprintf(ticket->method, sizeof(ticket->method), "%s", request->method); + snprintf(ticket->path, sizeof(ticket->path), "%s", path); + if (length > 0) + { + ticket->body = malloc((size_t)length + 1); + if (ticket->body) + { + memcpy(ticket->body, body, (size_t)length); + ticket->body[length] = 0; + ticket->body_length = length; + } + } + connection->ticket = ticket; + ticket_submit(ticket); + return; + } + respond_error(connection, 404, "not_found", "nothing is there", 0); +} + +void server_http_capabilities(struct server_json *json, const char *key) +{ + static const char *const capabilities[] = { + "events", "status", "rotation", "settings", "bans", + /* (the fleets': the owner's password from this machine, a ban of an + address, a drain and stop, addresses in the events, the server's + identity in the status) */ + "owner_web", "ban_address", "stop", "event_addresses", "identity" }; + int index; + + server_json_array_begin(json, key); + for (index = 0; index < (int)(sizeof(capabilities) / sizeof(capabilities[0])); index++) + server_json_string(json, NULL, capabilities[index]); + server_json_array_end(json); +} + +static void handle_upgrade(struct connection *connection) +{ + struct http_request *request = &connection->request; + char accept_input[128]; + unsigned char digest[20]; + char accept[32]; + char head[512]; + int length, count = 0, index, by_cookie; + struct session *session; + + /* (counted against the address's allowance, as every request but the + health's is) */ + http.requests_total++; + session = request_session(connection, &by_cookie); + if (session && session->role >= _server_role_owner && !connection_is_local(connection)) + session = NULL; + if (!address_allows_request(connection->real_address, session != NULL)) + { + respond_error(connection, 429, "rate_limited", "too many requests: wait a minute", 60); + return; + } + if (strcmp(request->method, "GET") || !text_equal_case(request->upgrade, "websocket") || !request->websocket_key[0]) + { + respond_error(connection, 400, "bad_request", "/ws is the RCON: a WebSocket (Upgrade: websocket)", 0); + return; + } + if (strcmp(request->websocket_version, "13")) + { + static const char refusal[] = "{\"ok\":false,\"error\":{\"code\":\"bad_request\",\"message\":\"WebSocket version 13\"}}"; + + respond(connection, 426, "application/json", refusal, (int)strlen(refusal), "Sec-WebSocket-Version: 13\r\n", 1); + return; + } + if (!strstr(request->websocket_protocol, "opence-rcon-1")) + { + respond_error(connection, 400, "bad_request", "the subprotocol is opence-rcon-1", 0); + return; + } + if (request->origin[0] && !origin_matches(connection)) + { + respond_error(connection, 403, "origin", "a connection from another site", 0); + return; + } + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + count += http.connections[index].socket >= 0 && http.connections[index].websocket; + if (count >= MAXIMUM_WEBSOCKETS) + { + respond_error(connection, 503, "busy", "8 remote consoles at once at most", 5); + return; + } + if (strlen(request->websocket_key) > 60) + { + respond_error(connection, 400, "bad_request", "a bad Sec-WebSocket-Key", 0); + return; + } + snprintf(accept_input, sizeof(accept_input), "%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11", request->websocket_key); + sha1((const unsigned char *)accept_input, (unsigned long)strlen(accept_input), digest); + server_base64_encode(digest, 20, accept); + length = snprintf(head, sizeof(head), + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\n\r\n", accept); + out_append(connection, head, length); + connection->websocket = 1; + connection->role = _server_role_none; + connection->bucket = COMMANDS_BURST; + connection->bucket_time = SDL_GetTicksNS(); + connection->auth_deadline = SDL_GetTicksNS() + (Uint64)AUTH_SECONDS * 1000000000ULL; + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "type", "hello"); + server_json_integer(&json, "protocol", 1); + server_json_string(&json, "server", config_string("server.name")); + server_json_string(&json, "build", server_mode_build()); + server_json_integer(&json, "network_version", HALO_PORT_NETWORK_VERSION); + server_http_capabilities(&json, "capabilities"); + server_json_object_end(&json); + ws_send(connection, server_json_text(&json)); + server_json_free(&json); + } + /* (signed in already: the page's cookie, from its own origin, or a + script's Bearer token) */ + if (session && (!by_cookie || origin_matches(connection))) + { + connection->role = session->role; + snprintf(connection->by, sizeof(connection->by), "%s", session->by); + ws_auth_answer(connection, 1, NULL, NULL, 0); + } +} + +/* ---------- connections */ + +static void connection_free(struct connection *connection) +{ + struct ws_message *message = connection->queue_head; + + while (message) + { + struct ws_message *next = message->next; + + free(message); + message = next; + } + if (connection->ticket) + { + int done; + + /* (a ticket the game's thread has answered is freed here; one it + still holds is abandoned to it, which frees it once it is done) */ + SDL_LockMutex(http.lock); + done = connection->ticket->done; + if (!done) + connection->ticket->abandoned = 1; + SDL_UnlockMutex(http.lock); + if (done) + ticket_free(connection->ticket); + connection->ticket = NULL; + } + if (connection->socket >= 0) + posix_socket_close(connection->socket); + free(connection->in); + free(connection->out); + free(connection->message); + memset(connection, 0, sizeof(*connection)); + connection->socket = -1; +} + +/* the bytes read so far: requests answered, frames taken; 0 to close */ +static int connection_take(struct connection *connection) +{ + if (connection->websocket) + return ws_read_frames(connection); + /* (a request at a time; none while its answers wait past OUTPUT_PAUSE + for a client that does not read them: taken again as they go) */ + while (!connection->ticket && !connection->close_after_write && out_pending(connection) < OUTPUT_PAUSE) + { + const unsigned char *end_of_head; + int status; + const char *code; + + if (!connection->have_head) + { + end_of_head = NULL; + { + int index; + + for (index = 3; index < connection->in_length; index++) + { + if (connection->in[index] == '\n' && connection->in[index - 1] == '\r' && connection->in[index - 2] == '\n' && + connection->in[index - 3] == '\r') + { + end_of_head = connection->in + index + 1; + break; + } + } + } + if (!end_of_head) + { + if (connection->in_length > MAXIMUM_HEAD) + { + connection->request.keep_alive = 0; + respond_error(connection, 413, "too_large", "8 KB of head at most", 0); + } + return 1; + } + connection->head_length = (int)(end_of_head - connection->in); + if (connection->head_length > MAXIMUM_HEAD || + !server_http_parse_head((const char *)connection->in, connection->head_length - 2, &connection->request, + &status, &code)) + { + connection->request.keep_alive = 0; + if (connection->head_length > MAXIMUM_HEAD) + respond_error(connection, 413, "too_large", "8 KB of head at most", 0); + else + respond_error(connection, status, code, status == 405 ? "GET, POST, PUT or DELETE" : + status == 413 ? "64 KB of body at most" : "a request this server does not take", 0); + return 1; + } + connection->have_head = 1; + /* (behind a trusted proxy, the client's address is its word: the + entry it appended; and so are the host and scheme it forwards) */ + connection->real_address = connection->address; + address_text(connection->address, connection->real_text, sizeof(connection->real_text)); + connection->forwarded = 0; + { + unsigned long proxy; + + if (config_string("server.web_trusted_proxy")[0] && + parse_address(config_string("server.web_trusted_proxy"), &proxy) && proxy == connection->address) + { + unsigned long forwarded; + char text[sizeof(connection->real_text)]; + + connection->forwarded = 1; + if (connection->request.forwarded_for[0] && + forwarded_address(connection->request.forwarded_for, &forwarded, text, sizeof(text))) + { + connection->real_address = forwarded; + snprintf(connection->real_text, sizeof(connection->real_text), "%s", text); + } + } + } + } + if (connection->in_length < connection->head_length + connection->request.content_length) + return 1; + if (!strcmp(connection->request.path, "/ws")) + handle_upgrade(connection); + else + handle_request(connection, (const char *)connection->in + connection->head_length, + (int)connection->request.content_length); + { + int used = connection->head_length + (int)connection->request.content_length; + + memmove(connection->in, connection->in + used, (size_t)(connection->in_length - used)); + connection->in_length -= used; + } + connection->have_head = 0; + connection->head_started = SDL_GetTicksNS(); + if (connection->websocket) + return ws_read_frames(connection); + } + return 1; +} + +/* a ticket the game's thread answered: its response written */ +static void connection_ticket_done(struct connection *connection) +{ + struct server_ticket *ticket = connection->ticket; + + connection->ticket = NULL; + if (ticket->kind == _ticket_ws_command) + { + ws_send(connection, ticket->response ? ticket->response : "{\"type\":\"reply\",\"ok\":false,\"lines\":[]}"); + } + else + { + char headers[64] = ""; + + if (ticket->retry_after > 0) + snprintf(headers, sizeof(headers), "Retry-After: %d\r\n", ticket->retry_after); + respond_json(connection, ticket->status ? ticket->status : 500, ticket->response ? ticket->response : + "{\"ok\":false,\"error\":{\"code\":\"internal\",\"message\":\"no answer\"}}", headers); + } + ticket_free(ticket); + if (!connection->websocket) + connection_take(connection); +} + +/* the connection to close for a new one when every slot is taken: the +oldest that waits for a request (its head or body: idle, or slow), none +of a WebSocket, a ticket or answers being sent; -1 if there is none */ +static int connection_to_evict(void) +{ + int index, oldest = -1; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &http.connections[index]; + + if (connection->socket < 0 || connection->websocket || connection->ticket || out_pending(connection) > 0) + continue; + if (oldest < 0 || connection->head_started < http.connections[oldest].head_started) + oldest = index; + } + return oldest; +} + +/* whether a connection is to close for its time: a request (its head and +body) not in within HEAD_SECONDS of the connection's opening or its last +answer, silence past IDLE_SECONDS (a signed-in WebSocket's, unless it is +closing, is kept); times later than now (a request answered since it was +read) are not past */ +static int connection_overdue(struct connection *connection, Uint64 now) +{ + if (!connection->websocket && !connection->ticket && out_pending(connection) == 0 && + now > connection->head_started && now - connection->head_started > (Uint64)HEAD_SECONDS * 1000000000ULL) + { + return 1; + } + return !connection->ticket && now > connection->last_activity && + now - connection->last_activity > (Uint64)IDLE_SECONDS * 1000000000ULL && + !(connection->websocket && connection->role != _server_role_none && !connection->closing); +} + +static void accept_connections(int listener) +{ + for (;;) + { + unsigned char address[16]; + int address_length = sizeof(address); + int socket = posix_socket_accept(listener, address, &address_length); + int index; + + if (socket < 0) + return; + for (index = 0; index < MAXIMUM_CONNECTIONS && http.connections[index].socket >= 0; index++) + { + } + if (index == MAXIMUM_CONNECTIONS) + { + /* (full: the oldest connection that waits for a request gives way, + else the new one goes) */ + index = connection_to_evict(); + if (index < 0) + { + posix_socket_close(socket); + continue; + } + connection_free(&http.connections[index]); + } + posix_socket_set_nonblocking(socket, 1); + memset(&http.connections[index], 0, sizeof(http.connections[index])); + http.connections[index].socket = socket; + http.connections[index].address = (unsigned long)address[4] << 24 | (unsigned long)address[5] << 16 | + (unsigned long)address[6] << 8 | address[7]; + http.connections[index].real_address = http.connections[index].address; + address_text(http.connections[index].address, http.connections[index].real_text, + sizeof(http.connections[index].real_text)); + http.connections[index].head_started = http.connections[index].last_activity = SDL_GetTicksNS(); + http.connections[index].role = _server_role_none; + } +} + +/* the pushes onto the WebSockets that take them, and the status events */ +static void distribute_pushes(void) +{ + struct push *pushes; + Uint64 now = SDL_GetTicksNS(); + int index; + + SDL_LockMutex(http.lock); + pushes = http.pushes_head; + http.pushes_head = http.pushes_tail = NULL; + http.pushes = 0; + SDL_UnlockMutex(http.lock); + while (pushes) + { + struct push *next = pushes->next; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + if (http.connections[index].socket >= 0 && ws_wants(&http.connections[index], pushes->group)) + { + ws_send(&http.connections[index], pushes->addressed && ws_addresses(&http.connections[index]) ? + pushes->text + pushes->addressed : pushes->text); + } + } + free(pushes); + pushes = next; + } + if (now - http.last_status_event >= (Uint64)STATUS_EVENT_MILLISECONDS * 1000000ULL) + { + http.last_status_event = now; + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + if (http.connections[index].socket >= 0 && ws_wants(&http.connections[index], "status")) + ws_send_status(&http.connections[index]); + } + } +} + +static int SDLCALL http_thread_main(void *listener_pointer) +{ + int listener = (int)(intptr_t)listener_pointer; + + for (;;) + { + int reads[MAXIMUM_CONNECTIONS + 1], writes[MAXIMUM_CONNECTIONS], errors[1]; + int read_count = 0, write_count = 0, error_count = 0; + Uint64 now; + int index; + + passwords_check(); + reads[read_count++] = listener; + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &http.connections[index]; + + if (connection->socket < 0) + continue; + if (connection->websocket) + ws_flush_queue(connection); + /* (nothing more is read from a client that does not read its + answers) */ + if ((!connection->close_after_write || connection->out_sent < connection->out_length) && + out_pending(connection) < OUTPUT_PAUSE) + { + reads[read_count++] = connection->socket; + } + if (connection->out_sent < connection->out_length) + writes[write_count++] = connection->socket; + } + posix_socket_select(reads, &read_count, writes, &write_count, errors, &error_count, 0, 20000, 0); + for (index = 0; index < read_count; index++) + { + if (reads[index] == listener) + accept_connections(listener); + } + /* (after the new connections' times, which are no later than it) */ + now = SDL_GetTicksNS(); + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &http.connections[index]; + int readable = 0, writable = 0, check; + + if (connection->socket < 0) + continue; + for (check = 0; check < read_count; check++) + readable |= reads[check] == connection->socket; + for (check = 0; check < write_count; check++) + writable |= writes[check] == connection->socket; + if (readable) + { + unsigned char buffer[16384]; + int count = posix_socket_recv(connection->socket, buffer, sizeof(buffer), 0); + + if (count <= 0) + { + int error = count < 0 ? posix_socket_last_error() : 0; + + /* (WSAEWOULDBLOCK: nothing yet) */ + if (count == 0 || error != 10035) + { + connection_free(connection); + continue; + } + } + else + { + int limit = connection->websocket ? WS_FRAME + 16 + WS_MESSAGE : MAXIMUM_HEAD + MAXIMUM_BODY + 16; + + connection->last_activity = now; + if (connection->in_length + count > limit) + { + connection_free(connection); + continue; + } + { + unsigned char *grown = realloc(connection->in, (size_t)connection->in_length + (size_t)count); + + if (!grown) + { + connection_free(connection); + continue; + } + connection->in = grown; + } + memcpy(connection->in + connection->in_length, buffer, (size_t)count); + connection->in_length += count; + if (!connection_take(connection)) + { + connection_free(connection); + continue; + } + } + } + if (writable && connection->out_sent < connection->out_length) + { + int count = posix_socket_send(connection->socket, connection->out + connection->out_sent, + connection->out_length - connection->out_sent, 0); + + if (count < 0 && posix_socket_last_error() != 10035) + { + connection_free(connection); + continue; + } + if (count > 0) + { + connection->out_sent += count; + connection->last_activity = now; + } + if (connection->out_sent == connection->out_length) + { + connection->out_sent = connection->out_length = 0; + /* (the next request's time from the answer's end) */ + if (!connection->have_head) + connection->head_started = now; + if (connection->close_after_write && !connection->queue_head) + { + connection_free(connection); + continue; + } + } + /* (the requests and frames held while the answers waited) */ + if (count > 0 && connection->in_length > 0 && out_pending(connection) < OUTPUT_PAUSE && + !connection_take(connection)) + { + connection_free(connection); + continue; + } + } + /* (output past OUTPUT_MOST, a connection to close with nothing + left to send, a request too slow to come, a connection silent too + long, a WebSocket that never signed in) */ + if (connection->out_overflowed || + (connection->close_after_write && !out_pending(connection) && !connection->queue_head) || + connection_overdue(connection, now)) + { + connection_free(connection); + continue; + } + if (connection->websocket && connection->role == _server_role_none && !connection->closing && + now > connection->auth_deadline) + { + ws_auth_answer(connection, 0, "denied", "no auth in 10 seconds", 0); + } + } + /* (the tickets the game's thread answered, or that waited too long: + one abandoned is the game's thread's from then, never read here + again) */ + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &http.connections[index]; + char id[sizeof(connection->ticket->id)]; + int done, expired; + + if (connection->socket < 0 || !connection->ticket) + continue; + SDL_LockMutex(http.lock); + done = connection->ticket->done; + expired = !done && now > connection->ticket->deadline; + if (expired) + { + memcpy(id, connection->ticket->id, sizeof(id)); + connection->ticket->abandoned = 1; + connection->ticket = NULL; + } + SDL_UnlockMutex(http.lock); + if (done) + { + connection_ticket_done(connection); + } + else if (expired) + { + if (connection->websocket) + { + ws_reply_refused(connection, id, "loading", "the game did not answer in time (a map loading?)"); + } + else + { + respond_error(connection, 503, "loading", "the game did not answer in time (a map loading?)", 5); + connection_take(connection); + } + } + } + distribute_pushes(); + } + return 0; +} + +/* ---------- the pushes from other threads */ + +static void push_add(const char *group, const char *text, const char *addressed_text) +{ + int length = (int)strlen(text); + int addressed_length = addressed_text ? (int)strlen(addressed_text) + 1 : 0; + struct push *push; + + if (!http.started) + return; + push = malloc(sizeof(*push) + (size_t)length + (size_t)addressed_length); + if (!push) + return; + push->next = NULL; + snprintf(push->group, sizeof(push->group), "%s", group); + push->length = length; + memcpy(push->text, text, (size_t)length + 1); + /* (the addressed text after the text and its end) */ + push->addressed = addressed_text ? length + 1 : 0; + if (addressed_text) + memcpy(push->text + length + 1, addressed_text, (size_t)addressed_length); + SDL_LockMutex(http.lock); + if (http.pushes >= WS_QUEUE * 4) + { + http.pushes_dropped++; + SDL_UnlockMutex(http.lock); + free(push); + return; + } + if (http.pushes_tail) + http.pushes_tail->next = push; + else + http.pushes_head = push; + http.pushes_tail = push; + http.pushes++; + SDL_UnlockMutex(http.lock); +} + +static void event_listener(unsigned long sequence, const char *group, const char *text, const char *addressed_text) +{ + (void)sequence; + push_add(group, text, addressed_text); +} + +static void log_listener(struct server_log_line const *line) +{ + struct server_json json; + char head[96]; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_integer(&json, "n", (long long)line->number); + server_json_string(&json, "level", server_log_level_name(line->level)); + server_json_string(&json, "cat", line->category); + server_json_string(&json, "text", line->text); + server_json_object_end(&json); + /* (no seq: a log line is no event of the ring, and its own number, n, + is its sequence, /api/log's cursor) */ + snprintf(head, sizeof(head), "{\"type\":\"event\",\"time\":%lld,\"event\":\"log\",", line->time); + { + const char *object = server_json_text(&json); + size_t length = strlen(head) + strlen(object); + char *text = malloc(length + 1); + + if (text && object[0] == '{') + { + strcpy(text, head); + strcat(text, object + 1); + push_add(!strcmp(line->category, "chat") ? "chat" : "log", text, NULL); + } + free(text); + } + server_json_free(&json); +} + +/* ---------- start */ + +int server_http_start(void) +{ + long port = config_integer("server.web_port"); + const char *bind_text = config_string("server.web_bind"); + unsigned long bind_address = 0; + unsigned char address[16]; + int listener, bound; + int reuse = 1; + + if (port <= 0) + return 1; + if (!strcmp(bind_text, "localhost")) + bind_address = 0x7F000001UL; + else if (!parse_address(bind_text, &bind_address)) + { + server_log(_server_log_error, "server", "server.web_bind \"%s\" is no IPv4 address", bind_text); + server_mode_exit(SERVER_EXIT_CONFIGURATION); + } + http.lock = SDL_CreateMutex(); + posix_random_bytes(http.session_key, sizeof(http.session_key)); + { + int index; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + http.connections[index].socket = -1; + } + memset(address, 0, sizeof(address)); + address[0] = 2; + address[2] = (unsigned char)(port >> 8); + address[3] = (unsigned char)port; + address[4] = (unsigned char)(bind_address >> 24); + address[5] = (unsigned char)(bind_address >> 16); + address[6] = (unsigned char)(bind_address >> 8); + address[7] = (unsigned char)bind_address; + listener = posix_socket(2, 1, 6); + if (listener < 0) + { + server_log(_server_log_error, "server", "the web port cannot be opened"); + return 0; + } + /* (SO_REUSEADDR, Winsock's level and name: a restart takes its port at + once; not on Windows, where it lets another program take the port too) */ +#ifndef _WIN32 + posix_socket_setsockopt(listener, 0xFFFF, 0x0004, &reuse, sizeof(reuse)); +#else + (void)reuse; +#endif + bound = posix_socket_bind(listener, address, sizeof(address)) == 0; + if (!bound || posix_socket_listen(listener, 16) != 0) + { + /* (Winsock's codes, as posix_socket_last_error has them: in use, an + address this machine lacks and a port it may not take are told + apart; a port in use exits 4, the others are the configuration's) */ + int error = posix_socket_last_error(); + char reason[64]; + + posix_socket_close(listener); + if (error == 10048) + { + server_log(_server_log_error, "server", "the web port %s:%ld (server.web_port) is taken by another program; " + "exiting", bind_text, port); + return 0; + } + if (error == 10013) + snprintf(reason, sizeof(reason), "not allowed (a port below 1024 needs privileges)"); + else if (error == 10049) + snprintf(reason, sizeof(reason), "this machine has no such address"); + else + snprintf(reason, sizeof(reason), "socket error %d", error); + server_log(_server_log_error, "server", "the web port %s:%ld (server.web_port, server.web_bind) cannot be %s: %s; " + "exiting", bind_text, port, bound ? "listened on" : "bound", reason); + if (error == 10013 || error == 10049) + server_mode_exit(SERVER_EXIT_CONFIGURATION); + return 0; + } + posix_socket_set_nonblocking(listener, 1); + http.listening = 1; + http.started = 1; + server_events_set_listener(event_listener); + server_log_set_listener(log_listener); + http.thread = SDL_CreateThread(http_thread_main, "server http", (void *)(intptr_t)listener); + if (!http.thread) + { + server_log(_server_log_error, "server", "the web port's thread cannot start"); + return 0; + } + server_log(_server_log_info, "server", "web: http://%s:%ld/ (the admin page%s, the API, the RCON)", bind_text, port, + config_boolean("server.web_ui") ? "" : " off"); + return 1; +} + +/* ---------- the game's thread */ + +/* the answer of a ticket: its status and JSON */ +static void ticket_answer(struct server_ticket *ticket, int status, struct server_json *json) +{ + ticket->status = status; + ticket->response = strdup(server_json_text(json)); +} + +static void ticket_error(struct server_ticket *ticket, int status, const char *code, const char *message) +{ + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 0); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message); + server_json_object_end(&json); + server_json_object_end(&json); + ticket_answer(ticket, status, &json); + server_json_free(&json); +} + +/* a command line run as the ticket's role: its lines into json's "lines"; +1 if it succeeded */ +static int ticket_run_line(struct server_ticket *ticket, const char *line, struct server_json *json) +{ + unsigned char succeeded = 0; + char *output; + + server_log(_server_log_info, "console", "%s: %s", ticket->by, server_console_line_is_secret(line) ? + "(a password's command)" : line); + output = server_commands_run(line, ticket->role, _server_origin_web, &succeeded); + server_json_array_begin(json, "lines"); + if (output) + { + const char *cursor = output; + + while (*cursor) + { + const char *end = strchr(cursor, '\n'); + int length = end ? (int)(end - cursor) : (int)strlen(cursor); + + if (length > 0) + server_json_string_length(json, NULL, cursor, length); + cursor += length + (end != NULL); + } + free(output); + } + server_json_array_end(json); + return succeeded; +} + +/* a command's answer: ok and its lines, or refused (its first line said +why: a role's, an unknown command's, a usage's) */ +static void ticket_command(struct server_ticket *ticket, const char *line) +{ + struct server_json json; + int succeeded; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + succeeded = ticket_run_line(ticket, line, &json); + server_json_object_end(&json); + if (succeeded) + { + ticket_answer(ticket, 200, &json); + } + else + { + /* {"ok":false,"error":{...},"lines":[...]} from the same lines */ + struct server_json_document document; + struct server_json refused; + char first[512] = "the command failed"; + const char *code = "failed"; + int status = 400; + const char *text = server_json_text(&json); + + if (server_json_parse(&document, text, (int)strlen(text), 8)) + { + server_json_get_string(&document, server_json_element(&document, server_json_member(&document, 0, "lines"), 0), + first, sizeof(first)); + server_json_document_free(&document); + } + if (strstr(first, "needs the ") && strstr(first, " role")) + { + code = "forbidden"; + status = 403; + } + else if (strstr(first, "not a command of the server's") || strstr(first, "no player") || + strstr(first, ": no ban ")) + { + code = "not_found"; + status = 404; + } + else if (strstr(first, "set by HALO_") || strstr(first, "is set by ")) + { + code = "pinned"; + status = 409; + } + server_json_begin(&refused); + server_json_object_begin(&refused, NULL); + server_json_boolean(&refused, "ok", 0); + server_json_object_begin(&refused, "error"); + server_json_string(&refused, "code", code); + server_json_string(&refused, "message", first); + server_json_object_end(&refused); + { + const char *lines = strstr(text, "\"lines\":"); + + if (lines) + { + char *copy = strdup(lines + 8); + + if (copy && copy[0]) + { + /* (the object's closing brace off) */ + copy[strlen(copy) - 1] = 0; + server_json_raw(&refused, "lines", copy); + } + free(copy); + } + } + server_json_object_end(&refused); + ticket_answer(ticket, status, &refused); + server_json_free(&refused); + } + server_json_free(&json); +} + +/* a player's slot, checked to be the named player (the page's view may be +old): 0 and answered if not */ +static int ticket_player(struct server_ticket *ticket, long slot, char *name, int size) +{ + char wanted[64]; + char current[32]; + + if (!body_string(ticket->body ? ticket->body : "", ticket->body_length, "name", wanted, sizeof(wanted)) || !wanted[0]) + { + ticket_error(ticket, 400, "schema", "name is needed (the player's, checked)"); + return 0; + } + if (!server_status_player_matches(slot, wanted, current, sizeof(current))) + { + char message[128]; + + if (!current[0]) + { + snprintf(message, sizeof(message), "no player in slot %ld", slot); + ticket_error(ticket, 404, "not_found", message); + } + else + { + struct server_json json; + + snprintf(message, sizeof(message), "slot %ld is %s now, not %s: nothing was done", slot, current, wanted); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 0); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", "changed"); + server_json_string(&json, "message", message); + server_json_string(&json, "name", current); + server_json_object_end(&json); + server_json_object_end(&json); + ticket_answer(ticket, 409, &json); + server_json_free(&json); + } + return 0; + } + snprintf(name, (size_t)size, "%s", current); + return 1; +} + +static int body_number(const char *body, int length, const char *key, double *value) +{ + struct server_json_document document; + int found; + + if (!server_json_parse(&document, body, length, 8)) + return 0; + found = server_json_get_number(&document, server_json_member(&document, 0, key), value); + server_json_document_free(&document); + return found; +} + +/* a text a line takes: no quotes, no control characters, 127 at most */ +static void line_text(const char *source, char *destination, int size) +{ + int length = 0; + + for (; *source && length < size - 1 && length < 127; source++) + { + if ((unsigned char)*source >= 0x20 && (unsigned char)*source < 0x7F && *source != '"') + destination[length++] = *source; + } + destination[length] = 0; +} + +static void ticket_api(struct server_ticket *ticket) +{ + const char *path = ticket->path; + const char *method = ticket->method; + const char *body = ticket->body ? ticket->body : ""; + int length = ticket->body_length; + struct server_json json; + + server_json_begin(&json); + if (!strncmp(path, "/api/players/", 13) && !strcmp(method, "POST")) + { + char action[16]; + char name[32]; + char line[SERVER_COMMAND_LINE_SIZE]; + long slot; + char *after; + + slot = strtol(path + 13, &after, 10); + if (after == path + 13 || *after != '/' || slot < 0 || slot > 127 || strlen(after + 1) >= sizeof(action)) + { + ticket_error(ticket, 404, "not_found", "nothing is there"); + } + else if (snprintf(action, sizeof(action), "%s", after + 1), strcmp(action, "kick") && strcmp(action, "ban") && + strcmp(action, "mute") && strcmp(action, "unmute") && strcmp(action, "tell")) + { + ticket_error(ticket, 404, "not_found", "nothing is there"); + } + else if (ticket_player(ticket, slot, name, sizeof(name))) + { + if (!strcmp(action, "ban")) + { + char duration[32] = "forever", reason[160] = "", kept[160]; + + body_string(body, length, "duration", duration, sizeof(duration)); + body_string(body, length, "reason", reason, sizeof(reason)); + line_text(reason, kept, sizeof(kept)); + snprintf(line, sizeof(line), "ban %ld %s %s", slot, duration[0] ? duration : "forever", kept); + } + else if (!strcmp(action, "mute")) + { + double minutes = 0; + + if (body_number(body, length, "minutes", &minutes) && (minutes < 0 || minutes > 1440)) + { + ticket_error(ticket, 400, "schema", "minutes is 1 to 1440"); + server_json_free(&json); + return; + } + if (minutes >= 1) + snprintf(line, sizeof(line), "mute %ld %ld", slot, (long)minutes); + else + snprintf(line, sizeof(line), "mute %ld", slot); + } + else if (!strcmp(action, "tell")) + { + char text[256], kept[160]; + + if (!body_string(body, length, "text", text, sizeof(text)) || !text[0]) + { + ticket_error(ticket, 400, "schema", "text is needed"); + server_json_free(&json); + return; + } + line_text(text, kept, sizeof(kept)); + snprintf(line, sizeof(line), "tell %ld %s", slot, kept); + } + else + { + snprintf(line, sizeof(line), "%s %ld", action, slot); + } + ticket_command(ticket, line); + } + } + else if (!strcmp(path, "/api/say") && !strcmp(method, "POST")) + { + char text[256], kept[160], line[SERVER_COMMAND_LINE_SIZE]; + + if (!body_string(body, length, "text", text, sizeof(text)) || !text[0]) + { + ticket_error(ticket, 400, "schema", "text is needed"); + } + else + { + line_text(text, kept, sizeof(kept)); + snprintf(line, sizeof(line), "say %s", kept); + ticket_command(ticket, line); + } + } + else if (!strcmp(path, "/api/command") && !strcmp(method, "POST")) + { + char line[SERVER_COMMAND_LINE_SIZE]; + + if (!body_string(body, length, "line", line, sizeof(line)) || !line[0]) + ticket_error(ticket, 400, "schema", "line is a command (511 characters at most)"); + else + ticket_command(ticket, line); + } + else if (!strcmp(path, "/api/rotation") && !strcmp(method, "GET")) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_rotation(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else if (!strcmp(path, "/api/rotation") && !strcmp(method, "PUT")) + { + struct server_json_document document; + char *text = malloc(4200); + int entries, index, ok = 1, text_length = 0; + + if (!text || !server_json_parse(&document, body, length, 8)) + { + ticket_error(ticket, 400, "bad_request", "the body is not JSON"); + free(text); + server_json_free(&json); + return; + } + entries = server_json_member(&document, 0, "entries"); + text[0] = 0; + if (entries < 0 || document.tokens[entries].type != _server_json_array || !document.tokens[entries].size || + document.tokens[entries].size > 64) + { + ok = 0; + } + for (index = 0; ok && server_json_element(&document, entries, index) >= 0; index++) + { + char entry[256]; + + if (!server_json_get_string(&document, server_json_element(&document, entries, index), entry, sizeof(entry)) || + strchr(entry, ',') || strchr(entry, '\n') || !entry[0]) + { + ok = 0; + break; + } + text_length += snprintf(text + text_length, (size_t)(4200 - text_length), "%s%s", + index ? ", " : "", entry); + if (text_length >= 4200 - 1) + ok = 0; + } + server_json_document_free(&document); + if (!ok) + { + ticket_error(ticket, 400, "schema", "entries is a list of 1 to 64 entries (map:gametype), with no commas"); + } + else + { + struct server_json rejected; + + server_json_begin(&rejected); + server_json_object_begin(&rejected, NULL); + if (server_director_set_rotation_text(text, &rejected)) + { + server_log(_server_log_info, "server", "rotation set by %s: %s", ticket->by, text); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_rotation(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else + { + const char *rejected_text; + + server_json_object_end(&rejected); + rejected_text = server_json_text(&rejected); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 0); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", "rejected"); + server_json_string(&json, "message", "some entries cannot be played: nothing changed"); + /* ({"rejected":[...]}'s array) */ + if (strlen(rejected_text) > 13) + { + char *array = strdup(rejected_text + 12); + + if (array) + { + array[strlen(array) - 1] = 0; + server_json_raw(&json, "rejected", array); + } + free(array); + } + server_json_object_end(&json); + server_json_object_end(&json); + ticket_answer(ticket, 400, &json); + } + server_json_free(&rejected); + } + free(text); + } + else if (!strcmp(path, "/api/rotation/next") && !strcmp(method, "POST")) + { + ticket_command(ticket, "sv_map_next"); + } + else if (!strcmp(path, "/api/rotation/play") && !strcmp(method, "POST")) + { + char map[128], gametype[64] = "", line[SERVER_COMMAND_LINE_SIZE]; + + if (!body_string(body, length, "map", map, sizeof(map)) || !map[0] || strchr(map, ' ') || strchr(map, '"')) + { + ticket_error(ticket, 400, "schema", "map is needed (no spaces)"); + } + else + { + body_string(body, length, "gametype", gametype, sizeof(gametype)); + if (strchr(gametype, ' ') || strchr(gametype, '"')) + ticket_error(ticket, 400, "schema", "a gametype holds no space"); + else + { + snprintf(line, sizeof(line), "sv_map %s %s", map, gametype); + ticket_command(ticket, line); + } + } + } + else if (!strcmp(path, "/api/maps") && !strcmp(method, "GET")) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_maps(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else if (!strcmp(path, "/api/gametypes") && !strcmp(method, "GET")) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_gametypes(&json); + server_gametype_files_json(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else if (!strncmp(path, "/api/gametypes/", 15)) + { + const char *name = path + 15; + char folder[1024], file[1200], temporary[1210]; + int index, valid = name[0] != 0 && strlen(name) <= 32; + + for (index = 0; valid && name[index]; index++) + { + valid = (name[index] >= 'a' && name[index] <= 'z') || (name[index] >= '0' && name[index] <= '9') || + name[index] == '_' || name[index] == '-'; + } + if (!valid) + { + ticket_error(ticket, 404, "not_found", "a gametype file's name is a-z, 0-9, _ and -, 32 at most"); + server_json_free(&json); + return; + } + server_gametypes_folder(folder, sizeof(folder)); + snprintf(file, sizeof(file), "%s/%s.toml", folder, name); + if (!strcmp(method, "GET")) + { + size_t size = 0; + char *text = config_file_read(file, &size); + + if (!text) + { + ticket_error(ticket, 404, "not_found", "no such gametype file"); + } + else + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_string(&json, "name", name); + server_json_string_length(&json, "text", text, (int)size); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + free(text); + } + } + else if (!strcmp(method, "PUT")) + { + char *text = malloc((size_t)length + 1); + char reason[256]; + int line = 0; + + if (!text || !body_string(body, length, "text", text, length + 1)) + { + ticket_error(ticket, 400, "schema", "text is needed (the file's TOML)"); + } + else if (!server_gametype_check_text(text, (int)strlen(text), reason, sizeof(reason), &line)) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 0); + server_json_object_begin(&json, "error"); + server_json_string(&json, "code", "invalid"); + server_json_string(&json, "message", reason); + server_json_integer(&json, "line", line); + server_json_object_end(&json); + server_json_object_end(&json); + ticket_answer(ticket, 400, &json); + } + else + { + FILE *handle; + int written = 0; + + posix_make_directory(folder); + snprintf(temporary, sizeof(temporary), "%s.tmp", file); + handle = fopen(temporary, "wb"); + if (handle) + { + written = fwrite(text, 1, strlen(text), handle) == strlen(text); + written = fclose(handle) == 0 && written && posix_server_rename(temporary, file); + } + if (!written) + { + ticket_error(ticket, 500, "internal", "the gametypes folder cannot be written"); + } + else + { + server_log(_server_log_info, "server", "gametypes/%s.toml written by %s", name, ticket->by); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + } + free(text); + } + else if (!strcmp(method, "DELETE")) + { + if (!posix_server_remove(file)) + { + ticket_error(ticket, 404, "not_found", "no such gametype file"); + } + else + { + server_log(_server_log_info, "server", "gametypes/%s.toml deleted by %s", name, ticket->by); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + } + else + { + ticket_error(ticket, 405, "method", "GET, PUT or DELETE"); + } + } + else if (!strcmp(path, "/api/bans") && !strcmp(method, "POST")) + { + char ip[64], hwid[64], names[96], duration[32], reason[160], message[160]; + long number = 0; + int answer; + + body_string(body, length, "ip", ip, sizeof(ip)); + body_string(body, length, "hwid", hwid, sizeof(hwid)); + body_string(body, length, "names", names, sizeof(names)); + body_string(body, length, "duration", duration, sizeof(duration)); + body_string(body, length, "reason", reason, sizeof(reason)); + /* (as the command, with the session's role: checked and logged as + sv_ban_address is) */ + answer = server_commands_ban_address_text(ip, hwid, names, duration, reason, ticket->role, message, + sizeof(message), &number); + if (answer != 200) + { + ticket_error(ticket, answer, answer == 500 ? "internal" : "schema", message); + } + else + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_integer(&json, "n", number); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + } + else if (!strcmp(path, "/api/stop") && !strcmp(method, "POST")) + { + struct server_json_document document; + char after[16] = "", notice[256] = "", kept[160], line[SERVER_COMMAND_LINE_SIZE]; + int cancel = 0, restart = 0, token, ok; + + if (!server_json_parse(&document, body, length, 8) || document.tokens[0].type != _server_json_object) + { + ticket_error(ticket, 400, "bad_request", "the body is a JSON object: after, notice, restart (or cancel)"); + server_json_free(&json); + return; + } + token = server_json_member(&document, 0, "cancel"); + ok = token < 0 || server_json_get_boolean(&document, token, &cancel); + token = server_json_member(&document, 0, "restart"); + ok = ok && (token < 0 || server_json_get_boolean(&document, token, &restart)); + token = server_json_member(&document, 0, "notice"); + ok = ok && (token < 0 || server_json_get_string(&document, token, notice, sizeof(notice))); + token = server_json_member(&document, 0, "after"); + if (token >= 0 && !server_json_get_string(&document, token, after, sizeof(after))) + after[0] = '?'; + server_json_document_free(&document); + if (!ok) + { + ticket_error(ticket, 400, "schema", "cancel and restart are true or false, notice a text"); + } + else if (cancel) + { + ticket_command(ticket, "sv_stop cancel"); + } + else if (strcmp(after, "now") && strcmp(after, "game") && strcmp(after, "empty")) + { + ticket_error(ticket, 400, "schema", "after is now, game or empty"); + } + else + { + line_text(notice, kept, sizeof(kept)); + snprintf(line, sizeof(line), "%s %s %s", restart ? "sv_restart" : "sv_stop", after, kept); + ticket_command(ticket, line); + } + } + else if (!strcmp(path, "/api/bans") && !strcmp(method, "GET")) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_commands_bans_json(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else if (!strncmp(path, "/api/bans/", 10) && !strcmp(method, "DELETE")) + { + char *after; + long number = strtol(path + 10, &after, 10); + + if (after == path + 10 || *after || number < 0) + ticket_error(ticket, 404, "not_found", "nothing is there"); + else + { + /* (as the command, with the session's role: logged and checked as + one typed) */ + char line[32]; + + snprintf(line, sizeof(line), "unban %ld", number); + ticket_command(ticket, line); + } + } + else if (!strcmp(path, "/api/settings") && !strcmp(method, "GET")) + { + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_commands_settings_json(&json); + server_json_object_end(&json); + ticket_answer(ticket, 200, &json); + } + else if (!strcmp(path, "/api/settings") && !strcmp(method, "PUT")) + { + struct server_json_document document; + int member, index; + + if (!server_json_parse(&document, body, length, 8) || document.tokens[0].type != _server_json_object) + { + ticket_error(ticket, 400, "bad_request", "the body is a JSON object of settings"); + server_json_free(&json); + return; + } + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_json_array_begin(&json, "applied"); + member = 1; + { + struct server_json errors; + + server_json_begin(&errors); + server_json_array_begin(&errors, NULL); + for (index = 0; index < document.tokens[0].size; index++) + { + char name[64], value[300], reason[160]; + int value_token = member + 1, code = 0; + struct server_json_token const *token = &document.tokens[value_token]; + + if (!server_json_get_string(&document, member, name, sizeof(name))) + name[0] = 0; + if (token->type == _server_json_string_token) + server_json_get_string(&document, value_token, value, sizeof(value)); + else + snprintf(value, sizeof(value), "%.*s", token->end - token->start, document.text + token->start); + if (name[0] && server_commands_set_setting_text(name, value, ticket->role, reason, sizeof(reason), &code)) + { + server_json_string(&json, NULL, name); + server_log(_server_log_info, "server", "%s changed by %s", name, ticket->by); + } + else + { + server_json_object_begin(&errors, NULL); + server_json_string(&errors, "name", name); + server_json_string(&errors, "code", code == 409 ? "pinned" : code == 404 ? "not_found" : + code == 403 ? "forbidden" : "schema"); + server_json_string(&errors, "message", reason); + server_json_object_end(&errors); + } + member = server_json_skip(&document, member); + } + server_json_array_end(&errors); + server_json_array_end(&json); + server_json_raw(&json, "errors", server_json_text(&errors)); + server_json_free(&errors); + } + server_json_object_end(&json); + server_json_document_free(&document); + ticket_answer(ticket, 200, &json); + } + else + { + ticket_error(ticket, 405, "method", "that method is not taken there"); + } + server_json_free(&json); +} + +static void refresh_snapshots(void) +{ + struct server_json json; + char *status_viewer, *status_admin, *players_viewer, *players_admin, *players_owner; + const char *text; + struct server_json_document document; + short state = server_director_state(); + + server_json_begin(&json); + server_status_write(&json, _server_role_viewer); + status_viewer = strdup(server_json_text(&json)); + server_json_free(&json); + server_json_begin(&json); + server_status_write(&json, _server_role_admin); + status_admin = strdup(server_json_text(&json)); + server_json_free(&json); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_players(&json, _server_role_viewer); + server_json_object_end(&json); + players_viewer = strdup(server_json_text(&json)); + server_json_free(&json); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_players(&json, _server_role_admin); + server_json_object_end(&json); + players_admin = strdup(server_json_text(&json)); + server_json_free(&json); + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "ok", 1); + server_status_players(&json, _server_role_owner); + server_json_object_end(&json); + players_owner = strdup(server_json_text(&json)); + server_json_free(&json); + SDL_LockMutex(http.lock); + free(http.status_viewer); + free(http.status_admin); + free(http.players_viewer); + free(http.players_admin); + free(http.players_owner); + http.status_viewer = status_viewer; + http.status_admin = status_admin; + http.players_viewer = players_viewer; + http.players_admin = players_admin; + http.players_owner = players_owner; + snprintf(http.state, sizeof(http.state), "%s", server_status_state_name(state)); + http.snapshot_time = SDL_GetTicksNS(); + /* (the metrics' numbers, from the status) */ + text = status_viewer ? status_viewer : ""; + if (server_json_parse(&document, text, (int)strlen(text), 16)) + { + double value; + + if (server_json_get_number(&document, server_json_member(&document, 0, "players"), &value)) + http.metrics_players = (long)value; + if (server_json_get_number(&document, server_json_member(&document, 0, "max_players"), &value)) + http.metrics_max_players = (long)value; + if (server_json_get_number(&document, server_json_member(&document, 0, "machines"), &value)) + http.metrics_machines = (long)value; + if (server_json_get_number(&document, server_json_member(&document, 0, "games_hosted"), &value)) + http.metrics_games = (long)value; + server_json_document_free(&document); + } + server_mode_frame_times(&http.metrics_frame_longest, &http.metrics_frame_mean); + SDL_UnlockMutex(http.lock); +} + +void server_http_main_thread(void) +{ + struct server_ticket *tickets, *ticket; + Uint64 now = SDL_GetTicksNS(); + int count = 0; + + if (!http.started) + return; + SDL_LockMutex(http.lock); + tickets = http.pending; + http.pending = NULL; + SDL_UnlockMutex(http.lock); + /* (in the order they came) */ + { + struct server_ticket *reversed = NULL; + + while (tickets) + { + struct server_ticket *next = tickets->next; + + tickets->next = reversed; + reversed = tickets; + tickets = next; + } + tickets = reversed; + } + while (tickets) + { + ticket = tickets; + tickets = ticket->next; + ticket->next = NULL; + if (count++ >= TICKETS_PER_FRAME) + { + /* (the rest next frame) */ + SDL_LockMutex(http.lock); + ticket->next = http.pending; + http.pending = ticket; + SDL_UnlockMutex(http.lock); + continue; + } + if (ticket->kind == _ticket_ws_command) + { + struct server_json json; + int succeeded; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "type", "reply"); + server_json_raw(&json, "id", ticket->id); + succeeded = ticket_run_line(ticket, ticket->body ? ticket->body : "", &json); + server_json_boolean(&json, "ok", succeeded); + server_json_object_end(&json); + ticket->response = strdup(server_json_text(&json)); + server_json_free(&json); + } + else + { + ticket_api(ticket); + } + SDL_LockMutex(http.lock); + ticket->done = 1; + if (ticket->abandoned) + { + SDL_UnlockMutex(http.lock); + ticket_free(ticket); + continue; + } + SDL_UnlockMutex(http.lock); + /* (a change shows in the status at once) */ + http.last_snapshot = 0; + } + if (now - http.last_snapshot >= (Uint64)SNAPSHOT_MILLISECONDS * 1000000ULL) + { + http.last_snapshot = now; + refresh_snapshots(); + } +} + +#else + +int server_http_start(void) +{ + return 1; +} + +void server_http_main_thread(void) +{ +} + +void server_http_capabilities(struct server_json *json, const char *key) +{ + (void)json; + (void)key; +} + +#endif diff --git a/port/linux/src/server_http.h b/port/linux/src/server_http.h new file mode 100644 index 00000000..e7e7f77a --- /dev/null +++ b/port/linux/src/server_http.h @@ -0,0 +1,29 @@ +/* +SERVER_HTTP.H + +A dedicated server's web port (server_http.c; docs/dedicated_server.md, +"HTTP" and "The RCON"): the admin page, the JSON API, the remote console +over WebSocket, /healthz and /metrics, served on a thread of their own so +that they answer while the game loads a map. What needs the game (a +command, a change) is handed to the game's thread, which runs it between +frames (server_http_main_thread); what only reads it is answered from the +snapshots that thread leaves. +*/ + +#ifndef __HALO_LINUX_SERVER_HTTP_H +#define __HALO_LINUX_SERVER_HTTP_H + +/* the port bound and its thread started (server.web_port; nothing for 0): +0 if the port is taken */ +int server_http_start(void); +/* the game's thread, each frame: the requests waiting for it run, and the +snapshots refreshed */ +void server_http_main_thread(void); + +struct server_json; + +/* the server's capabilities (the RCON's hello's, the status's) as an array +of the key */ +void server_http_capabilities(struct server_json *json, const char *key); + +#endif diff --git a/port/linux/src/server_json.c b/port/linux/src/server_json.c new file mode 100644 index 00000000..b1a997ed --- /dev/null +++ b/port/linux/src/server_json.c @@ -0,0 +1,803 @@ +/* +SERVER_JSON.C + +JSON written and read for a dedicated server and the server browser's +master servers (server_json.h). +*/ + +#include "server_json.h" + +#include +#include +#include + +#define JSMN_STATIC +#define JSMN_PARENT_LINKS +#define JSMN_STRICT +#include "../../third_party/jsmn/jsmn.h" + +/* ---------- writing */ + +void server_json_begin(struct server_json *json) +{ + memset(json, 0, sizeof(*json)); +} + +const char *server_json_text(struct server_json const *json) +{ + return json->text && !json->failed ? json->text : ""; +} + +void server_json_free(struct server_json *json) +{ + free(json->text); + memset(json, 0, sizeof(*json)); +} + +static void json_append(struct server_json *json, const char *text, int length) +{ + if (json->failed || length <= 0) + return; + if (json->length + length + 1 > json->capacity) + { + int capacity = json->capacity ? json->capacity : 512; + char *grown; + + while (capacity < json->length + length + 1) + capacity *= 2; + if (capacity > SERVER_JSON_MAXIMUM_SIZE || !(grown = realloc(json->text, (size_t)capacity))) + { + json->failed = 1; + return; + } + json->text = grown; + json->capacity = capacity; + } + memcpy(json->text + json->length, text, (size_t)length); + json->length += length; + json->text[json->length] = 0; +} + +static void json_append_text(struct server_json *json, const char *text) +{ + json_append(json, text, (int)strlen(text)); +} + +/* text as a JSON string's inside: quotes, backslashes and control +characters escaped; bytes that are not UTF-8 written as U+FFFD */ +static void json_append_escaped(struct server_json *json, const char *text, int length) +{ + static const char hex[] = "0123456789abcdef"; + int index = 0; + + json_append(json, "\"", 1); + while (index < length && text[index]) + { + unsigned char character = (unsigned char)text[index]; + int run = index; + + /* (the plain characters a run at a time) */ + while (run < length && (unsigned char)text[run] >= 0x20 && (unsigned char)text[run] < 0x80 && + text[run] != '"' && text[run] != '\\') + { + run++; + } + if (run > index) + { + json_append(json, text + index, run - index); + index = run; + continue; + } + if (character == '"' || character == '\\') + { + char escaped[2] = { '\\', (char)character }; + + json_append(json, escaped, 2); + index++; + } + else if (character < 0x20) + { + char escaped[6] = { '\\', 'u', '0', '0', hex[character >> 4], hex[character & 15] }; + + if (character == '\n') + json_append(json, "\\n", 2); + else if (character == '\t') + json_append(json, "\\t", 2); + else if (character == '\r') + json_append(json, "\\r", 2); + else + json_append(json, escaped, 6); + index++; + } + else + { + /* a UTF-8 sequence, checked whole: no overlong form, surrogate + or code point past U+10FFFF (RFC 3629's table) */ + int bytes = character >= 0xF0 && character < 0xF5 ? 4 : character >= 0xE0 && character < 0xF0 ? 3 : + character >= 0xC2 && character < 0xE0 ? 2 : 0; + int valid = bytes > 0 && index + bytes <= length; + int byte; + + for (byte = 1; valid && byte < bytes; byte++) + valid = ((unsigned char)text[index + byte] & 0xC0) == 0x80; + if (valid && bytes >= 3) + { + unsigned char second = (unsigned char)text[index + 1]; + + valid = !(character == 0xE0 && second < 0xA0) && !(character == 0xED && second > 0x9F) && + !(character == 0xF0 && second < 0x90) && !(character == 0xF4 && second > 0x8F); + } + if (valid) + { + json_append(json, text + index, bytes); + index += bytes; + } + else + { + json_append(json, "\\ufffd", 6); + index++; + } + } + } + json_append(json, "\"", 1); +} + +/* the separator and key before a member */ +static void json_member(struct server_json *json, const char *key) +{ + if (json->depth > 0) + { + if (json->has_member[json->depth - 1]) + json_append(json, ",", 1); + json->has_member[json->depth - 1] = 1; + } + if (key) + { + json_append_escaped(json, key, (int)strlen(key)); + json_append(json, ":", 1); + } +} + +static void json_open(struct server_json *json, const char *key, const char *bracket) +{ + json_member(json, key); + json_append_text(json, bracket); + if (json->depth >= SERVER_JSON_MAXIMUM_DEPTH) + { + json->failed = 1; + return; + } + json->has_member[json->depth++] = 0; +} + +static void json_close(struct server_json *json, const char *bracket) +{ + if (json->depth > 0) + json->depth--; + json_append_text(json, bracket); +} + +void server_json_object_begin(struct server_json *json, const char *key) +{ + json_open(json, key, "{"); +} + +void server_json_object_end(struct server_json *json) +{ + json_close(json, "}"); +} + +void server_json_array_begin(struct server_json *json, const char *key) +{ + json_open(json, key, "["); +} + +void server_json_array_end(struct server_json *json) +{ + json_close(json, "]"); +} + +void server_json_string(struct server_json *json, const char *key, const char *value) +{ + json_member(json, key); + if (value) + json_append_escaped(json, value, (int)strlen(value)); + else + json_append(json, "null", 4); +} + +void server_json_string_length(struct server_json *json, const char *key, const char *value, int length) +{ + json_member(json, key); + json_append_escaped(json, value, length); +} + +void server_json_integer(struct server_json *json, const char *key, long long value) +{ + char text[32]; + + json_member(json, key); + snprintf(text, sizeof(text), "%lld", value); + json_append_text(json, text); +} + +void server_json_real(struct server_json *json, const char *key, double value) +{ + char text[48]; + + json_member(json, key); + /* (JSON has no infinity nor NaN) */ + if (value != value || value > 1e300 || value < -1e300) + snprintf(text, sizeof(text), "null"); + else + snprintf(text, sizeof(text), "%.6g", value); + json_append_text(json, text); +} + +void server_json_boolean(struct server_json *json, const char *key, int value) +{ + json_member(json, key); + json_append_text(json, value ? "true" : "false"); +} + +void server_json_null(struct server_json *json, const char *key) +{ + json_member(json, key); + json_append_text(json, "null"); +} + +void server_json_raw(struct server_json *json, const char *key, const char *value) +{ + json_member(json, key); + json_append_text(json, value && value[0] ? value : "null"); +} + +/* ---------- reading */ + +/* ---------- the grammar (RFC 8259), which jsmn checks only in part: it +takes [1,], {"a":1 "b":2} and control characters in strings */ + +struct json_check +{ + const char *text; + int length; + int at; + /* the deepest nesting followed (the parse's limit, which also counts + keys, is checked after) */ + int maximum_depth; +}; + +static void json_check_space(struct json_check *check) +{ + while (check->at < check->length && (check->text[check->at] == ' ' || check->text[check->at] == '\t' || + check->text[check->at] == '\n' || check->text[check->at] == '\r')) + { + check->at++; + } +} + +static int json_check_digit(struct json_check *check) +{ + return check->at < check->length && check->text[check->at] >= '0' && check->text[check->at] <= '9'; +} + +static int json_check_hex(char character) +{ + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); +} + +static int json_check_string(struct json_check *check) +{ + check->at++; + while (check->at < check->length) + { + unsigned char character = (unsigned char)check->text[check->at]; + + if (character == '"') + { + check->at++; + return 1; + } + if (character < 0x20) + return 0; + if (character == '\\') + { + char escaped; + + if (++check->at >= check->length) + return 0; + escaped = check->text[check->at]; + if (escaped == 'u') + { + int index; + + for (index = 1; index <= 4; index++) + { + if (check->at + index >= check->length || !json_check_hex(check->text[check->at + index])) + return 0; + } + check->at += 4; + } + else if (!strchr("\"\\/bfnrt", escaped) || !escaped) + { + return 0; + } + } + check->at++; + } + return 0; +} + +static int json_check_number(struct json_check *check) +{ + if (check->text[check->at] == '-') + check->at++; + if (check->at < check->length && check->text[check->at] == '0') + check->at++; + else if (json_check_digit(check)) + while (json_check_digit(check)) + check->at++; + else + return 0; + if (check->at < check->length && check->text[check->at] == '.') + { + check->at++; + if (!json_check_digit(check)) + return 0; + while (json_check_digit(check)) + check->at++; + } + if (check->at < check->length && (check->text[check->at] == 'e' || check->text[check->at] == 'E')) + { + check->at++; + if (check->at < check->length && (check->text[check->at] == '+' || check->text[check->at] == '-')) + check->at++; + if (!json_check_digit(check)) + return 0; + while (json_check_digit(check)) + check->at++; + } + return 1; +} + +static int json_check_word(struct json_check *check, const char *word) +{ + int length = (int)strlen(word); + + if (check->length - check->at < length || memcmp(check->text + check->at, word, (size_t)length)) + return 0; + check->at += length; + return 1; +} + +static int json_check_value(struct json_check *check, int depth) +{ + char character; + + json_check_space(check); + if (check->at >= check->length) + return 0; + character = check->text[check->at]; + if (character == '{' || character == '[') + { + char close = character == '{' ? '}' : ']'; + + if (depth >= check->maximum_depth) + return 0; + check->at++; + json_check_space(check); + if (check->at < check->length && check->text[check->at] == close) + { + check->at++; + return 1; + } + for (;;) + { + if (character == '{') + { + json_check_space(check); + if (check->at >= check->length || check->text[check->at] != '"' || !json_check_string(check)) + return 0; + json_check_space(check); + if (check->at >= check->length || check->text[check->at] != ':') + return 0; + check->at++; + } + if (!json_check_value(check, depth + 1)) + return 0; + json_check_space(check); + if (check->at >= check->length) + return 0; + if (check->text[check->at] == close) + { + check->at++; + return 1; + } + if (check->text[check->at] != ',') + return 0; + check->at++; + } + } + if (character == '"') + return json_check_string(check); + if (character == '-' || (character >= '0' && character <= '9')) + return json_check_number(check); + return json_check_word(check, "true") || json_check_word(check, "false") || json_check_word(check, "null"); +} + +/* whether the text is one JSON value, and nothing but space after it */ +static int json_check(const char *text, int length, int maximum_depth) +{ + struct json_check check; + + check.text = text; + check.length = length; + check.at = 0; + check.maximum_depth = maximum_depth + 1; + if (!json_check_value(&check, 0)) + return 0; + json_check_space(&check); + return check.at == length; +} + +int server_json_parse_tokens(struct server_json_document *document, const char *text, int length, int maximum_depth, + int maximum_tokens) +{ + jsmn_parser parser; + jsmntok_t *tokens; + int count, index; + + memset(document, 0, sizeof(*document)); + if (!text || length <= 0 || maximum_tokens <= 0 || !json_check(text, length, maximum_depth)) + return 0; + tokens = malloc(sizeof(jsmntok_t) * (size_t)maximum_tokens); + if (!tokens) + return 0; + jsmn_init(&parser); + count = jsmn_parse(&parser, text, (size_t)length, tokens, (unsigned int)maximum_tokens); + if (count <= 0) + { + free(tokens); + return 0; + } + document->tokens = malloc(sizeof(struct server_json_token) * (size_t)count); + if (!document->tokens) + { + free(tokens); + return 0; + } + for (index = 0; index < count; index++) + { + int depth = 0, parent = tokens[index].parent; + + /* (nesting counted up the parents: a document of a thousand open + brackets is refused before anything walks it) */ + while (parent >= 0 && depth <= maximum_depth) + { + depth++; + parent = tokens[parent].parent; + } + if (depth > maximum_depth) + { + free(tokens); + server_json_document_free(document); + return 0; + } + document->tokens[index].type = tokens[index].type; + document->tokens[index].start = tokens[index].start; + document->tokens[index].end = tokens[index].end; + document->tokens[index].size = tokens[index].size; + } + free(tokens); + document->text = text; + document->length = length; + document->count = count; + return 1; +} + +int server_json_parse(struct server_json_document *document, const char *text, int length, int maximum_depth) +{ + return server_json_parse_tokens(document, text, length, maximum_depth, SERVER_JSON_MAXIMUM_TOKENS); +} + +void server_json_document_free(struct server_json_document *document) +{ + free(document->tokens); + memset(document, 0, sizeof(*document)); +} + +int server_json_skip(struct server_json_document const *document, int token) +{ + int remaining = 1; + + /* (each token's children follow it, size of them: an object's are its + keys, and each key's its value) */ + while (remaining > 0 && token < document->count) + { + remaining += document->tokens[token].size - 1; + token++; + } + return token; +} + +static int json_token_equals(struct server_json_document const *document, int token, const char *text) +{ + struct server_json_token const *current = &document->tokens[token]; + int length = (int)strlen(text); + + return current->type == _server_json_string_token && current->end - current->start == length && + !memcmp(document->text + current->start, text, (size_t)length); +} + +int server_json_member(struct server_json_document const *document, int token, const char *key) +{ + int member, child; + + if (token < 0 || token >= document->count || document->tokens[token].type != _server_json_object) + return -1; + child = token + 1; + for (member = 0; member < document->tokens[token].size && child + 1 < document->count; member++) + { + /* (jsmn makes a key's value its child: skipping the key skips both) */ + if (json_token_equals(document, child, key)) + return child + 1; + child = server_json_skip(document, child); + } + return -1; +} + +int server_json_element(struct server_json_document const *document, int token, int index) +{ + int element, child; + + if (token < 0 || token >= document->count || document->tokens[token].type != _server_json_array || + index < 0 || index >= document->tokens[token].size) + { + return -1; + } + child = token + 1; + for (element = 0; element < index && child < document->count; element++) + child = server_json_skip(document, child); + return child < document->count ? child : -1; +} + +static int json_hex(const char *text, unsigned *value) +{ + int index; + + *value = 0; + for (index = 0; index < 4; index++) + { + char character = text[index]; + + *value <<= 4; + if (character >= '0' && character <= '9') + *value |= (unsigned)(character - '0'); + else if (character >= 'a' && character <= 'f') + *value |= (unsigned)(character - 'a' + 10); + else if (character >= 'A' && character <= 'F') + *value |= (unsigned)(character - 'A' + 10); + else + return 0; + } + return 1; +} + +static int json_get_string(struct server_json_document const *document, int token, char *text, int size) +{ + struct server_json_token const *current; + const char *source; + int length, index, out = 0; + + if (token < 0 || token >= document->count || size <= 0) + return 0; + current = &document->tokens[token]; + if (current->type != _server_json_string_token) + return 0; + source = document->text + current->start; + length = current->end - current->start; + for (index = 0; index < length; index++) + { + char character = source[index]; + char encoded[4]; + int encoded_length = 1; + + encoded[0] = character; + if (character == '\\' && index + 1 < length) + { + index++; + switch (source[index]) + { + case 'n': encoded[0] = '\n'; break; + case 't': encoded[0] = '\t'; break; + case 'r': encoded[0] = '\r'; break; + case 'b': encoded[0] = '\b'; break; + case 'f': encoded[0] = '\f'; break; + case 'u': + { + unsigned code; + + if (index + 4 >= length) + return 0; + /* (U+0000 refused: a NUL would cut the text the caller reads short, + past the checks of what follows it) */ + if (!json_hex(source + index + 1, &code) || !code) + return 0; + index += 4; + /* (a surrogate pair's two halves) */ + if (code >= 0xD800 && code < 0xDC00 && index + 6 < length && source[index + 1] == '\\' && + source[index + 2] == 'u') + { + unsigned low; + + if (json_hex(source + index + 3, &low) && low >= 0xDC00 && low < 0xE000) + { + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + index += 6; + } + } + if (code >= 0xD800 && code < 0xE000) + code = 0xFFFD; + if (code < 0x80) + { + encoded[0] = (char)code; + } + else if (code < 0x800) + { + encoded[0] = (char)(0xC0 | (code >> 6)); + encoded[1] = (char)(0x80 | (code & 0x3F)); + encoded_length = 2; + } + else if (code < 0x10000) + { + encoded[0] = (char)(0xE0 | (code >> 12)); + encoded[1] = (char)(0x80 | ((code >> 6) & 0x3F)); + encoded[2] = (char)(0x80 | (code & 0x3F)); + encoded_length = 3; + } + else + { + encoded[0] = (char)(0xF0 | (code >> 18)); + encoded[1] = (char)(0x80 | ((code >> 12) & 0x3F)); + encoded[2] = (char)(0x80 | ((code >> 6) & 0x3F)); + encoded[3] = (char)(0x80 | (code & 0x3F)); + encoded_length = 4; + } + break; + } + default: encoded[0] = source[index]; break; + } + } + if (out + encoded_length >= size) + return 0; + memcpy(text + out, encoded, (size_t)encoded_length); + out += encoded_length; + } + text[out] = 0; + return 1; +} + +int server_json_get_string(struct server_json_document const *document, int token, char *text, int size) +{ + /* (the text empty, and terminated, unless the string was read whole: + a caller that ignores the answer reads no part of it) */ + if (json_get_string(document, token, text, size)) + return 1; + if (size > 0) + text[0] = 0; + return 0; +} + +int server_json_get_number(struct server_json_document const *document, int token, double *value) +{ + struct server_json_token const *current; + char text[64]; + char *end; + int length; + + if (token < 0 || token >= document->count) + return 0; + current = &document->tokens[token]; + length = current->end - current->start; + if (current->type != _server_json_primitive || length <= 0 || length >= (int)sizeof(text)) + return 0; + memcpy(text, document->text + current->start, (size_t)length); + text[length] = 0; + /* (a number of JSON's grammar, json_check's, and finite: 1e999 is too) */ + if (!((text[0] >= '0' && text[0] <= '9') || text[0] == '-')) + return 0; + *value = strtod(text, &end); + return *end == 0 && *value - *value == 0.0; +} + +int server_json_get_boolean(struct server_json_document const *document, int token, int *value) +{ + struct server_json_token const *current; + + if (token < 0 || token >= document->count) + return 0; + current = &document->tokens[token]; + if (current->type != _server_json_primitive) + return 0; + if (current->end - current->start == 4 && !memcmp(document->text + current->start, "true", 4)) + *value = 1; + else if (current->end - current->start == 5 && !memcmp(document->text + current->start, "false", 5)) + *value = 0; + else + return 0; + return 1; +} + +int server_json_is_null(struct server_json_document const *document, int token) +{ + struct server_json_token const *current; + + if (token < 0 || token >= document->count) + return 0; + current = &document->tokens[token]; + return current->type == _server_json_primitive && current->end - current->start == 4 && + !memcmp(document->text + current->start, "null", 4); +} + +/* ---------- base64 */ + +static const char base64_alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +void server_base64_encode(const unsigned char *bytes, int size, char *text) +{ + int index, out = 0; + + for (index = 0; index < size; index += 3) + { + unsigned long group = (unsigned long)bytes[index] << 16; + + if (index + 1 < size) + group |= (unsigned long)bytes[index + 1] << 8; + if (index + 2 < size) + group |= bytes[index + 2]; + text[out++] = base64_alphabet[(group >> 18) & 63]; + text[out++] = base64_alphabet[(group >> 12) & 63]; + text[out++] = index + 1 < size ? base64_alphabet[(group >> 6) & 63] : '='; + text[out++] = index + 2 < size ? base64_alphabet[group & 63] : '='; + } + text[out] = 0; +} + +int server_base64_decode(const char *text, unsigned char *bytes, int maximum) +{ + int length = (int)strlen(text); + int index, count = 0; + + if (length % 4) + return -1; + for (index = 0; index < length; index += 4) + { + unsigned long group = 0; + int part, padding = 0; + + for (part = 0; part < 4; part++) + { + char character = text[index + part]; + const char *found = character ? strchr(base64_alphabet, character) : NULL; + + if (character == '=' && index + 4 == length && part >= 2) + { + padding++; + group <<= 6; + continue; + } + if (!found || padding) + return -1; + group = group << 6 | (unsigned long)(found - base64_alphabet); + } + if (count + 3 - padding > maximum) + return -1; + bytes[count++] = (unsigned char)(group >> 16); + if (padding < 2) + bytes[count++] = (unsigned char)(group >> 8); + if (padding < 1) + bytes[count++] = (unsigned char)group; + } + return count; +} diff --git a/port/linux/src/server_json.h b/port/linux/src/server_json.h new file mode 100644 index 00000000..eb86a041 --- /dev/null +++ b/port/linux/src/server_json.h @@ -0,0 +1,120 @@ +/* +SERVER_JSON.H + +JSON for a dedicated server's API, events and log (server_json.c): a writer +that grows its text and escapes strings, and a reader over jsmn +(port/third_party/jsmn) for the few request bodies and the master servers' +lists. The game side (port/linux/game/server_*.c) writes with it too: only +pointers, ints and strings cross. +*/ + +#ifndef __HALO_LINUX_SERVER_JSON_H +#define __HALO_LINUX_SERVER_JSON_H + +enum +{ + /* the deepest nesting written */ + SERVER_JSON_MAXIMUM_DEPTH = 24, + /* the most text written (a reply, an event) */ + SERVER_JSON_MAXIMUM_SIZE = 4 * 1024 * 1024, +}; + +struct server_json +{ + char *text; + int length; + int capacity; + /* text could not grow, or nesting went too deep: the text is not whole */ + int failed; + int depth; + /* whether each open object or array has a member yet */ + unsigned char has_member[SERVER_JSON_MAXIMUM_DEPTH]; +}; + +void server_json_begin(struct server_json *json); +/* the text, NUL terminated ("" after a failure); the writer's to free */ +const char *server_json_text(struct server_json const *json); +void server_json_free(struct server_json *json); + +/* key is the member's name in an object, NULL in an array or at the top */ +void server_json_object_begin(struct server_json *json, const char *key); +void server_json_object_end(struct server_json *json); +void server_json_array_begin(struct server_json *json, const char *key); +void server_json_array_end(struct server_json *json); +/* NULL writes null */ +void server_json_string(struct server_json *json, const char *key, const char *value); +/* a string of at most length bytes */ +void server_json_string_length(struct server_json *json, const char *key, const char *value, int length); +void server_json_integer(struct server_json *json, const char *key, long long value); +void server_json_real(struct server_json *json, const char *key, double value); +void server_json_boolean(struct server_json *json, const char *key, int value); +void server_json_null(struct server_json *json, const char *key); +/* text already JSON (another writer's), as the member's value */ +void server_json_raw(struct server_json *json, const char *key, const char *value); + +/* ---------- reading */ + +enum +{ + /* the most tokens of a document read (a request's body, a message); + and of a master server's list (512 entries of some 50 tokens each: + master_list.c) */ + SERVER_JSON_MAXIMUM_TOKENS = 32768, + SERVER_JSON_MAXIMUM_LIST_TOKENS = 65536, +}; + +enum +{ + _server_json_undefined = 0, + _server_json_object = 1, + _server_json_array = 2, + _server_json_string_token = 4, + _server_json_primitive = 8, +}; + +struct server_json_token +{ + int type; + int start; + int end; + int size; +}; + +struct server_json_document +{ + const char *text; + int length; + int count; + struct server_json_token *tokens; +}; + +/* parses text (length bytes; not NUL terminated is fine): 1 on success, +with nesting no deeper than maximum_depth; server_json_document_free frees */ +int server_json_parse(struct server_json_document *document, const char *text, int length, int maximum_depth); +/* ... with at most maximum_tokens tokens, not SERVER_JSON_MAXIMUM_TOKENS */ +int server_json_parse_tokens(struct server_json_document *document, const char *text, int length, int maximum_depth, + int maximum_tokens); +void server_json_document_free(struct server_json_document *document); +/* the index of the token after token (and all it holds) */ +int server_json_skip(struct server_json_document const *document, int token); +/* the value token of the member key of the object at token, -1 if none */ +int server_json_member(struct server_json_document const *document, int token, const char *key); +/* the element at index of the array at token, -1 if none */ +int server_json_element(struct server_json_document const *document, int token, int index); +/* a string token's text, unescaped (\uXXXX to UTF-8), into text: 1 if it is +a string that fits (and holds no \u0000) */ +int server_json_get_string(struct server_json_document const *document, int token, char *text, int size); +/* a number token's value: 1 if it is a number */ +int server_json_get_number(struct server_json_document const *document, int token, double *value); +/* a true or false token's: 1 if it is one */ +int server_json_get_boolean(struct server_json_document const *document, int token, int *value); +/* whether the token is null */ +int server_json_is_null(struct server_json_document const *document, int token); + + +/* base64 (RFC 4648, with padding) of bytes, into text (4 * ((size + 2) / 3) ++ 1); and the bytes of base64 text: their count, -1 if it is not */ +void server_base64_encode(const unsigned char *bytes, int size, char *text); +int server_base64_decode(const char *text, unsigned char *bytes, int maximum); + +#endif diff --git a/port/linux/src/server_log.c b/port/linux/src/server_log.c new file mode 100644 index 00000000..c20c56cb --- /dev/null +++ b/port/linux/src/server_log.c @@ -0,0 +1,245 @@ +/* +SERVER_LOG.C + +A dedicated server's log (server_log.h). +*/ + +#include "server_log.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "server_json.h" +#include "posix_server.h" + +#include +#include +#include +#include + +static struct +{ + pthread_mutex_t lock; + struct server_log_line lines[SERVER_LOG_RING_SIZE]; + unsigned long latest; + int json; + void (*listener)(struct server_log_line const *line); +} server_log_globals = { PTHREAD_MUTEX_INITIALIZER }; + +const char *server_log_level_name(int level) +{ + return level == _server_log_error ? "error" : level == _server_log_warning ? "warning" : "info"; +} + +void server_log_set_json(int json) +{ + server_log_globals.json = json; +} + +void server_log_set_listener(void (*listener)(struct server_log_line const *line)) +{ + pthread_mutex_lock(&server_log_globals.lock); + server_log_globals.listener = listener; + pthread_mutex_unlock(&server_log_globals.lock); +} + +/* a line's fields, its text to printable characters (a player's name or +chat line never breaks a line of the log) */ +static void server_log_fill(struct server_log_line *line, int level, const char *category, const char *text) +{ + int index; + + line->time = posix_server_unix_milliseconds(); + line->level = level; + snprintf(line->category, sizeof(line->category), "%s", category ? category : "server"); + for (index = 0; text[index] && index < SERVER_LOG_TEXT_SIZE - 1; index++) + { + unsigned char character = (unsigned char)text[index]; + + line->text[index] = character < 0x20 || character == 0x7F ? ' ' : (char)character; + } + line->text[index] = 0; +} + +/* the line kept, and given to the listener; under the lock */ +static struct server_log_line const *server_log_keep(int level, const char *category, const char *text) +{ + struct server_log_line *line; + + server_log_globals.latest++; + line = &server_log_globals.lines[server_log_globals.latest % SERVER_LOG_RING_SIZE]; + line->number = server_log_globals.latest; + server_log_fill(line, level, category, text); + return line; +} + +void server_log_ring(int level, const char *category, const char *text) +{ + struct server_log_line copy; + void (*listener)(struct server_log_line const *line); + + pthread_mutex_lock(&server_log_globals.lock); + copy = *server_log_keep(level, category, text); + listener = server_log_globals.listener; + pthread_mutex_unlock(&server_log_globals.lock); + if (listener) + listener(©); +} + +/* a line on standard output, kept in the ring and given to the listener +when kept (else the standard output's alone: a secret's, which the web's +viewers of the log must not read) */ +static void server_log_write(int level, const char *category, const char *text, int kept) +{ + char when[32]; + struct server_log_line copy; + void (*listener)(struct server_log_line const *line) = NULL; + + pthread_mutex_lock(&server_log_globals.lock); + if (kept) + { + copy = *server_log_keep(level, category, text); + listener = server_log_globals.listener; + } + else + { + /* (as kept, with no number) */ + memset(©, 0, sizeof(copy)); + server_log_fill(©, level, category, text); + } + posix_server_utc_text((unsigned long)(copy.time / 1000), when, sizeof(when)); + if (server_log_globals.json) + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "time", when); + server_json_string(&json, "level", server_log_level_name(level)); + server_json_string(&json, "cat", copy.category); + server_json_string(&json, "msg", copy.text); + server_json_object_end(&json); + fprintf(stdout, "%s\n", server_json_text(&json)); + server_json_free(&json); + } + else + { + fprintf(stdout, "%s %s %s: %s\n", when, + level == _server_log_error ? "ERROR" : level == _server_log_warning ? "WARN" : "INFO", copy.category, + copy.text); + } + fflush(stdout); + pthread_mutex_unlock(&server_log_globals.lock); + if (listener) + listener(©); +} + +void server_log(int level, const char *category, const char *format, ...) +{ + char text[SERVER_LOG_TEXT_SIZE]; + va_list arguments; + + va_start(arguments, format); + vsnprintf(text, sizeof(text), format, arguments); + va_end(arguments); + server_log_write(level, category, text, 1); +} + +void server_log_private(int level, const char *category, const char *format, ...) +{ + char text[SERVER_LOG_TEXT_SIZE]; + va_list arguments; + + va_start(arguments, format); + vsnprintf(text, sizeof(text), format, arguments); + va_end(arguments); + server_log_write(level, category, text, 0); +} + +int server_log_read(unsigned long after, struct server_log_line *lines, int maximum, unsigned long *next, + unsigned long *missed) +{ + unsigned long oldest, number; + int count = 0; + + pthread_mutex_lock(&server_log_globals.lock); + /* (a cursor past the latest, as after a restart: from now on) */ + if (after > server_log_globals.latest) + after = server_log_globals.latest; + oldest = server_log_globals.latest >= SERVER_LOG_RING_SIZE ? + server_log_globals.latest - SERVER_LOG_RING_SIZE + 1 : 1; + *missed = after + 1 < oldest ? oldest - (after + 1) : 0; + number = after + 1 < oldest ? oldest : after + 1; + for (; number <= server_log_globals.latest && count < maximum; number++) + lines[count++] = server_log_globals.lines[number % SERVER_LOG_RING_SIZE]; + *next = number - 1; + pthread_mutex_unlock(&server_log_globals.lock); + return count; +} + +unsigned long server_log_latest(void) +{ + unsigned long latest; + + pthread_mutex_lock(&server_log_globals.lock); + latest = server_log_globals.latest; + pthread_mutex_unlock(&server_log_globals.lock); + return latest; +} + +#else + +void server_log(int level, const char *category, const char *format, ...) +{ + (void)level; + (void)category; + (void)format; +} + +void server_log_private(int level, const char *category, const char *format, ...) +{ + (void)level; + (void)category; + (void)format; +} + +void server_log_ring(int level, const char *category, const char *text) +{ + (void)level; + (void)category; + (void)text; +} + +int server_log_read(unsigned long after, struct server_log_line *lines, int maximum, unsigned long *next, + unsigned long *missed) +{ + (void)after; + (void)lines; + (void)maximum; + *next = 0; + *missed = 0; + return 0; +} + +unsigned long server_log_latest(void) +{ + return 0; +} + +void server_log_set_listener(void (*listener)(struct server_log_line const *line)) +{ + (void)listener; +} + +void server_log_set_json(int json) +{ + (void)json; +} + +const char *server_log_level_name(int level) +{ + (void)level; + return "info"; +} + +#endif diff --git a/port/linux/src/server_log.h b/port/linux/src/server_log.h new file mode 100644 index 00000000..f2034e57 --- /dev/null +++ b/port/linux/src/server_log.h @@ -0,0 +1,72 @@ +/* +SERVER_LOG.H + +A dedicated server's log (server_log.c): its lines on standard output, as +text or JSON (server.log_format), each also kept in a ring of the last +SERVER_LOG_RING_SIZE that the API, the remote console and the web page read +from (each line numbered, the next asked for after the last one seen). Any +thread may write. + +The categories: "server", "lobby", "game", "player", "chat", "moderation", +"net", "master", "console" (commands and their answers), "health", +"platform" (the platform layer's lines, kept in the ring alone: they go to +the standard error). +*/ + +#ifndef __HALO_LINUX_SERVER_LOG_H +#define __HALO_LINUX_SERVER_LOG_H + +enum +{ + _server_log_info, + _server_log_warning, + _server_log_error, +}; + +enum +{ + SERVER_LOG_RING_SIZE = 2048, + SERVER_LOG_CATEGORY_SIZE = 16, + SERVER_LOG_TEXT_SIZE = 384, +}; + +struct server_log_line +{ + unsigned long number; + long long time; + int level; + char category[SERVER_LOG_CATEGORY_SIZE]; + char text[SERVER_LOG_TEXT_SIZE]; +}; + +/* a line on standard output and in the ring */ +void server_log(int level, const char *category, const char *format, ...) +#ifdef __GNUC__ + __attribute__((format(printf, 3, 4))) +#endif + ; +/* a line on standard output alone, kept out of the ring the web's viewers +read (/api/log, sv_log) and of the remote console's: a secret's */ +void server_log_private(int level, const char *category, const char *format, ...) +#ifdef __GNUC__ + __attribute__((format(printf, 3, 4))) +#endif + ; +/* a line in the ring alone */ +void server_log_ring(int level, const char *category, const char *text); +/* the lines numbered after after (0: from the oldest), up to maximum; how +many. next is the number to ask after next time, missed how many left the +ring before they were read */ +int server_log_read(unsigned long after, struct server_log_line *lines, int maximum, unsigned long *next, + unsigned long *missed); +/* the number of the latest line (0: none yet) */ +unsigned long server_log_latest(void); +/* each line as it is written (the remote console's log events), on the +thread that wrote it; NULL for none */ +void server_log_set_listener(void (*listener)(struct server_log_line const *line)); +/* standard output's lines written as JSON (server.log_format "json") */ +void server_log_set_json(int json); +/* the level's name: "info", "warning", "error" */ +const char *server_log_level_name(int level); + +#endif diff --git a/port/linux/src/server_master.c b/port/linux/src/server_master.c new file mode 100644 index 00000000..58e3394e --- /dev/null +++ b/port/linux/src/server_master.c @@ -0,0 +1,955 @@ +/* +SERVER_MASTER.C + +A dedicated server's publishing to the official master servers +(server_master.h; docs/master_protocol.md). +*/ + +#include "platform.h" +#include "port_config.h" +#include "server_master.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "p2p.h" +#include "p2p_internal.h" +#include "posix.h" +#include "posix_server.h" +#include "server_events.h" +#include "server_json.h" +#include "server_log.h" +#include "update.h" +#include "master_list.h" + +#include + +#include +#include +#include +#include + +/* ---------- the publisher key */ + +static int hex_value(char character) +{ + if (character >= '0' && character <= '9') + return character - '0'; + if (character >= 'a' && character <= 'f') + return character - 'a' + 10; + if (character >= 'A' && character <= 'F') + return character - 'A' + 10; + return -1; +} + +/* a seed's 64 hex digits (spaces and a comment after them allowed): 1 */ +static int seed_from_text(const char *text, unsigned char *seed) +{ + int index; + + while (*text == ' ' || *text == '\t' || *text == '\n' || *text == '\r') + text++; + for (index = 0; index < SERVER_MASTER_SEED_SIZE; index++) + { + int high = hex_value(text[2 * index]), low = high >= 0 ? hex_value(text[2 * index + 1]) : -1; + + if (high < 0 || low < 0) + return 0; + seed[index] = (unsigned char)(high << 4 | low); + } + text += 2 * SERVER_MASTER_SEED_SIZE; + return !*text || *text == ' ' || *text == '\t' || *text == '\r' || *text == '\n' || *text == '#'; +} + +/* the key file's path: beside config.toml unless a full path */ +static void key_file_path(char *path, int size) +{ + const char *setting = config_string("server.publisher_key_file"); + char folder[1024]; + + if (setting[0] == '/' || (setting[0] && setting[1] == ':')) + { + snprintf(path, (size_t)size, "%s", setting); + return; + } + config_folder(folder, sizeof(folder)); + snprintf(path, (size_t)size, "%s%s", folder, setting[0] ? setting : "publisher.key"); +} + +/* the seed: HALO_SERVER_PUBLISHER_KEY's, else the file's first line that is +not a comment; 1 */ +static int publisher_seed(unsigned char *seed) +{ + const char *environment = getenv("HALO_SERVER_PUBLISHER_KEY"); + char path[1100]; + char *text; + size_t size; + const char *line; + int found = 0; + + if (environment && environment[0]) + return seed_from_text(environment, seed); + key_file_path(path, sizeof(path)); + text = config_file_read(path, &size); + if (!text) + return 0; + for (line = text; *line && !found;) + { + const char *end = line + strcspn(line, "\n"); + + while (*line == ' ' || *line == '\t') + line++; + if (*line != '#' && line < end) + found = seed_from_text(line, seed); + line = *end ? end + 1 : end; + } + memset(text, 0, size); + free(text); + return found; +} + +static void key_id_of(const unsigned char *public_key, char *key_id) +{ + unsigned char digest[P2P_SHA256_SIZE]; + + p2p_sha256(public_key, SERVER_MASTER_PUBLIC_KEY_SIZE, digest); + p2p_hex(digest, SERVER_MASTER_KEY_ID_SIZE, key_id); +} + +int server_master_publisher(unsigned char *public_key, char *key_id) +{ + unsigned char seed[SERVER_MASTER_SEED_SIZE]; + + if (!publisher_seed(seed)) + return 0; + p2p_ed25519_public(seed, public_key, NULL); + memset(seed, 0, sizeof(seed)); + key_id_of(public_key, key_id); + return 1; +} + +/* a signature by the publisher key: 1 */ +static int publisher_sign(const void *message, int size, unsigned char *signature) +{ + unsigned char seed[SERVER_MASTER_SEED_SIZE]; + unsigned char public_key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + + if (!publisher_seed(seed)) + return 0; + p2p_ed25519_public(seed, public_key, NULL); + p2p_ed25519_sign(seed, public_key, message, size, signature); + memset(seed, 0, sizeof(seed)); + return 1; +} + +int server_master_keygen(void) +{ + unsigned char seed[SERVER_MASTER_SEED_SIZE]; + unsigned char public_key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + char hex[2 * SERVER_MASTER_SEED_SIZE + 1]; + char path[1100]; + char text[512]; + FILE *file; + int written; + + key_file_path(path, sizeof(path)); + if (getenv("HALO_SERVER_PUBLISHER_KEY") && getenv("HALO_SERVER_PUBLISHER_KEY")[0]) + { + if (!publisher_seed(seed)) + { + fprintf(stderr, "HALO_SERVER_PUBLISHER_KEY is not 64 hexadecimal digits\n"); + return 0; + } + } + else if (!publisher_seed(seed)) + { + file = fopen(path, "rb"); + if (file) + { + fclose(file); + fprintf(stderr, "%s is there but holds no key (64 hexadecimal digits): move it aside to make a new one\n", + path); + return 0; + } + posix_random_bytes(seed, sizeof(seed)); + p2p_hex(seed, SERVER_MASTER_SEED_SIZE, hex); + snprintf(text, sizeof(text), + "# The publisher key of this server's operator for the OpenCE master servers (halo --server\n" + "# --keygen). Keep it secret: whoever has it publishes as you. Its public key is in the\n" + "# application you make to a master.\n%s\n", hex); + memset(hex, 0, sizeof(hex)); + /* (only its owner may read it, from its creation: no moment when the + key is in a file others can read) */ + written = posix_server_write_private_file(path, text, (unsigned long)strlen(text)); + memset(text, 0, sizeof(text)); + if (!written) + { + fprintf(stderr, "cannot write %s\n", path); + return 0; + } + fprintf(stdout, "wrote a new publisher key to %s\n", path); + } + else + { + fprintf(stdout, "the publisher key in %s:\n", path); + } + p2p_ed25519_public(seed, public_key, NULL); + memset(seed, 0, sizeof(seed)); + key_id_of(public_key, key_id); + p2p_hex(public_key, SERVER_MASTER_PUBLIC_KEY_SIZE, hex); + fprintf(stdout, "public key: ed25519:%s\nkey id: %s\n", hex, key_id); + return 1; +} + +int server_master_sign_text(const char *text) +{ + unsigned char signature[P2P_SIGNATURE_SIZE]; + char hex[2 * P2P_SIGNATURE_SIZE + 1]; + + if (!publisher_sign(text, (int)strlen(text), signature)) + { + fprintf(stderr, "there is no publisher key (halo --server --keygen makes one)\n"); + return 0; + } + p2p_hex(signature, P2P_SIGNATURE_SIZE, hex); + fprintf(stdout, "%s\n", hex); + return 1; +} + +/* ---------- the masters */ + +struct master +{ + char url[256]; + unsigned char key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + unsigned char key_id[SERVER_MASTER_KEY_ID_SIZE]; + /* the publisher thread's, read under the lock by server_master_status */ + int listed; + char probe[16]; + char code[32]; + char message[160]; + unsigned long updated; + /* a refusal's: when it may be asked again */ + unsigned long retry_at; + /* whether the last publish listed the game (a player's host that stops + hosting is withdrawn, and a server gone private: its tombstone is sent) */ + int published; + /* the master revoked the key or its publisher (key_revoked, + publisher_revoked): not asked again this run */ + int revoked; + /* the outcome last logged, when the failed probe's hint was, and when + an outcome logged once a minute was */ + char logged[200]; + unsigned long hint_logged; + unsigned long repeat_logged; +}; + +enum +{ + PUBLISH_SECONDS = 30, + CHANGE_SECONDS = 5, + STOP_MILLISECONDS = 2000, + /* a probe's answers a second at most, in all and to one address, the + nonces remembered, and the addresses counted in a second */ + PROBE_ANSWERS_PER_SECOND = 10, + PROBE_ANSWERS_PER_ADDRESS = 2, + REMEMBERED_NONCES = 64, + REMEMBERED_SOURCES = 32, + PROBE_SIZE = 35, + PROBE_ANSWER_SIZE = 103, + MAXIMUM_BODY = 16 * 1024, + MAXIMUM_ANSWER = 64 * 1024, +}; + +static struct +{ + SDL_Mutex *lock; + SDL_Condition *wake; + SDL_Thread *thread; + int started; + int stopping; + int stopped; + struct master masters[SERVER_MASTER_MAXIMUM_MASTERS]; + int master_count; + /* the publisher key, held for the run (the probes' answers need it on the + internet play thread) */ + int has_key; + unsigned char seed[SERVER_MASTER_SEED_SIZE]; + unsigned char public_key[SERVER_MASTER_PUBLIC_KEY_SIZE]; + char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + char instance[33]; + char server_id[17]; + /* the game's state (server_master_set_state), and a change waiting */ + char state[1024]; + int changed; + unsigned long serial; + /* the probes answered: when, how many this second, the nonces, and + how many to each address this second */ + unsigned long probe_second; + int probe_answers; + unsigned char nonces[REMEMBERED_NONCES][16]; + unsigned long nonce_seconds[REMEMBERED_NONCES]; + int next_nonce; + unsigned long source_addresses[REMEMBERED_SOURCES]; + unsigned long source_seconds[REMEMBERED_SOURCES]; + int source_answers[REMEMBERED_SOURCES]; +} master; + +/* the instance: server.instance, else a slug of the server's name */ +static void instance_name(char *instance, int size) +{ + const char *setting = config_string("server.instance"); + /* (a player's host: "host", unless server.instance names it) */ + const char *source = setting[0] ? setting : halo_server_mode() ? config_string("server.name") : "host"; + int length = 0, index; + + for (index = 0; source[index] && length < size - 1 && length < 32; index++) + { + char character = (char)tolower((unsigned char)source[index]); + + if ((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '_' || + character == '-') + { + instance[length++] = character; + } + else if ((character == ' ' || character == '.') && length && instance[length - 1] != '-') + { + instance[length++] = '-'; + } + } + while (length && instance[length - 1] == '-') + length--; + /* (the first a letter or a digit) */ + while (length && (instance[0] == '-' || instance[0] == '_')) + { + memmove(instance, instance + 1, (size_t)length - 1); + length--; + } + instance[length] = 0; + if (!length) + snprintf(instance, (size_t)size, "main"); +} + +static void hex_text(const unsigned char *bytes, int size, char *text) +{ + p2p_hex(bytes, size, text); +} + +/* ---------- a publish */ + +/* the Authorization header of a signed request to the master of that key +id (its list key's, pinned in masters.txt: a request signed for one master +is no good to another) */ +static void sign_request(const unsigned char *master_key_id, const char *method, const char *path, const char *body, + unsigned long size, char *header, int header_size) +{ + char master_key_id_text[2 * SERVER_MASTER_KEY_ID_SIZE + 1]; + unsigned char nonce_bytes[16]; + char nonce[33]; + unsigned char digest[P2P_SHA256_SIZE]; + char digest_text[2 * P2P_SHA256_SIZE + 1]; + char text[2600]; + unsigned char signature[P2P_SIGNATURE_SIZE]; + char signature_text[100]; + unsigned long now = posix_server_unix_seconds(); + + posix_random_bytes(nonce_bytes, sizeof(nonce_bytes)); + hex_text(nonce_bytes, 16, nonce); + p2p_sha256(body ? body : "", (int)size, digest); + hex_text(digest, P2P_SHA256_SIZE, digest_text); + hex_text(master_key_id, SERVER_MASTER_KEY_ID_SIZE, master_key_id_text); + snprintf(text, sizeof(text), "OpenCE-Master-v1\n%s\n%s\n%s\n%lu\n%s\n%s", master_key_id_text, method, path, now, + nonce, digest_text); + p2p_ed25519_sign(master.seed, master.public_key, text, (int)strlen(text), signature); + server_base64_encode(signature, P2P_SIGNATURE_SIZE, signature_text); + snprintf(header, (size_t)header_size, + "Authorization: OpenCE-Ed25519 keyid=%s, ts=%lu, nonce=%s, sig=%s\r\nContent-Type: application/json\r\n", + master.key_id, now, nonce, signature_text); +} + +/* the publish's body: the listing, what it lacks, closed for a tombstone */ +static int publish_body(struct server_json *json, int closed, int *has_listing) +{ + unsigned char listing[400]; + char listing_text[600]; + int size = p2p_lobby_host_listing(listing, sizeof(listing)); + struct server_json_document document; + char public_address[64]; + + *has_listing = size > 0; + server_json_begin(json); + server_json_object_begin(json, NULL); + server_json_integer(json, "protocol", 1); + server_json_string(json, "instance", master.instance); + if (size > 0) + { + server_base64_encode(listing, size, listing_text); + server_json_string(json, "listing", listing_text); + } + /* (the port the tunnel's socket has: a player's host binds any free one, + network.tunnel_port 0; it is in network byte order) */ + { + unsigned short port = p2p_tunnel_local_port(); + + server_json_integer(json, "tunnel_port", (long long)(unsigned short)(port << 8 | port >> 8)); + } + server_json_boolean(json, "dedicated", halo_server_mode() != 0); + server_json_string(json, "build", server_mode_build()); + if (config_string("server.region")[0]) + server_json_string(json, "region", config_string("server.region")); + /* (network.public_address as internet play looked it up, an IPv4 + address and port: a host name, or IPv6, the master would refuse) */ + if (config_boolean("server.master_publish_address") && p2p_public_address(public_address, sizeof(public_address))) + server_json_string(json, "address", public_address); + /* (what the game's state adds: the variant's name) */ + SDL_LockMutex(master.lock); + if (master.state[0] && server_json_parse(&document, master.state, (int)strlen(master.state), 8)) + { + char variant[64]; + + if (server_json_get_string(&document, server_json_member(&document, 0, "variant"), variant, sizeof(variant)) && + variant[0]) + { + server_json_string(json, "variant", variant); + } + server_json_document_free(&document); + } + server_json_integer(json, "serial", (long long)master.serial); + SDL_UnlockMutex(master.lock); + server_json_boolean(json, "closed", closed); + server_json_object_end(json); + return 1; +} + +/* a response's header's value as a number (its name in any case): 0 if it +is not there */ +static unsigned long header_number(const char *headers, const char *name) +{ + size_t length = strlen(name); + const char *line = headers; + + while (line && *line) + { + size_t index; + + for (index = 0; index < length && line[index] && + tolower((unsigned char)line[index]) == tolower((unsigned char)name[index]); index++) + { + } + if (index == length && line[length] == ':') + return strtoul(line + length + 1, NULL, 10); + line = strchr(line, '\n'); + if (line) + line++; + } + return 0; +} + +/* whether a master's refusal is logged again once a minute while it lasts +(docs/master_protocol.md, "Errors") */ +static int logged_each_minute(const char *code) +{ + return !strcmp(code, "unknown_key") || !strcmp(code, "publisher_pending") || !strcmp(code, "application_denied") || + !strcmp(code, "listing_taken"); +} + +/* a master's answer taken: its state, logged when it changes */ +static void publish_answer(struct master *each, struct update_response *response, const char *error, int closed) +{ + struct server_json_document document; + char code[32] = "", message[160] = "", probe[16] = "", reason[160] = "", outcome[200]; + int listed = 0, value; + unsigned long retry = 0, now = posix_server_unix_seconds(); + double master_time = 0; + int has_master_time = 0; + + if (error) + { + snprintf(code, sizeof(code), "unreachable"); + snprintf(message, sizeof(message), "%s", error); + } + else if (server_json_parse(&document, response->body, (int)response->body_size, 8)) + { + int failure = server_json_member(&document, 0, "error"); + + if (server_json_get_boolean(&document, server_json_member(&document, 0, "listed"), &value)) + listed = value; + server_json_get_string(&document, server_json_member(&document, 0, "probe"), probe, sizeof(probe)); + server_json_get_string(&document, server_json_member(&document, 0, "reason"), reason, sizeof(reason)); + if (failure >= 0) + { + server_json_get_string(&document, server_json_member(&document, failure, "code"), code, sizeof(code)); + server_json_get_string(&document, server_json_member(&document, failure, "message"), message, + sizeof(message)); + } + has_master_time = server_json_get_number(&document, server_json_member(&document, 0, "master_time"), + &master_time); + server_json_document_free(&document); + } + else + { + snprintf(code, sizeof(code), "status_%d", response->status); + snprintf(message, sizeof(message), "the master answered %d", response->status); + } + if (response && response->status == 429) + { + /* (a day at most: a master's word, but not a run's end) */ + retry = header_number(response->headers, "Retry-After"); + each->retry_at = now + (retry ? (retry < 86400 ? retry : 86400) : 30); + } + /* (revoked for good: not asked again; a suspended publisher, an unknown + key: asked less often) */ + else if (!strcmp(code, "key_revoked") || !strcmp(code, "publisher_revoked")) + { + each->revoked = 1; + each->published = 0; + } + else if (!strcmp(code, "publisher_suspended") || !strcmp(code, "unknown_key")) + { + each->retry_at = now + 60; + } + /* (the machine's clock off by more than the master takes: by how much, + if the master said its time) */ + if (!strcmp(code, "clock") && has_master_time) + { + snprintf(message, sizeof(message), "this machine's clock is %.0f seconds %s the master's: set it right", + master_time > (double)now ? master_time - (double)now : (double)now - master_time, + master_time > (double)now ? "behind" : "ahead of"); + } + SDL_LockMutex(master.lock); + each->listed = listed; + snprintf(each->probe, sizeof(each->probe), "%s", probe); + snprintf(each->code, sizeof(each->code), "%s", code); + snprintf(each->message, sizeof(each->message), "%s", message[0] ? message : reason); + each->updated = posix_server_unix_seconds(); + SDL_UnlockMutex(master.lock); + snprintf(outcome, sizeof(outcome), "%d %s %s", listed, probe, code); + if (strcmp(outcome, each->logged) || (logged_each_minute(code) && now - each->repeat_logged >= 60)) + { + int changed = strcmp(outcome, each->logged) != 0; + struct server_json json; + + snprintf(each->logged, sizeof(each->logged), "%s", outcome); + each->repeat_logged = now; + if (closed && !code[0]) + server_log(_server_log_info, "master", "%s: withdrawn", each->url); + else if (listed) + server_log(_server_log_info, "master", "%s: listed (probe %s)", each->url, probe[0] ? probe : "-"); + else if (!strcmp(code, "publisher_pending")) + server_log(_server_log_warning, "master", "%s: publisher pending: apply at %s/apply with the public key " + "sv_publisher shows", each->url, each->url); + else if (!strcmp(code, "unknown_key")) + server_log(_server_log_warning, "master", "%s: unknown key: apply with key %s (%s/apply; sv_publisher " + "shows its public key)", each->url, master.key_id, each->url); + else if (each->revoked) + server_log(_server_log_warning, "master", "%s: %s (%s): not published there again", each->url, code, + message); + else if (code[0]) + server_log(_server_log_warning, "master", "%s: not listed: %s (%s)", each->url, code, message); + else + server_log(_server_log_info, "master", "%s: not listed yet (probe %s)", each->url, probe[0] ? probe : "-"); + /* (the event at a change alone) */ + if (changed) + { + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "event", "master"); + server_json_string(&json, "url", each->url); + server_json_boolean(&json, "listed", listed); + server_json_string(&json, "probe", probe); + server_json_string(&json, "code", code); + server_json_string(&json, "message", message[0] ? message : reason); + server_json_object_end(&json); + server_event_add("master", server_json_text(&json)); + server_json_free(&json); + } + } + /* (the failed probe's hint, once a minute until it passes) */ + if (!strcmp(probe, "failed") && posix_server_unix_seconds() - each->hint_logged >= 60) + { + each->hint_logged = posix_server_unix_seconds(); + server_log(_server_log_warning, "master", "%s: %s", each->url, reason[0] ? reason : + "the master cannot reach this server: let its UDP port through your firewall or NAT, or set " + "network.public_address"); + } +} + +static void publish_to(struct master *each, int closed) +{ + struct server_json json; + struct update_response response; + char header[512], url[400], path[64], error[256]; + int has_listing; + + if (each->revoked || (!closed && each->retry_at > posix_server_unix_seconds())) + return; + publish_body(&json, closed, &has_listing); + /* (no listing: the game is not hosting for the internet, or no longer, + when the listing published is withdrawn) */ + if (!has_listing && !closed) + { + server_json_free(&json); + if (!each->published) + return; + closed = 1; + publish_body(&json, closed, &has_listing); + } + each->published = !closed; + snprintf(path, sizeof(path), "/v1/servers/%s", master.server_id); + snprintf(url, sizeof(url), "%s%s", each->url, path); + { + const char *body = server_json_text(&json); + unsigned long size = (unsigned long)strlen(body); + /* (the path after the scheme and host: https:// or a test's http://) */ + const char *host = strstr(each->url, "://"); + const char *base_path = strchr(host ? host + 3 : each->url, '/'); + char signed_path[400]; + + /* (a master served under a path signs the whole path) */ + snprintf(signed_path, sizeof(signed_path), "%s%s", base_path ? base_path : "", path); + sign_request(each->key_id, "PUT", signed_path, body, size, header, sizeof(header)); + if (update_https_request("PUT", url, header, body, size, MAXIMUM_ANSWER, &response, error, sizeof(error))) + { + publish_answer(each, &response, NULL, closed); + free(response.body); + } + else + { + publish_answer(each, NULL, error, closed); + } + } + server_json_free(&json); +} + +static int SDLCALL master_thread_main(void *unused) +{ + Uint64 last_publish = 0; + int index; + + (void)unused; + SDL_LockMutex(master.lock); + while (!master.stopping) + { + Uint64 now = SDL_GetTicksNS(); + int due = !last_publish || now - last_publish >= (Uint64)PUBLISH_SECONDS * 1000000000ULL || + (master.changed && now - last_publish >= (Uint64)CHANGE_SECONDS * 1000000000ULL); + int withdrawing = 0; + + /* (a listing published to a master: one more publish, which withdraws + it, once the server is private or off the internet) */ + for (index = 0; index < master.master_count; index++) + withdrawing |= master.masters[index].published; + /* (a player's host: its PUBLIC, which the listing says) */ + if (!due || (!withdrawing && ((halo_server_mode() && !config_boolean("server.public")) || + !config_boolean("network.online")))) + { + SDL_WaitConditionTimeout(master.wake, master.lock, 1000); + continue; + } + master.changed = 0; + master.serial++; + last_publish = now; + SDL_UnlockMutex(master.lock); + for (index = 0; index < master.master_count; index++) + publish_to(&master.masters[index], 0); + SDL_LockMutex(master.lock); + } + SDL_UnlockMutex(master.lock); + /* the tombstone, to each (a stop waits for it 2 seconds at most): a + player's host's only where its game is listed */ + for (index = 0; index < master.master_count; index++) + { + if (halo_server_mode() || master.masters[index].published) + publish_to(&master.masters[index], 1); + } + SDL_LockMutex(master.lock); + master.stopped = 1; + SDL_BroadcastCondition(master.wake); + SDL_UnlockMutex(master.lock); + return 0; +} + +static void master_start(int quiet); + +void server_master_start(void) +{ + master_start(0); +} + +void server_master_start_host(void) +{ + if (!halo_server_mode()) + master_start(1); +} + +/* the masters and the key read, and the thread started if there are both +(quiet: a player's host, which says nothing of what it lacks) */ +static void master_start(int quiet) +{ + char urls[SERVER_MASTER_MAXIMUM_MASTERS][256]; + unsigned char keys[SERVER_MASTER_MAXIMUM_MASTERS][SERVER_MASTER_PUBLIC_KEY_SIZE]; + int index; + + master.lock = SDL_CreateMutex(); + master.wake = SDL_CreateCondition(); + master.master_count = master_list_read(config_string("server.masters_file"), urls, keys, + SERVER_MASTER_MAXIMUM_MASTERS); + for (index = 0; index < master.master_count; index++) + { + unsigned char digest[P2P_SHA256_SIZE]; + + snprintf(master.masters[index].url, sizeof(master.masters[index].url), "%s", urls[index]); + memcpy(master.masters[index].key, keys[index], SERVER_MASTER_PUBLIC_KEY_SIZE); + p2p_sha256(keys[index], SERVER_MASTER_PUBLIC_KEY_SIZE, digest); + memcpy(master.masters[index].key_id, digest, SERVER_MASTER_KEY_ID_SIZE); + } + master.has_key = publisher_seed(master.seed); + if (master.has_key) + { + char text[64]; + unsigned char digest[P2P_SHA256_SIZE]; + + p2p_ed25519_public(master.seed, master.public_key, NULL); + key_id_of(master.public_key, master.key_id); + instance_name(master.instance, sizeof(master.instance)); + snprintf(text, sizeof(text), "%s%s", master.key_id, master.instance); + p2p_sha256(text, (int)strlen(text), digest); + hex_text(digest, 8, master.server_id); + } + if (!config_boolean("server.master_publish")) + return; + if (quiet && (!master.master_count || !master.has_key)) + return; + if (!master.master_count) + { + server_log(_server_log_info, "master", "no master servers (%s): the server is joined by its invite and on the LAN", + config_string("server.masters_file")); + return; + } + if (!master.has_key) + { + server_log(_server_log_warning, "master", "no publisher key (halo --server --keygen makes one, " + "HALO_SERVER_PUBLISHER_KEY holds one): not published to the %d master server%s", master.master_count, + master.master_count == 1 ? "" : "s"); + return; + } + server_log(_server_log_info, "master", "publishing to %d master server%s as %s (key id %s, server id %s)", + master.master_count, master.master_count == 1 ? "" : "s", master.instance, master.key_id, master.server_id); + master.thread = SDL_CreateThread(master_thread_main, "server masters", NULL); + master.started = master.thread != NULL; + /* (a player's host withdrawn as the game exits: the director stops a + dedicated server's itself) */ + if (quiet && master.started) + atexit(server_master_stop); +} + +void server_master_set_state(const char *members, int changed) +{ + if (!master.started) + return; + SDL_LockMutex(master.lock); + snprintf(master.state, sizeof(master.state), "%s", members ? members : ""); + if (changed) + { + master.changed = 1; + SDL_SignalCondition(master.wake); + } + SDL_UnlockMutex(master.lock); +} + +void server_master_changed(void) +{ + if (!master.started) + return; + SDL_LockMutex(master.lock); + master.changed = 1; + SDL_SignalCondition(master.wake); + SDL_UnlockMutex(master.lock); +} + +void server_master_stop(void) +{ + Uint64 deadline; + + if (!master.started) + return; + deadline = SDL_GetTicksNS() + (Uint64)STOP_MILLISECONDS * 1000000ULL; + SDL_LockMutex(master.lock); + master.stopping = 1; + SDL_SignalCondition(master.wake); + while (!master.stopped && SDL_GetTicksNS() < deadline) + SDL_WaitConditionTimeout(master.wake, master.lock, 100); + SDL_UnlockMutex(master.lock); +} + +/* whether a probe from the address may be answered this second: two to an +address at most, counted (the addresses of this second only: more than the +table holds is past the ten a second in all) */ +static int probe_source_allowed(unsigned long address, unsigned long second) +{ + int index, free_index = -1; + + for (index = 0; index < REMEMBERED_SOURCES; index++) + { + if (master.source_seconds[index] != second) + { + if (free_index < 0) + free_index = index; + } + else if (master.source_addresses[index] == address) + { + return ++master.source_answers[index] <= PROBE_ANSWERS_PER_ADDRESS; + } + } + if (free_index < 0) + return 0; + master.source_addresses[free_index] = address; + master.source_seconds[free_index] = second; + master.source_answers[free_index] = 1; + return 1; +} + +int server_master_probe(const unsigned char *data, int size, unsigned long address, unsigned char *answer, + int *answer_size) +{ + unsigned char message[5 + SERVER_MASTER_KEY_ID_SIZE + 16]; + unsigned long second; + int index, known = 0; + + if (size != PROBE_SIZE || memcmp(data, "OPCE-PROBE\x01", 11) || !master.has_key) + return 0; + for (index = 0; index < master.master_count; index++) + known |= !memcmp(master.masters[index].key_id, data + 11, SERVER_MASTER_KEY_ID_SIZE); + if (!known) + return 0; + second = posix_server_unix_seconds(); + if (second != master.probe_second) + { + master.probe_second = second; + master.probe_answers = 0; + } + /* (a nonce answered once a second at most, as docs/master_protocol.md + says: a master's probe round sends one nonce for its attempts, a second + or more apart, and a new one each round; a longer window than a second + would refuse a round's retries. A nonce resent counts against nothing) */ + for (index = 0; index < REMEMBERED_NONCES; index++) + { + if (master.nonce_seconds[index] == second && !memcmp(master.nonces[index], data + 19, 16)) + return 0; + } + /* (two a second to an address, and ten in all: a probe reflects + nothing, and probes forged from one address do not use up the answers a + master's would have) */ + if (!probe_source_allowed(address, second) || ++master.probe_answers > PROBE_ANSWERS_PER_SECOND) + return 0; + memcpy(master.nonces[master.next_nonce], data + 19, 16); + master.nonce_seconds[master.next_nonce] = second; + master.next_nonce = (master.next_nonce + 1) % REMEMBERED_NONCES; + memcpy(message, "probe", 5); + memcpy(message + 5, data + 11, SERVER_MASTER_KEY_ID_SIZE + 16); + memcpy(answer, "OPCE-PROBE-ACK\x01", 15); + memcpy(answer + 15, data + 11, SERVER_MASTER_KEY_ID_SIZE + 16); + p2p_ed25519_sign(master.seed, master.public_key, message, (int)sizeof(message), answer + 39); + *answer_size = PROBE_ANSWER_SIZE; + return 1; +} + +void server_master_identity(char *instance, int instance_size, char *key_id, int key_id_size) +{ + instance_name(instance, instance_size); + snprintf(key_id, (size_t)key_id_size, "%s", master.has_key ? master.key_id : ""); +} + +void server_master_status(struct server_json *json) +{ + int index; + + server_json_array_begin(json, "masters"); + if (master.lock) + SDL_LockMutex(master.lock); + for (index = 0; index < master.master_count; index++) + { + struct master const *each = &master.masters[index]; + + server_json_object_begin(json, NULL); + server_json_string(json, "url", each->url); + server_json_boolean(json, "listed", each->listed); + server_json_string(json, "probe", each->probe[0] ? each->probe : (master.started ? "pending" : "off")); + server_json_string(json, "code", master.started ? each->code : !master.has_key ? "no_key" : "off"); + server_json_string(json, "message", each->message); + server_json_integer(json, "updated", each->updated); + server_json_object_end(json); + } + if (master.lock) + SDL_UnlockMutex(master.lock); + server_json_array_end(json); +} + +#else + +int server_master_keygen(void) +{ + return 0; +} + +int server_master_sign_text(const char *text) +{ + (void)text; + return 0; +} + +int server_master_publisher(unsigned char *public_key, char *key_id) +{ + (void)public_key; + (void)key_id; + return 0; +} + +void server_master_start(void) +{ +} + +void server_master_start_host(void) +{ +} + +void server_master_set_state(const char *members, int changed) +{ + (void)members; + (void)changed; +} + +void server_master_stop(void) +{ +} + +void server_master_changed(void) +{ +} + +int server_master_probe(const unsigned char *data, int size, unsigned long address, unsigned char *answer, + int *answer_size) +{ + (void)data; + (void)size; + (void)address; + (void)answer; + (void)answer_size; + return 0; +} + +void server_master_identity(char *instance, int instance_size, char *key_id, int key_id_size) +{ + if (instance_size > 0) + instance[0] = 0; + if (key_id_size > 0) + key_id[0] = 0; +} + +void server_master_status(struct server_json *json) +{ + (void)json; +} + +#endif diff --git a/port/linux/src/server_master.h b/port/linux/src/server_master.h new file mode 100644 index 00000000..0a294421 --- /dev/null +++ b/port/linux/src/server_master.h @@ -0,0 +1,70 @@ +/* +SERVER_MASTER.H + +A dedicated server's publishing to the official master servers +(server_master.c; docs/master_protocol.md): its operator's publisher key +(publisher.key, made by halo --server --keygen), the masters of masters.txt, +the state of the game published to each every 30 seconds and at once when +it changes, signed with the key, and the masters' reachability probes +answered on the tunnel's port. It runs on a thread of its own: a slow master +never holds the game. +*/ + +#ifndef __HALO_LINUX_SERVER_MASTER_H +#define __HALO_LINUX_SERVER_MASTER_H + +struct server_json; + +enum +{ + SERVER_MASTER_PUBLIC_KEY_SIZE = 32, + SERVER_MASTER_SEED_SIZE = 32, + SERVER_MASTER_KEY_ID_SIZE = 8, + /* "ed25519:" and the key in hex */ + SERVER_MASTER_PUBLIC_KEY_TEXT_SIZE = 8 + 64 + 1, + SERVER_MASTER_MAXIMUM_MASTERS = 4, +}; + +/* --keygen: a new publisher key written (unless there is one), its public +key and id printed; 1 on success */ +int server_master_keygen(void); +/* --sign : the key's signature of the text printed in hex; 1 */ +int server_master_sign_text(const char *text); + +/* the publisher key (HALO_SERVER_PUBLISHER_KEY, else the file): 1 with its +public key and id (16 hex) */ +int server_master_publisher(unsigned char *public_key, char *key_id); + +/* the publishing thread started (server.master_publish, with masters and +a key) */ +void server_master_start(void); +/* a player's host (not a dedicated server's): published as above while it +hosts a public internet game, when this machine has a publisher key and +masters.txt names masters (docs/master_protocol.md); nothing otherwise */ +void server_master_start_host(void); +/* the game's state to publish (the director's, as it changes): its JSON +object's members (name, map, gametype, variant, players, ...); changed +asks for a publish within 5 seconds */ +void server_master_set_state(const char *members, int changed); +/* the hosted game changed (p2p_lobby.c: public or not, its players, its +name or map): a publish within 5 seconds, as for a changed state */ +void server_master_changed(void); +/* the listings withdrawn as the server stops (a tombstone to each master: +2 seconds at most) */ +void server_master_stop(void); + +/* a datagram the tunnel's socket received (p2p.c) from the address (IPv4, +network byte order): 1 if it was a master's probe to answer, its answer (at +most 128 bytes) then in answer, to be sent back to where it came from */ +int server_master_probe(const unsigned char *data, int size, unsigned long address, unsigned char *answer, + int *answer_size); + +/* the name the masters know this server by: server.instance as published +(else a slug of server.name), and the publisher key's id (16 hex; empty +without a key), as the start read them */ +void server_master_identity(char *instance, int instance_size, char *key_id, int key_id_size); + +/* each master's state, for the status (masters[]) */ +void server_master_status(struct server_json *json); + +#endif diff --git a/port/linux/src/server_mode.c b/port/linux/src/server_mode.c new file mode 100644 index 00000000..46c9565a --- /dev/null +++ b/port/linux/src/server_mode.c @@ -0,0 +1,539 @@ +/* +SERVER_MODE.C + +A dedicated server (server_mode.h; docs/dedicated_server.md): the game run +with no window, sound or player of its own (port_config.c forces those +settings for the run), hosting one game after another +(port/linux/game/server_director.c). This file is its start, before the +game's: the command line's other answers (--version, --keygen...), its +signals, its game data and settings checked before anything is bound (a +panel sees a server that cannot run stop at once, with its exit code, +rather than one that looks healthy and hosts nothing), its frames' pace +without a display to wait for, and its exits. +*/ + +#include "platform.h" +#include "port_config.h" +#include "posix.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "posix_server.h" +#include "server_console.h" +#include "server_http.h" +#include "server_log.h" +#include "server_master.h" +#include "server_rotation.h" + +#include +#include +#include +#include + +#ifndef HALO_BUILD_NUMBER +#define HALO_BUILD_NUMBER 0 +#endif + +/* the game side's check of a rotation entry's gametype +(port/linux/game/server_gametypes.c) */ +void server_gametype_check_entry(struct server_entry *entry); + +enum +{ + /* frames a second while no other machine is connected */ + SERVER_IDLE_FRAME_RATE = 10, + /* a frame this late resynchronises the pace (a map loaded) */ + SERVER_RESYNCHRONISE_NANOSECONDS = 250 * 1000 * 1000, + SERVER_MAXIMUM_EXIT_HOOKS = 8, +}; + +static struct +{ + int checked; + int active; + Uint64 started; + /* (and in seconds since 1970) */ + unsigned long started_unix; + /* the pace: the next frame's deadline, and whether the server is idle */ + Uint64 deadline; + int idle; + /* the frames' work this minute and the last: the longest and the sum */ + Uint64 frame_began; + Uint64 minute_began; + double minute_longest, minute_sum; + long minute_frames; + double last_longest, last_mean; + void (*exit_hooks[SERVER_MAXIMUM_EXIT_HOOKS])(void); + int exit_hook_count; + int exiting; +} server_mode; + +int halo_server_mode(void) +{ + if (!server_mode.checked) + { + server_mode.checked = 1; + server_mode.active = config_server_mode_enabled(); + } + return server_mode.active; +} + +int halo_server_debug_echo(void) +{ + static int echo = -1; + + if (echo < 0) + echo = config_boolean("server.log_debug_echo") != 0; + return echo; +} + +const char *server_mode_build(void) +{ + static char build[32]; + + if (!build[0]) + snprintf(build, sizeof(build), "build-%d", (int)HALO_BUILD_NUMBER); + return build; +} + +double server_mode_uptime(void) +{ + return server_mode.started ? (double)(SDL_GetTicksNS() - server_mode.started) / 1e9 : 0.0; +} + +unsigned long server_mode_started(void) +{ + return server_mode.started_unix; +} + +unsigned long server_mode_process_id(void) +{ + return (unsigned long)posix_process_id(); +} + +/* ---------- exits */ + +void server_mode_at_exit(void (*hook)(void)) +{ + if (server_mode.exit_hook_count < SERVER_MAXIMUM_EXIT_HOOKS) + server_mode.exit_hooks[server_mode.exit_hook_count++] = hook; +} + +void server_mode_exit(int code) +{ + if (!server_mode.exiting) + { + server_mode.exiting = 1; + while (server_mode.exit_hook_count > 0) + server_mode.exit_hooks[--server_mode.exit_hook_count](); + } + fflush(NULL); + exit(code); +} + +void server_mode_fatal(const char *text) +{ + static int fatal; + const char *line = text ? text : ""; + + if (fatal++) + return; + /* (the listings' withdrawal and the other exit hooks given a few + seconds: a stuck one ends the process with the code all the same) */ + posix_server_quit_deadline(5, SERVER_EXIT_FATAL); + /* (each line of it: the last ones are the error's) */ + while (*line) + { + const char *end = strchr(line, '\n'); + int length = end ? (int)(end - line) : (int)strlen(line); + + if (length > 0) + server_log(_server_log_error, "server", "fatal: %.*s", length, line); + line += length + (end != NULL); + } + server_log(_server_log_error, "server", "stopping after a fatal error (exit code %d)", SERVER_EXIT_FATAL); + server_mode_exit(SERVER_EXIT_FATAL); +} + +/* ---------- the command line */ + +static void print_help(void) +{ + fprintf(stdout, + "halo [--server] [--config ] [--keygen] [--sign ] [--probe []] [--version] [--help]\n" + " --server a dedicated server (docs/dedicated_server.md)\n" + " --probe [] prints the invite's game (or the LAN's first) as one line of JSON\n" + " --config the settings file (else config.toml beside the executable)\n" + " --keygen writes a publisher key for the master servers and prints its public key\n" + " --sign prints the publisher key's signature of the text (an application's proof)\n" + " --version prints the build and its network version\n"); +} + +/* the command line's arguments that are answered and end the process */ +static void answer_command_line(void) +{ + char argument[1024]; + int index; + + for (index = 1; posix_command_line_argument(index, argument, sizeof(argument)); index++) + { + if (!strcmp(argument, "--version") || !strcmp(argument, "-v")) + { + fprintf(stdout, "OpenCE %s (network version %d)\n", server_mode_build(), HALO_PORT_NETWORK_VERSION); + exit(EXIT_SUCCESS); + } + if (!strcmp(argument, "--help") || !strcmp(argument, "-h")) + { + print_help(); + exit(EXIT_SUCCESS); + } + if (!strcmp(argument, "--keygen")) + exit(server_master_keygen() ? EXIT_SUCCESS : SERVER_EXIT_CONFIGURATION); + if (!strcmp(argument, "--sign")) + { + char text[1024]; + + if (!posix_command_line_argument(index + 1, text, sizeof(text))) + { + fprintf(stderr, "--sign: give the text to sign\n"); + exit(SERVER_EXIT_CONFIGURATION); + } + exit(server_master_sign_text(text) ? EXIT_SUCCESS : SERVER_EXIT_CONFIGURATION); + } + } +} + +/* ---------- the checks before anything runs */ + +/* the game data: the Xbox's main menu map, which the game cannot start +without, and (server_mode_check_rotation) the rotation's maps */ +static void check_game_data(void) +{ + char path[1024]; + long version, type; + + platform_translate_path("d:\\maps\\ui.map", path, sizeof(path)); + if (!server_map_header(path, &version, &type) || version != 5) + { + server_log(_server_log_error, "server", "no usable maps in %s (looked for ui.map%s, bloodgulch.map); exiting", + platform_data_root(), version ? " of the Xbox's" : ""); + server_log(_server_log_error, "server", + "put the Xbox maps of your Halo: Combat Evolved disc image in %s/maps (paths.data, HALO_DATA_ROOT)", + platform_data_root()); + server_mode_exit(SERVER_EXIT_NO_GAME_DATA); + } +} + +static void check_rotation(void) +{ + static struct server_rotation rotation; + int index, missing = 0; + + server_rotation_parse(config_string("server.rotation"), &rotation); + for (index = 0; index < rotation.count; index++) + { + struct server_entry *entry = &rotation.entries[index]; + + server_rotation_resolve(entry); + server_gametype_check_entry(entry); + if (entry->rejected[0]) + { + server_log(_server_log_warning, "server", "rotation entry %d (%s) left out: %s", index + 1, entry->text, + entry->rejected); + missing += entry->map_missing; + } + } + if (rotation.truncated) + { + server_log(_server_log_warning, "server", "the rotation has more than %d entries or %d characters: the rest left out", + SERVER_ROTATION_MAXIMUM_ENTRIES, SERVER_ROTATION_MAXIMUM_TEXT); + } + if (server_rotation_playable(&rotation)) + return; + if (rotation.count && missing == rotation.count) + { + server_log(_server_log_error, "server", "no usable maps in %s for the rotation (server.rotation); exiting", + platform_data_root()); + server_mode_exit(SERVER_EXIT_NO_GAME_DATA); + } + if (config_boolean("server.rotation_fallback")) + { + server_log(_server_log_warning, "server", "the rotation has nothing playable: bloodgulch:slayer instead " + "(server.rotation_fallback)"); + return; + } + server_log(_server_log_error, "server", "the rotation (server.rotation) has nothing playable; exiting"); + server_mode_exit(SERVER_EXIT_CONFIGURATION); +} + +/* a web password that is set: 8 characters at least with the web port +bound beyond this machine (else the server stops), a warning if shorter */ +static void check_web_password(const char *setting, int loopback) +{ + const char *password = config_string(setting); + + if (!password[0] || strlen(password) >= 8) + return; + if (!loopback) + { + server_log(_server_log_error, "server", "%s is shorter than 8 characters, with the web port bound beyond " + "this machine (%s); exiting", setting, config_string("server.web_bind")); + server_mode_exit(SERVER_EXIT_CONFIGURATION); + } + server_log(_server_log_warning, "server", "%s is shorter than 8 characters", setting); +} + +/* the web port's settings: a password beyond loopback, and one made for +it if there is none */ +static void check_web_settings(void) +{ + const char *bind = config_string("server.web_bind"); + const char *password = config_string("server.web_password"); + int loopback = !strcmp(bind, "127.0.0.1") || !strcmp(bind, "localhost") || !strncmp(bind, "127.", 4); + + if (config_integer("server.web_port") <= 0) + return; + if (config_integer("server.web_port") > 65535) + { + server_log(_server_log_error, "server", "server.web_port %ld is no port; exiting", config_integer("server.web_port")); + server_mode_exit(SERVER_EXIT_CONFIGURATION); + } + if (!password[0]) + { + static const char alphabet[] = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + unsigned char random[20]; + char made[24]; + int index; + + posix_random_bytes(random, sizeof(random)); + for (index = 0; index < (int)sizeof(random); index++) + made[index] = alphabet[random[index] % (sizeof(alphabet) - 1)]; + made[sizeof(random)] = 0; + if (config_write("server.web_password", made)) + { + server_log(_server_log_info, "server", "the web page's admin password was made for this server (in " + "config.toml as server.web_password); it is on the standard output alone"); + server_log_private(_server_log_info, "server", "the web page's admin password: %s", made); + } + else + { + server_log(_server_log_warning, "server", "config.toml cannot be written: the web page's admin password " + "for this run is on the standard output alone (set server.web_password)"); + server_log_private(_server_log_warning, "server", "the web page's admin password for this run: %s", made); + } + } + /* (the made one is long enough; the moderator's and the viewer's are + checked as the admin's) */ + check_web_password("server.web_password", loopback); + check_web_password("server.web_moderator_password", loopback); + check_web_password("server.web_viewer_password", loopback); + /* (the owner's, which stops the server and runs the game's console: + never short, wherever the port is bound) */ + if (config_string("server.web_owner_password")[0] && strlen(config_string("server.web_owner_password")) < 8) + { + server_log(_server_log_error, "server", "server.web_owner_password is shorter than 8 characters; exiting"); + server_mode_exit(SERVER_EXIT_CONFIGURATION); + } +} + +/* the tunnel's UDP port, for internet play: taken by another program, the +server would host only on the LAN */ +static void check_tunnel_port(void) +{ + long port = config_integer("network.tunnel_port"); + unsigned char address[16]; + int socket; + + if (!config_boolean("network.online") || port <= 0 || port > 65535) + return; + memset(address, 0, sizeof(address)); + /* (a sockaddr_in: AF_INET, the port, any address) */ + address[0] = 2; + address[2] = (unsigned char)(port >> 8); + address[3] = (unsigned char)port; + socket = posix_socket(2, 2, 17); + if (socket < 0) + return; + if (posix_socket_bind(socket, address, sizeof(address)) != 0) + { + posix_socket_close(socket); + server_log(_server_log_error, "server", "the UDP port %ld (network.tunnel_port, HALO_NET_TUNNEL_PORT) is taken " + "by another program; exiting", port); + server_mode_exit(SERVER_EXIT_PORT_IN_USE); + } + posix_socket_close(socket); +} + +void server_mode_start(void) +{ + char config[1024]; + + answer_command_line(); + if (!halo_server_mode()) + return; + server_mode.started = SDL_GetTicksNS(); + server_mode.started_unix = posix_server_unix_seconds(); + posix_server_stdout_line_buffered(); + posix_server_signals_install(); + /* (SDL's own would turn SIGINT and SIGTERM into a quit event, which the + server's signals do better) */ + SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1"); + server_log_set_json(!strcmp(config_string("server.log_format"), "json")); + config_folder(config, sizeof(config)); + server_log(_server_log_info, "server", "OpenCE %s, network version %d, dedicated server; data root %s, saves %s, " + "settings in %s", server_mode_build(), HALO_PORT_NETWORK_VERSION, platform_data_root(), platform_save_root(), + config[0] ? config : "./"); + check_game_data(); + check_rotation(); + check_web_settings(); + check_tunnel_port(); + server_console_start(); + if (!server_http_start()) + server_mode_exit(SERVER_EXIT_PORT_IN_USE); + server_master_start(); +} + +int server_mode_signals(int *quit_signal) +{ + return posix_server_signals_take(quit_signal); +} + +/* ---------- the frames' pace */ + +void server_mode_set_idle(int idle) +{ + server_mode.idle = idle; +} + +void server_mode_pace_frame(void) +{ + long rate = server_mode.idle ? SERVER_IDLE_FRAME_RATE : config_integer("server.frame_rate"); + Uint64 now = SDL_GetTicksNS(); + Uint64 period; + double work; + + if (rate < 30) + rate = 30; + if (rate > 120) + rate = 120; + if (server_mode.idle) + rate = SERVER_IDLE_FRAME_RATE; + period = 1000000000ULL / (Uint64)rate; + /* (the frame's work: from the end of the last wait to now) */ + if (server_mode.frame_began) + { + work = (double)(now - server_mode.frame_began) / 1e6; + if (work > server_mode.minute_longest) + server_mode.minute_longest = work; + server_mode.minute_sum += work; + server_mode.minute_frames++; + } + if (!server_mode.minute_began) + server_mode.minute_began = now; + if (now - server_mode.minute_began >= 60ULL * 1000000000ULL) + { + server_mode.last_longest = server_mode.minute_longest; + server_mode.last_mean = server_mode.minute_frames ? server_mode.minute_sum / server_mode.minute_frames : 0.0; + server_mode.minute_longest = server_mode.minute_sum = 0.0; + server_mode.minute_frames = 0; + server_mode.minute_began = now; + } + if (!server_mode.deadline) + server_mode.deadline = now; + server_mode.deadline += period; + if (now > server_mode.deadline + SERVER_RESYNCHRONISE_NANOSECONDS) + server_mode.deadline = now; + else if (server_mode.deadline > now) + SDL_DelayPrecise(server_mode.deadline - now); + server_mode.frame_began = SDL_GetTicksNS(); +} + +void server_mode_frame_times(double *longest, double *mean) +{ + /* (this minute's until a whole one has gone by) */ + if (server_mode.last_longest > 0.0 || server_mode.last_mean > 0.0) + { + *longest = server_mode.last_longest > server_mode.minute_longest ? server_mode.last_longest : + server_mode.minute_longest; + *mean = server_mode.last_mean; + } + else + { + *longest = server_mode.minute_longest; + *mean = server_mode.minute_frames ? server_mode.minute_sum / server_mode.minute_frames : 0.0; + } +} + +#else + +int halo_server_mode(void) +{ + return 0; +} + +int halo_server_debug_echo(void) +{ + return 0; +} + +void server_mode_start(void) +{ +} + +int server_mode_signals(int *quit_signal) +{ + *quit_signal = 0; + return 0; +} + +void server_mode_pace_frame(void) +{ +} + +void server_mode_set_idle(int idle) +{ + (void)idle; +} + +void server_mode_frame_times(double *longest, double *mean) +{ + *longest = *mean = 0.0; +} + +void server_mode_fatal(const char *text) +{ + (void)text; +} + +void server_mode_exit(int code) +{ + exit(code); +} + +void server_mode_at_exit(void (*hook)(void)) +{ + (void)hook; +} + +const char *server_mode_build(void) +{ + return "build-0"; +} + +double server_mode_uptime(void) +{ + return 0.0; +} + +unsigned long server_mode_started(void) +{ + return 0; +} + +unsigned long server_mode_process_id(void) +{ + return 0; +} + +#endif diff --git a/port/linux/src/server_mode.h b/port/linux/src/server_mode.h new file mode 100644 index 00000000..d9a3154d --- /dev/null +++ b/port/linux/src/server_mode.h @@ -0,0 +1,73 @@ +/* +SERVER_MODE.H + +A dedicated server (halo --server: server_mode.c; docs/dedicated_server.md): +whether this run is one, its start before the game's, its signals, its +frames' pace and its exit codes. The Linux and Windows builds have it; the +Android and web builds compile the server's files to nothing +(HALO_SERVER_BUILD 0), and halo_server_mode() is always 0 there. +*/ + +#ifndef __HALO_LINUX_SERVER_MODE_H +#define __HALO_LINUX_SERVER_MODE_H + +#if defined(HALO_ANDROID) || defined(HALO_WEB) +#define HALO_SERVER_BUILD 0 +#else +#define HALO_SERVER_BUILD 1 +#endif + +/* the exit codes (docs/dedicated_server.md) */ +enum +{ + SERVER_EXIT_STOPPED = 0, + SERVER_EXIT_FATAL = 1, + SERVER_EXIT_NO_GAME_DATA = 2, + SERVER_EXIT_CONFIGURATION = 3, + SERVER_EXIT_PORT_IN_USE = 4, + SERVER_EXIT_RESTART = 5, +}; + +/* whether this run is a dedicated server */ +int halo_server_mode(void); + +/* main()'s first (source/shell/shell_xbox.c): --version, --help, --keygen +and --sign answered (and the process ended), and a dedicated server's +start: its signals, its game data and settings checked (ending it with +exit code 2 or 3 if they will not do), its web port bound (4 if taken) */ +void server_mode_start(void); + +/* the signals that came since the last call (posix_server.h's bits); the +quitting one's number in quit_signal */ +int server_mode_signals(int *quit_signal); + +/* waits out the rest of the frame (server.frame_rate a second, or 10 a +second while idle): before the frame's time is measured (main.c) */ +void server_mode_pace_frame(void); +/* whether the server is idle (no other machine connected): its frames +slower */ +void server_mode_set_idle(int idle); +/* the frames' time (milliseconds): the longest of the last minute's and the +mean, for the health line and the metrics */ +void server_mode_frame_times(double *longest, double *mean); + +/* the game's fatal error (halt_and_catch_fire, main_loop_of_death): its +last lines logged, and the process ended with exit code 1 */ +void server_mode_fatal(const char *text); +/* ends the process with the code, the logs flushed; the exit hooks +(server_mode_at_exit) run first */ +void server_mode_exit(int code); +/* a function run as the process ends by server_mode_exit (the listings' +withdrawal), the last added first */ +void server_mode_at_exit(void (*hook)(void)); + +/* the build, "build-" ("build-0" for one not built by CI) */ +const char *server_mode_build(void); +/* the seconds since the server started */ +double server_mode_uptime(void); +/* when the server started (seconds since 1970; 0 for none), and the +process's id */ +unsigned long server_mode_started(void); +unsigned long server_mode_process_id(void); + +#endif diff --git a/port/linux/src/server_rotation.c b/port/linux/src/server_rotation.c new file mode 100644 index 00000000..278a1526 --- /dev/null +++ b/port/linux/src/server_rotation.c @@ -0,0 +1,750 @@ +/* +SERVER_ROTATION.C + +A dedicated server's rotation and gametype rules (server_rotation.h). +*/ + +#include "server_rotation.h" + +#include +#include +#include +#include + +#ifndef SERVER_ROTATION_NO_FILES +#include "platform.h" +#include "port_config.h" +#include "posix.h" +#endif + +/* ---------- the rules */ + +static const char *const boolean_words[] = { "false", "true", NULL }; +static const char *const weapon_set_words[] = +{ + /* (game_engine.c's enum game_engine_weapons) */ + "normal", "pistols", "rifles", "plasma", "sniper", "no_sniping", "rocket_launchers", "shotguns", "short_range", + "human", "no_grenades", "covenant", "classic", "heavy", NULL, +}; +static const char *const vehicle_set_words[] = +{ + /* (its enum game_engine_vehicles) */ + "default", "none", "warthog", "ghost", "scorpion", NULL, +}; +static const char *const goal_radar_words[] = { "motion_tracker", "nav_point", "none", NULL }; +static const char *const friendly_fire_words[] = { "on", "off", "shields_only", "explosives_only", NULL }; +static const char *const radar_players_words[] = { "all", "friends", "none", NULL }; +static const char *const loadout_words[] = { "category", "custom", NULL }; +static const char *const loadout_weapon_words[] = +{ + "none", "random", "assault_rifle", "pistol", "shotgun", "sniper_rifle", "rocket_launcher", "plasma_pistol", + "plasma_rifle", "needler", NULL, +}; +static const char *const team_vehicles_words[] = { "gametype", "pc", "custom", NULL }; + +#define ALL _server_engine_all +#define RULE_INTEGER(key, alias, table, engines, minimum, maximum, description) \ + { key, alias, table, engines, _server_rule_integer, minimum, maximum, NULL, description } +#define RULE_BOOLEAN(key, alias, table, engines, description) \ + { key, alias, table, engines, _server_rule_boolean, 0, 1, boolean_words, description } +#define RULE_CHOICE(key, alias, table, engines, words, description) \ + { key, alias, table, engines, _server_rule_choice, 0, 0, words, description } + +const struct server_rule_definition server_rule_definitions[] = +{ + RULE_INTEGER("score_to_win", "score", "universal", ALL, 0, 999, + "The score that wins (kills, captures, laps, seconds of the ball or the hill...); 0 for no limit"), + RULE_INTEGER("lives", NULL, "universal", ALL, 0, 99, "Lives each player has; 0 for no limit"), + RULE_INTEGER("respawn_time", "respawn", "universal", ALL, 0, 300, "Seconds before a dead player respawns"), + RULE_INTEGER("respawn_time_growth", NULL, "universal", ALL, 0, 300, "Seconds added to the respawn at each death"), + RULE_INTEGER("suicide_penalty", NULL, "universal", ALL, 0, 300, "Seconds added to a suicide's respawn"), + RULE_INTEGER("health", NULL, "universal", ALL, 25, 400, "Players' health, in percent"), + RULE_BOOLEAN("teams", NULL, "universal", ALL, "Teams (CTF always has them)"), + RULE_BOOLEAN("odd_man_out", NULL, "universal", ALL, "The player in the lead is shown to everyone"), + RULE_BOOLEAN("radar", NULL, "universal", ALL, "Players on the motion tracker"), + RULE_BOOLEAN("friend_indicators", NULL, "universal", ALL, "Teammates' markers over their heads"), + RULE_BOOLEAN("infinite_grenades", NULL, "universal", ALL, "Grenades never run out"), + RULE_BOOLEAN("no_shields", NULL, "universal", ALL, "Players have no shields"), + RULE_BOOLEAN("invisible", NULL, "universal", ALL, "Players are always camouflaged"), + RULE_BOOLEAN("generic_equipment", NULL, "universal", ALL, "Every map starts players with the same weapons"), + RULE_CHOICE("weapon_set", "weapons", "universal", ALL, weapon_set_words, "The weapons on the map"), + RULE_CHOICE("vehicle_set", "vehicles", "universal", ALL, vehicle_set_words, "The vehicles on the map"), + RULE_CHOICE("goal_radar", NULL, "universal", ALL, goal_radar_words, + "How the flag, the ball or the hill is shown"), + + RULE_INTEGER("time_limit", "time", "options", ALL, 0, 1440, "The game's minutes; 0 for no limit"), + RULE_CHOICE("friendly_fire", "ff", "options", ALL, friendly_fire_words, "Damage to teammates"), + RULE_INTEGER("friendly_fire_penalty", NULL, "options", ALL, 0, 300, + "Seconds added to a team killer's respawn"), + RULE_INTEGER("vehicle_respawn_time", NULL, "options", ALL, 0, 300, + "Seconds before a destroyed vehicle respawns; 0 for never"), + RULE_BOOLEAN("auto_team_balance", "balance", "options", ALL, "Joining players go to the smaller team"), + RULE_CHOICE("radar_players", NULL, "options", ALL, radar_players_words, + "Which players the motion tracker shows"), + RULE_CHOICE("loadout", NULL, "options", ALL, loadout_words, + "The starting weapons: the weapon set's (category), or primary and secondary (custom)"), + RULE_CHOICE("primary_weapon", NULL, "options", ALL, loadout_weapon_words, "A custom loadout's first weapon"), + RULE_CHOICE("secondary_weapon", NULL, "options", ALL, loadout_weapon_words, + "A custom loadout's second weapon"), + RULE_BOOLEAN("no_map_weapons", NULL, "options", ALL, "No weapons lie on the map"), + RULE_CHOICE("team_vehicles", NULL, "options", ALL, team_vehicles_words, + "Each team's vehicles: the vehicle set's (gametype), every one the map places (pc), or the counts below (custom)"), + RULE_INTEGER("warthogs", NULL, "options", ALL, 0, 4, "Warthogs of each team, with team_vehicles custom"), + RULE_INTEGER("ghosts", NULL, "options", ALL, 0, 4, "Ghosts of each team, with team_vehicles custom"), + RULE_INTEGER("scorpions", NULL, "options", ALL, 0, 4, "Scorpions of each team, with team_vehicles custom"), + RULE_INTEGER("rocket_warthogs", NULL, "options", ALL, 0, 4, + "Rocket Warthogs of each team, with team_vehicles custom"), + RULE_INTEGER("banshees", NULL, "options", ALL, 0, 4, "Banshees of each team, with team_vehicles custom"), + RULE_INTEGER("gun_turrets", NULL, "options", ALL, 0, 4, "Gun turrets of each team, with team_vehicles custom"), + RULE_BOOLEAN("hardcore", NULL, "options", ALL, + "Hardcore: no motion sensor, no aim assist, a kill restores the killer's health and shields"), + + RULE_BOOLEAN("assault", NULL, "ctf", _server_engine_ctf, "One team defends the flag, the other attacks"), + RULE_BOOLEAN("reset_on_capture", NULL, "ctf", _server_engine_ctf, "Both flags go home at a capture"), + RULE_BOOLEAN("flag_must_reset", NULL, "ctf", _server_engine_ctf, "A dropped flag goes home only by its timer"), + RULE_BOOLEAN("flag_at_home_to_score", NULL, "ctf", _server_engine_ctf, + "A team's own flag must be home to score"), + RULE_INTEGER("single_flag_time", NULL, "ctf", _server_engine_ctf, 0, 600, + "Seconds of each side's turn in a single flag game"), + RULE_BOOLEAN("no_death_bonus", NULL, "slayer", _server_engine_slayer, "No point for a kill after a death"), + RULE_BOOLEAN("no_kill_penalty", NULL, "slayer", _server_engine_slayer, "No point taken for a suicide"), + RULE_BOOLEAN("kill_in_order", NULL, "slayer", _server_engine_slayer, "Players must be killed in order"), + RULE_BOOLEAN("moving_hill", NULL, "king", _server_engine_king, "The hill moves"), + RULE_BOOLEAN("random_start", NULL, "oddball", _server_engine_oddball, "The ball starts at a random place"), + RULE_BOOLEAN("ball_spawn_delay", NULL, "oddball", _server_engine_oddball, "The ball respawns after a delay"), + RULE_INTEGER("speed_with_ball", NULL, "oddball", _server_engine_oddball, 0, 3, "The ball carrier's speed"), + RULE_INTEGER("trait_with_ball", NULL, "oddball", _server_engine_oddball, 0, 3, "The ball carrier's trait"), + RULE_INTEGER("trait_without_ball", NULL, "oddball", _server_engine_oddball, 0, 3, "The others' trait"), + RULE_INTEGER("ball_type", NULL, "oddball", _server_engine_oddball, 0, 2, "The kind of ball game"), + RULE_INTEGER("ball_spawn_count", NULL, "oddball", _server_engine_oddball, 1, 16, "Balls on the map"), + RULE_INTEGER("race_type", NULL, "race", _server_engine_race, 0, 2, "The kind of race"), + RULE_INTEGER("team_scoring", NULL, "race", _server_engine_race, 0, 2, "How a team's laps are counted"), +}; + +int server_rule_definition_count(void) +{ + return (int)(sizeof(server_rule_definitions) / sizeof(server_rule_definitions[0])); +} + +static int text_equal_case(const char *a, const char *b) +{ + for (; *a && *b; a++, b++) + { + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) + return 0; + } + return !*a && !*b; +} + +const struct server_rule_definition *server_rule_find(const char *key) +{ + int index; + + for (index = 0; index < server_rule_definition_count(); index++) + { + const struct server_rule_definition *rule = &server_rule_definitions[index]; + + if (text_equal_case(key, rule->key) || (rule->alias && text_equal_case(key, rule->alias))) + return rule; + } + return NULL; +} + +/* a whole number of text (no sign but a leading -), into value: 1 if it is */ +static int whole_number(const char *text, long *value) +{ + char *end; + long number; + + if (!text[0] || strlen(text) > 9) + return 0; + if (!(isdigit((unsigned char)text[0]) || (text[0] == '-' && isdigit((unsigned char)text[1])))) + return 0; + number = strtol(text, &end, 10); + if (*end) + return 0; + *value = number; + return 1; +} + +int server_rule_value(const struct server_rule_definition *rule, const char *text, long *value, char *reason, + int reason_size) +{ + long number; + int index; + + switch (rule->type) + { + case _server_rule_integer: + if (!whole_number(text, &number)) + { + snprintf(reason, (size_t)reason_size, "%s takes a whole number, not \"%s\"", rule->key, text); + return 0; + } + if (number < rule->minimum || number > rule->maximum) + { + snprintf(reason, (size_t)reason_size, "%s takes %ld to %ld, not %ld", rule->key, rule->minimum, + rule->maximum, number); + return 0; + } + *value = number; + return 1; + case _server_rule_boolean: + if (text_equal_case(text, "true") || text_equal_case(text, "yes") || text_equal_case(text, "on") || + !strcmp(text, "1")) + { + *value = 1; + return 1; + } + if (text_equal_case(text, "false") || text_equal_case(text, "no") || text_equal_case(text, "off") || + !strcmp(text, "0")) + { + *value = 0; + return 1; + } + snprintf(reason, (size_t)reason_size, "%s takes true or false, not \"%s\"", rule->key, text); + return 0; + case _server_rule_choice: + for (index = 0; rule->choices[index]; index++) + { + if (text_equal_case(text, rule->choices[index])) + { + *value = index; + return 1; + } + } + if (whole_number(text, &number) && number >= 0 && number < index) + { + *value = number; + return 1; + } + { + int length = snprintf(reason, (size_t)reason_size, "%s takes ", rule->key); + + for (index = 0; rule->choices[index] && length < reason_size; index++) + { + length += snprintf(reason + length, (size_t)(reason_size - length), "%s%s", + index ? (rule->choices[index + 1] ? ", " : " or ") : "", rule->choices[index]); + } + if (length < reason_size) + snprintf(reason + length, (size_t)(reason_size - length), ", not \"%s\"", text); + } + return 0; + } + return 0; +} + +/* ---------- the rotation's text */ + +static int is_space(char character) +{ + return character == ' ' || character == '\t' || character == '\r' || character == '\n'; +} + +/* a name of letters, digits, "_", "-" and "." (no path) */ +static int plain_name(const char *text) +{ + int index; + + for (index = 0; text[index]; index++) + { + unsigned char character = (unsigned char)text[index]; + + if (!(isalnum(character) || character == '_' || character == '-' || character == '.')) + return 0; + } + return index > 0 && text[0] != '.'; +} + +/* a level's path: letters, digits, "_", "-", "." and backslashes, no part +of it only dots (nothing leaves the maps folder) */ +static int level_path(const char *text) +{ + const char *part = text; + int index; + + if (!text[0] || text[0] == '\\') + return 0; + for (index = 0;; index++) + { + unsigned char character = (unsigned char)text[index]; + + if (character == '\\' || !character) + { + int length = (int)(text + index - part); + int dots = 0, at; + + for (at = 0; at < length; at++) + dots += part[at] == '.'; + if (!length || dots == length) + return 0; + if (!character) + return 1; + part = text + index + 1; + continue; + } + if (!(isalnum(character) || character == '_' || character == '-' || character == '.')) + return 0; + } +} + +int server_rotation_parse_entry(const char *text, int length, struct server_entry *entry) +{ + char copy[SERVER_ENTRY_TEXT_SIZE]; + char *colon, *rule; + int start = 0, end = length, index; + + memset(entry, 0, sizeof(*entry)); + while (start < end && is_space(text[start])) + start++; + while (end > start && is_space(text[end - 1])) + end--; + if (start >= end) + return 0; + /* (a comment) */ + if (text[start] == '#') + return 0; + if (end - start >= (int)sizeof(copy)) + { + snprintf(entry->text, sizeof(entry->text), "%.*s...", (int)sizeof(entry->text) - 4, text + start); + snprintf(entry->rejected, sizeof(entry->rejected), "longer than %d characters", (int)sizeof(copy) - 1); + return 1; + } + memcpy(copy, text + start, (size_t)(end - start)); + copy[end - start] = 0; + snprintf(entry->text, sizeof(entry->text), "%s", copy); + for (index = 0; copy[index]; index++) + { + if ((unsigned char)copy[index] < 0x20 || (unsigned char)copy[index] >= 0x7F) + { + snprintf(entry->rejected, sizeof(entry->rejected), "has a character that is not printable ASCII"); + return 1; + } + } + /* map[:gametype[+rule...]] (a map's path has no colon) */ + colon = strchr(copy, ':'); + rule = NULL; + if (colon) + { + *colon = 0; + rule = strchr(colon + 1, '+'); + if (rule) + *rule++ = 0; + } + /* the map */ + { + char *map = copy; + size_t map_length; + char prefix[12] = ""; + + while (is_space(*map)) + map++; + map_length = strlen(map); + while (map_length && is_space(map[map_length - 1])) + map[--map_length] = 0; + /* (forward slashes as the game's backslashes) */ + for (index = 0; map[index]; index++) + { + if (map[index] == '/') + map[index] = '\\'; + } + if (map_length >= sizeof(entry->map)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "the map's name is too long"); + return 1; + } + snprintf(entry->map, sizeof(entry->map), "%s", map); + /* (custom_maps\ in any case) */ + if (map_length >= 12 && map[11] == '\\') + snprintf(prefix, sizeof(prefix), "%.11s", map); + if (text_equal_case(prefix, "custom_maps")) + { + entry->map_kind = _server_map_custom; + memcpy(entry->map, "custom_maps", 11); + if (!plain_name(map + 12)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "\"%s\" is not a map's name", map + 12); + return 1; + } + snprintf(entry->map_name, sizeof(entry->map_name), "%s", map + 12); + snprintf(entry->level, sizeof(entry->level), "custom_maps\\%s", map + 12); + } + else if (strchr(map, '\\')) + { + const char *last = strrchr(map, '\\') + 1; + + entry->map_kind = _server_map_path; + if (!level_path(map)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "\"%s\" is not a level's path", map); + return 1; + } + snprintf(entry->map_name, sizeof(entry->map_name), "%s", last); + snprintf(entry->level, sizeof(entry->level), "%s", map); + } + else + { + entry->map_kind = _server_map_name; + if (!plain_name(map)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "\"%s\" is not a map's name", map); + return 1; + } + snprintf(entry->map_name, sizeof(entry->map_name), "%s", map); + if (snprintf(entry->level, sizeof(entry->level), "levels\\test\\%s\\%s", map, map) >= + (int)sizeof(entry->level)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "the map's name is too long"); + return 1; + } + } + for (index = 0; entry->map_name[index]; index++) + entry->map_name[index] = (char)tolower((unsigned char)entry->map_name[index]); + if (!strcmp(entry->map_name, "ui")) + { + snprintf(entry->rejected, sizeof(entry->rejected), "ui is the main menu's map"); + return 1; + } + } + /* the gametype */ + snprintf(entry->gametype, sizeof(entry->gametype), "slayer"); + if (colon) + { + char *gametype = colon + 1; + size_t gametype_length; + + while (is_space(*gametype)) + gametype++; + gametype_length = strlen(gametype); + while (gametype_length && is_space(gametype[gametype_length - 1])) + gametype[--gametype_length] = 0; + if (!gametype_length || gametype_length >= sizeof(entry->gametype) || + !plain_name(!strncmp(gametype, "file=", 5) ? gametype + 5 : gametype)) + { + snprintf(entry->rejected, sizeof(entry->rejected), "\"%s\" is not a gametype's name", gametype); + return 1; + } + for (index = 0; gametype[index]; index++) + gametype[index] = (char)tolower((unsigned char)gametype[index]); + snprintf(entry->gametype, sizeof(entry->gametype), "%s", gametype); + } + /* the rules */ + while (rule) + { + char *next = strchr(rule, '+'); + char *equals; + const struct server_rule_definition *definition; + long value; + int existing; + + if (next) + *next++ = 0; + equals = strchr(rule, '='); + while (is_space(*rule)) + rule++; + if (!equals) + { + snprintf(entry->rejected, sizeof(entry->rejected), "the rule \"%s\" has no value (key=value)", rule); + return 1; + } + *equals = 0; + { + char *key_end = equals; + char *value_text = equals + 1; + size_t value_length; + + while (key_end > rule && is_space(key_end[-1])) + *--key_end = 0; + while (is_space(*value_text)) + value_text++; + value_length = strlen(value_text); + while (value_length && is_space(value_text[value_length - 1])) + value_text[--value_length] = 0; + definition = server_rule_find(rule); + if (!definition) + { + snprintf(entry->rejected, sizeof(entry->rejected), "no rule is named \"%s\"", rule); + return 1; + } + if (!server_rule_value(definition, value_text, &value, entry->rejected, sizeof(entry->rejected))) + return 1; + } + /* (a rule given twice: the last) */ + for (existing = 0; existing < entry->rule_count && strcmp(entry->rules[existing].key, definition->key); + existing++) + { + } + if (existing == entry->rule_count) + { + if (entry->rule_count >= SERVER_RULES_MAXIMUM) + { + snprintf(entry->rejected, sizeof(entry->rejected), "more than %d rules", SERVER_RULES_MAXIMUM); + return 1; + } + entry->rule_count++; + } + snprintf(entry->rules[existing].key, sizeof(entry->rules[existing].key), "%s", definition->key); + entry->rules[existing].value = value; + rule = next; + } + return 1; +} + +void server_rotation_parse(const char *text, struct server_rotation *rotation) +{ + int length = (int)strlen(text); + int start = 0; + + memset(rotation, 0, sizeof(*rotation)); + if (length > SERVER_ROTATION_MAXIMUM_TEXT) + { + /* (cut at the separator before the cut: the entry it crosses is not + played cut short) */ + length = SERVER_ROTATION_MAXIMUM_TEXT; + while (length > 0 && text[length] != ',' && text[length] != '\n' && text[length] != ';') + length--; + rotation->truncated = 1; + } + while (start < length) + { + int end = start; + + while (end < length && is_space(text[end]) && text[end] != '\n') + end++; + /* (a comment runs to its line's end, commas and all) */ + if (end < length && text[end] == '#') + { + while (end < length && text[end] != '\n') + end++; + start = end + 1; + continue; + } + while (end < length && text[end] != ',' && text[end] != '\n' && text[end] != ';') + end++; + if (rotation->count >= SERVER_ROTATION_MAXIMUM_ENTRIES) + { + struct server_entry ignored; + + if (server_rotation_parse_entry(text + start, end - start, &ignored)) + rotation->truncated = 1; + } + else if (server_rotation_parse_entry(text + start, end - start, &rotation->entries[rotation->count])) + { + rotation->count++; + } + start = end + 1; + } +} + +int server_rotation_playable(struct server_rotation const *rotation) +{ + int count = 0, index; + + for (index = 0; index < rotation->count; index++) + count += !rotation->entries[index].rejected[0]; + return count; +} + +void server_rotation_text(struct server_rotation const *rotation, char *text, int size) +{ + int length = 0, index; + + if (size <= 0) + return; + text[0] = 0; + for (index = 0; index < rotation->count && length < size; index++) + length += snprintf(text + length, (size_t)(size - length), "%s%s", index ? ", " : "", rotation->entries[index].text); +} + +/* ---------- the maps on disk */ + +#ifndef SERVER_ROTATION_NO_FILES + +int server_map_header(const char *path, long *version, long *scenario_type) +{ + unsigned char header[0x800]; + FILE *file = fopen(path, "rb"); + size_t read; + + *version = 0; + *scenario_type = -1; + if (!file) + return 0; + read = fread(header, 1, sizeof(header), file); + fclose(file); + /* ('head' and 'foot', little-endian; Halo PC's and Custom Edition's + headers end where the Xbox's does) */ + if (read != sizeof(header) || memcmp(header, "daeh", 4) || memcmp(header + 0x7FC, "toof", 4)) + return 0; + *version = (long)(header[4] | header[5] << 8 | header[6] << 16 | (unsigned long)header[7] << 24); + *scenario_type = (long)(short)(header[0x60] | header[0x61] << 8); + return 1; +} + +/* the map file of a name in a folder (d:\maps, an absolute folder): the +first of its spellings found, into path */ +static int map_file(const char *folder, const char *name, char *path, int size) +{ + char xbox_path[512]; + long version, type; + + if (folder[0] == 'd' && folder[1] == ':') + { + snprintf(xbox_path, sizeof(xbox_path), "%s\\%s.map", folder, name); + platform_translate_path(xbox_path, path, (unsigned long)size); + } + else + { + snprintf(path, (size_t)size, "%s/%s.map", folder, name); + } + return server_map_header(path, &version, &type); +} + +/* a campaign level plays only as co-op, a multiplayer map never so, and +the main menu's not at all (the scenario's type in its header); the maps +folder's an Xbox cache file, custom_maps' a Custom Edition one (version) */ +static void server_rotation_check_kind(struct server_entry *entry, long type, long version) +{ + int coop = !strcmp(entry->gametype, "coop"); + long wanted = entry->map_kind == _server_map_custom ? 609 : 5; + + if (version != wanted) + snprintf(entry->rejected, sizeof(entry->rejected), "%s is not %s cache file (version %ld, not %ld)", + entry->map_name, wanted == 5 ? "an Xbox" : "a Custom Edition", version, wanted); + else if (type == 2) + snprintf(entry->rejected, sizeof(entry->rejected), "%s is the main menu's map", entry->map_name); + else if (type == 0 && !coop) + snprintf(entry->rejected, sizeof(entry->rejected), "%s is a campaign level (as co-op: %s:coop)", + entry->map_name, entry->map); + else if (type != 0 && coop) + snprintf(entry->rejected, sizeof(entry->rejected), "%s is not a campaign level, for co-op", entry->map_name); +} + +/* the custom_maps folder, as the game finds it */ +static void custom_maps_folder(char *folder, int size) +{ + const char *setting = config_string("paths.custom_maps"); + + if (setting[0]) + snprintf(folder, (size_t)size, "%s", setting); + else + platform_translate_path("d:\\custom_maps", folder, (unsigned long)size); +} + +void server_rotation_resolve(struct server_entry *entry) +{ + char path[1024]; + char folder[1024]; + long version, type; + + if (entry->rejected[0]) + return; + custom_maps_folder(folder, sizeof(folder)); + /* a name: the Xbox's map of it, else Custom Edition's */ + if (entry->map_kind == _server_map_name) + { + if (map_file("d:\\maps", entry->map_name, path, sizeof(path))) + { + server_map_header(path, &version, &type); + server_rotation_check_kind(entry, type, version); + return; + } + if (map_file(folder, entry->map_name, path, sizeof(path)) || + (platform_custom_edition_root()[0] && + (snprintf(folder, sizeof(folder), "%s/maps", platform_custom_edition_root()), + map_file(folder, entry->map_name, path, sizeof(path))))) + { + entry->map_kind = _server_map_custom; + snprintf(entry->level, sizeof(entry->level), "custom_maps\\%s", entry->map_name); + server_map_header(path, &version, &type); + server_rotation_check_kind(entry, type, version); + return; + } + snprintf(entry->rejected, sizeof(entry->rejected), "there is no map %s (maps/%s.map, custom_maps/%s.map)", + entry->map_name, entry->map_name, entry->map_name); + entry->map_missing = 1; + return; + } + if (entry->map_kind == _server_map_custom) + { + if (map_file(folder, entry->map_name, path, sizeof(path)) || + (platform_custom_edition_root()[0] && + (snprintf(folder, sizeof(folder), "%s/maps", platform_custom_edition_root()), + map_file(folder, entry->map_name, path, sizeof(path))))) + { + server_map_header(path, &version, &type); + server_rotation_check_kind(entry, type, version); + return; + } + snprintf(entry->rejected, sizeof(entry->rejected), "there is no Custom Edition map %s (custom_maps/%s.map)", + entry->map_name, entry->map_name); + entry->map_missing = 1; + return; + } + /* a path: the maps folder's file of its last part */ + if (!map_file("d:\\maps", entry->map_name, path, sizeof(path))) + { + snprintf(entry->rejected, sizeof(entry->rejected), "there is no map file maps/%s.map for %s", entry->map_name, + entry->level); + entry->map_missing = 1; + return; + } + server_map_header(path, &version, &type); + server_rotation_check_kind(entry, type, version); +} + +/* the multiplayer maps of a folder (an absolute one), each told; how many */ +static int maps_in_folder(const char *folder, int custom, + void (*each)(void *context, const char *name, const char *level, const char *kind), void *context) +{ + void *directory = posix_directory_open(folder); + char name[256]; + int count = 0; + + if (!directory) + return 0; + while (posix_directory_next(directory, name, sizeof(name))) + { + size_t length = strlen(name); + char path[1400]; + char map[SERVER_MAP_SIZE]; + char level[SERVER_MAP_SIZE + 32]; + long version, type; + size_t index; + + if (length < 5 || length - 4 >= sizeof(map) || strcmp(name + length - 4, ".map")) + continue; + snprintf(map, sizeof(map), "%.*s", (int)(length - 4), name); + for (index = 0; map[index]; index++) + map[index] = (char)tolower((unsigned char)map[index]); + /* (the resource maps, and the main menu's) */ + if (!strcmp(map, "bitmaps") || !strcmp(map, "sounds") || !strcmp(map, "loc") || !strcmp(map, "ui")) + continue; + snprintf(path, sizeof(path), "%s/%s", folder, name); + if (!server_map_header(path, &version, &type) || type != 1 || (custom ? version != 609 : version != 5)) + continue; + if (custom) + snprintf(level, sizeof(level), "custom_maps\\%s", map); + else + snprintf(level, sizeof(level), "levels\\test\\%s\\%s", map, map); + each(context, map, level, custom ? "custom" : "xbox"); + count++; + } + posix_directory_close(directory); + return count; +} + +int server_maps_each(void (*each)(void *context, const char *name, const char *level, const char *kind), + void *context) +{ + char folder[1024]; + int count; + + platform_translate_path("d:\\maps", folder, sizeof(folder)); + count = maps_in_folder(folder, 0, each, context); + custom_maps_folder(folder, sizeof(folder)); + count += maps_in_folder(folder, 1, each, context); + return count; +} + +#endif diff --git a/port/linux/src/server_rotation.h b/port/linux/src/server_rotation.h new file mode 100644 index 00000000..7226c666 --- /dev/null +++ b/port/linux/src/server_rotation.h @@ -0,0 +1,151 @@ +/* +SERVER_ROTATION.H + +A dedicated server's rotation (server.rotation) and gametypes' rules +(server_rotation.c): the grammar read into plain structures, and the table of +the rules a rotation entry (+score=50) or a gametype file +(gametypes/.toml, server_toml.c) may set, with their ranges. Nothing +here touches the game: port/linux/game/server_gametypes.c applies the rules +to a game's variant and options, and checks each against its game engine. + + rotation := entry { ("," | newline) entry } + entry := map [ ":" gametype { "+" rule } ] + map := name | "custom_maps\" name | a level's path + gametype := a built-in's name | an OpenCE gametype's | a gametype file's name + | "file=" a saved gametype's name | "coop" + rule := key "=" value +*/ + +#ifndef __HALO_LINUX_SERVER_ROTATION_H +#define __HALO_LINUX_SERVER_ROTATION_H + +enum +{ + SERVER_ROTATION_MAXIMUM_ENTRIES = 64, + SERVER_ROTATION_MAXIMUM_TEXT = 4096, + SERVER_RULES_MAXIMUM = 48, + SERVER_ENTRY_TEXT_SIZE = 256, + SERVER_MAP_SIZE = 128, + SERVER_GAMETYPE_SIZE = 64, + SERVER_RULE_KEY_SIZE = 32, + SERVER_RULE_VALUE_SIZE = 32, + SERVER_REASON_SIZE = 160, +}; + +/* a rule's type */ +enum +{ + _server_rule_integer, + _server_rule_boolean, + _server_rule_choice, +}; + +/* the game engines a rule is of */ +enum +{ + _server_engine_ctf = 1 << 0, + _server_engine_slayer = 1 << 1, + _server_engine_oddball = 1 << 2, + _server_engine_king = 1 << 3, + _server_engine_race = 1 << 4, + _server_engine_all = 0x1F, +}; + +struct server_rule_definition +{ + const char *key; + /* a shorter name for the rotation's +rule=value (NULL: none) */ + const char *alias; + /* the gametype file's table it is in: "universal", "options", or its + engine's ("ctf", "slayer", "king", "oddball", "race") */ + const char *table; + int engines; + int type; + long minimum; + long maximum; + /* a choice's words, NULL ended; the value is the word's index */ + const char *const *choices; + const char *description; +}; + +/* the rules, in order; their count */ +extern const struct server_rule_definition server_rule_definitions[]; +int server_rule_definition_count(void); +/* the rule of a key or alias (in any case), NULL if none */ +const struct server_rule_definition *server_rule_find(const char *key); +/* the value text (a number, true/false/yes/no/on/off/1/0, a choice's word or +its index) as the rule's value: 1 if it is one in range, else 0 and why */ +int server_rule_value(const struct server_rule_definition *rule, const char *text, long *value, char *reason, + int reason_size); + +struct server_rule +{ + /* the definition's key (an alias is taken as its key) */ + char key[SERVER_RULE_KEY_SIZE]; + long value; +}; + +enum +{ + /* how an entry's map was given */ + _server_map_name, + _server_map_custom, + _server_map_path, +}; + +struct server_entry +{ + /* as written, spaces trimmed */ + char text[SERVER_ENTRY_TEXT_SIZE]; + /* as given: "bloodgulch", "custom_maps\hugeass", a path */ + char map[SERVER_MAP_SIZE]; + int map_kind; + /* the level the game plays (levels\test\bloodgulch\bloodgulch, + custom_maps\hugeass): the map's, once resolved (server_rotation_resolve) */ + char level[SERVER_MAP_SIZE]; + /* the map's own name: bloodgulch, hugeass */ + char map_name[SERVER_MAP_SIZE]; + /* lowercased; "slayer" when none was given */ + char gametype[SERVER_GAMETYPE_SIZE]; + struct server_rule rules[SERVER_RULES_MAXIMUM]; + int rule_count; + /* why it cannot be played (empty: it can) */ + char rejected[SERVER_REASON_SIZE]; + /* rejected because its map is missing (not because of what it says) */ + int map_missing; +}; + +struct server_rotation +{ + struct server_entry entries[SERVER_ROTATION_MAXIMUM_ENTRIES]; + int count; + /* entries past SERVER_ROTATION_MAXIMUM_ENTRIES, or text past + SERVER_ROTATION_MAXIMUM_TEXT, left out */ + int truncated; +}; + +/* one entry's text read: 1 if it says something (its rejected says +whether it can be played) */ +int server_rotation_parse_entry(const char *text, int length, struct server_entry *entry); +/* the rotation's text read: every entry, rejected ones too */ +void server_rotation_parse(const char *text, struct server_rotation *rotation); +/* the number of entries that are not rejected */ +int server_rotation_playable(struct server_rotation const *rotation); +/* the rotation as one text, entries separated by ", " */ +void server_rotation_text(struct server_rotation const *rotation, char *text, int size); + +/* the entry's map found on disk (the data root's maps folder, custom_maps): +its level and map name set, or it rejected (map_missing) */ +void server_rotation_resolve(struct server_entry *entry); + +/* each multiplayer map there is: the maps folder's (kind "xbox") and +custom_maps' (kind "custom"), by its name, with its level's path; how many */ +int server_maps_each(void (*each)(void *context, const char *name, const char *level, const char *kind), + void *context); + +/* a cache file's header: its version (5 for the Xbox's, 7 for Halo PC's, +609 for Custom Edition's) and its scenario's type (0 campaign, 1 +multiplayer, 2 the main menu); 0 if it is not a cache file */ +int server_map_header(const char *path, long *version, long *scenario_type); + +#endif diff --git a/port/linux/src/server_toml.c b/port/linux/src/server_toml.c new file mode 100644 index 00000000..c69f9909 --- /dev/null +++ b/port/linux/src/server_toml.c @@ -0,0 +1,273 @@ +/* +SERVER_TOML.C + +A dedicated server's gametype files (server_toml.h). +*/ + +#include "server_toml.h" +#include "server_mode.h" + +#if HALO_SERVER_BUILD + +#include "platform.h" +#include "port_config.h" +#include "posix.h" +#include "tomlc17.h" + +#include +#include +#include +#include + +static const char *const engine_tables[] = { "ctf", "slayer", "king", "oddball", "race" }; + +int server_gametype_file_name_valid(const char *name) +{ + int index; + + for (index = 0; name[index]; index++) + { + unsigned char character = (unsigned char)name[index]; + + if (!(isalnum(character) || character == '_' || character == '-') || index >= SERVER_GAMETYPE_SIZE - 6) + return 0; + } + return index > 0; +} + +/* a value of the file as its rule takes it: its text */ +static int gametype_value_text(toml_datum_t datum, char *text, int size) +{ + switch (datum.type) + { + case TOML_BOOLEAN: + snprintf(text, (size_t)size, "%s", datum.u.boolean ? "true" : "false"); + return 1; + case TOML_INT64: + snprintf(text, (size_t)size, "%lld", (long long)datum.u.int64); + return 1; + case TOML_STRING: + snprintf(text, (size_t)size, "%s", datum.u.s); + return 1; + default: + return 0; + } +} + +static int gametype_error(struct server_gametype_file *file, int line, const char *format, const char *a, + const char *b) +{ + snprintf(file->error, sizeof(file->error), format, a, b); + file->error_line = line; + return 0; +} + +int server_gametype_file_parse(const char *text, int length, struct server_gametype_file *file) +{ + toml_result_t result; + int section_index; + int ok = 1; + + memset(file, 0, sizeof(*file)); + if (length > SERVER_GAMETYPE_FILE_MAXIMUM_SIZE) + return gametype_error(file, 0, "the file is larger than %s%s", "64 KB", ""); + result = toml_parse(text, length); + if (!result.ok) + { + int line = 0; + const char *at = strstr(result.errmsg, "(line "); + + if (at) + sscanf(at, "(line %d)", &line); + gametype_error(file, line, "%s%s", result.errmsg, ""); + toml_free(result); + return 0; + } + for (section_index = 0; ok && section_index < result.toptab.u.tab.size; section_index++) + { + const char *key = result.toptab.u.tab.key[section_index]; + toml_datum_t value = result.toptab.u.tab.value[section_index]; + int key_index, table_index; + + if (value.type != TOML_TABLE) + { + if (!strcmp(key, "base") && value.type == TOML_STRING && strlen(value.u.s) < sizeof(file->base)) + { + int index; + + snprintf(file->base, sizeof(file->base), "%s", value.u.s); + for (index = 0; file->base[index]; index++) + file->base[index] = (char)tolower((unsigned char)file->base[index]); + } + else if (!strcmp(key, "name") && value.type == TOML_STRING) + { + size_t index; + + if (strlen(value.u.s) >= sizeof(file->name)) + { + ok = gametype_error(file, value.lineno, "name is longer than %s characters%s", "11", ""); + break; + } + for (index = 0; value.u.s[index]; index++) + { + if ((unsigned char)value.u.s[index] < 0x20 || (unsigned char)value.u.s[index] >= 0x7F) + { + ok = gametype_error(file, value.lineno, "name has a character that is not printable %s%s", + "ASCII", ""); + break; + } + } + if (ok) + snprintf(file->name, sizeof(file->name), "%s", value.u.s); + } + else if (!strcmp(key, "base") || !strcmp(key, "name")) + { + ok = gametype_error(file, value.lineno, "%s is a string of %s characters at most", key, + !strcmp(key, "base") ? "63" : "11"); + } + else + { + ok = gametype_error(file, value.lineno, + "%s is not base or name (a rule goes in its table: [universal], [options], [%s])", key, + "ctf] ... [race"); + } + continue; + } + /* a table: universal, options or an engine's */ + for (table_index = 0; table_index < (int)(sizeof(engine_tables) / sizeof(engine_tables[0])) && + strcmp(key, engine_tables[table_index]); table_index++) + { + } + if (strcmp(key, "universal") && strcmp(key, "options") && + table_index == (int)(sizeof(engine_tables) / sizeof(engine_tables[0]))) + { + ok = gametype_error(file, value.lineno, "[%s] is no table of a gametype file%s", key, ""); + break; + } + if (table_index < (int)(sizeof(engine_tables) / sizeof(engine_tables[0]))) + { + if (file->engine_table[0] && strcmp(file->engine_table, key)) + { + ok = gametype_error(file, value.lineno, "[%s] and [%s]: a gametype has one game engine", file->engine_table, + key); + break; + } + snprintf(file->engine_table, sizeof(file->engine_table), "%s", key); + } + for (key_index = 0; ok && key_index < value.u.tab.size; key_index++) + { + const char *rule_key = value.u.tab.key[key_index]; + toml_datum_t rule_value = value.u.tab.value[key_index]; + const struct server_rule_definition *rule = server_rule_find(rule_key); + char text_value[64]; + char reason[SERVER_REASON_SIZE]; + long number; + int existing; + + if (!rule || strcmp(rule->key, rule_key)) + { + ok = gametype_error(file, rule_value.lineno, "no rule is named %s (in [%s])", rule_key, key); + break; + } + if (strcmp(rule->table, key)) + { + char where[80]; + + snprintf(where, sizeof(where), "[%s], not [%s]", rule->table, key); + ok = gametype_error(file, rule_value.lineno, "%s goes in %s", rule_key, where); + break; + } + if (!gametype_value_text(rule_value, text_value, sizeof(text_value)) || + !server_rule_value(rule, text_value, &number, reason, sizeof(reason))) + { + if (!gametype_value_text(rule_value, text_value, sizeof(text_value))) + snprintf(reason, sizeof(reason), "%s takes a number, true or false, or a word", rule_key); + ok = gametype_error(file, rule_value.lineno, "%s%s", reason, ""); + break; + } + for (existing = 0; existing < file->rule_count && strcmp(file->rules[existing].key, rule->key); existing++) + { + } + if (existing == file->rule_count) + { + if (file->rule_count >= SERVER_RULES_MAXIMUM) + { + ok = gametype_error(file, rule_value.lineno, "more than %s rules%s", "48", ""); + break; + } + file->rule_count++; + } + snprintf(file->rules[existing].key, sizeof(file->rules[existing].key), "%s", rule->key); + file->rules[existing].value = number; + } + } + if (ok && !file->base[0]) + ok = gametype_error(file, 1, "there is no base = \"\" (slayer, team_slayer, ctf, %s%s", "...)", ""); + toml_free(result); + return ok; +} + +void server_gametypes_folder(char *path, int size) +{ + char folder[1024]; + struct posix_file_information information; + + /* (beside config.toml when there is one there: a server's own, among + several sharing a data root; else the data root's) */ + config_folder(folder, sizeof(folder)); + snprintf(path, (size_t)size, "%sgametypes", folder); + if (folder[0] && posix_stat(path, &information) == 0 && (information.flags & _posix_file_is_directory)) + return; + platform_translate_path("d:\\gametypes", path, (unsigned long)size); +} + +int server_gametype_saved_read(const char *name, void *data, int size) +{ + char folder[1024]; + char path[1200]; + size_t length = 0; + char *text; + + if (!server_gametype_file_name_valid(name) || size < 0) + return -1; + server_gametypes_folder(folder, sizeof(folder)); + snprintf(path, sizeof(path), "%s/%s.gametype", folder, name); + text = config_file_read(path, &length); + if (!text) + return -1; + if (length > (size_t)size) + length = (size_t)size; + memcpy(data, text, length); + free(text); + return (int)length; +} + +int server_gametype_file_read(const char *name, struct server_gametype_file *file, int *absent) +{ + char folder[1024]; + char path[1200]; + size_t size = 0; + char *text; + int ok; + + memset(file, 0, sizeof(*file)); + *absent = 0; + if (!server_gametype_file_name_valid(name)) + { + *absent = 1; + return 0; + } + server_gametypes_folder(folder, sizeof(folder)); + snprintf(path, sizeof(path), "%s/%s.toml", folder, name); + text = config_file_read(path, &size); + if (!text) + { + *absent = 1; + return 0; + } + ok = server_gametype_file_parse(text, (int)size, file); + free(text); + return ok; +} + +#endif diff --git a/port/linux/src/server_toml.h b/port/linux/src/server_toml.h new file mode 100644 index 00000000..8e00c27e --- /dev/null +++ b/port/linux/src/server_toml.h @@ -0,0 +1,62 @@ +/* +SERVER_TOML.H + +A dedicated server's gametype files (server_toml.c): gametypes/.toml +in the data root, read with tomlc17 into its base gametype, its name and the +rules it sets (server_rotation.h's), each checked to be a rule of the table +it is in and within its range. port/linux/game/server_gametypes.c builds the +game's variant from them. + + base = "team_slayer" # a built-in or OpenCE gametype: its rules are the defaults + name = "BTB Slayer" # 11 characters, shown in the lobby and the server browser + [universal] # any game engine's rules + score_to_win = 50 + [options] # the PC version's options + time_limit = 15 + friendly_fire = "off" + [slayer] # the base's game engine's: ctf, slayer, king, oddball, race + no_death_bonus = true +*/ + +#ifndef __HALO_LINUX_SERVER_TOML_H +#define __HALO_LINUX_SERVER_TOML_H + +#include "server_rotation.h" + +enum +{ + SERVER_GAMETYPE_NAME_SIZE = 12, + /* the largest gametype file read */ + SERVER_GAMETYPE_FILE_MAXIMUM_SIZE = 64 * 1024, +}; + +struct server_gametype_file +{ + char base[SERVER_GAMETYPE_SIZE]; + /* the name shown (empty: the base's) */ + char name[SERVER_GAMETYPE_NAME_SIZE]; + /* the engine table it has ("" if none): the base must be of it */ + char engine_table[16]; + struct server_rule rules[SERVER_RULES_MAXIMUM]; + int rule_count; + /* why it cannot be used (empty: it can), and the line */ + char error[SERVER_REASON_SIZE]; + int error_line; +}; + +/* a gametype file's text read: 1 if it can be used, else 0 with error set */ +int server_gametype_file_parse(const char *text, int length, struct server_gametype_file *file); +/* the gametype file of the name (gametypes/.toml in the data root): +1 if it is there and can be used; 0 with error set (or empty if there is +no such file, and absent set) */ +int server_gametype_file_read(const char *name, struct server_gametype_file *file, int *absent); +/* the gametypes folder (the data root's), with no separator after it */ +void server_gametypes_folder(char *path, int size); +/* the first size bytes of the saved gametype .gametype in that folder +(a dedicated server's file= entries: the game's files take Xbox +paths only): how many were read, -1 if it cannot be */ +int server_gametype_saved_read(const char *name, void *data, int size); +/* whether a name may be a gametype file's: letters, digits, "_" and "-" */ +int server_gametype_file_name_valid(const char *name); + +#endif diff --git a/port/linux/src/text_hires.c b/port/linux/src/text_hires.c index e11517b0..0b3ef5d4 100644 --- a/port/linux/src/text_hires.c +++ b/port/linux/src/text_hires.c @@ -305,7 +305,7 @@ void text_hires_register_atlas(const unsigned long *texture, unsigned long width unsigned int text_hires_atlas_texture(unsigned long data) { - if (!data || data != placeholder_data || !atlas) + if (!data || data != placeholder_data || !atlas || !xgpu_gl_ready()) return 0; if (!atlas_texture) { diff --git a/port/linux/src/update.h b/port/linux/src/update.h index 67469243..26db550d 100644 --- a/port/linux/src/update.h +++ b/port/linux/src/update.h @@ -23,6 +23,26 @@ of it; 1 on success, else 0 and why in error */ int update_download(const char *url, const char *path, unsigned long long maximum, update_progress_proc progress, void *context, char *error, int error_size); +/* one HTTP request (update_https_request's answer): the status (0 if none +came), the response's headers as received ("Name: value" lines, each ended +by "\n"), and its body (malloc'd, NUL terminated: the caller frees it) */ +struct update_response +{ + int status; + char headers[4096]; + char *body; + unsigned long body_size; +}; + +/* a request (method "GET", "PUT", "DELETE", "POST") to an https:// url, or +an http:// one on this machine (127.0.0.1, localhost: a test's), with +extra header lines (each ended by "\r\n"; may be NULL) and a body (may be +NULL); no redirect followed, at most maximum bytes of body taken, the +certificate and host name checked; 1 if an answer came (response holds it), +else 0 and why in error. Any thread may make one */ +int update_https_request(const char *method, const char *url, const char *headers, const void *body, + unsigned long body_size, unsigned long maximum, struct update_response *response, char *error, int error_size); + /* the full path of this executable; 1 on success */ int update_executable_path(char *path, int size); diff --git a/port/linux/src/updater.c b/port/linux/src/updater.c index be3a447f..9acd099d 100644 --- a/port/linux/src/updater.c +++ b/port/linux/src/updater.c @@ -425,6 +425,13 @@ static int SDLCALL updater_download_thread(void *context) return 0; } +/* the release's zip's address */ +static void updater_zip_url(char *url, size_t size) +{ + snprintf(url, size, "https://github.com/" UPDATE_REPOSITORY "/releases/download/build-%ld/" UPDATE_ASSET, + updater_latest_build); +} + /* downloads the release's zip, showing how far it has got in a window of its own (drawn in software, clear of the game's OpenGL); 1 when it is there */ static int updater_download_zip(const char *zip_path, char *error, size_t error_size) @@ -437,8 +444,7 @@ static int updater_download_zip(const char *zip_path, char *error, size_t error_ memset(&download, 0, sizeof(download)); download.lock = SDL_CreateMutex(); - snprintf(download.url, sizeof(download.url), - "https://github.com/" UPDATE_REPOSITORY "/releases/download/build-%ld/" UPDATE_ASSET, updater_latest_build); + updater_zip_url(download.url, sizeof(download.url)); snprintf(download.zip_path, sizeof(download.zip_path), "%s", zip_path); thread = SDL_CreateThread(updater_download_thread, "update download", &download); if (!thread) @@ -587,12 +593,22 @@ static int updater_replace_files(char names[][256], int name_count, char *error, return 1; } -/* downloads, unpacks and puts in place the new build, and starts it; returns -only if something failed */ -static void updater_update(void) +/* the release's zip downloaded on this thread, with no window (a dedicated +server's); 1 when it is there */ +static int updater_download_zip_quietly(const char *zip_path, char *error, size_t error_size) +{ + char url[512]; + + updater_zip_url(url, sizeof(url)); + return update_download(url, zip_path, MAXIMUM_UPDATE_SIZE, NULL, NULL, error, (int)error_size); +} + +/* downloads (showing how far it has got, if windowed), unpacks and puts in +place the new build: 1, else 0 and why */ +static int updater_install(int windowed, char *error, size_t error_size) { char names[MAXIMUM_UPDATE_FILES][256]; - char zip_path[1200], partial[1200], error[512] = ""; + char zip_path[1200], partial[1200]; int name_count = 0; updater_path(partial, sizeof(partial), UPDATE_DIRECTORY); @@ -600,19 +616,31 @@ static void updater_update(void) platform_log("update: downloading build %ld (" UPDATE_ASSET ")", updater_latest_build); if (!update_make_directory(partial)) { - snprintf(error, sizeof(error), "could not make %s (is the game's folder read-only?)", partial); + snprintf(error, error_size, "could not make %s (is the game's folder read-only?)", partial); } - else if (updater_download_zip(zip_path, error, sizeof(error)) && - updater_unpack(zip_path, names, &name_count, error, sizeof(error))) + else if ((windowed ? updater_download_zip(zip_path, error, error_size) : + updater_download_zip_quietly(zip_path, error, error_size)) && + updater_unpack(zip_path, names, &name_count, error, error_size) && + updater_replace_files(names, name_count, error, error_size)) { - if (updater_replace_files(names, name_count, error, sizeof(error))) - { - update_delete_file(partial); - platform_log("update: starting build %ld", updater_latest_build); - if (update_launch(updater_executable)) - exit(EXIT_SUCCESS); - snprintf(error, sizeof(error), "the new version is in place, but could not be started: start it again"); - } + update_delete_file(partial); + return 1; + } + return 0; +} + +/* downloads, unpacks and puts in place the new build, and starts it; returns +only if something failed */ +static void updater_update(void) +{ + char error[512] = ""; + + if (updater_install(1, error, sizeof(error))) + { + platform_log("update: starting build %ld", updater_latest_build); + if (update_launch(updater_executable)) + exit(EXIT_SUCCESS); + snprintf(error, sizeof(error), "the new version is in place, but could not be started: start it again"); } platform_log("update: failed: %s", error); { @@ -691,6 +719,144 @@ void updater_start(void) } } +/* ---------- a dedicated server's (server.auto_update: server_director.c) */ + +/* looks for a new version on a thread of its own, unless a look is under +way (update.auto and the tests' rules are not asked: the setting is the +operator's): 0 if it cannot be looked for (a build without a number) */ +int updater_server_check(void) +{ + char *slash; + + if (HALO_BUILD_NUMBER <= 0) + return 0; + if (!updater_directory[0]) + { + if (!update_executable_path(updater_executable, sizeof(updater_executable))) + return 0; + snprintf(updater_directory, sizeof(updater_directory), "%s", updater_executable); + slash = strrchr(updater_directory, PATH_SEPARATOR[0]); + if (!slash) + { + updater_directory[0] = 0; + return 0; + } + *slash = 0; + updater_clean_up(); + } + if (SDL_GetAtomicInt(&updater_state) == _updater_checking) + return 1; + SDL_SetAtomicInt(&updater_state, _updater_checking); + { + SDL_Thread *thread = SDL_CreateThread(updater_check_thread, "update check", NULL); + + if (thread) + SDL_DetachThread(thread); + else + SDL_SetAtomicInt(&updater_state, _updater_handled); + } + return 1; +} + +/* the newer build the last look found, 0 if none (or the look goes on) */ +long updater_server_available(void) +{ + return SDL_GetAtomicInt(&updater_state) == _updater_available ? updater_latest_build : 0; +} + +/* the newer build put in place of this one's files, not started (the server +stops, to be started again by what runs it): 1, else 0 and why. The game's +thread waits for the whole download: updater_server_install_start does not */ +int updater_server_install(char *error, int error_size) +{ + if (SDL_GetAtomicInt(&updater_state) != _updater_available) + { + snprintf(error, (size_t)error_size, "no newer build is known"); + return 0; + } + SDL_SetAtomicInt(&updater_state, _updater_handled); + return updater_install(0, error, (size_t)error_size); +} + +enum +{ + _updater_install_none, + _updater_install_running, + _updater_install_installed, + _updater_install_failed, +}; + +/* the server's install on a thread of its own: its state, and why it +failed (written before the state says so) */ +static SDL_AtomicInt updater_install_state; +static char updater_install_error[512]; + +static void updater_install_failed(const char *why) +{ + snprintf(updater_install_error, sizeof(updater_install_error), "%s", why); + SDL_SetAtomicInt(&updater_install_state, _updater_install_failed); +} + +static int SDLCALL updater_install_thread(void *context) +{ + char error[512] = ""; + + (void)context; + if (updater_install(0, error, sizeof(error))) + SDL_SetAtomicInt(&updater_install_state, _updater_install_installed); + else + updater_install_failed(error[0] ? error : "the install failed"); + return 0; +} + +/* the newer build put in place of this one's files on a thread of its own +(the game goes on meanwhile: updater_server_install_poll tells its end): 1 +if it began or goes on, else 0 (updater_server_install_poll says why) */ +int updater_server_install_start(void) +{ + SDL_Thread *thread; + + if (SDL_GetAtomicInt(&updater_install_state) == _updater_install_running) + return 1; + if (SDL_GetAtomicInt(&updater_state) != _updater_available) + { + updater_install_failed("no newer build is known"); + return 0; + } + SDL_SetAtomicInt(&updater_state, _updater_handled); + SDL_SetAtomicInt(&updater_install_state, _updater_install_running); + thread = SDL_CreateThread(updater_install_thread, "update install", NULL); + if (!thread) + { + updater_install_failed("could not start the install"); + return 0; + } + SDL_DetachThread(thread); + return 1; +} + +/* the install's state: 0 while it goes on, 1 when the new build is in +place, -1 if it failed (or none was started), and why in error */ +int updater_server_install_poll(char *error, int size) +{ + int state = SDL_GetAtomicInt(&updater_install_state); + + if (size > 0) + error[0] = 0; + switch (state) + { + case _updater_install_running: + return 0; + case _updater_install_installed: + return 1; + case _updater_install_failed: + snprintf(error, (size_t)size, "%s", updater_install_error); + return -1; + } + snprintf(error, (size_t)size, "no install was started"); + return -1; +} + /* whether the game runs under gamescope (the Steam Deck's Game Mode), where a system dialog such as SDL's message box crashes the game. gamescope sets GAMESCOPE_WAYLAND_DISPLAY for the programs it runs and Game Mode's session @@ -793,6 +959,35 @@ void updater_start(void) { } +int updater_server_check(void) +{ + return 0; +} + +long updater_server_available(void) +{ + return 0; +} + +int updater_server_install(char *error, int error_size) +{ + if (error_size > 0) + error[0] = 0; + return 0; +} + +int updater_server_install_start(void) +{ + return 0; +} + +int updater_server_install_poll(char *error, int size) +{ + if (size > 0) + error[0] = 0; + return -1; +} + #ifdef HALO_WEB #include diff --git a/port/linux/src/xbox_files.c b/port/linux/src/xbox_files.c index a3c31a8f..2adf8a54 100644 --- a/port/linux/src/xbox_files.c +++ b/port/linux/src/xbox_files.c @@ -19,6 +19,7 @@ matched case-insensitively, like the Xbox's FATX volumes. #include "platform.h" #include "posix.h" #include "port_config.h" +#include "server_mode.h" #include #include @@ -112,7 +113,10 @@ const char *platform_data_root(void) } } #if !defined(HALO_ANDROID) && !defined(HALO_WEB) - if (!has_maps(root) && executable_directory[0] && platform_offer_game_data(executable_directory) && + /* (a dedicated server asks nobody: it says what is missing and + stops, server_mode.c) */ + if (!has_maps(root) && executable_directory[0] && !halo_server_mode() && + platform_offer_game_data(executable_directory) && has_maps(executable_directory)) { snprintf(root, sizeof(root), "%s", executable_directory); @@ -204,6 +208,57 @@ const char *platform_custom_edition_root(void) return root; } +/* whether the first length characters of a and b match, ignoring case +(the Windows build has no strings.h); a shorter string matches only up to +its end, which must come at the same place in both */ +static BOOL names_match(const char *a, const char *b, unsigned long length) +{ + unsigned long index; + + for (index = 0; index < length; index++) + { + if (tolower((unsigned char)a[index]) != tolower((unsigned char)b[index])) + return FALSE; + if (!a[index]) + return TRUE; + } + return TRUE; +} + +/* the data root's files and folders kept elsewhere when the settings say +(paths.logs, paths.custom_maps): a read-only data root, a dedicated +server's maps mounted so. The rest of the path after them in rest */ +static int translate_elsewhere(const char *cursor, char *resolved, unsigned long size, const char **rest) +{ + static const char *const logs[] = { "debug.txt", "bans.txt", "cheaters.txt" }; + const char *logs_folder = config_string("paths.logs"); + const char *custom_maps = config_string("paths.custom_maps"); + unsigned long index; + + while (*cursor == '\\' || *cursor == '/') + cursor++; + for (index = 0; logs_folder[0] && index < sizeof(logs) / sizeof(logs[0]); index++) + { + if (names_match(cursor, logs[index], strlen(logs[index]) + 1)) + { + make_directories(logs_folder); + snprintf(resolved, size, "%s", logs_folder); + trim_separators(resolved); + *rest = cursor; + return 1; + } + } + if (custom_maps[0] && names_match(cursor, "custom_maps", 11) && (!cursor[11] || cursor[11] == '\\' || + cursor[11] == '/')) + { + snprintf(resolved, size, "%s", custom_maps); + trim_separators(resolved); + *rest = cursor + 11; + return 1; + } + return 0; +} + void platform_translate_path(const char *xbox_path, char *host_path, unsigned long host_path_size) { char resolved[1024]; @@ -219,6 +274,10 @@ void platform_translate_path(const char *xbox_path, char *host_path, unsigned lo { snprintf(resolved, sizeof(resolved), "%s", platform_custom_edition_root()); } + else if (drive == 'd' && translate_elsewhere(cursor + 2, resolved, sizeof(resolved), &cursor)) + { + cursor -= 2; + } else if (drive != 'd') { struct posix_file_information information; diff --git a/port/linux/src/xgpu.h b/port/linux/src/xgpu.h index a29c8aee..6e706e4b 100644 --- a/port/linux/src/xgpu.h +++ b/port/linux/src/xgpu.h @@ -52,6 +52,9 @@ changes GL state behind it (binding a texture to upload it, deleting one) must call this afterwards. */ void xgpu_gl_state_invalidate(void); +/* whether there is an OpenGL context (not with debug.null_renderer, a +dedicated server's): textures are made only then */ +int xgpu_gl_ready(void); /* a texture about to be deleted: the binding shadows forget it, since GL may give its name to a new texture (call before glDeleteTextures) */ void xgpu_gl_texture_deleted(unsigned int texture); diff --git a/port/third_party/jsmn/LICENSE b/port/third_party/jsmn/LICENSE new file mode 100644 index 00000000..c84fb2e9 --- /dev/null +++ b/port/third_party/jsmn/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2010 Serge A. Zaitsev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/port/third_party/jsmn/README.md b/port/third_party/jsmn/README.md new file mode 100644 index 00000000..0240988d --- /dev/null +++ b/port/third_party/jsmn/README.md @@ -0,0 +1,19 @@ +# jsmn + +A minimal JSON tokenizer in C, by Serge Zaitsev, under the MIT licence (see +`LICENSE`). + +Upstream: https://github.com/zserge/jsmn, commit +`25647e692c7906b96ffd2b05ca54c097948e879c` (2021-10-14, the repository's +latest). `jsmn.h` SHA-256 +`c04533e9181e1e33baceb0f55ac449b05145bb936e8c68cc77dfe0d8277514fb`, `LICENSE` +SHA-256 `4675b94a50d2afe811c52785463c854f1156056632cce17cc7133939eac8ed55`. + +Only `jsmn.h` and `LICENSE` are kept, unchanged. + +`port/linux/src/server_json.c` reads JSON with it: the master servers' lists +of servers, for the server browser of every build (`master_list.c`), and a +dedicated server's HTTP API requests. It builds into every build: Linux, +Windows, Android and the web. Its notice goes with each: the releases carry +`jsmn-LICENSE.txt` (`tools/ci_build.py`), the web build its `licenses.txt` +(`port/web/licenses.py`). diff --git a/port/third_party/jsmn/jsmn.h b/port/third_party/jsmn/jsmn.h new file mode 100644 index 00000000..8ac14c1b --- /dev/null +++ b/port/third_party/jsmn/jsmn.h @@ -0,0 +1,471 @@ +/* + * MIT License + * + * Copyright (c) 2010 Serge Zaitsev + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +#ifndef JSMN_H +#define JSMN_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef JSMN_STATIC +#define JSMN_API static +#else +#define JSMN_API extern +#endif + +/** + * JSON type identifier. Basic types are: + * o Object + * o Array + * o String + * o Other primitive: number, boolean (true/false) or null + */ +typedef enum { + JSMN_UNDEFINED = 0, + JSMN_OBJECT = 1 << 0, + JSMN_ARRAY = 1 << 1, + JSMN_STRING = 1 << 2, + JSMN_PRIMITIVE = 1 << 3 +} jsmntype_t; + +enum jsmnerr { + /* Not enough tokens were provided */ + JSMN_ERROR_NOMEM = -1, + /* Invalid character inside JSON string */ + JSMN_ERROR_INVAL = -2, + /* The string is not a full JSON packet, more bytes expected */ + JSMN_ERROR_PART = -3 +}; + +/** + * JSON token description. + * type type (object, array, string etc.) + * start start position in JSON data string + * end end position in JSON data string + */ +typedef struct jsmntok { + jsmntype_t type; + int start; + int end; + int size; +#ifdef JSMN_PARENT_LINKS + int parent; +#endif +} jsmntok_t; + +/** + * JSON parser. Contains an array of token blocks available. Also stores + * the string being parsed now and current position in that string. + */ +typedef struct jsmn_parser { + unsigned int pos; /* offset in the JSON string */ + unsigned int toknext; /* next token to allocate */ + int toksuper; /* superior token node, e.g. parent object or array */ +} jsmn_parser; + +/** + * Create JSON parser over an array of tokens + */ +JSMN_API void jsmn_init(jsmn_parser *parser); + +/** + * Run JSON parser. It parses a JSON data string into and array of tokens, each + * describing + * a single JSON object. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens); + +#ifndef JSMN_HEADER +/** + * Allocates a fresh unused token from the token pool. + */ +static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *tok; + if (parser->toknext >= num_tokens) { + return NULL; + } + tok = &tokens[parser->toknext++]; + tok->start = tok->end = -1; + tok->size = 0; +#ifdef JSMN_PARENT_LINKS + tok->parent = -1; +#endif + return tok; +} + +/** + * Fills token type and boundaries. + */ +static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type, + const int start, const int end) { + token->type = type; + token->start = start; + token->end = end; + token->size = 0; +} + +/** + * Fills next available token with JSON primitive. + */ +static int jsmn_parse_primitive(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + int start; + + start = parser->pos; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + switch (js[parser->pos]) { +#ifndef JSMN_STRICT + /* In strict mode primitive must be followed by "," or "}" or "]" */ + case ':': +#endif + case '\t': + case '\r': + case '\n': + case ' ': + case ',': + case ']': + case '}': + goto found; + default: + /* to quiet a warning from gcc*/ + break; + } + if (js[parser->pos] < 32 || js[parser->pos] >= 127) { + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } +#ifdef JSMN_STRICT + /* In strict mode primitive must be followed by a comma/object/array */ + parser->pos = start; + return JSMN_ERROR_PART; +#endif + +found: + if (tokens == NULL) { + parser->pos--; + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + parser->pos--; + return 0; +} + +/** + * Fills next token with JSON string. + */ +static int jsmn_parse_string(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + + int start = parser->pos; + + /* Skip starting quote */ + parser->pos++; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c = js[parser->pos]; + + /* Quote: end of string */ + if (c == '\"') { + if (tokens == NULL) { + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + return 0; + } + + /* Backslash: Quoted symbol expected */ + if (c == '\\' && parser->pos + 1 < len) { + int i; + parser->pos++; + switch (js[parser->pos]) { + /* Allowed escaped symbols */ + case '\"': + case '/': + case '\\': + case 'b': + case 'f': + case 'r': + case 'n': + case 't': + break; + /* Allows escaped symbol \uXXXX */ + case 'u': + parser->pos++; + for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0'; + i++) { + /* If it isn't a hex character we have an error */ + if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */ + (js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */ + (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */ + parser->pos = start; + return JSMN_ERROR_INVAL; + } + parser->pos++; + } + parser->pos--; + break; + /* Unexpected symbol */ + default: + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } + } + parser->pos = start; + return JSMN_ERROR_PART; +} + +/** + * Parse JSON string and fill tokens. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens) { + int r; + int i; + jsmntok_t *token; + int count = parser->toknext; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c; + jsmntype_t type; + + c = js[parser->pos]; + switch (c) { + case '{': + case '[': + count++; + if (tokens == NULL) { + break; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + return JSMN_ERROR_NOMEM; + } + if (parser->toksuper != -1) { + jsmntok_t *t = &tokens[parser->toksuper]; +#ifdef JSMN_STRICT + /* In strict mode an object or array can't become a key */ + if (t->type == JSMN_OBJECT) { + return JSMN_ERROR_INVAL; + } +#endif + t->size++; +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + } + token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY); + token->start = parser->pos; + parser->toksuper = parser->toknext - 1; + break; + case '}': + case ']': + if (tokens == NULL) { + break; + } + type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY); +#ifdef JSMN_PARENT_LINKS + if (parser->toknext < 1) { + return JSMN_ERROR_INVAL; + } + token = &tokens[parser->toknext - 1]; + for (;;) { + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + token->end = parser->pos + 1; + parser->toksuper = token->parent; + break; + } + if (token->parent == -1) { + if (token->type != type || parser->toksuper == -1) { + return JSMN_ERROR_INVAL; + } + break; + } + token = &tokens[token->parent]; + } +#else + for (i = parser->toknext - 1; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + parser->toksuper = -1; + token->end = parser->pos + 1; + break; + } + } + /* Error if unmatched closing bracket */ + if (i == -1) { + return JSMN_ERROR_INVAL; + } + for (; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + parser->toksuper = i; + break; + } + } +#endif + break; + case '\"': + r = jsmn_parse_string(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + case '\t': + case '\r': + case '\n': + case ' ': + break; + case ':': + parser->toksuper = parser->toknext - 1; + break; + case ',': + if (tokens != NULL && parser->toksuper != -1 && + tokens[parser->toksuper].type != JSMN_ARRAY && + tokens[parser->toksuper].type != JSMN_OBJECT) { +#ifdef JSMN_PARENT_LINKS + parser->toksuper = tokens[parser->toksuper].parent; +#else + for (i = parser->toknext - 1; i >= 0; i--) { + if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) { + if (tokens[i].start != -1 && tokens[i].end == -1) { + parser->toksuper = i; + break; + } + } + } +#endif + } + break; +#ifdef JSMN_STRICT + /* In strict mode primitives are: numbers and booleans */ + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case 't': + case 'f': + case 'n': + /* And they must not be keys of the object */ + if (tokens != NULL && parser->toksuper != -1) { + const jsmntok_t *t = &tokens[parser->toksuper]; + if (t->type == JSMN_OBJECT || + (t->type == JSMN_STRING && t->size != 0)) { + return JSMN_ERROR_INVAL; + } + } +#else + /* In non-strict mode every unquoted value is a primitive */ + default: +#endif + r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + +#ifdef JSMN_STRICT + /* Unexpected char in strict mode */ + default: + return JSMN_ERROR_INVAL; +#endif + } + } + + if (tokens != NULL) { + for (i = parser->toknext - 1; i >= 0; i--) { + /* Unmatched opened object or array */ + if (tokens[i].start != -1 && tokens[i].end == -1) { + return JSMN_ERROR_PART; + } + } + } + + return count; +} + +/** + * Creates a new parser based over a given buffer with an array of tokens + * available. + */ +JSMN_API void jsmn_init(jsmn_parser *parser) { + parser->pos = 0; + parser->toknext = 0; + parser->toksuper = -1; +} + +#endif /* JSMN_HEADER */ + +#ifdef __cplusplus +} +#endif + +#endif /* JSMN_H */ diff --git a/port/web/licenses.py b/port/web/licenses.py index 98c01d33..2200491f 100644 --- a/port/web/licenses.py +++ b/port/web/licenses.py @@ -21,6 +21,7 @@ NOTICES = [ ("tomlc17 (config.toml, mod.toml, load_order.toml)", "port/third_party/tomlc17/LICENSE"), ("musl (the maths functions)", "port/third_party/musl-math/COPYRIGHT"), ("stb_vorbis (Custom Edition maps' and mods' sounds)", "port/third_party/stb_vorbis/LICENSE"), + ("jsmn (the server browser's lists of servers)", "port/third_party/jsmn/LICENSE"), ("Overpass (the menus' text and the page's)", "port/assets/fonts/Overpass-OFL.txt"), ("OpenCE (the title font, from Newtown)", "port/assets/fonts/OpenCE-OFL.txt"), ("Newtown", "port/assets/fonts/Newtown-LICENSE.txt"), diff --git a/port/web/site/app.js b/port/web/site/app.js index 0bac7e61..c0abd105 100644 --- a/port/web/site/app.js +++ b/port/web/site/app.js @@ -22,7 +22,7 @@ then fills the page. the next time), ?play starts the game at once when it can. - ?join= (a halo://join/ link's code, which native builds and the game here make: port/linux/src/p2p.c) joins that internet game once the - game starts. + game starts; the Servers panel (servers.js) sets it with Play here. */ 'use strict'; @@ -655,6 +655,21 @@ $('invite-copy').addEventListener('click', async () => { }); $('invite-close').addEventListener('click', () => { $('invite').hidden = true; }); +// Play here on the Servers panel (servers.js): the server's invite as ?join=, +// and the game starts at once when it can +function joinServer(code) { + if (!/^[0-9a-f]{64}$/.test(code) || gameStarted) return; + params.set('join', code); + history.replaceState(null, '', `?${params}`); + if (canPlay()) { + play(); + return; + } + location.hash = '#play-panel'; + updatePlay(); + $('play-note').textContent += ' Play then joins the server you chose.'; +} + function loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script'); @@ -838,6 +853,11 @@ document.addEventListener('visibilitychange', async () => { updatePlay(); } checkForUpdate(); + window.HaloServers.start({ + networkVersion: () => stamp.network_version, + visible: () => !gameStarted, + onPlay: joinServer, + }); if (/^[0-9a-f]{32}$/.test(params.get('room') || '')) await joinRoom(params.get('room')); if (params.has('play')) play(); })(); diff --git a/port/web/site/index.html b/port/web/site/index.html index 2a0761ff..b15780d4 100644 --- a/port/web/site/index.html +++ b/port/web/site/index.html @@ -34,6 +34,7 @@ Game data Mods Play together + Servers This browser Settings and data About @@ -111,6 +112,20 @@ +
+

Servers

+

Looking…

+
+
    +
    +

    The official master servers list dedicated servers and the games players host for the + internet. Each list is signed, and this page checks it before it shows a game. The listed servers are not + contacted: Play here starts the game and joins the one you choose.

    +
    + +
    +
    +

    This browser

    @@ -213,6 +228,7 @@ + diff --git a/port/web/site/masters.json b/port/web/site/masters.json new file mode 100644 index 00000000..abb983d5 --- /dev/null +++ b/port/web/site/masters.json @@ -0,0 +1 @@ +{"masters":[]} diff --git a/port/web/site/servers.js b/port/web/site/servers.js new file mode 100644 index 00000000..a3bf0e18 --- /dev/null +++ b/port/web/site/servers.js @@ -0,0 +1,400 @@ +/* +SERVERS.JS + +The launcher's Servers panel: the games the official master servers list +(masters.json names them, each with its Ed25519 key; the official ones, +built in, when it names none), fetched every 30 +seconds while the page shows, and joined with Play here (the page's ?join=). + +- A master's list (GET /v1/servers) is signed over the exact bytes of its + body (the header Signature: ed25519=, Signature-Key its key's id; + docs/master_protocol.md). The page checks it with WebCrypto's Ed25519 and + refuses a list whose signature fails, without its Signature-Key or with + another key's, that is not protocol 1 of that key, or whose signed body's + time (master.time) is more than 5 minutes old (a list replayed later) or + ahead of this machine's clock. A browser without Ed25519 refuses every + list, and says why: nothing unchecked is shown. (A master answers with + Access-Control-Expose-Headers naming the signature's headers, without + which the page cannot read them.) +- Each entry carries the host's own signed listing (port/linux/src/ + p2p_lobby.c), whose signature the page checks too, and whose name, map, + gametype, players and network version are what a row shows (the entry's + own fields are the master's word); an open game's listing holds the token + of its invite, and the invite is the hash of the host's key with it + (p2p.c). A game with a password keeps its token sealed: the game's own + server browser joins those. +- Entries of several masters with one id are one game: the newest updated + wins. Every field is checked for its type, names lose their control + characters, and 512 entries at most are kept. Listed servers are never + contacted: the region stands in for a ping. +*/ + +'use strict'; + +(function (root) { + const REFRESH = 30000; + const MAXIMUM_AGE = 300; + const MAXIMUM_ENTRIES = 512; + const MAXIMUM_MASTERS = 4; + // the official master servers, asked when masters.json names none (as + // master_list.c's master_list_read_defaults) + const DEFAULT_MASTERS = [ + { url: 'https://master.opencommunityedition.com', key: 'ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa' }, + ]; + const LISTING_LABEL = 'hceu-lobby-1'; + // a listing (p2p_lobby.c listing_make): HL, format 1, network version, flags, + // sequence, time, the host's key, its token (sealed: 56 bytes), counts, + // three texts, a stamp of 8, the signature + const LISTING_IN_PROGRESS = 2; + const LISTING_TEAMS = 4; + const LISTING_PASSWORD = 16; + const LISTING_CLOSED = 8; + const LISTING_DEDICATED = 32; + const LISTING_SIZES = [140, 268]; + const LISTING_TEXTS = [32, 32, 24]; + + const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + + function hexBytes(text) { + return new Uint8Array(text.match(/../g).map((pair) => parseInt(pair, 16))); + } + + function base64Bytes(text) { + const binary = atob(text); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } + + // masters.json: {"masters":[{"url":"https://…","key":"ed25519:<64 hex>"}]}; + // a URL as master_list.c takes one: https, or plain http to this machine + // alone, with a path the master is served under or none, its trailing + // slashes dropped + function parseMasters(json) { + const masters = json && Array.isArray(json.masters) ? json.masters : []; + return checkMasters(masters); + } + + // masters.json's masters, else (it names none, or cannot be read) the + // official ones + function mastersOrDefault(json) { + const masters = parseMasters(json); + return masters.length ? masters : checkMasters(DEFAULT_MASTERS); + } + + function checkMasters(masters) { + return masters.filter((master) => master && typeof master.url === 'string' && typeof master.key === 'string' && + master.url.length < 256 && + /^(https:\/\/[^\s/?#]+|http:\/\/(127\.0\.0\.1|localhost)(:\d+)?)(\/[^\s?#]*)?$/.test(master.url) && + /^ed25519:[0-9a-f]{64}$/.test(master.key)) + .slice(0, MAXIMUM_MASTERS) + .map((master) => ({ url: master.url.replace(/\/+$/, ''), key: hexBytes(master.key.slice(8)) })); + } + + // an Ed25519 key for verify, or null where the browser has no Ed25519 + async function importKey(subtle, raw) { + try { + return await subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']); + } catch { + return null; + } + } + + const NO_ED25519 = 'this browser cannot check Ed25519 signatures'; + const cleanText = (value, maximum) => (typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim().slice(0, maximum) : ''); + const isCount = (value, maximum) => Number.isInteger(value) && value >= 0 && value <= maximum; + + // a list's body checked against its master's key: {ok, verified, reason, + // json}; ok false refuses the whole list (Signature-Time, not signed, is + // not read: the signed body's time is) + async function verifyList(subtle, master, bytes, headers, now) { + const refuse = (reason) => ({ ok: false, verified: false, reason }); + const signature = /^ed25519=([A-Za-z0-9+/]+={0,2})$/.exec((headers.get('Signature') || '').trim()); + if (!signature) return refuse('no signature'); + const keyId = hex(new Uint8Array(await subtle.digest('SHA-256', master.key))).slice(0, 16); + const named = (headers.get('Signature-Key') || '').trim(); + if (!named) return refuse('no signature key'); + if (named !== keyId) return refuse('signed with another key'); + const key = await importKey(subtle, master.key); + if (!key) return refuse(NO_ED25519); + let good = false; + try { + good = await subtle.verify({ name: 'Ed25519' }, key, base64Bytes(signature[1]), bytes); + } catch { + good = false; + } + if (!good) return refuse('a bad signature'); + let json; + try { + json = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return refuse('not JSON'); + } + // (docs/master_protocol.md, the list: its protocol, its key, and the + // time in the signed body, which the unsigned header only repeats) + if (!json || json.protocol !== 1 || !json.master || !Array.isArray(json.servers)) return refuse('not a list'); + if (json.master.key_id !== keyId) return refuse('signed with another key'); + if (!Number.isInteger(json.master.time) || now - json.master.time > MAXIMUM_AGE) return refuse('an old list'); + if (json.master.time - now > MAXIMUM_AGE) return refuse('a list dated ahead of this machine\'s clock'); + return { ok: true, verified: true, json }; + } + + // an entry of a list with every field of its type, else null + function readEntry(raw) { + if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || !/^[0-9A-Za-z_-]{1,64}$/.test(raw.id)) return null; + if (!isCount(raw.players, 255) || !isCount(raw.max_players, 255) || !Number.isInteger(raw.updated)) return null; + if (typeof raw.listing !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(raw.listing)) return null; + const publisher = raw.publisher && typeof raw.publisher === 'object' ? raw.publisher : {}; + return { + id: raw.id, name: cleanText(raw.name, 32), map: cleanText(raw.map, 32), gametype: cleanText(raw.gametype, 24), + variant: cleanText(raw.variant, 24), players: raw.players, max_players: raw.max_players, teams: raw.teams === true, + in_progress: raw.in_progress === true, password: raw.password === true, dedicated: raw.dedicated === true, + network_version: Number.isInteger(raw.network_version) ? raw.network_version : 0, build: cleanText(raw.build, 32), + region: cleanText(raw.region, 16), reachable: raw.reachable !== false, updated: raw.updated, + publisher: cleanText(publisher.name, 48), listing: raw.listing, + }; + } + + // a listing's host's signing key (its bytes 14 to 46), as hex: what tells + // one game from another across masters, as the game's own browser does; + // null for a listing too short to hold one + function hostKeyOf(base64) { + try { + const text = atob(base64); + if (text.length < 46) return null; + let key = ''; + for (let index = 14; index < 46; index++) key += text.charCodeAt(index).toString(16).padStart(2, '0'); + return key; + } catch { + return null; + } + } + + // every master's entries as one list: one game a host key (a master's id + // for it is that master's, and another's may shadow it), its newest; + // dedicated servers first, then the fullest + function mergeServers(lists) { + const games = new Map(); + for (const entries of lists) { + for (const entry of entries) { + const game = hostKeyOf(entry.listing) || 'id:' + entry.id; + const known = games.get(game); + if (!known || entry.updated > known.updated) games.set(game, entry); + } + } + return [...games.values()].sort((a, b) => (b.dedicated - a.dedicated) || (b.players - a.players) || + a.name.localeCompare(b.name)).slice(0, MAXIMUM_ENTRIES); + } + + // ---------- the host's listing, and the invite in it + + const P = (1n << 255n) - 19n; + // (the u of Curve25519's points of small order) + const SMALL_ORDER = [0n, 1n, P - 1n, 325606250916557431795983626356110631294008115727848805560023387167927233504n, + 39382357235489614581723060781553021112529911719440698176882885853963445705823n]; + + function power(base, exponent) { + let result = 1n; + base %= P; + while (exponent > 0n) { + if (exponent & 1n) result = (result * base) % P; + base = (base * base) % P; + exponent >>= 1n; + } + return result; + } + + // an Ed25519 public key's X25519 one (u = (1 + y) / (1 - y)), as + // Monocypher's crypto_eddsa_to_x25519; null for one of small order + function edwardsToX25519(key) { + let y = 0n; + for (let index = 31; index >= 0; index--) y = (y << 8n) | BigInt(index === 31 ? key[index] & 0x7f : key[index]); + const denominator = ((1n - y) % P + P) % P; + if (y >= P || denominator === 0n) return null; + const u = ((1n + y) * power(denominator, P - 2n)) % P; + if (SMALL_ORDER.includes(u)) return null; + const bytes = new Uint8Array(32); + for (let index = 0, value = u; index < 32; index++, value >>= 8n) bytes[index] = Number(value & 0xffn); + return bytes; + } + + // {invite (64 hex, or '' with a password), locked, verified, dedicated, + // name, map, gametype, players, max_players, teams, in_progress, + // network_version} of a listing's base64, or null when it is not one, its + // signature fails, or the browser cannot check it + async function decodeListing(subtle, base64) { + let bytes; + try { + bytes = base64Bytes(base64); + } catch { + return null; + } + if (bytes.length < LISTING_SIZES[0] || bytes.length > LISTING_SIZES[1] || bytes[0] !== 0x48 || bytes[1] !== 0x4c || + bytes[2] !== 1) { + return null; + } + const flags = bytes[5]; + if (flags & LISTING_CLOSED) return null; + const key = bytes.slice(14, 46); + const locked = (flags & LISTING_PASSWORD) !== 0; + let offset = 46 + (locked ? 56 : 16); + const token = bytes.slice(46, 62); + const counts = bytes.slice(offset, offset + 3); + offset += 3; + const texts = []; + for (const maximum of LISTING_TEXTS) { + if (offset >= bytes.length) return null; + const length = bytes[offset++]; + if (length > maximum || offset + length > bytes.length) return null; + // (as the game's sanitize: a zero ends it, a byte past printable ASCII is ?) + const text = Array.from(bytes.slice(offset, offset + length), (byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : byte ? '?' : '\0')); + texts.push(text.join('').split('\0')[0]); + offset += length; + } + offset += 8; + if (offset + 64 !== bytes.length) return null; + const hostKey = await importKey(subtle, key); + if (!hostKey) return null; + const label = new TextEncoder().encode(LISTING_LABEL); + const signed = new Uint8Array(label.length + offset); + signed.set(label); + signed.set(bytes.slice(0, offset), label.length); + let good = false; + try { + good = await subtle.verify({ name: 'Ed25519' }, hostKey, bytes.slice(offset), signed); + } catch { + good = false; + } + if (!good) return null; + const x25519 = edwardsToX25519(key); + if (!x25519) return null; + const hash = new Uint8Array(await subtle.digest('SHA-256', x25519)).slice(0, 16); + return { invite: locked ? '' : hex(hash) + hex(token), locked, verified: true, + dedicated: (flags & LISTING_DEDICATED) !== 0, name: texts[0], map: texts[1], gametype: texts[2], + players: counts[0], max_players: counts[1], teams: (flags & LISTING_TEAMS) !== 0, + in_progress: (flags & LISTING_IN_PROGRESS) !== 0, network_version: (bytes[3] << 8) | bytes[4] }; + } + + // ---------- fetching + + async function fetchMaster(master, options) { + const { fetch: get, subtle, now, networkVersion } = options; + const query = Number.isInteger(networkVersion) ? `?network_version=${networkVersion}` : ''; + let response; + try { + response = await get(`${master.url}/v1/servers${query}`, { mode: 'cors', credentials: 'omit', cache: 'no-store' }); + } catch { + return { master, entries: [], error: 'cannot be reached' }; + } + if (!response.ok) return { master, entries: [], error: `HTTP ${response.status}` }; + const bytes = new Uint8Array(await response.arrayBuffer()); + const checked = await verifyList(subtle, master, bytes, response.headers, now()); + if (!checked.ok) return { master, entries: [], error: checked.reason }; + const entries = []; + for (const raw of checked.json.servers.slice(0, MAXIMUM_ENTRIES)) { + const entry = readEntry(raw); + if (!entry) continue; + const listing = await decodeListing(subtle, entry.listing); + if (!listing) continue; + // (what the host signed, over the master's word for it) + entries.push({ ...entry, name: listing.name, map: listing.map, gametype: listing.gametype, players: listing.players, + max_players: listing.max_players, teams: listing.teams, in_progress: listing.in_progress, + network_version: listing.network_version, invite: listing.invite, locked: listing.locked || entry.password, + listingVerified: listing.verified, dedicated: entry.dedicated || listing.dedicated }); + } + return { master, entries, verified: checked.verified }; + } + + // what a row shows, and whether Play here can join it + function serverRow(entry, networkVersion) { + const players = `${entry.players}/${entry.max_players} players`; + const game = [entry.map, entry.variant || entry.gametype].filter(Boolean).join(', '); + const detail = [game, players, entry.region, entry.dedicated ? 'dedicated' : 'player-hosted', + entry.publisher && `by ${entry.publisher}`].filter(Boolean).join(' · '); + let reason = ''; + if (Number.isInteger(networkVersion) && entry.network_version !== networkVersion) { + reason = `Needs network version ${entry.network_version} (this build is ${networkVersion})`; + } else if (entry.locked || !entry.invite) { + reason = 'A password: join it from the game\'s server browser'; + } else if (entry.players >= entry.max_players) { + reason = 'Full'; + } + return { id: entry.id, name: entry.name || 'A game', detail, invite: entry.invite, playable: !reason, reason, + dedicated: entry.dedicated }; + } + + // ---------- the panel + + function start(options) { + const doc = root.document; + const $ = (id) => doc.getElementById(id); + let masters = []; + let timer = null; + let busy = false; + + function render(results) { + const lists = results.map((result) => result.entries); + const rows = mergeServers(lists).map((entry) => serverRow(entry, options.networkVersion())); + $('servers-list').replaceChildren(...rows.map((row) => { + const item = doc.createElement('li'); + const label = doc.createElement('span'); + const name = doc.createElement('span'); + const detail = doc.createElement('span'); + const play = doc.createElement('button'); + item.className = row.dedicated ? 'dedicated' : ''; + name.className = 'name'; + name.textContent = row.name; + detail.className = 'detail'; + detail.textContent = row.detail; + label.append(name, detail); + play.textContent = 'Play here'; + play.disabled = !row.playable; + play.title = row.reason || 'Start the game and join this server'; + play.addEventListener('click', () => options.onPlay(row.invite)); + item.append(label, play); + return item; + })); + const failed = results.filter((result) => result.error && result.error !== NO_ED25519) + .map((result) => `${new URL(result.master.url).host}: ${result.error}`); + const unchecked = results.some((result) => result.error === NO_ED25519); + $('servers-status').textContent = (rows.length ? `${rows.length} game${rows.length === 1 ? '' : 's'} listed.` : + 'No games are listed now.') + (failed.length ? ` Not heard from ${failed.join('; ')}.` : '') + + (unchecked ? ' This browser cannot check the lists\' Ed25519 signatures, so it shows none: the game\'s own ' + + 'Join Game lists them.' : ''); + } + + async function refresh() { + if (busy || !masters.length || doc.visibilityState !== 'visible' || !options.visible()) return; + busy = true; + try { + const now = () => Math.floor(Date.now() / 1000); + render(await Promise.all(masters.map((master) => fetchMaster(master, { + fetch: root.fetch.bind(root), subtle: root.crypto.subtle, now, networkVersion: options.networkVersion() })))); + } finally { + busy = false; + } + } + + (async () => { + try { + masters = mastersOrDefault(await (await root.fetch('masters.json', { cache: 'no-cache' })).json()); + } catch { + masters = mastersOrDefault(null); + } + if (!masters.length) { + $('servers-status').textContent = 'No master servers are listed yet: games hosted for the internet are found ' + + 'in the game\'s own Join Game, and invites still join them.'; + $('servers-refresh').hidden = true; + return; + } + $('servers-refresh').addEventListener('click', refresh); + doc.addEventListener('visibilitychange', refresh); + timer = setInterval(refresh, REFRESH); + refresh(); + })(); + return { refresh, stop: () => clearInterval(timer) }; + } + + const api = { parseMasters, mastersOrDefault, verifyList, readEntry, mergeServers, edwardsToX25519, decodeListing, fetchMaster, serverRow, + start, MAXIMUM_AGE }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.HaloServers = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/port/web/site/style.css b/port/web/site/style.css index 41c3d0f9..89b21dd4 100644 --- a/port/web/site/style.css +++ b/port/web/site/style.css @@ -265,6 +265,27 @@ code { font-size: 0.95em; } .mods .detail { color: var(--soft); font-size: 14px; text-transform: none; letter-spacing: 0; } .mods button { min-height: 36px; padding: 0 16px; font-size: 13px; } +/* the servers (servers.js: a name, its game, and Play here): rows, a + dedicated server's with the blue edge */ +.servers { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.servers:empty { display: none; } +.servers li { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + min-height: 44px; + padding: 6px 8px 6px 18px; + border: 2px solid transparent; + border-radius: 12px; + background: var(--row); +} +.servers li.dedicated { border-color: var(--edge-dim); } +.servers .name { display: block; color: var(--label); text-transform: uppercase; letter-spacing: 0.03em; overflow-wrap: anywhere; } +.servers .detail { display: block; color: var(--soft); font-size: 14px; } +.servers button { min-height: 36px; padding: 0 16px; font-size: 13px; } + /* the copy of the maps: a meter in a row */ .progress { flex-direction: column; align-items: stretch; } .progress-bar { height: 10px; border-radius: 5px; background: var(--navy-deep); box-shadow: inset 0 0 0 1px var(--edge-dim); overflow: hidden; } diff --git a/port/web/src/web_library.js b/port/web/src/web_library.js index 2aa6f994..6dfd7e7a 100644 --- a/port/web/src/web_library.js +++ b/port/web/src/web_library.js @@ -106,6 +106,49 @@ addToLibrary({ entry.socket.close(); }, + // the server browser's master servers (port/linux/src/master_list.c): a + // GET of the url (the C side's copy, freed here once read), its answer + // written into the C side's slot (struct web_fetch_slot: id, done, status, + // body, size, Signature, Signature-Key), which its thread waits on. The + // slot is claimed for the answer first, its id changed to -id + // (compareExchange): an answer that comes after the thread gave up on it + // (its id 0, or another request's) is dropped, and the thread, which gives + // up the same way, never takes an answer meant for an earlier request. 1 MB + // at most, 10 seconds at most. (async: the game's thread does not wait for + // the page) + web_js_fetch__proxy: 'async', + web_js_fetch__deps: ['malloc', 'free'], + web_js_fetch: (url, slot, id) => { + var address = UTF8ToString(url); + _free(url); + var finish = (status, bytes, signature, key) => { + if (Atomics.compareExchange(HEAP32, slot >> 2, id, -id) !== id) return; + var body = 0; + if (bytes && bytes.length <= 1048576) { + body = _malloc(bytes.length + 1); + if (body) { + HEAPU8.set(bytes, body); + HEAPU8[body + bytes.length] = 0; + } + } + HEAP32[(slot >> 2) + 2] = body ? status : 0; + HEAP32[(slot >> 2) + 3] = body; + HEAP32[(slot >> 2) + 4] = body ? bytes.length : 0; + stringToUTF8(signature || '', slot + 20, 128); + stringToUTF8(key || '', slot + 148, 32); + Atomics.store(HEAP32, (slot >> 2) + 1, 1); + }; + var controller = new AbortController(); + var timer = setTimeout(() => controller.abort(), 10000); + fetch(address, { signal: controller.signal, cache: 'no-store' }) + .then((response) => response.arrayBuffer().then((buffer) => { + clearTimeout(timer); + finish(response.status, new Uint8Array(buffer), response.headers.get('Signature'), + response.headers.get('Signature-Key')); + })) + .catch(() => finish(0, null, '', '')); + }, + // internet play's WebRTC connections (port/web/src/web_p2p.c), which the // page makes (port/web/site/p2p.js, Module.haloP2P): a command, the // connection, and a copy of its text or packet, freed here diff --git a/port/web/tests/internet.mjs b/port/web/tests/internet.mjs index 319d9b9e..0d5d0193 100644 --- a/port/web/tests/internet.mjs +++ b/port/web/tests/internet.mjs @@ -13,7 +13,11 @@ // - the native build hosting a public game, which a browser finds in the // server browser and joins (debug.network_test "browse"); // - a browser hosting a public game, which the native build joins the same -// way. +// way; +// - the native build as a dedicated server (halo --server, listed through +// the brokers: server.list_on_brokers), which a browser finds in the +// server browser and joins. The server runs from a copy of in the +// test's folder, its settings, saves and logs there too. // // Each must reach the other over WebRTC (the native build's ICE-lite end and // DTLS server, port/linux/src/p2p_webrtc.c) and play: both log the two @@ -21,7 +25,7 @@ // build's system link would find it. import { spawn } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -98,6 +102,9 @@ function native(name, test, extra = {}) { HALO_HIDDEN_WINDOW: '1', SDL_VIDEO_DRIVER: 'x11', HALO_NET_BROKERS_FILE: path.join(work, 'brokers.txt'), HALO_NET_JOIN_FROM_CLIPBOARD: '0', HALO_NET_ALLOW_UPNP: 'false', HALO_DISCORD_APPLICATION: '', HALO_EXIT_AFTER: '600', ...extra }; + // (its saves and home the test's, not the person's running it) + Object.assign(env, { HALO_SAVE_ROOT: path.join(work, name + '-saves'), HOME: path.join(work, name + '-home'), + XDG_DATA_HOME: path.join(work, name + '-xdg') }); delete env.WAYLAND_DISPLAY; const child = spawn('xvfb-run', ['-a', '-s', '-screen 0 640x480x24', halo], { cwd: path.dirname(halo), env, detached: true }); child.stdout.on('data', (data) => log.add(data)); @@ -109,6 +116,37 @@ function native(name, test, extra = {}) { // (ended already) } }; + running.push(log); + return log; +} + +// the native build as a dedicated server, from a copy of it in the test's +// folder (its config.toml beside it): its standard output's lines +function dedicated(name) { + const log = side(name); + const folder = path.join(work, name); + mkdirSync(folder, { recursive: true }); + copyFileSync(halo, path.join(folder, 'halo')); + const sdl = path.join(path.dirname(halo), 'libSDL3.so.0'); + if (existsSync(sdl)) copyFileSync(sdl, path.join(folder, 'libSDL3.so.0')); + // (none of the environment's HALO_* settings: the server's are the test's) + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('HALO_'))); + const env = { ...inherited, HALO_DATA_ROOT: path.dirname(maps), HALO_LOG_ROOT: folder, + HALO_SAVE_ROOT: path.join(folder, 'saves'), HOME: folder, XDG_DATA_HOME: path.join(folder, 'xdg'), + HALO_NET_BROKERS_FILE: path.join(work, 'brokers.txt'), HALO_NET_ONLINE: 'true', HALO_DISCORD_APPLICATION: '', + HALO_SERVER_LIST_ON_BROKERS: 'true', HALO_SERVER_MASTER_PUBLISH: 'false', HALO_SERVER_WEB_PORT: '0', + HALO_SERVER_LOBBY_SECONDS: '3', HALO_SERVER_ROTATION: 'bloodgulch:slayer', HALO_SERVER_NAME: 'Web Test', + HALO_NET_TUNNEL_PORT: String(tcpPort + 2000), HALO_NET_ALLOW_UPNP: 'false' }; + const child = spawn(path.join(folder, 'halo'), ['--server'], { cwd: folder, env }); + child.stdout.on('data', (data) => log.add(data)); + child.stderr.on('data', (data) => log.add(data)); + log.stop = () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + }; + running.push(log); + log.ready = () => log.lines.some((line) => /server: ready/.test(line)); + log.joined = () => log.lines.some((line) => /player: .* joined/.test(line)) && + log.lines.some((line) => /game: started/.test(line)); return log; } @@ -118,6 +156,8 @@ async function until(test, seconds) { } const opened = []; +// (the native builds started, each stopped at the end however the test ends) +const running = []; try { await sleep(1500); const first = await browser('first'); @@ -162,10 +202,28 @@ try { } joiner.stop(); } + + console.log('the native build as a dedicated server, a browser joining from the server browser'); + await first.page.goto('about:blank'); + const dedicatedServer = dedicated('server'); + if (!await until(() => dedicatedServer.ready(), 120)) fail('the dedicated server was not ready'); + else { + second.log.lines.length = 0; + await play(second, 'set=HALO_NETWORK_TEST=browse'); + // (the server has no player: the browser's player alone, in a game the server started) + if (!await until(() => dedicatedServer.joined() && + second.log.lines.some((line) => /network test: tick .*player 0: /.test(line)), 120 + PLAY_SECONDS)) { + fail('the browser did not play on the dedicated server'); + } + if (!dedicatedServer.lines.some((line) => /WebRTC connected/.test(line))) { + fail('the dedicated server had no WebRTC connection'); + } + } if (!process.exitCode) console.log('internet play: ok'); } catch (error) { fail(String(error && error.stack ? error.stack : error)); } finally { + for (const side of running) side.stop(); for (const game of opened) await game.context.close().catch(() => {}); server.kill(); broker.kill(); diff --git a/port/web/tests/servers.test.js b/port/web/tests/servers.test.js new file mode 100644 index 00000000..446ddac1 --- /dev/null +++ b/port/web/tests/servers.test.js @@ -0,0 +1,294 @@ +// The launcher's Servers panel (port/web/site/servers.js) on lists signed +// here with Ed25519 keys made for the test: what it verifies and refuses, +// how it merges masters' entries, and the invite it reads from a host's +// listing. node --test "port/web/tests/*.test.js" + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const servers = require('../site/servers.js'); + +const subtle = globalThis.crypto.subtle; +const NOW = 1760900000; + +function keyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + const jwk = privateKey.export({ format: 'jwk' }); + return { privateKey, raw: Buffer.from(jwk.x, 'base64url'), seed: Buffer.from(jwk.d, 'base64url'), publicKey }; +} + +const master = keyPair(); +const masterEntry = { url: 'https://master1.example.org', key: master.raw }; +const keyId = crypto.createHash('sha256').update(master.raw).digest('hex').slice(0, 16); + +// a host's listing as p2p_lobby.c's listing_make writes it, signed +function listing(host, { flags = 1, token = crypto.randomBytes(16), name = 'Fast CTF', map = 'bloodgulch', gametype = 'ctf', + players = 3, maximum = 16, version = 38 } = {}) { + const text = (value) => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]); + const head = Buffer.alloc(14); + head.write('HL', 0, 'latin1'); + head[2] = 1; + head.writeUInt16BE(version, 3); + head[5] = flags; + head.writeUInt32BE(7, 6); + head.writeUInt32BE(NOW, 10); + const secret = flags & 16 ? crypto.randomBytes(56) : token; + const signed = Buffer.concat([head, host.raw, secret, Buffer.from([players, maximum, 1]), text(name), text(map), text(gametype), + Buffer.alloc(8)]); + const signature = crypto.sign(null, Buffer.concat([Buffer.from('hceu-lobby-1'), signed]), host.privateKey); + return { base64: Buffer.concat([signed, signature]).toString('base64'), token }; +} + +// the invite p2p.c makes: the hash of the host's X25519 key, and the token +function expectedInvite(host, token) { + const scalar = crypto.createHash('sha512').update(host.seed).digest().subarray(0, 32); + const der = Buffer.concat([Buffer.from('302e020100300506032b656e04220420', 'hex'), scalar]); + const x25519 = crypto.createPublicKey(crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })) + .export({ format: 'jwk' }).x; + return crypto.createHash('sha256').update(Buffer.from(x25519, 'base64url')).digest().subarray(0, 16).toString('hex') + + token.toString('hex'); +} + +function entry(id, fields = {}) { + const host = keyPair(); + const made = listing(host); + return { id, publisher: { id: 'p_1', name: "Milenko's servers", since: '2026-10' }, name: 'OpenCE Dedicated', + map: 'bloodgulch', gametype: 'ctf', variant: 'CTF', players: 5, max_players: 16, teams: true, in_progress: false, + password: false, dedicated: true, network_version: 38, build: 'build-206', region: 'eu', reachable: true, + updated: NOW - 10, listing: made.base64, ...fields }; +} + +// a master's answer: the body, signed +function signedList(list, { time = NOW, key = master, keyHeader = keyId, body } = {}) { + const bytes = Buffer.from(body || JSON.stringify({ protocol: 1, master: { name: 'Master 1', key_id: keyId, time }, servers: list })); + const headers = new Headers({ 'Signature': 'ed25519=' + crypto.sign(null, bytes, key.privateKey).toString('base64'), + 'Signature-Key': keyHeader, 'Signature-Time': String(time), 'Content-Type': 'application/json' }); + return { bytes: new Uint8Array(bytes), headers }; +} + +test("masters.json: https masters (with a path or none) with an Ed25519 key, four at most", () => { + const key = 'ed25519:' + 'ab'.repeat(32); + const parsed = servers.parseMasters({ masters: [ + { url: 'https://master1.example.org/', key }, { url: 'http://master2.example.org', key }, + { url: 'https://master3.example.org/opence//', key }, { url: 'https://master4.example.org', key: 'ed25519:abc' }, + { url: 'http://127.0.0.1.example.net', key }, { url: 'https://q.example/?a', key }, + { url: 'http://127.0.0.1:8090', key }, { url: 'https://a.example', key }, { url: 'https://b.example', key }, + ] }); + assert.deepStrictEqual(parsed.map((item) => item.url), ['https://master1.example.org', + 'https://master3.example.org/opence', 'http://127.0.0.1:8090', 'https://a.example']); + assert.deepStrictEqual(servers.parseMasters({ masters: [{ url: 'http://localhost/m/', key }] }).map((item) => item.url), + ['http://localhost/m']); + assert.strictEqual(parsed[0].key.length, 32); + assert.deepStrictEqual(servers.parseMasters({ masters: [] }), []); + assert.deepStrictEqual(servers.parseMasters(null), []); + assert.deepStrictEqual(require('../site/masters.json'), { masters: [] }); +}); + +test("the official master is asked when masters.json names none", () => { + const official = ['https://master.opencommunityedition.com']; + const key = 'ed25519:' + 'ab'.repeat(32); + assert.deepStrictEqual(servers.mastersOrDefault({ masters: [] }).map((item) => item.url), official); + assert.deepStrictEqual(servers.mastersOrDefault(null).map((item) => item.url), official); + assert.deepStrictEqual(servers.mastersOrDefault({ masters: [{ url: 'http://master2.example.org', key }] }) + .map((item) => item.url), official); + assert.deepStrictEqual(Buffer.from(servers.mastersOrDefault(null)[0].key).toString('hex'), + '89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa'); + assert.deepStrictEqual(servers.mastersOrDefault({ masters: [{ url: 'https://a.example', key }] }) + .map((item) => item.url), ['https://a.example']); +}); + +test('a signed list is read; a changed, stale or misnamed one is refused', async () => { + const good = signedList([entry('8c1e0001')]); + const checked = await servers.verifyList(subtle, masterEntry, good.bytes, good.headers, NOW + 30); + assert.ok(checked.ok && checked.verified); + assert.strictEqual(checked.json.servers.length, 1); + + const changed = signedList([entry('8c1e0001')]); + changed.bytes[changed.bytes.length - 3] ^= 1; + assert.strictEqual((await servers.verifyList(subtle, masterEntry, changed.bytes, changed.headers, NOW)).reason, 'a bad signature'); + + const other = signedList([entry('8c1e0001')], { key: keyPair() }); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, other.bytes, other.headers, NOW)).reason, 'a bad signature'); + + const stale = signedList([], { time: NOW - servers.MAXIMUM_AGE - 1 }); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, stale.bytes, stale.headers, NOW)).reason, 'an old list'); + // (an old body under a new Signature-Time: the header is not signed, the body's time is) + const replayed = signedList([], { time: NOW - 3600 }); + replayed.headers.set('Signature-Time', String(NOW)); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, replayed.bytes, replayed.headers, NOW)).reason, 'an old list'); + + const misnamed = signedList([], { keyHeader: '0000000000000000' }); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, misnamed.bytes, misnamed.headers, NOW)).reason, + 'signed with another key'); + const wrongProtocol = signedList([], { body: JSON.stringify({ protocol: 2, master: { key_id: keyId, time: NOW }, servers: [] }) }); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, wrongProtocol.bytes, wrongProtocol.headers, NOW)).reason, + 'not a list'); + const otherId = signedList([], { body: JSON.stringify({ protocol: 1, master: { key_id: '1'.repeat(16), time: NOW }, servers: [] }) }); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, otherId.bytes, otherId.headers, NOW)).reason, + 'signed with another key'); + const unsigned = signedList([]); + unsigned.headers.delete('Signature'); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, unsigned.bytes, unsigned.headers, NOW)).reason, 'no signature'); + // (a list without its Signature-Key is refused, as the game refuses it) + const unnamed = signedList([]); + unnamed.headers.delete('Signature-Key'); + assert.strictEqual((await servers.verifyList(subtle, masterEntry, unnamed.bytes, unnamed.headers, NOW)).reason, + 'no signature key'); + // (the unsigned Signature-Time is not needed, nor read) + const untimed = signedList([]); + untimed.headers.delete('Signature-Time'); + assert.ok((await servers.verifyList(subtle, masterEntry, untimed.bytes, untimed.headers, NOW)).ok); + untimed.headers.set('Signature-Time', 'nonsense'); + assert.ok((await servers.verifyList(subtle, masterEntry, untimed.bytes, untimed.headers, NOW)).ok); + // (dated ahead of this machine's clock: 300 seconds taken, 301 not) + const ahead = signedList([], { time: NOW + servers.MAXIMUM_AGE }); + assert.ok((await servers.verifyList(subtle, masterEntry, ahead.bytes, ahead.headers, NOW)).ok); + const further = signedList([], { time: NOW + servers.MAXIMUM_AGE + 1 }); + assert.match((await servers.verifyList(subtle, masterEntry, further.bytes, further.headers, NOW)).reason, /ahead/); +}); + +test('a browser without Ed25519 refuses the lists: nothing unchecked is shown', async () => { + const withoutEd25519 = { + importKey: async () => { throw new DOMException('Unrecognized name', 'NotSupportedError'); }, + verify: async () => { throw new Error('not called'); }, + digest: (name, data) => subtle.digest(name, data), + }; + const list = signedList([entry('8c1e0001')]); + const checked = await servers.verifyList(withoutEd25519, masterEntry, list.bytes, list.headers, NOW); + assert.deepStrictEqual([checked.ok, checked.verified], [false, false]); + assert.match(checked.reason, /cannot check Ed25519/); + const fetched = await servers.fetchMaster(masterEntry, { fetch: async () => new Response(list.bytes, { headers: list.headers }), + subtle: withoutEd25519, now: () => NOW, networkVersion: 38 }); + assert.deepStrictEqual(fetched.entries, []); + assert.match(fetched.error, /cannot check Ed25519/); + assert.strictEqual(await servers.decodeListing(withoutEd25519, listing(keyPair()).base64), null); +}); + +test("a host's listing gives the invite p2p.c makes; a password's keeps it sealed", async () => { + const host = keyPair(); + const open = listing(host); + const decoded = await servers.decodeListing(subtle, open.base64); + assert.strictEqual(decoded.invite, expectedInvite(host, open.token)); + assert.deepStrictEqual([decoded.locked, decoded.verified, decoded.dedicated, decoded.name, decoded.map], + [false, true, false, 'Fast CTF', 'bloodgulch']); + assert.deepStrictEqual([decoded.players, decoded.max_players, decoded.network_version, decoded.teams, decoded.in_progress], + [3, 16, 38, false, false]); + const counted = await servers.decodeListing(subtle, listing(host, { flags: 1 | 2 | 4, players: 7, maximum: 12, version: 39 }).base64); + assert.deepStrictEqual([counted.players, counted.max_players, counted.network_version, counted.teams, counted.in_progress], + [7, 12, 39, true, true]); + assert.strictEqual((await servers.decodeListing(subtle, listing(host, { flags: 1 | 32 }).base64)).dedicated, true); + // (names as the game's sanitize reads them) + assert.strictEqual((await servers.decodeListing(subtle, listing(host, { name: 'Caf\u00e9\u0000hidden' }).base64)).name, 'Caf??'); + assert.strictEqual(await servers.decodeListing(subtle, listing(host, { name: 'x'.repeat(33) }).base64), null); + const locked = await servers.decodeListing(subtle, listing(host, { flags: 1 | 16 }).base64); + assert.deepStrictEqual([locked.invite, locked.locked], ['', true]); + // (the longest listing: a password's, every name at its most, 268 bytes) + const longest = listing(host, { flags: 1 | 16, name: 'n'.repeat(32), map: 'm'.repeat(32), gametype: 'g'.repeat(24) }); + assert.strictEqual(Buffer.from(longest.base64, 'base64').length, 268); + assert.strictEqual((await servers.decodeListing(subtle, longest.base64)).name, 'n'.repeat(32)); + const forged = Buffer.from(open.base64, 'base64'); + forged[20] ^= 1; + assert.strictEqual(await servers.decodeListing(subtle, forged.toString('base64')), null); + assert.strictEqual(await servers.decodeListing(subtle, listing(host, { flags: 8 }).base64), null); + assert.strictEqual(await servers.decodeListing(subtle, Buffer.from('HL').toString('base64')), null); + assert.strictEqual(await servers.decodeListing(subtle, '%%%'), null); + // (a key of small order has no X25519 key a host could hold) + assert.strictEqual(servers.edwardsToX25519(new Uint8Array(32).fill(0).map((_, index) => (index === 0 ? 1 : 0))), null); +}); + +test("several masters' entries: one game a host key, the newest; dedicated first, then the fullest", () => { + const read = (fields) => servers.readEntry(entry(fields.id, fields)); + const same = listing(keyPair()).base64; + const merged = servers.mergeServers([ + [read({ id: 'a1', name: 'Old name', updated: NOW - 50, listing: same }), read({ id: 'b2', dedicated: false, players: 9 })], + [read({ id: 'a1', name: 'New name', updated: NOW - 5, listing: same }), read({ id: 'c3', players: 2 }), + read({ id: 'd4', players: 12 })], + ]); + assert.deepStrictEqual(merged.map((item) => item.id), ['d4', 'a1', 'c3', 'b2']); + assert.strictEqual(merged[1].name, 'New name'); + // (one game under two masters' ids is one; one id of two hosts' games, two) + const shadowed = servers.mergeServers([ + [read({ id: 'x1', updated: NOW - 9, listing: same })], [read({ id: 'y2', updated: NOW - 3, listing: same })], + ]); + assert.deepStrictEqual(shadowed.map((item) => item.id), ['y2']); + const apart = servers.mergeServers([[read({ id: 'z9' })], [read({ id: 'z9' })]]); + assert.strictEqual(apart.length, 2); +}); + +test('every field is checked, and names lose their control characters', () => { + assert.strictEqual(servers.readEntry(entry('x', { players: '5' })), null); + assert.strictEqual(servers.readEntry(entry('x', { players: 300 })), null); + assert.strictEqual(servers.readEntry(entry('bad id!')), null); + assert.strictEqual(servers.readEntry(entry('x', { listing: 'not base64!' })), null); + assert.strictEqual(servers.readEntry(null), null); + const odd = servers.readEntry(entry('x', { name: 'Evil\u0000\u001b[31m name' + 'x'.repeat(80), dedicated: 'yes', region: 7 })); + assert.strictEqual(odd.name, 'Evil[31m name' + 'x'.repeat(19)); + assert.strictEqual(odd.dedicated, false); + assert.strictEqual(odd.region, ''); +}); + +test('a row: what it shows, and when Play here cannot join', () => { + const base = { ...servers.readEntry(entry('a1')), invite: 'ab'.repeat(32), locked: false }; + const row = servers.serverRow(base, 38); + assert.ok(row.playable); + assert.strictEqual(row.detail, "bloodgulch, CTF · 5/16 players · eu · dedicated · by Milenko's servers"); + assert.match(servers.serverRow({ ...base, network_version: 39 }, 38).reason, /network version 39/); + assert.match(servers.serverRow({ ...base, locked: true, invite: '' }, 38).reason, /password/); + assert.strictEqual(servers.serverRow({ ...base, players: 16 }, 38).reason, 'Full'); + assert.strictEqual(servers.serverRow({ ...base, dedicated: false, publisher: '', region: '' }, 38).detail, + 'bloodgulch, CTF · 5/16 players · player-hosted'); +}); + +test("a master fetched: its list checked, its listings read, a broken one left out", async () => { + const host = keyPair(); + const made = listing(host); + const list = signedList([entry('a1', { listing: made.base64 }), entry('b2', { listing: 'SEw=' }), { id: 'c3' }]); + const asked = []; + const fetched = await servers.fetchMaster(masterEntry, { + fetch: async (url, init) => { + asked.push([url, init.mode, init.credentials]); + return new Response(list.bytes, { headers: list.headers }); + }, + subtle, now: () => NOW, networkVersion: 38, + }); + assert.deepStrictEqual(asked, [['https://master1.example.org/v1/servers?network_version=38', 'cors', 'omit']]); + assert.strictEqual(fetched.verified, true); + assert.deepStrictEqual(fetched.entries.map((item) => item.id), ['a1']); + assert.strictEqual(fetched.entries[0].invite, expectedInvite(host, made.token)); + // (what the host signed is shown, not the master's word for it) + const signedHost = keyPair(); + const signedListing = listing(signedHost, { name: 'Signed name', map: 'hangemhigh', gametype: 'Slayer', players: 2, + maximum: 8, version: 39 }); + const claimed = signedList([entry('e5', { listing: signedListing.base64, name: 'Claimed name', map: 'bloodgulch', + players: 15, max_players: 16, network_version: 38 })]); + const shown = await servers.fetchMaster(masterEntry, { fetch: async () => new Response(claimed.bytes, { headers: claimed.headers }), + subtle, now: () => NOW, networkVersion: 39 }); + assert.deepStrictEqual(['name', 'map', 'gametype', 'players', 'max_players', 'network_version'].map((field) => shown.entries[0][field]), + ['Signed name', 'hangemhigh', 'Slayer', 2, 8, 39]); + assert.strictEqual(servers.serverRow(shown.entries[0], 39).playable, true); + const down = await servers.fetchMaster(masterEntry, { fetch: async () => { throw new TypeError('offline'); }, + subtle, now: () => NOW }); + assert.deepStrictEqual([down.entries, down.error], [[], 'cannot be reached']); + const refused = await servers.fetchMaster(masterEntry, { fetch: async () => new Response('no', { status: 503 }), subtle, now: () => NOW }); + assert.strictEqual(refused.error, 'HTTP 503'); + const stale = await servers.fetchMaster(masterEntry, { fetch: async () => new Response(list.bytes, { headers: list.headers }), + subtle, now: () => NOW + 3600 }); + assert.deepStrictEqual([stale.entries, stale.error], [[], 'an old list']); +}); + +test("docs/master_protocol.md's listing vector: verified, dedicated, its token in the invite", async () => { + const vector = '484c01' + '0026' + '25' + '0000019c' + '68f533a0' + + 'fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025' + 'a0a1a2a3a4a5a6a7a8a9aaabacadaeaf' + '051001' + + '10' + '4f70656e434520446564696361746564' + '0a' + '626c6f6f6467756c6368' + '03' + '435446' + '0000000000000000' + + '633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f'; + const bytes = Buffer.from(vector, 'hex'); + assert.strictEqual(bytes.length, 169); + const decoded = await servers.decodeListing(subtle, bytes.toString('base64')); + assert.deepStrictEqual([decoded.verified, decoded.dedicated, decoded.locked, decoded.name, decoded.map, decoded.gametype], + [true, true, false, 'OpenCE Dedicated', 'bloodgulch', 'CTF']); + assert.match(decoded.invite, /^[0-9a-f]{32}a0a1a2a3a4a5a6a7a8a9aaabacadaeaf$/); + bytes[100] ^= 1; + assert.strictEqual(await servers.decodeListing(subtle, bytes.toString('base64')), null); +}); diff --git a/port/windows/port.json b/port/windows/port.json index 8f0a79ce..dcf07584 100644 --- a/port/windows/port.json +++ b/port/windows/port.json @@ -8,7 +8,8 @@ "msvc_wide.c", "posix_update.c", "posix_upnp.c", - "posix_trace_marker.c" + "posix_trace_marker.c", + "posix_server.c" ], "libraries": [ "ws2_32", diff --git a/port/windows/src/win32_server.c b/port/windows/src/win32_server.c new file mode 100644 index 00000000..d5f8478a --- /dev/null +++ b/port/windows/src/win32_server.c @@ -0,0 +1,363 @@ +/* +WIN32_SERVER.C + +A dedicated server's signals, standard input and output, and the process's +use of memory and processor (port/linux/src/posix_server.h), on Windows, in +place of posix_server.c: Ctrl+C, Ctrl+Break, the console's closing and the +system's shutdown ask to quit (a second Ctrl+C ends the process at once); +standard input is read by a thread of its own into a buffer, so that a +console's and a pipe's are read alike without waiting. The release build is +a windowed program, which has no console: a server started from a command +prompt writes to the prompt's (and one started with no console, by a service +manager, to the files or pipes its standard handles are). +*/ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "posix_server.h" + +enum +{ + _posix_signal_quit_ctrl_c = 2, + INPUT_BUFFER_SIZE = 16384, +}; + +static volatile LONG server_signals; +static volatile LONG server_quit_signal; +static volatile LONG server_quit_count; +static volatile LONG server_deadline_code; + +static CRITICAL_SECTION input_lock; +static char input_buffer[INPUT_BUFFER_SIZE]; +static int input_length; +static int input_ended; +static int input_started; + +static BOOL WINAPI server_console_handler(DWORD type) +{ + switch (type) + { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + case CTRL_LOGOFF_EVENT: + case CTRL_SHUTDOWN_EVENT: + if (InterlockedIncrement(&server_quit_count) > 1) + ExitProcess(128 + _posix_signal_quit_ctrl_c); + InterlockedExchange(&server_quit_signal, _posix_signal_quit_ctrl_c); + InterlockedOr(&server_signals, _posix_server_signal_quit); + /* (a closing console ends the process when this returns: the server + is given the seconds Windows allows to stop) */ + if (type == CTRL_CLOSE_EVENT || type == CTRL_LOGOFF_EVENT || type == CTRL_SHUTDOWN_EVENT) + Sleep(4500); + return TRUE; + } + return FALSE; +} + +void posix_server_signals_install(void) +{ + SetConsoleCtrlHandler(server_console_handler, TRUE); +} + +int posix_server_signals_take(int *quit_signal) +{ + int taken = (int)InterlockedExchange(&server_signals, 0); + + if (quit_signal) + *quit_signal = (int)server_quit_signal; + return taken; +} + +static DWORD WINAPI deadline_thread(LPVOID seconds) +{ + Sleep((DWORD)(UINT_PTR)seconds * 1000); + ExitProcess(server_quit_signal ? 128 + (UINT)server_quit_signal : (UINT)server_deadline_code); + return 0; +} + +void posix_server_quit_deadline(int seconds, int exit_code) +{ + HANDLE thread; + + server_deadline_code = exit_code; + thread = CreateThread(NULL, 0, deadline_thread, (LPVOID)(UINT_PTR)(seconds > 0 ? seconds : 1), 0, NULL); + if (thread) + CloseHandle(thread); +} + +/* a handle that is no console's and no file's, as a windowed program's are +when it is started from a command prompt */ +static int handle_missing(DWORD which) +{ + HANDLE handle = GetStdHandle(which); + + return !handle || handle == INVALID_HANDLE_VALUE || GetFileType(handle) == FILE_TYPE_UNKNOWN; +} + +void posix_server_stdout_line_buffered(void) +{ + /* (the prompt's console, else none: a service's handles are its own) */ + if (handle_missing(STD_OUTPUT_HANDLE) && AttachConsole(ATTACH_PARENT_PROCESS)) + { + FILE *ignored; + + freopen_s(&ignored, "CONOUT$", "w", stdout); + freopen_s(&ignored, "CONOUT$", "w", stderr); + if (handle_missing(STD_INPUT_HANDLE)) + freopen_s(&ignored, "CONIN$", "r", stdin); + } + setvbuf(stdout, NULL, _IOLBF, 1024); +} + +int posix_server_stdin_is_terminal(void) +{ + return _isatty(_fileno(stdin)) != 0; +} + +static DWORD WINAPI input_thread(LPVOID unused) +{ + HANDLE handle = GetStdHandle(STD_INPUT_HANDLE); + + (void)unused; + for (;;) + { + char buffer[1024]; + DWORD count = 0; + + if (!handle || handle == INVALID_HANDLE_VALUE || !ReadFile(handle, buffer, sizeof(buffer), &count, NULL) || + !count) + { + EnterCriticalSection(&input_lock); + input_ended = 1; + LeaveCriticalSection(&input_lock); + return 0; + } + EnterCriticalSection(&input_lock); + /* (a reader that does not take what it is given loses the rest) */ + if (input_length + (int)count > INPUT_BUFFER_SIZE) + count = (DWORD)(INPUT_BUFFER_SIZE - input_length); + memcpy(input_buffer + input_length, buffer, count); + input_length += (int)count; + LeaveCriticalSection(&input_lock); + } +} + +int posix_server_stdin_read(char *buffer, int size) +{ + int count; + + if (!input_started) + { + HANDLE thread; + + input_started = 1; + InitializeCriticalSection(&input_lock); + thread = CreateThread(NULL, 0, input_thread, NULL, 0, NULL); + if (!thread) + return -1; + CloseHandle(thread); + } + EnterCriticalSection(&input_lock); + count = input_length < size ? input_length : size; + if (count) + { + memcpy(buffer, input_buffer, (size_t)count); + memmove(input_buffer, input_buffer + count, (size_t)(input_length - count)); + input_length -= count; + } + else if (input_ended) + { + count = -1; + } + LeaveCriticalSection(&input_lock); + /* (the console's line ends: CR LF) */ + return count; +} + +unsigned long posix_server_resident_bytes(void) +{ + PROCESS_MEMORY_COUNTERS counters; + + memset(&counters, 0, sizeof(counters)); + if (!K32GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters))) + return 0; + return (unsigned long)counters.WorkingSetSize; +} + +unsigned long posix_server_cpu_milliseconds(void) +{ + FILETIME created, exited, kernel, user; + unsigned long long total; + + if (!GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) + return 0; + total = ((unsigned long long)kernel.dwHighDateTime << 32 | kernel.dwLowDateTime) + + ((unsigned long long)user.dwHighDateTime << 32 | user.dwLowDateTime); + return (unsigned long)(total / 10000ULL); +} + +static int wide_path(const char *path, wchar_t *wide, int size) +{ + return MultiByteToWideChar(CP_UTF8, 0, path, -1, wide, size) > 0; +} + +int posix_server_rename(const char *from, const char *to) +{ + wchar_t wide_from[MAX_PATH * 2], wide_to[MAX_PATH * 2]; + + return wide_path(from, wide_from, MAX_PATH * 2) && wide_path(to, wide_to, MAX_PATH * 2) && + MoveFileExW(wide_from, wide_to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); +} + +int posix_server_write_file(const char *path, const char *text, unsigned long size) +{ + char temporary[4200]; + wchar_t wide[MAX_PATH * 2]; + FILE *file; + int written; + + if (snprintf(temporary, sizeof(temporary), "%s.tmp", path) >= (int)sizeof(temporary) || + !wide_path(temporary, wide, MAX_PATH * 2)) + { + return 0; + } + file = _wfopen(wide, L"wb"); + if (!file) + return 0; + written = fwrite(text, 1, size, file) == size; + if (fclose(file) != 0 || !written) + { + DeleteFileW(wide); + return 0; + } + return posix_server_rename(temporary, path); +} + +/* an access list that lets the process's user alone at a file (as a +mode of 0600 does): in acl, of size bytes; 1 if it was made */ +static int owner_only_acl(ACL *acl, DWORD size) +{ + HANDLE token; + union + { + TOKEN_USER user; + BYTE bytes[256]; + } information; + DWORD length = 0; + int made; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return 0; + made = GetTokenInformation(token, TokenUser, &information, sizeof(information), &length) && + InitializeAcl(acl, size, ACL_REVISION) && + AddAccessAllowedAce(acl, ACL_REVISION, FILE_ALL_ACCESS, information.user.User.Sid); + CloseHandle(token); + return made; +} + +/* (the access list replaces the folder's, which is not inherited) */ +#define OWNER_ONLY_INFORMATION (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) + +int posix_server_private_file(const char *path) +{ + wchar_t wide[MAX_PATH * 2]; + union + { + ACL acl; + BYTE bytes[512]; + } acl; + + return wide_path(path, wide, MAX_PATH * 2) && owner_only_acl(&acl.acl, sizeof(acl)) && + SetNamedSecurityInfoW(wide, SE_FILE_OBJECT, OWNER_ONLY_INFORMATION, NULL, NULL, &acl.acl, NULL) == + ERROR_SUCCESS; +} + +int posix_server_write_private_file(const char *path, const void *data, unsigned long size) +{ + wchar_t wide[MAX_PATH * 2]; + union + { + ACL acl; + BYTE bytes[512]; + } acl; + SECURITY_DESCRIPTOR descriptor; + SECURITY_ATTRIBUTES attributes; + HANDLE file; + DWORD written = 0; + int succeeded; + + if (!wide_path(path, wide, MAX_PATH * 2) || !owner_only_acl(&acl.acl, sizeof(acl)) || + !InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) || + !SetSecurityDescriptorDacl(&descriptor, TRUE, &acl.acl, FALSE) || + !SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED)) + { + return 0; + } + attributes.nLength = sizeof(attributes); + attributes.lpSecurityDescriptor = &descriptor; + attributes.bInheritHandle = FALSE; + /* (created with the access list; a file that was there keeps its own, + so it is given this one before anything is written) */ + file = CreateFileW(wide, GENERIC_WRITE | WRITE_DAC, 0, &attributes, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) + return 0; + if (SetSecurityInfo(file, SE_FILE_OBJECT, OWNER_ONLY_INFORMATION, NULL, NULL, &acl.acl, NULL) != ERROR_SUCCESS) + { + CloseHandle(file); + return 0; + } + succeeded = WriteFile(file, data, (DWORD)size, &written, NULL) && written == (DWORD)size; + return CloseHandle(file) && succeeded; +} + +int posix_server_remove(const char *path) +{ + wchar_t wide[MAX_PATH * 2]; + + return wide_path(path, wide, MAX_PATH * 2) && DeleteFileW(wide); +} + +static unsigned long long unix_hundred_nanoseconds(void) +{ + FILETIME now; + + GetSystemTimeAsFileTime(&now); + /* (the FILETIME epoch is 1601) */ + return ((unsigned long long)now.dwHighDateTime << 32 | now.dwLowDateTime) - 116444736000000000ULL; +} + +unsigned long posix_server_unix_seconds(void) +{ + return (unsigned long)(unix_hundred_nanoseconds() / 10000000ULL); +} + +long long posix_server_unix_milliseconds(void) +{ + return (long long)(unix_hundred_nanoseconds() / 10000ULL); +} + +void posix_server_utc_text(unsigned long seconds, char *text, int size) +{ + time_t when = (time_t)seconds; + struct tm utc; + + if (gmtime_s(&utc, &when) || !strftime(text, (size_t)size, "%Y-%m-%dT%H:%M:%SZ", &utc)) + snprintf(text, (size_t)size, "%lu", seconds); +} + +void posix_server_local_text(unsigned long seconds, char *text, int size) +{ + time_t when = (time_t)seconds; + struct tm local; + + if (localtime_s(&local, &when) || !strftime(text, (size_t)size, "%Y-%m-%d %H:%M:%S", &local)) + snprintf(text, (size_t)size, "%lu", seconds); +} diff --git a/port/windows/src/win32_update.c b/port/windows/src/win32_update.c index 59e96fb0..8a65d3a1 100644 --- a/port/windows/src/win32_update.c +++ b/port/windows/src/win32_update.c @@ -14,6 +14,7 @@ Paths are UTF-8, as SDL gives them. #include #include #include +#include #include #include "update.h" @@ -168,6 +169,168 @@ done: return succeeded; } +/* ---------- any request */ + +int update_https_request(const char *method, const char *url, const char *headers, const void *body, + unsigned long body_size, unsigned long maximum, struct update_response *response, char *error, int error_size) +{ + wchar_t wide_url[2048], host[256], url_path[2048], wide_method[16]; + wchar_t *wide_headers = NULL; + URL_COMPONENTS components; + HINTERNET session = NULL, connection = NULL, request = NULL; + DWORD status = 0, status_size = sizeof(status); + DWORD protocols, flags = 0; + char *data = NULL; + unsigned long received = 0, capacity = 0; + int succeeded = 0; + + memset(response, 0, sizeof(*response)); + if (!wide_from_utf8(url, wide_url, 2048) || !wide_from_utf8(method, wide_method, 16)) + { + snprintf(error, (size_t)error_size, "a bad address"); + return 0; + } + memset(&components, 0, sizeof(components)); + components.dwStructSize = sizeof(components); + components.lpszHostName = host; + components.dwHostNameLength = 256; + components.lpszUrlPath = url_path; + components.dwUrlPathLength = 2048; + if (!WinHttpCrackUrl(wide_url, 0, 0, &components) || + (components.nScheme != INTERNET_SCHEME_HTTPS && + !(components.nScheme == INTERNET_SCHEME_HTTP && (!wcscmp(host, L"127.0.0.1") || !_wcsicmp(host, L"localhost"))))) + { + snprintf(error, (size_t)error_size, "not an https:// address: %s", url); + return 0; + } + if (components.nScheme == INTERNET_SCHEME_HTTPS) + flags = WINHTTP_FLAG_SECURE; + if (headers && headers[0]) + { + int length = MultiByteToWideChar(CP_UTF8, 0, headers, -1, NULL, 0); + + wide_headers = length > 0 ? malloc(sizeof(wchar_t) * (size_t)length) : NULL; + if (!wide_headers || !MultiByteToWideChar(CP_UTF8, 0, headers, -1, wide_headers, length)) + { + free(wide_headers); + snprintf(error, (size_t)error_size, "bad headers"); + return 0; + } + } + session = WinHttpOpen(L"opence", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!session) + { + set_error(error, error_size, "could not start WinHTTP"); + goto done; + } + protocols = WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2 | WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3; + if (!WinHttpSetOption(session, WINHTTP_OPTION_SECURE_PROTOCOLS, &protocols, sizeof(protocols))) + { + protocols = WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2; + WinHttpSetOption(session, WINHTTP_OPTION_SECURE_PROTOCOLS, &protocols, sizeof(protocols)); + } + WinHttpSetTimeouts(session, TIMEOUT_MILLISECONDS, TIMEOUT_MILLISECONDS, TIMEOUT_MILLISECONDS, + TIMEOUT_MILLISECONDS); + connection = WinHttpConnect(session, host, components.nPort, 0); + if (connection) + request = WinHttpOpenRequest(connection, wide_method, url_path, NULL, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (request) + { + /* (no redirect followed: a master's answer is its own) */ + DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy)); + } + if (!request || !WinHttpSendRequest(request, wide_headers ? wide_headers : WINHTTP_NO_ADDITIONAL_HEADERS, + wide_headers ? (DWORD)-1L : 0, (void *)body, body ? (DWORD)body_size : 0, body ? (DWORD)body_size : 0, 0) || + !WinHttpReceiveResponse(request, NULL)) + { + set_error(error, error_size, "could not reach the server"); + goto done; + } + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) + { + set_error(error, error_size, "no answer from the server"); + goto done; + } + response->status = (int)status; + { + wchar_t raw[4096]; + DWORD raw_size = sizeof(raw); + + /* (the headers after the status line, one a line) */ + if (WinHttpQueryHeaders(request, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, raw, &raw_size, + WINHTTP_NO_HEADER_INDEX)) + { + char text[4096]; + int read, written = 0; + const char *first; + + WideCharToMultiByte(CP_UTF8, 0, raw, -1, text, sizeof(text), NULL, NULL); + text[sizeof(text) - 1] = 0; + first = strstr(text, "\r\n"); + for (read = first ? (int)(first - text) + 2 : 0; text[read] && written < (int)sizeof(response->headers) - 1; + read++) + { + if (text[read] != '\r') + response->headers[written++] = text[read]; + } + response->headers[written] = 0; + } + } + for (;;) + { + char buffer[16384]; + DWORD count = 0; + + if (!WinHttpReadData(request, buffer, sizeof(buffer), &count)) + { + set_error(error, error_size, "the answer broke off"); + goto done; + } + if (!count) + break; + if (received + count > maximum) + { + snprintf(error, (size_t)error_size, "the answer is larger than %lu bytes", maximum); + goto done; + } + if (received + count + 1 > capacity) + { + unsigned long grown_capacity = capacity ? capacity * 2 : 16384; + char *grown; + + while (grown_capacity < received + count + 1) + grown_capacity *= 2; + grown = realloc(data, grown_capacity); + if (!grown) + goto done; + data = grown; + capacity = grown_capacity; + } + memcpy(data + received, buffer, count); + received += count; + data[received] = 0; + } + response->body = data ? data : calloc(1, 1); + response->body_size = received; + data = NULL; + succeeded = 1; + +done: + free(data); + free(wide_headers); + if (request) + WinHttpCloseHandle(request); + if (connection) + WinHttpCloseHandle(connection); + if (session) + WinHttpCloseHandle(session); + return succeeded; +} + /* ---------- files and processes */ int update_executable_path(char *path, int size) diff --git a/source/cseries/errors.c b/source/cseries/errors.c index e792cb56..6c5ef7ec 100644 --- a/source/cseries/errors.c +++ b/source/cseries/errors.c @@ -158,12 +158,19 @@ static void write_to_debug_file( { } + /* port: a dedicated server's debug.txt also on the standard error + (server.log_debug_echo), and there alone when the file cannot be written + (a read-only data root) */ + if (halo_server_mode() && halo_server_debug_echo()) + fprintf(stderr, "debug: %s%s", string, string[0] && string[csstrlen(string) - 1] == '\n' ? "" : "\n"); file = debug_file_port; if (!file) { file = fopen("d:\\debug.txt", "a+b"); if (!file) { + if (halo_server_mode() && !halo_server_debug_echo()) + fprintf(stderr, "debug: %s%s", string, string[0] && string[csstrlen(string) - 1] == '\n' ? "" : "\n"); __sync_lock_release(&debug_file_port_lock); return; } diff --git a/source/game/game_engine.c b/source/game/game_engine.c index 1c2fcc02..f7865939 100644 --- a/source/game/game_engine.c +++ b/source/game/game_engine.c @@ -5624,6 +5624,13 @@ void game_engine_playlist_next( struct game_variant variant; csstrcpy(global_stage.map_name, "levels\\test\\carousel\\carousel"); + /* port: a dedicated server's rotation's next map, not carousel, which it + may not have (port/linux/game/server_director.c) */ + if (halo_server_mode() && server_director_first_level()) + { + csstrncpy(global_stage.map_name, server_director_first_level(), sizeof(global_stage.map_name) - 1); + global_stage.map_name[sizeof(global_stage.map_name) - 1] = 0; + } map_name = main_get_multiplayer_map_name(); if (map_name && map_name[0]) { diff --git a/source/game/player_queues_new.c b/source/game/player_queues_new.c index f9527ad8..012bd1ed 100644 --- a/source/game/player_queues_new.c +++ b/source/game/player_queues_new.c @@ -748,9 +748,11 @@ boolean update_client_dequeue( if (game_connection() == _game_connection_network_client) return update_client_dequeue_distributed(actions); update = update_client_get_update(update_client_globals.next_update_number_to_dequeue); + /* port: a dedicated server's game goes on with nobody in it, its update + holding no action (port/linux/game/server_director.c ends it) */ if (!update || update_client_globals.next_update_number_to_dequeue>update_client_globals.latest_update_number_received || - update->update.action_count<=0 || + (update->update.action_count<=0 && !(halo_server_mode() && update->update.action_count == 0)) || update->update.action_count>MAXIMUM_NUMBER_OF_PLAYERS) { return FALSE; @@ -823,8 +825,9 @@ long update_client_get_maximum_possible_server_time( { struct update *update = update_client_get_update(update_number); + /* port: as update_client_dequeue, a dedicated server's empty game */ if (!update || - update->update.action_count<=0 || + (update->update.action_count<=0 && !(halo_server_mode() && update->update.action_count == 0)) || update->update.action_count>MAXIMUM_NUMBER_OF_PLAYERS) { break; diff --git a/source/hs/hs.c b/source/hs/hs.c index a76dee3c..e120c057 100644 --- a/source/hs/hs.c +++ b/source/hs/hs.c @@ -2806,6 +2806,7 @@ symbols in this file: #include "networking/network_game_manager.h" #include "networking/network_server_manager.h" #include "network_votekick.h" /* port: port/linux/game/network_votekick.c */ +#include "server_director.h" /* port: port/linux/game/server_director.c */ #include "objects/damage.h" #include "objects/object_lights.h" #include "objects/scenery.h" @@ -15547,6 +15548,14 @@ static boolean hs_compile_and_evaluate_command( if (profile_console_command(expression)) return TRUE; #endif + /* port: a dedicated server's commands (sv_*, say, kick, ban...), with the + role the line came with (port/linux/game/server_commands.c) */ + { + boolean server_result; + + if (server_command_try(expression, &server_result)) + return server_result; + } /* port: the co-op host's bringto, which brings every player to the host (players.c; a client is told it is the host's) */ if (hs_host_player_command(expression, "bringto")) diff --git a/source/interface/terminal.c b/source/interface/terminal.c index 4647aa3e..4e726c01 100644 --- a/source/interface/terminal.c +++ b/source/interface/terminal.c @@ -380,6 +380,9 @@ void terminal_printf( line->buffer[NUMBEROF(line->buffer)-2] = 0; line->tabstop = strstr(line->buffer, "|t") != 0; telnet_console_print(line->buffer); + /* port: a dedicated server's log, and its commands' replies + (port/linux/src/server_console.c) */ + server_console_output(line->buffer); } } diff --git a/source/main/console.c b/source/main/console.c index 8db63a2d..040ef924 100644 --- a/source/main/console.c +++ b/source/main/console.c @@ -400,6 +400,11 @@ void console_startup( FILE *file; short newest_previous_command_index; + /* port: a dedicated server's init file is its own commands, which it + runs itself with its owner's role (port/linux/src/server_console.c), + and none of them (a password) is kept in the console's history */ + if (halo_server_mode()) + return; if (!game_in_editor()) { file = fopen("d:\\init.txt", "r"); diff --git a/source/main/main.c b/source/main/main.c index 89a90c99..2dd0d8e4 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -683,6 +683,7 @@ typedef char screenshot_and_framerate_globals_size_assert[ sizeof(struct _screenshot_and_framerate_globals) == 0x38B ? 1 : -1]; void network_test_update(boolean main_menu_loaded, real seconds); +#include "server_director.h" /* port: port/linux/game/server_director.c */ /* ---------- prototypes */ @@ -3145,6 +3146,10 @@ void halt_and_catch_fire( emscripten_cancel_main_loop(); abort(); #endif + /* port: a dedicated server has no screen to halt on: its error logged, + it ends with exit code 1 (port/linux/game/server_director.c) */ + if (halo_server_mode()) + server_fatal(error_get()); if (!global_screenshot_count.halt_recursion_lock) { scenario = global_scenario_try_and_get(); @@ -3268,6 +3273,9 @@ void halt_and_catch_fire( void main_loop_of_death( void) { + /* port: as halt_and_catch_fire, on a dedicated server */ + if (halo_server_mode()) + server_fatal(error_get()); while (TRUE) { input_frame_begin(); @@ -3556,6 +3564,9 @@ static boolean main_loop_iteration( /* automated system link tests (port/linux/game/network_test.c) */ network_test_update(main_globals.main_menu_scenario_loaded, main_globals.seconds_elapsed); + /* port: a dedicated server's hosting, rotation and commands + (port/linux/game/server_director.c) */ + server_update(main_globals.main_menu_scenario_loaded, main_globals.seconds_elapsed); /* port: voice chat, in the lobby and in game (port/linux/game/network_voice.c) */ network_voice_update(); /* port: apply and replicate the host's mouse aim-assist policy. */ @@ -3599,7 +3610,17 @@ static boolean main_loop_iteration( } profile_scope_exit(main_network_start_frame_section) - profile_scope(main_update_time_section, main_update_time();) + /* port: a dedicated server's frames paced by its clock, with no + display to wait for (port/linux/src/server_mode.c) */ + if (halo_server_mode()) + { + server_mode_pace_frame(); + profile_scope(main_update_time_section, main_update_time_unthrottled();) + } + else + { + profile_scope(main_update_time_section, main_update_time();) + } profile_scope_enter(main_ui_update_section) process_ui_widgets(); bink_playback_update(); @@ -3674,14 +3695,16 @@ static boolean main_loop_iteration( profile_render_end(); } } - else + /* port: nothing drawn on a dedicated server, nor waited for */ + else if (!halo_server_mode()) { profile_render_start(); main_pregame_render(); profile_render_end(); } - profile_scope(main_throttle_section, main_rasterizer_throttle();) + if (!halo_server_mode()) + profile_scope(main_throttle_section, main_rasterizer_throttle();) if (render_frame && !debug_no_drawing) { diff --git a/source/networking/network_client_manager.c b/source/networking/network_client_manager.c index 797bf577..94cb1b48 100644 --- a/source/networking/network_client_manager.c +++ b/source/networking/network_client_manager.c @@ -599,6 +599,7 @@ struct message_client_ping /* port/linux/src/p2p.c's */ void p2p_hardware_id(char *hex, int size); +void p2p_join_refused(int rejection_code); struct message_client_join_game_request { @@ -1846,7 +1847,11 @@ boolean network_game_client_remove_player( } } - if (network_player_index == MAXIMUM_NUMBER_OF_PLAYERS) + /* port: a dedicated server's own machine never has a player + (port/linux/game/server_director.c): it leaves only when a player + of its own goes, which none is */ + if (network_player_index == MAXIMUM_NUMBER_OF_PLAYERS && + (!halo_server_mode() || player->machine_index == client->machine_index)) { network_game_client_all_local_players_have_quit(); network_event("no local players remain in the game, exiting the game now"); @@ -2358,6 +2363,8 @@ void network_game_client_rejected_by_game( "unable to join game: reason= #%d/%s", rejection_code, reason); + /* port: why, for the server browser's line (p2p_join_progress) */ + p2p_join_refused(rejection_code); /* port: the join went to the pregame screen at once (and a machine refused a game in progress, network_game_server_refuse_late_joiner, is in it): with no game behind it, it is left for the main menu, which says @@ -3153,7 +3160,6 @@ boolean network_game_client_advertised_game_compatible( unsigned int ours = HALO_PORT_NETWORK_VERSION; unsigned int theirs; boolean distributed; - char message[400]; if (game_index < 0 || game_index >= MAXIMUM_NETWORK_ADVERTISED_GAMES) return FALSE; @@ -3165,6 +3171,20 @@ boolean network_game_client_advertised_game_compatible( network_event("joining a host of network version %u", theirs); return TRUE; } + if (tell) + network_game_client_tell_version(theirs, distributed); + return FALSE; +} + +/* port: the player told why a host of this network version (and netcode) +cannot be joined: an advertised game's, or a server browser's row's */ +void network_game_client_tell_version( + unsigned int theirs, + boolean distributed) +{ + unsigned int ours = HALO_PORT_NETWORK_VERSION; + char message[400]; + if (theirs == ours) { csprintf(message, @@ -3187,13 +3207,9 @@ boolean network_game_client_advertised_game_compatible( "Ask the host to update the game.", ours, theirs); } - if (tell) - { - network_event("not joining a host of network version %u%s (this machine's is %u)", theirs, - distributed ? "" : " with the lockstep netcode", ours); - platform_show_message("Halo: cannot join this game", message); - } - return FALSE; + network_event("not joining a host of network version %u%s (this machine's is %u)", theirs, + distributed ? "" : " with the lockstep netcode", ours); + platform_show_message("Halo: cannot join this game", message); } /* port: whether the advertised game is under way (HALO_PORT_ADVERTISED_IN_PROGRESS_FLAG), @@ -3208,6 +3224,49 @@ boolean network_game_client_advertised_game_in_progress( (network_game_client_advertised_versions[game_index].flags & HALO_PORT_ADVERTISED_IN_PROGRESS_FLAG) != 0; } +/* port: whether the advertised game's host is a dedicated server +(HALO_PORT_ADVERTISED_DEDICATED_FLAG), for the lobby's line */ +boolean network_game_client_advertised_game_dedicated( + struct network_game_client *client, + struct network_advertised_game const *game) +{ + long game_index = client ? game - client->available_games : NONE; + + return game_index >= 0 && game_index < MAXIMUM_NETWORK_ADVERTISED_GAMES && + (network_game_client_advertised_versions[game_index].flags & HALO_PORT_ADVERTISED_DEDICATED_FLAG) != 0; +} + +/* port: the client's index'th advertised game, as a probe tells it (halo +--probe: port/linux/game/network_test.c) */ +boolean network_game_client_port_advertised_game_summary( + long index, + struct network_advertised_game_summary *summary) +{ + struct network_game_client *client = global_network_game_client_get(); + struct network_advertised_game *game; + + if (!client || index < 0 || index >= MAXIMUM_NETWORK_ADVERTISED_GAMES) + return FALSE; + game = &client->available_games[index]; + if (!network_game_client_advertised_game_is_valid(game)) + return FALSE; + csmemset(summary, 0, sizeof(*summary)); + csmemcpy(summary->name, game->game_name, sizeof(summary->name)); + summary->name[NUMBEROF(summary->name) - 1] = 0; + csmemcpy(summary->map_name, game->map.name, sizeof(summary->map_name)); + summary->map_name[sizeof(summary->map_name) - 1] = 0; + summary->engine_type = game->engine_type; + summary->player_count = (short)game->player_count; + summary->maximum_player_count = game->maximum_player_count; + summary->open = game->open; + summary->has_teams = game->has_teams; + summary->in_progress = network_game_client_advertised_game_in_progress(client, game); + summary->dedicated = network_game_client_advertised_game_dedicated(client, game); + summary->version = network_game_client_advertised_versions[index].version; + summary->kind = network_game_client_advertised_versions[index].kind; + return TRUE; +} + /* port: the advertised game's kind (an OpenCE gametype's mode, and Hardcore: port/linux/game/game_modes.h), for the server browser's name of it */ byte network_game_client_advertised_game_kind( diff --git a/source/networking/network_client_manager.h b/source/networking/network_client_manager.h index 8849aea2..11943e58 100644 --- a/source/networking/network_client_manager.h +++ b/source/networking/network_client_manager.h @@ -14,6 +14,23 @@ header included in hcex build. /* ---------- structures */ +/* port: a game this client found, as a probe tells it (halo --probe: +port/linux/game/network_test.c) */ +struct network_advertised_game_summary +{ + wchar_t name[16]; + char map_name[0x80]; + short engine_type; + short player_count; + short maximum_player_count; + boolean open; + boolean has_teams; + boolean in_progress; + boolean dedicated; + word version; + byte kind; +}; + struct network_advertised_game; struct network_connection; struct network_game; @@ -54,6 +71,19 @@ boolean network_game_client_advertised_game_compatible( struct network_game_client *client, struct network_advertised_game const *game, boolean tell); +/* port: the player told why a host of this network version (and netcode) +cannot be joined (the server browser's rows of another version too) */ +void network_game_client_tell_version( + unsigned int theirs, + boolean distributed); +/* port: whether the advertised game's host is a dedicated server */ +boolean network_game_client_advertised_game_dedicated( + struct network_game_client *client, + struct network_advertised_game const *game); +/* port: the client's index'th advertised game, if it is a game: TRUE */ +boolean network_game_client_port_advertised_game_summary( + long index, + struct network_advertised_game_summary *summary); boolean network_game_client_initiate_join_game( struct network_game_client *client, struct network_advertised_game *game, diff --git a/source/networking/network_connection.c b/source/networking/network_connection.c index 85136236..59e559bf 100644 --- a/source/networking/network_connection.c +++ b/source/networking/network_connection.c @@ -1957,7 +1957,9 @@ static boolean network_connection_flush_reliable( } else if (sent == _transport_result_operation_would_block) { - if (current_time - connection->reliable_outgoing_time >= NETWORK_CONNECTION_WRITE_TIMEOUT) + /* port: network.client_timeout_seconds (NETWORK_CONNECTION_WRITE_TIMEOUT + by default) */ + if (current_time - connection->reliable_outgoing_time >= network_client_timeout_milliseconds()) { error(_error_silent, "a reliable connection's peer has stopped reading"); return FALSE; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 5f873e5e..03a3f33b 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -476,6 +476,7 @@ symbols in this file: #include "interface/player_ui.h" #include "tag_files/tag_files.h" #include "map_share.h" /* port: port/linux/game/map_share.c */ +#include "server_director.h" /* port: port/linux/game/server_director.c */ #include "bots.h" /* port: port/linux/game/bots.c */ /* port: internet play's Discord presence (port/linux/src/p2p.c), and the @@ -819,10 +820,13 @@ static long network_game_server_frequent_updates_until[MAXIMUM_NETWORK_MACHINE_C network_game_server_client_machine_may_slow_countdown) */ static boolean network_game_server_countdown_slowed[MAXIMUM_NETWORK_MACHINE_COUNT]; -/* port/linux/game/network_distributed.c's (the host's bans: bans.txt) */ +/* port/linux/game/network_distributed.c's (the host's bans: bans.txt; a +ban's end, 0 for ever; an internet play peer's real address for its +stand-in) */ boolean network_distributed_banned(unsigned long address, char const *hardware_id); -void network_distributed_ban(long machine_index, unsigned long address, char const *names); +unsigned long network_distributed_ban(long machine_index, unsigned long address, char const *names); void network_distributed_kick(char const *names); +unsigned long distributed_real_address(unsigned long address); /* port/linux/game/network_votekick.c's (the players' votes to kick) */ void network_votekick_machine_joined(long machine_index); boolean network_votekick_kept_out(unsigned long address, char const *hardware_id); @@ -839,9 +843,10 @@ void console_warning(const char *format, ...); /* port: the client machines to drop, as this server next looks at its machines: those the distributed netcode asked to (their games sped up: -network_game_server_kick_machine) and those the host banned, whose -addresses are kept out of this server's games while it lasts; and those the -host kicked, which may join again at once */ +network_game_server_kick_machine) and those the host banned for ever, whose +machines are kept out of this server's games while it lasts; and those the +host kicked, or banned for a while (bans.txt keeps them out till the ban +ends), which this list does not keep out */ enum { MAXIMUM_KICKED_ADDRESSES = 64, @@ -856,7 +861,16 @@ static byte network_game_server_kick_pending[MAXIMUM_NETWORK_MACHINE_COUNT]; /* port: each client machine's hardware id as it told it joining, hex only (p2p_hardware_id_sanitize), by slot */ static char network_game_server_hardware_ids[MAXIMUM_NETWORK_MACHINE_COUNT][P2P_HARDWARE_ID_SIZE]; -static unsigned long network_game_server_kicked_addresses[MAXIMUM_KICKED_ADDRESSES]; +/* port: those kept out: each one's address as it joined (host byte order; +an internet play peer's stand-in), its real address (the peer's endpoint, +which bans.txt has: a peer may take a new stand-in) and its hardware id; +any of them keeps a machine out (an address of 0, an empty id: none) */ +static struct +{ + unsigned long address; + unsigned long real_address; + char hardware_id[P2P_HARDWARE_ID_SIZE]; +} network_game_server_kicked[MAXIMUM_KICKED_ADDRESSES]; static long network_game_server_kicked_address_next; /* port: when each client machine joined (system_milliseconds): one that has @@ -1103,13 +1117,16 @@ static void network_game_server_machine_names( } /* port: the host's ban: the machine dropped, its address in bans.txt -(network_distributed_ban), and kept out */ +(network_distributed_ban), and kept out; one for a while is left to +bans.txt, whose line ends it */ static void network_game_server_ban_machine( long machine_index, char const *names) { - network_distributed_ban(machine_index, network_game_server_client_machine_addresses[machine_index], names); - network_game_server_kick_pending[machine_index] = _kick_kept_out; + unsigned long until = network_distributed_ban(machine_index, + network_game_server_client_machine_addresses[machine_index], names); + + network_game_server_kick_pending[machine_index] = until ? _kick_rejoinable : _kick_kept_out; } /* port: the host's kick: the machine dropped, every machine told, and @@ -1444,8 +1461,9 @@ void network_game_server_dispose( } /* port: whether the host's network is gone: at once but in a game, where a -link that comes back within NETWORK_GAME_SERVER_CLIENT_TIMEOUT (an address -renewed, a wifi drop) goes on (a check more than two seconds after the last +link that comes back within network.client_timeout_seconds +(network_client_timeout_milliseconds: an address renewed, a wifi drop) goes +on (a check more than two seconds after the last starts the count over) */ static boolean network_game_server_network_lost( struct network_game_server *server) @@ -1480,7 +1498,7 @@ static boolean network_game_server_network_lost( return TRUE; if (!down_time || !recent) down_time = now; - return now - down_time > NETWORK_GAME_SERVER_CLIENT_TIMEOUT; + return now - down_time > network_client_timeout_milliseconds(); } /* port: wide text as the listing has it: ASCII, a Latin letter with a mark @@ -2146,6 +2164,10 @@ boolean network_game_server_game_is_open( { game_is_open = FALSE; } + /* port: nor while a dedicated server waits to stop (a drain: + port/linux/game/server_director.c) */ + if (halo_server_mode() && server_director_refuses_joins()) + game_is_open = FALSE; match_assert(NETWORK_SERVER_MANAGER_FILE, 0x217, (TRUE == game_is_open) || (FALSE == game_is_open)); @@ -2211,17 +2233,26 @@ boolean network_game_server_accept_client_machine_into_game( machines one index */ machine_index = machine->machine_index; network_game_server_refusal_code = _rejection_code_game_is_closed; - /* port: not a machine of an address dropped for cheating */ + /* port: not a machine dropped for cheating, or banned for ever (its +address, real address or hardware id) */ { struct transport_address address = { { { 0 } } }; + unsigned long real_address; + char const *hardware_id = VALID_INDEX(machine_index, MAXIMUM_NETWORK_MACHINE_COUNT) ? + network_game_server_hardware_ids[machine_index] : ""; long index; network_connection_get_address(machine->connection, &address, FALSE); - for (index = 0; index < MAXIMUM_KICKED_ADDRESSES && address.address.long_words[0]; index++) + real_address = address.address.long_words[0] ? distributed_real_address(address.address.long_words[0]) : 0; + for (index = 0; index < MAXIMUM_KICKED_ADDRESSES && !network_game_server_client_machine_is_local(server, machine); + index++) { - if (network_game_server_kicked_addresses[index] == address.address.long_words[0]) + if ((address.address.long_words[0] && + network_game_server_kicked[index].address == address.address.long_words[0]) || + (real_address && network_game_server_kicked[index].real_address == real_address) || + (hardware_id[0] && !csstrcmp(network_game_server_kicked[index].hardware_id, hardware_id))) { - network_event("refusing a machine @ %s: dropped from this game for cheating", + network_event("refusing a machine @ %s: dropped from this game for cheating, or banned", transport_address_to_string(&address)); network_game_server_refusal_code = _rejection_code_blacklisted_machine; return FALSE; @@ -3491,6 +3522,14 @@ can join the game in progress. */ boolean server_ok_to_countdown( struct network_game_server *server) { + /* port: a dedicated server's lobby, whose own machine has no player: + its director's rules, server.min_players and server.team_start + (port/linux/game/server_director.c) */ + if (halo_server_mode()) + { + return server_has_enough_machines(server) && + server_director_ok_to_countdown(server->game.player_count, server_needs_more_teams(server)); + } if (server_has_enough_machines(server) && (!server_needs_more_teams(server) || server_players_on_one_machine(server)) && (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server))) @@ -3676,6 +3715,9 @@ boolean network_game_server_game_can_start( { match_assert(NETWORK_SERVER_MANAGER_FILE, 0x782, server); + /* port: a dedicated server's, as server_ok_to_countdown */ + if (halo_server_mode()) + return server->state == 0 && server_ok_to_countdown(server); return server->state == 0 && (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server)); } @@ -3915,8 +3957,10 @@ static boolean network_game_server_client_machine_timed_out( { return FALSE; } + /* port: network.client_timeout_seconds (NETWORK_GAME_SERVER_CLIENT_TIMEOUT + by default) */ if (TEST_FLAG(machine->flags, _network_client_machine_level_loaded_bit)) - return silence > NETWORK_GAME_SERVER_CLIENT_TIMEOUT; + return silence > network_client_timeout_milliseconds(); /* (port: downloading the map, which it says every few seconds, before its players join: port/linux/game/map_share.c) */ if (map_share_machine_downloading(machine->machine_index)) @@ -4557,6 +4601,100 @@ void network_game_server_port_clear_queued_players( } } +/* port: a dedicated server's lobby (port/linux/game/server_director.c), +whose countdown its director holds and starts: the milliseconds left (NONE +when it is not counting down), and whether the director holds it */ +long network_game_server_port_countdown_milliseconds( + struct network_game_server *server) +{ + if (!server || !server->countdown_state.active) + return NONE; + return countdown_timer_get_time_remaining(&server->countdown_state.timer); +} + +boolean network_game_server_port_countdown_paused( + struct network_game_server *server) +{ + return server && server->countdown_state.paused; +} + +/* port: ... the countdown let go and started with that long to run (0: +the game starts once every machine has the map) */ +void network_game_server_port_countdown_begin( + struct network_game_server *server, + long milliseconds) +{ + if (!server || server->state != _network_game_server_state_pregame || server->sent_start_game_message) + return; + server->countdown_state.paused = FALSE; + if (server->countdown_state.active) + { + if (countdown_timer_get_time_remaining(&server->countdown_state.timer) > milliseconds) + countdown_timer_set_time_remaining(&server->countdown_state.timer, milliseconds); + return; + } + network_game_server_begin_game_start_countdown(server, milliseconds); + server->countdown_state.last_countdown_message_time = 0; +} + +/* port: ... whether the game's start was sent (its machines load it) */ +boolean network_game_server_port_start_sent( + struct network_game_server *server) +{ + return server && server->sent_start_game_message; +} + +/* port: ... whether the lobby may count down now, as the server checks it +each tick (server_ok_to_countdown): a countdown begun without it is stopped +at once */ +boolean network_game_server_port_ok_to_countdown( + struct network_game_server *server) +{ + return server && server_ok_to_countdown(server); +} + +/* port: a machine let into this game again (a dedicated server's unban: +port/linux/game/server_commands.c): those kept out by that address (host +byte order: as it joined, or its real one) or hardware id (empty: none) no +longer are */ +void network_game_server_port_forget_kicked_address( + unsigned long address, + char const *hardware_id) +{ + long index; + + for (index = 0; index < MAXIMUM_KICKED_ADDRESSES; index++) + { + if ((address && (network_game_server_kicked[index].address == address || + network_game_server_kicked[index].real_address == address)) || + (hardware_id && hardware_id[0] && !csstrcmp(network_game_server_kicked[index].hardware_id, hardware_id))) + { + csmemset(&network_game_server_kicked[index], 0, sizeof(network_game_server_kicked[index])); + } + } +} + +/* port: ... the other machines connected to it (joined to the game, if +joined) */ +long network_game_server_port_remote_machine_count( + struct network_game_server *server, + boolean joined) +{ + long count = 0; + long index; + + for (index = 0; server && index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[index]; + + if (network_game_server_client_machine_is_local(server, machine)) + continue; + if (joined ? network_game_server_client_machine_is_joined_to_game(server, machine) : machine->connection != NULL) + count++; + } + return count; +} + /* port: a gametype's PC options: the menus' (player_ui_set_game_variant_options) when it is the menus' gametype, else its defaults */ static void network_game_server_variant_options( @@ -4878,8 +5016,13 @@ static boolean network_game_server_handle_client_machines( network_game_server_kick_pending[machine_index] = _kick_none; if (kept_out && address && !network_game_server_client_machine_is_local(server, client_machine)) { - network_game_server_kicked_addresses[network_game_server_kicked_address_next++ % - MAXIMUM_KICKED_ADDRESSES] = address; + long kicked = network_game_server_kicked_address_next++ % MAXIMUM_KICKED_ADDRESSES; + + network_game_server_kicked[kicked].address = address; + network_game_server_kicked[kicked].real_address = distributed_real_address(address); + csstrncpy(network_game_server_kicked[kicked].hardware_id, network_game_server_hardware_ids[machine_index], + P2P_HARDWARE_ID_SIZE - 1); + network_game_server_kicked[kicked].hardware_id[P2P_HARDWARE_ID_SIZE - 1] = 0; } message = create_network_game_message(_message_server_machine_rejected, &rejection, sizeof(rejection)); if (message) @@ -4993,8 +5136,11 @@ static boolean network_game_server_have_all_machines_have_precached( /* port: a machine with no player is joining or leaving, and one still downloading the map does not have it. Neither holds the - start. A download joins the game in progress once it is done. */ - if (!network_game_server_machine_has_players(server, machine->machine_index) || + start. A download joins the game in progress once it is done. A + dedicated server's own machine has no player, and its map must be + loaded (port/linux/game/server_director.c) */ + if ((!network_game_server_machine_has_players(server, machine->machine_index) && + !(halo_server_mode() && network_game_server_client_machine_is_local(server, machine))) || map_share_machine_needs_map(machine->machine_index)) continue; client_has_precached = TEST_FLAG( diff --git a/source/networking/network_server_manager.h b/source/networking/network_server_manager.h index 29b18ad0..3d9665ec 100644 --- a/source/networking/network_server_manager.h +++ b/source/networking/network_server_manager.h @@ -48,6 +48,24 @@ void network_game_server_pause_countdown( starts with none (network_test.c's host) */ void network_game_server_port_clear_queued_players( struct network_game_server *server); +/* port: a dedicated server's lobby (port/linux/game/server_director.c) */ +long network_game_server_port_countdown_milliseconds( + struct network_game_server *server); +boolean network_game_server_port_countdown_paused( + struct network_game_server *server); +void network_game_server_port_countdown_begin( + struct network_game_server *server, + long milliseconds); +boolean network_game_server_port_start_sent( + struct network_game_server *server); +boolean network_game_server_port_ok_to_countdown( + struct network_game_server *server); +void network_game_server_port_forget_kicked_address( + unsigned long address, + char const *hardware_id); +long network_game_server_port_remote_machine_count( + struct network_game_server *server, + boolean joined); void network_game_generate_join_game_token( byte *join_token); void network_game_server_kick_machine( diff --git a/source/networking/network_server_message_handler.c b/source/networking/network_server_message_handler.c index 6f53151e..7251b227 100644 --- a/source/networking/network_server_message_handler.c +++ b/source/networking/network_server_message_handler.c @@ -1793,6 +1793,10 @@ static boolean network_game_server_handle_message_client_broadcast_game_search( { advertisement.reserved[HALO_PORT_ADVERTISED_FLAGS_OFFSET] |= HALO_PORT_ADVERTISED_IN_PROGRESS_FLAG; } + /* port: a dedicated server, with no player of its own + (port/linux/game/server_director.c) */ + if (halo_server_mode()) + advertisement.reserved[HALO_PORT_ADVERTISED_FLAGS_OFFSET] |= HALO_PORT_ADVERTISED_DEDICATED_FLAG; /* (an OpenCE gametype's kind, the server browser's name for it) */ advertisement.reserved[HALO_PORT_ADVERTISED_KIND_OFFSET] = game_modes_kind(&game->variant, &game->variant_options); diff --git a/source/saved games/playlist_profile.c b/source/saved games/playlist_profile.c index f4db8ffa..9c965d4b 100644 --- a/source/saved games/playlist_profile.c +++ b/source/saved games/playlist_profile.c @@ -436,6 +436,29 @@ boolean playlist_profile_get_from_path( return success; } +/* port: a saved gametype's file, read whole already, its PC options too +(the Xbox's defaults for one saved without them): a dedicated server's +file= rotation entries, read from its gametypes folder by the +platform layer (port/linux/game/server_gametypes.c) */ +boolean playlist_profile_get_from_data_with_options( + byte const *data, + unsigned long size, + struct game_variant *variant, + struct game_variant_options *options) +{ + XCALCSIG_SIGNATURE checksum; + + if (!data || size < SAVED_GAME_FILE_BLOCK_SIZE) + return FALSE; + saved_game_file_generate_checksum(data, PLAYLIST_PROFILE_CHECKSUM_DATA_SIZE, &checksum); + if (csmemcmp(&checksum, data + PLAYLIST_PROFILE_CHECKSUM_DATA_SIZE, sizeof(checksum))) + return FALSE; + csmemcpy(variant, data, sizeof(struct game_variant)); + if (!playlist_profile_options_from_block(data, options)) + game_variant_options_default(variant, options); + return TRUE; +} + boolean playlist_profile_get_display_name( long playlist_profile_index, wchar_t *display_name) diff --git a/source/saved games/playlist_profile.h b/source/saved games/playlist_profile.h index 5438c7f5..d841ca4a 100644 --- a/source/saved games/playlist_profile.h +++ b/source/saved games/playlist_profile.h @@ -52,6 +52,13 @@ void playlist_profile_delete( boolean playlist_profile_get_from_path( char *full_path, struct game_variant *variant); +/* port: ... from the file's bytes, read already, and its PC options too +(playlist_profile.c) */ +boolean playlist_profile_get_from_data_with_options( + byte const *data, + unsigned long size, + struct game_variant *variant, + struct game_variant_options *options); boolean playlist_profile_get_display_name( long playlist_profile_index, wchar_t *display_name); diff --git a/source/shell/shell_xbox.c b/source/shell/shell_xbox.c index 02695dec..12a8f19b 100644 --- a/source/shell/shell_xbox.c +++ b/source/shell/shell_xbox.c @@ -104,6 +104,10 @@ shell_idle( int main( void) { + /* port: the command line's --version, --keygen..., and a dedicated + server's checks of its game data and settings, before anything is + allocated or bound (port/linux/src/server_mode.c) */ + server_mode_start(); fuck_code_in_the_eye(); rasterizer_preinitialize__fill_you_up_with_the_devils_cock(); physical_memory_allocate(); diff --git a/tools/android_build.py b/tools/android_build.py index 6f41c92b..b2ce28e4 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -610,12 +610,18 @@ def generate_android_build(n: Writer, sln: Any) -> None: host_objects.append(obj) # internet play's DTLS with browsers (posix_dtls.c, with # port/third_party/mbedtls), as the other posix_*.c in the host - mbedtls_cflags = " ".join([host_cflags, f"-I{MBEDTLS_DIR / 'include'}", f"-I{MBEDTLS_DIR / 'library'}"]) - for source in [LINUX_DIR / "src" / "posix_dtls.c", *sorted((MBEDTLS_DIR / "library").glob("*.c"))]: + # (with its locks: internet play's thread and the server browser's master + # servers' both use it, as on Linux) + mbedtls_cflags = " ".join([host_cflags, f"-I{MBEDTLS_DIR / 'include'}", f"-I{MBEDTLS_DIR / 'library'}", + "-DMBEDTLS_THREADING_C", "-DMBEDTLS_THREADING_PTHREAD"]) + # (and the server browser's requests to the master servers: posix_update.c's, + # host_https.c) + for source in [LINUX_DIR / "src" / "posix_dtls.c", LINUX_DIR / "src" / "posix_update.c", + *sorted((MBEDTLS_DIR / "library").glob("*.c"))]: obj = host_obj_dir / ("mbedtls_" + source.name + ".o" if source.parent.parent == MBEDTLS_DIR else source.name + ".o") n.build(outputs=obj, rule="android_host_cc", inputs=source, - variables={"cflags": mbedtls_cflags + (" -w" if source.name != "posix_dtls.c" else "")}) + variables={"cflags": mbedtls_cflags + (" -w" if source.parent.parent == MBEDTLS_DIR else "")}) host_objects.append(obj) table_obj = host_obj_dir / "host_import_table.c.o" n.build(outputs=table_obj, rule="android_host_cc", inputs=host_table_c, variables={"cflags": host_cflags}) diff --git a/tools/ce_menus.py b/tools/ce_menus.py index fc164c60..a697da43 100644 --- a/tools/ce_menus.py +++ b/tools/ce_menus.py @@ -68,12 +68,23 @@ LEFT_OUT = { # port_settings.WIDGET_PATCHES) "ui\\shell\\main_menu\\multiplayer_type_select\\checking_updates_screen_create", "ui\\shell\\main_menu\\multiplayer_type_select\\checking_updates_screen_join", + # (the server browser's Filters' PING, which is not known before a game + # is joined (the browser does not ping the master servers' servers), and + # TEAMPLAY OPTIONS, which the port's Filters leave to GAME TYPE) + "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\op_ping", + "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\op_game_options", } # children moved: (parent, child) to (x, y); the main menu's Quit a row below # Mods, which takes Credits' place; Multiplayer's Edit Gametypes and divider # below Co-op, keeping its 32-unit highlight inside the description panel's # bottom curve (port_settings.WIDGET_PATCHES) CHILD_OFFSETS = { + # the server browser's source tab (ALL GAMES, DEDICATED, P2P GAMES), 20 + # units wider than CE's for its words (port_settings.WIDGET_PATCHES): 9 + # units left, so that its words and arrows are centred in the tab the + # screen's border picture outlines (its own box is gone) + ("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\op_browser_mode", + "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\browser_mode_spinner"): (16, 1), # Player Options' rows 27 apart, not 30, so that the host's AIM ASSIST and # SHIELD EFFECTS fit below them and above the help, which some fill with # three lines (port_settings.WIDGET_PATCHES) @@ -91,6 +102,15 @@ CHILD_OFFSETS = { **{("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\join_game_items_list", f"ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\server_item_{row}"): (10, 102 + 17 * (row - 1)) for row in range(1, 16)}, + # the server browser's count of servers, wider to the left + # (port_settings.WIDGET_PATCHES) + ("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\screen_stats_container", + "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\server_count_label"): (410, 60), + # the server browser's Filters' rows, closed up where PING and TEAMPLAY + # OPTIONS were (LEFT_OUT) + **{("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\filters_options_menu", + f"ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\op_{row}"): (54, 133 + 30 * index) + for index, row in enumerate(("game_type", "installed_maps"))}, # the map lists' first row, the SINGLEPLAYER or MULTIPLAYER chooser # (port_settings.MAP_KIND_CHOOSER), where the gametype list has its own **{(f"ui\\shell\\main_menu\\{list_tag}", "ui\\shell\\main_menu\\new_select\\list_item_0"): (82, 73) @@ -157,6 +177,9 @@ WIRED = { "mouse emit x event", "emit custom activation event", "single prev cl item activated", "controls back handler", "gamespy back handler", "gamespy dismiss error", "gamespy dismiss filters", "gamespy screen init", "gamespy screen dispose", "mp type set mode", + # (the server browser's: menu_functions.c's lobby_browser_*) + "gamespy select header", "gamespy select button", "gamespy select item", "gamespy screen update", + "gamespy update filter settings", "campaign menu init", "campaign menu continue", "difficulty item select", "solo map list update", "load game menu init", "load game menu dispose", "load game menu activated", "load game list update", "load game menu delete request", "load game menu delete finish", diff --git a/tools/ci_build.py b/tools/ci_build.py index 76f92cdd..1897fd12 100644 --- a/tools/ci_build.py +++ b/tools/ci_build.py @@ -21,9 +21,18 @@ The web build (port/web/README.md) needs Emscripten (emcc on the PATH, as emsdk_env.sh puts it); dist/halo-web-/ is the whole site, which a static host serves as it is: the workflow publishes the release build's to GitHub Pages. + + python tools/ci_build.py sums release/ + +writes release/SHA256SUMS: a line for each file of the folder (the +release's assets: the workflow zips each build there), " ", +as sha256sum writes it and `sha256sum -c` reads it. The workflow signs it +with the release key (tools/release_sign.sh: SHA256SUMS.sig) and publishes +both with the release. """ import argparse +import hashlib import os import shutil import subprocess @@ -52,7 +61,29 @@ def run(command, cwd=ROOT): subprocess.run([str(part) for part in command], cwd=cwd, check=True) +def write_sums(folder: Path) -> int: + """folder/SHA256SUMS: each file's SHA-256 and name, in name order (the + sums file itself and its signature left out)""" + lines = [] + for path in sorted(folder.iterdir(), key=lambda item: item.name): + if not path.is_file() or path.name in ("SHA256SUMS", "SHA256SUMS.sig"): + continue + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + digest.update(chunk) + lines.append(f"{digest.hexdigest()} {path.name}\n") + if not lines: + print(f"{folder}: no files to sum", file=sys.stderr) + return 1 + (folder / "SHA256SUMS").write_text("".join(lines), encoding="utf-8", newline="\n") + print(f"{folder / 'SHA256SUMS'}: {len(lines)} files", flush=True) + return 0 + + def main() -> int: + if len(sys.argv) == 3 and sys.argv[1] == "sums": + return write_sums(Path(sys.argv[2])) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("platform", choices=sorted(OUTPUTS)) parser.add_argument("config", choices=["debug", "release", "profile"]) @@ -135,6 +166,10 @@ def main() -> int: # voice chat's codec (port/third_party/opus), in every build, whose BSD # license asks binaries to carry its notice shutil.copy2(ROOT / "port/third_party/opus/COPYING", dist / "opus-COPYING.txt") + # the JSON tokenizer (port/third_party/jsmn) of the server browser's + # lists and the dedicated server's API, in every build, whose MIT + # license asks copies to carry its notice + shutil.copy2(ROOT / "port/third_party/jsmn/LICENSE", dist / "jsmn-LICENSE.txt") # voice chat's speaker icons and the Mods menu's warning icons # (port/assets/icons/lucide, drawn into the menus' bitmaps), whose ISC # license asks copies to carry its notice @@ -143,6 +178,22 @@ def main() -> int: # Android's APK has its own copy) if args.platform != "android": shutil.copy2(ROOT / "port/assets/network/brokers.txt", dist / "brokers.txt") + # the master servers that list dedicated servers, for the server + # browser and the dedicated server (network.masters_file and + # server.masters_file, docs/dedicated_server.md) + masters = ROOT / "port/assets/network/masters.txt" + if masters.exists(): + shutil.copy2(masters, dist / "masters.txt") + if args.platform in ("linux", "windows"): + # the dedicated server's examples: init.txt's commands and gametype + # files (docs/dedicated_server.md, "Files") + shutil.copy2(ROOT / "deploy/init.txt.example", dist / "init.txt.example") + shutil.copytree(ROOT / "deploy/gametypes", dist / "gametypes") + if args.platform == "linux": + # halo --server's script, and the ways to deploy a server + # (deploy/: systemd, Docker, Pterodactyl, Kubernetes, other panels) + shutil.copy2(ROOT / "deploy/halo-server", dist / "halo-server") + shutil.copytree(ROOT / "deploy", dist / "deploy", ignore=shutil.ignore_patterns("*.zip", "__pycache__")) return 0 diff --git a/tools/egg_build.py b/tools/egg_build.py new file mode 100644 index 00000000..95e6c955 --- /dev/null +++ b/tools/egg_build.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Writes the Pterodactyl egg of the dedicated server again, with its +installation script, and checks it: + + python tools/egg_build.py # deploy/pterodactyl/install.sh into the egg + python tools/egg_build.py --check # exit 1 if the egg is not what this would write + +The egg (deploy/pterodactyl/egg-opence-dedicated-server.json) is edited by +hand except for scripts.installation.script, which is install.sh's text. +The checks follow what the panel's importer and Wings take (PTDL_v2): +the fields and their types, the variables' environment names, their rules +in Laravel's syntax (the rules eggs use), each default against its rules, +and the startup command's {{...}} placeholders. tools/test_egg.py runs them. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EGG = ROOT / "deploy/pterodactyl/egg-opence-dedicated-server.json" +INSTALL = ROOT / "deploy/pterodactyl/install.sh" + +# what Wings sets itself, which a variable may not be (the panel's +# EggVariable::RESERVED_ENV_NAMES) +RESERVED = {"SERVER_MEMORY", "SERVER_IP", "SERVER_PORT", "ENV", "HOME", "USER", "STARTUP", "SERVER_UUID", "UUID"} +# placeholders the startup command may hold besides the variables' +STARTUP_BUILTINS = {"SERVER_MEMORY", "SERVER_IP", "SERVER_PORT"} +# Laravel rules with no argument, and with one (the ones eggs use) +RULES_PLAIN = {"required", "nullable", "sometimes", "string", "integer", "numeric", "boolean", "alpha_dash", + "alpha_num", "url", "present"} +RULES_WITH_ARGUMENT = {"max", "min", "between", "in", "not_in", "regex", "not_regex", "digits_between", "size"} + + +def egg_text(egg: dict) -> str: + """The file's text for egg: four-space indents, as the panel exports.""" + return json.dumps(egg, indent=4, ensure_ascii=False) + "\n" + + +def build(egg: dict, script: str) -> dict: + """egg with script as its installation script.""" + egg = json.loads(json.dumps(egg)) + egg["scripts"]["installation"]["script"] = script.replace("\r\n", "\n") + return egg + + +def parse_rules(rules: str) -> list: + """[(name, argument or None)] of a Laravel rules string; ValueError if malformed.""" + if not isinstance(rules, str) or not rules: + raise ValueError("rules must be a non-empty string") + parsed = [] + for part in rules.split("|"): + name, colon, argument = part.partition(":") + if name in RULES_PLAIN: + if colon: + raise ValueError(f"{name} takes no argument") + parsed.append((name, None)) + elif name in RULES_WITH_ARGUMENT: + if not argument: + raise ValueError(f"{name} needs an argument") + if name in ("max", "min", "size") and not re.fullmatch(r"\d+", argument): + raise ValueError(f"{name}:{argument} is not a number") + if name in ("between", "digits_between") and not re.fullmatch(r"\d+,\d+", argument): + raise ValueError(f"{name}:{argument} is not two numbers") + if name in ("between", "digits_between"): + low, high = (int(value) for value in argument.split(",")) + if low > high: + raise ValueError(f"{name}:{argument} is empty") + if name in ("regex", "not_regex"): + match = re.fullmatch(r"/(.*)/([a-z]*)", argument, re.S) + if not match: + raise ValueError(f"{name}:{argument} is not /pattern/") + re.compile(match.group(1)) + parsed.append((name, argument)) + else: + raise ValueError(f"unknown rule {name!r}") + names = [name for name, _ in parsed] + if "required" in names and "nullable" in names: + raise ValueError("both required and nullable") + if len(set(names)) != len(names): + raise ValueError("a rule given twice") + return parsed + + +def value_passes(value: str, rules: list) -> str: + """Why value fails the parsed rules, as Laravel would judge an + environment value (a string), or "" if it passes.""" + names = {name for name, _ in rules} + if value == "": + return "" if "nullable" in names or "required" not in names else "empty, but required" + numeric = "integer" in names or "numeric" in names + if "integer" in names and not re.fullmatch(r"-?\d+", value): + return "not an integer" + if "numeric" in names and not re.fullmatch(r"-?\d+(\.\d+)?", value): + return "not a number" + if "boolean" in names and value not in ("0", "1", "true", "false"): + return "not a boolean" + if "alpha_dash" in names and not re.fullmatch(r"[\w-]+", value): + return "not letters, digits, dashes and underscores" + if "alpha_num" in names and not re.fullmatch(r"[^\W_]+", value): + return "not letters and digits" + size = float(value) if numeric else len(value) + for name, argument in rules: + if name == "max" and size > int(argument): + return f"over max:{argument}" + if name == "min" and size < int(argument): + return f"under min:{argument}" + if name == "between": + low, high = (int(part) for part in argument.split(",")) + if not low <= size <= high: + return f"outside between:{argument}" + if name == "in" and value not in argument.split(","): + return f"not in {argument}" + if name == "regex" and not re.search(re.fullmatch(r"/(.*)/[a-z]*", argument, re.S).group(1), value): + return f"does not match {argument}" + return "" + + +def check(egg: dict) -> list: + """What is wrong with egg, as lines (none: it is fine).""" + problems = [] + + def need(container, key, kind, where): + if key not in container: + problems.append(f"{where}{key}: missing") + return None + if not isinstance(container[key], kind): + problems.append(f"{where}{key}: not {kind}") + return None + return container[key] + + if not isinstance(egg, dict): + return ["the egg is not an object"] + meta = need(egg, "meta", dict, "") + if meta is not None: + if meta.get("version") != "PTDL_v2": + problems.append("meta.version: not PTDL_v2") + if "update_url" not in meta: + problems.append("meta.update_url: missing") + for key in ("_comment", "exported_at", "name", "author", "description", "startup"): + need(egg, key, str, "") + author = egg.get("author", "") + if isinstance(author, str) and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", author): + problems.append("author: not an e-mail address") + if isinstance(egg.get("exported_at"), str) and not re.fullmatch( + r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\+00:00", egg["exported_at"]): + problems.append("exported_at: not an ISO 8601 time") + if "features" not in egg or not isinstance(egg["features"], (list, type(None))): + problems.append("features: not a list or null") + images = need(egg, "docker_images", dict, "") + if images is not None: + if not images: + problems.append("docker_images: empty") + for label, image in images.items(): + if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+(:[A-Za-z0-9._-]+)?(@sha256:[0-9a-f]{64})?", image): + problems.append(f"docker_images[{label!r}]: not an image reference") + need(egg, "file_denylist", list, "") + + config = need(egg, "config", dict, "") + if config is not None: + for key in ("files", "startup", "logs", "stop"): + if need(config, key, str, "config.") is None: + continue + if key == "stop": + continue + try: + parsed = json.loads(config[key]) + except ValueError: + problems.append(f"config.{key}: not JSON in a string") + continue + if not isinstance(parsed, dict): + problems.append(f"config.{key}: not an object") + elif key == "startup": + # (Wings takes a string or a list of them, any of which + # found in a line of the console marks the server started) + done = parsed.get("done") + lines = [done] if isinstance(done, str) else done + if not isinstance(lines, list) or not lines or not all( + isinstance(line, str) and line for line in lines): + problems.append("config.startup: no done line") + if isinstance(config.get("stop"), str) and not config["stop"]: + problems.append("config.stop: empty") + + scripts = need(egg, "scripts", dict, "") + installation = need(scripts, "installation", dict, "scripts.") if scripts is not None else None + if installation is not None: + for key in ("script", "container", "entrypoint"): + need(installation, key, str, "scripts.installation.") + if not str(installation.get("script", "")).startswith("#!/bin/bash"): + problems.append("scripts.installation.script: does not start with #!/bin/bash") + if "\r" in str(installation.get("script", "")): + problems.append("scripts.installation.script: has carriage returns") + + variables = need(egg, "variables", list, "") + names = set() + for index, variable in enumerate(variables or []): + where = f"variables[{index}]." + if not isinstance(variable, dict): + problems.append(f"variables[{index}]: not an object") + continue + for key in ("name", "description", "env_variable", "default_value", "rules", "field_type"): + need(variable, key, str, where) + for key in ("user_viewable", "user_editable"): + need(variable, key, bool, where) + env = variable.get("env_variable", "") + if not isinstance(env, str): + continue + where = f"variables[{env}]." + if not re.fullmatch(r"[A-Z][A-Z0-9_]{0,190}", env): + problems.append(f"{where}env_variable: not upper-case letters, digits and underscores") + if env in RESERVED: + problems.append(f"{where}env_variable: reserved by Wings") + if env in names: + problems.append(f"{where}env_variable: given twice") + names.add(env) + if variable.get("field_type") not in ("text", None): + problems.append(f"{where}field_type: not text") + if variable.get("user_editable") and not variable.get("user_viewable"): + problems.append(f"{where}user_editable without user_viewable: the owner cannot see it to edit it") + try: + rules = parse_rules(variable.get("rules")) + except (ValueError, re.error) as error: + problems.append(f"{where}rules: {error}") + continue + default = variable.get("default_value") + if isinstance(default, str): + failure = value_passes(default, rules) + if failure: + problems.append(f"{where}default_value {default!r}: {failure}") + + startup = egg.get("startup", "") + if isinstance(startup, str): + placeholders = re.findall(r"{{\s*([^{}]*?)\s*}}", startup) + if "SERVER_PORT" not in placeholders: + problems.append("startup: no {{SERVER_PORT}}") + for placeholder in placeholders: + if placeholder.startswith(("server.build.", "env.")): + continue + if placeholder not in STARTUP_BUILTINS and placeholder not in names: + problems.append(f"startup: {{{{{placeholder}}}}} is neither Wings' nor a variable's") + if placeholder != placeholder.upper(): + problems.append(f"startup: {{{{{placeholder}}}}} is not upper case (Wings upper-cases names)") + if re.search(r"{{|}}", re.sub(r"{{\s*[^{}]*?\s*}}", "", startup)): + problems.append("startup: unbalanced braces") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", help="only check that the egg is up to date and valid") + args = parser.parse_args() + + egg = json.loads(EGG.read_text(encoding="utf-8")) + built = build(egg, INSTALL.read_text(encoding="utf-8")) + problems = check(built) + for problem in problems: + print(f"{EGG.relative_to(ROOT)}: {problem}", file=sys.stderr) + if problems: + return 1 + text = egg_text(built) + if args.check: + if EGG.read_text(encoding="utf-8") != text: + print(f"{EGG.relative_to(ROOT)} is out of date: run python tools/egg_build.py", file=sys.stderr) + return 1 + return 0 + if EGG.read_text(encoding="utf-8") != text: + EGG.write_text(text, encoding="utf-8") + print(f"wrote {EGG.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/embed_assets.py b/tools/embed_assets.py index a2236cc8..f9276237 100644 --- a/tools/embed_assets.py +++ b/tools/embed_assets.py @@ -136,6 +136,22 @@ def hud_assets_build(n: Any, prefix: str, output: Path) -> List[Path]: return [output] +def server_ui_build(n: Any, prefix: str, output: Path) -> List[Path]: + """Emits the rule that generates a dedicated server's admin page as C + (tools/server_ui_embed.py, port/assets/server_ui); returns [output]""" + ui = Path("port/assets/server_ui") + inputs = sorted(path for path in (ROOT / ui).glob("*") if path.is_file()) + n.rule( + name=f"{prefix}_server_ui", + command="$python tools/server_ui_embed.py $out", + description=f"{prefix.upper()} EMBED $out", + ) + n.build(outputs=output, rule=f"{prefix}_server_ui", + implicit=[Path("tools/server_ui_embed.py"), Path("tools/server_sim.py"), + *(ui / path.name for path in inputs)]) + return [output] + + def png_size(data: bytes, name: str) -> tuple: """The width and height of an 8-bit RGBA, non-interlaced PNG (the only kind port/linux/src/hud_hires.c reads).""" diff --git a/tools/harness/__init__.py b/tools/harness/__init__.py index e351e53f..017400a8 100644 --- a/tools/harness/__init__.py +++ b/tools/harness/__init__.py @@ -98,5 +98,5 @@ def build(test, generated): def run(executable, case): """Run one case: (exit status, output).""" - result = subprocess.run([str(executable), case], capture_output=True, text=True, timeout=60) + result = subprocess.run([str(executable), case], capture_output=True, text=True, errors="replace", timeout=60) return result.returncode, (result.stdout + result.stderr).strip() diff --git a/tools/harness/tests/chat_logic.c b/tools/harness/tests/chat_logic.c index 4fdf9076..fc0cbd87 100644 --- a/tools/harness/tests/chat_logic.c +++ b/tools/harness/tests/chat_logic.c @@ -446,6 +446,18 @@ int main(int argc, char **argv) CHECK(chat_refused(links, "see discord.gg/x") == NULL, "a link refused where links are allowed"); } + /* a public game's chat: off in one a player hosts (on the host, and on a + client that joined it from the server browser), on in a dedicated + server's, whose own setting then decides; a game not listed keeps its + host's setting */ + CASE("listing") + { + CHECK(chat_listing_allows(FALSE, FALSE), "a private or LAN game has no chat"); + CHECK(chat_listing_allows(FALSE, TRUE), "an unlisted dedicated server has no chat"); + CHECK(!chat_listing_allows(TRUE, FALSE), "a public game a player hosts has chat"); + CHECK(chat_listing_allows(TRUE, TRUE), "a public dedicated server has no chat"); + } + /* the on-screen keyboard's characters: a letter in capitals with shift, nothing else changed; none on the last row, or off the keyboard */ CASE("keyboard-character") diff --git a/tools/harness/tests/config_write.c b/tools/harness/tests/config_write.c new file mode 100644 index 00000000..41536024 --- /dev/null +++ b/tools/harness/tests/config_write.c @@ -0,0 +1,161 @@ +/* +CONFIG_WRITE.C (test) + +config_write (port/linux/src/port_config.c): a setting set for now and its +line in config.toml changed, a string as long as a server's rotation +(escaped as a TOML basic string) included, and nothing written by a run +that only answers its command line. test_config_write.py takes the code +and its types from the sources; the settings and the file are the test's. +*/ + +#include "harness.h" + +#include +#include + +#include "types.inc" + +static const struct config_setting config_settings[] = +{ + { "display.vsync", _config_boolean, "true", NULL, _environment_value, _platform_all, "" }, + { "server.rotation", _config_string, "\"\"", NULL, _environment_value, _platform_all, "" }, +}; +#define NUMBER_OF_CONFIG_SETTINGS (sizeof(config_settings) / sizeof(config_settings[0])) + +static struct config_value config_values[NUMBER_OF_CONFIG_SETTINGS]; +static pthread_mutex_t config_lock = PTHREAD_MUTEX_INITIALIZER; +static volatile unsigned long config_change_count; +static int config_answer_only; + +/* config.toml: what it holds, how many times it was written, and how many +of those by a run that only answers its command line */ +static char *file_text; +static int file_writes; +static int answering_writes; + +static long config_setting_index(const char *name); + +static const struct config_value *config_value(const char *name, enum config_type type) +{ + (void)type; + return &config_values[config_setting_index(name)]; +} + +static void config_path(char *path, size_t size) +{ + snprintf(path, size, "config.toml"); +} + +static char *config_read_file(const char *path, size_t *size) +{ + (void)path; + *size = file_text ? strlen(file_text) : 0; + return file_text ? strdup(file_text) : NULL; +} + +static int config_write_file(const char *path, const char *text) +{ + (void)path; + free(file_text); + file_text = strdup(text); + file_writes++; + answering_writes += config_answer_only != 0; + return 1; +} + +#include "under_test.inc" + +/* the file's line for the key, NULL if none */ +static const char *file_line(const char *key) +{ + const char *line; + + for (line = file_text; line && *line; line = strchr(line, '\n') ? strchr(line, '\n') + 1 : line + strlen(line)) + { + if (!strncmp(line, key, strlen(key)) && line[strlen(key)] == ' ') + return line; + } + return NULL; +} + +static char value[8192]; +static char expected[8 * 8192]; + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + const char *line; + size_t index; + + file_text = strdup("[display]\nvsync = true\n\n[server]\nrotation = \"bloodgulch\"\n"); + config_values[1].string = strdup("bloodgulch"); + /* a rotation as long as one may be, with quotes, backslashes and control + characters escaped */ + CASE("long-string") + { + char *end; + + for (index = 0; index < CONFIG_MAXIMUM_WRITTEN_STRING; index++) + value[index] = "ab\"\\\x01 "[index % 6]; + value[CONFIG_MAXIMUM_WRITTEN_STRING] = 0; + CHECK(config_write("server.rotation", value), "a rotation of %d characters was refused", + CONFIG_MAXIMUM_WRITTEN_STRING); + CHECK(!strcmp(config_values[1].string, value), "the rotation set was not the one written"); + end = expected + sprintf(expected, "rotation = \""); + for (index = 0; value[index]; index++) + { + if (value[index] == '\x01') + end += sprintf(end, "\\u0001"); + else if (value[index] == '"' || value[index] == '\\') + end += sprintf(end, "\\%c", value[index]); + else + *end++ = value[index]; + } + strcpy(end, "\"\n"); + line = file_line("rotation"); + CHECK(line && !strncmp(line, expected, strlen(expected)), "the file's line was %.60s", line ? line : "none"); + CHECK(!strstr(file_text, "bloodgulch") && strstr(file_text, "vsync = true\n"), + "the file's other lines were changed:\n%.200s", file_text); + return 0; + } + /* each character escaped as \u0001: the longest line */ + CASE("all-escaped") + { + memset(value, '\x01', CONFIG_MAXIMUM_WRITTEN_STRING); + value[CONFIG_MAXIMUM_WRITTEN_STRING] = 0; + CHECK(config_write("server.rotation", value), "a rotation of control characters was refused"); + line = file_line("rotation"); + CHECK(line && strlen(line) == strlen("rotation = \"\"\n") + 6 * CONFIG_MAXIMUM_WRITTEN_STRING, + "the file's line was %d characters", line ? (int)strlen(line) : 0); + return 0; + } + /* longer than a rotation may be: refused, the setting and the file as + they were */ + CASE("too-long") + { + memset(value, 'a', CONFIG_MAXIMUM_WRITTEN_STRING + 1); + value[CONFIG_MAXIMUM_WRITTEN_STRING + 1] = 0; + CHECK(!config_write("server.rotation", value), "a rotation of %d characters was taken", + CONFIG_MAXIMUM_WRITTEN_STRING + 1); + CHECK(!strcmp(config_values[1].string, "bloodgulch") && file_writes == 0, "a refused rotation was set"); + /* (a boolean's text stays short) */ + memset(value, 'a', 300); + value[300] = 0; + CHECK(!config_write("display.vsync", value), "a boolean's text of 300 characters was taken"); + CHECK(config_write("display.vsync", "false") && file_line("vsync") && + !strncmp(file_line("vsync"), "vsync = false\n", 14), "a boolean was not written"); + return 0; + } + /* a run that only answers its command line (a probe's) writes nothing: + the value is its own */ + CASE("answer-only") + { + config_answer_only = 1; + CHECK(config_write("server.rotation", "prisoner"), "the write was refused"); + CHECK(!strcmp(config_values[1].string, "prisoner"), "the value was not set"); + CHECK(file_writes == 0 && answering_writes == 0 && strstr(file_text, "bloodgulch"), "config.toml was written"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/console_lines.c b/tools/harness/tests/console_lines.c new file mode 100644 index 00000000..13748bd2 --- /dev/null +++ b/tools/harness/tests/console_lines.c @@ -0,0 +1,190 @@ +/* +CONSOLE_LINES.C (test) + +A dedicated server's command lines (port/linux/src/server_console.c): the +init file's and standard input's, a line too long to run left out rather +than run cut short, and server.console_exit_on_eof's "quit" after the +standard input's last line. test_console_lines.py takes the whole file with +its headers; the settings, the standard input and the log are the test's. +*/ + +#include "harness.h" + +#include + +#include "server_log.h" + +static const char *init_file = ""; +static int exit_on_eof; +/* standard input: what it has, how much is read, and how much a read +gives at most */ +static const char *input = ""; +static size_t input_read; +static int input_chunk = 4096; +static int warnings; + +int config_boolean(const char *name) +{ + if (!strcmp(name, "server.console")) + return 1; + return !strcmp(name, "server.console_exit_on_eof") && exit_on_eof; +} + +const char *config_string(const char *name) +{ + return !strcmp(name, "server.init_file") ? init_file : ""; +} + +const char *platform_data_root(void) +{ + return "."; +} + +int halo_server_mode(void) +{ + return 1; +} + +int posix_server_stdin_read(char *buffer, int size) +{ + size_t left = strlen(input) - input_read; + int count = size < input_chunk ? size : input_chunk; + + if (!left) + return -1; + if ((size_t)count > left) + count = (int)left; + memcpy(buffer, input + input_read, (size_t)count); + input_read += (size_t)count; + return count; +} + +void server_log(int level, const char *category, const char *format, ...) +{ + (void)category; + (void)format; + if (level == _server_log_warning) + warnings++; +} + +#include "under_test.inc" + +static char line[SERVER_COMMAND_LINE_SIZE]; + +/* the next line run, NULL if none */ +static const char *next_line(void) +{ + int origin; + + return server_console_next(line, sizeof(line), &origin) ? line : NULL; +} + +/* a line of so many x's, after its first character */ +static char *long_line(char *text, char first, int length) +{ + memset(text, 'x', (size_t)length); + text[0] = first; + text[length] = 0; + return text; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + const char *taken; + + /* the standard input ended with a line of its own: it, then quit */ + CASE("eof-quit") + { + exit_on_eof = 1; + input = "sv_status\nsay hi"; + input_chunk = 3; + taken = next_line(); + CHECK(taken && !strcmp(taken, "sv_status"), "the first line was %s", taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "say hi"), "the unterminated last line was %s", taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "quit"), "after the last line came %s, not quit", taken ? taken : "none"); + CHECK(!next_line(), "a line came after the quit"); + CHECK(!next_line(), "a line came after the quit"); + return 0; + } + /* a standard input ending with a newline: quit after its last line */ + CASE("eof-quit-terminated") + { + exit_on_eof = 1; + input = "sv_status\n"; + taken = next_line(); + CHECK(taken && !strcmp(taken, "sv_status"), "the line was %s", taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "quit"), "after the last line came %s, not quit", taken ? taken : "none"); + CHECK(!next_line(), "a line came after the quit"); + return 0; + } + /* without server.console_exit_on_eof: the last line, and no quit */ + CASE("eof-go-on") + { + input = "sv_status\nsay hi"; + taken = next_line(); + CHECK(taken && !strcmp(taken, "sv_status"), "the first line was %s", taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "say hi"), "the unterminated last line was %s", taken ? taken : "none"); + CHECK(!next_line(), "a line came after the standard input's end"); + return 0; + } + /* standard input's lines: SERVER_COMMAND_LINE_SIZE - 1 characters run, + more are left out, the unterminated last one too */ + CASE("input-limits") + { + static char text[4 * SERVER_COMMAND_LINE_SIZE]; + char longest[SERVER_COMMAND_LINE_SIZE + 8]; + + snprintf(text, sizeof(text), "%s\n", long_line(longest, 'a', SERVER_COMMAND_LINE_SIZE - 1)); + snprintf(text + strlen(text), sizeof(text) - strlen(text), "%s\n", long_line(longest, 'b', + SERVER_COMMAND_LINE_SIZE)); + snprintf(text + strlen(text), sizeof(text) - strlen(text), "after\n%s", long_line(longest, 'c', + SERVER_COMMAND_LINE_SIZE)); + exit_on_eof = 1; + input = text; + input_chunk = 100; + taken = next_line(); + CHECK(taken && taken[0] == 'a' && strlen(taken) == SERVER_COMMAND_LINE_SIZE - 1, "the longest line came as %s", + taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "after"), "after the longest came %.20s (%d characters), not after", + taken ? taken : "none", taken ? (int)strlen(taken) : 0); + taken = next_line(); + CHECK(taken && !strcmp(taken, "quit"), "after the line too long came %.20s, not quit", taken ? taken : "none"); + CHECK(warnings == 2, "%d lines were left out, not 2", warnings); + return 0; + } + /* the init file's lines: as standard input's */ + CASE("init-limits") + { + char path[] = "/tmp/halo-harness-init-XXXXXX"; + char longest[SERVER_COMMAND_LINE_SIZE + 128]; + int descriptor = mkstemp(path); + FILE *file = descriptor >= 0 ? fdopen(descriptor, "w") : NULL; + + CHECK(file != NULL, "no init file could be written"); + fprintf(file, "%s\n", long_line(longest, 'a', SERVER_COMMAND_LINE_SIZE - 1)); + fprintf(file, "%s\n", long_line(longest, 'b', SERVER_COMMAND_LINE_SIZE)); + fprintf(file, "%s\n", long_line(longest, 'c', SERVER_COMMAND_LINE_SIZE + 1)); + fprintf(file, "%s\r\n", long_line(longest, 'd', SERVER_COMMAND_LINE_SIZE + 100)); + fprintf(file, "# a comment\nlast"); + fclose(file); + init_file = path; + server_console_start(); + unlink(path); + taken = next_line(); + CHECK(taken && taken[0] == 'a' && strlen(taken) == SERVER_COMMAND_LINE_SIZE - 1, "the longest line came as %.20s", + taken ? taken : "none"); + taken = next_line(); + CHECK(taken && !strcmp(taken, "last"), "after the longest came %.20s (%d characters), not last", + taken ? taken : "none", taken ? (int)strlen(taken) : 0); + CHECK(warnings == 3, "%d lines were left out, not 3", warnings); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/console_secret.c b/tools/harness/tests/console_secret.c new file mode 100644 index 00000000..7ea3b2cd --- /dev/null +++ b/tools/harness/tests/console_secret.c @@ -0,0 +1,38 @@ +/* server_console_line_is_secret (port/linux/src/server_console.c): which +command lines are kept out of the log, as their command is matched (in any +case) */ +#include "harness.h" + +#include +#include + +#include "under_test.inc" + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + CASE("secrets") + { + static const char *const secret[] = { + "sv_password hunter22", "SV_PASSWORD x", " sv_password", "sv_set server.web_password abcdefgh", + "SV_SET server.web_password abcdefgh", "sv_set SERVER.WEB_MODERATOR_PASSWORD abcdefgh", + "sv_set server.password x", "\tsv_set server.web_viewer_password x", "sv_set server.publisher_key_file x", + "sv_set \"server.web_password\" abcdefgh", "\"sv_password\" x", "\"SV_SET\" \"server.password\" x", + NULL, + }; + static const char *const plain[] = { + "sv_set server.name Passwords", "sv_status", "say my password is safe", "sv_passwordless", + "sv_settings", "sv_set", "", "kick password", "sv_pass x", "sv_se server.web_password x", NULL, + }; + int index; + + for (index = 0; secret[index]; index++) + CHECK(server_console_line_is_secret(secret[index]), "\"%s\" was not a secret's", secret[index]); + for (index = 0; plain[index]; index++) + CHECK(!server_console_line_is_secret(plain[index]), "\"%s\" was taken for a secret's", plain[index]); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/lobby_browser.c b/tools/harness/tests/lobby_browser.c new file mode 100644 index 00000000..fa8faab3 --- /dev/null +++ b/tools/harness/tests/lobby_browser.c @@ -0,0 +1,194 @@ +#include "harness.h" +#include "enums.inc" +/* The server browser's choice and order of the games found +(port/linux/src/p2p_lobby.c): synthetic listings, filtered and sorted as the +menus have them (menu_functions.c's lobby_browser_update). */ +#include "under_test.inc" + +static struct p2p_listing games[16]; +static int count; + +/* a game: its name, map, engine, players of most, dedicated, locked */ +static void add(const char *name, const char *map, int engine, int players, int most, int dedicated, int locked) +{ + struct p2p_listing *game = &games[count++]; + + memset(game, 0, sizeof(*game)); + snprintf(game->name, sizeof(game->name), "%s", name); + snprintf(game->map, sizeof(game->map), "%s", map); + game->engine_type = (unsigned char)engine; + game->player_count = (unsigned char)players; + game->maximum_player_count = (unsigned char)most; + game->dedicated = (unsigned char)dedicated; + game->master = (unsigned char)dedicated; + game->locked = (unsigned char)locked; + game->open = 1; + game->ping = -1; + game->network_version = HALO_PORT_NETWORK_VERSION; +} + +/* the standard set: two dedicated servers, three player games */ +static void standard(void) +{ + count = 0; + add("Bravo dedicated", "bloodgulch", 1, 12, 16, 1, 0); + add("alpha player", "chillout", 3, 2, 6, 0, 0); + add("Charlie empty", "prisoner", 2, 0, 16, 0, 1); + add("delta full", "sidewinder", 2, 8, 8, 0, 0); + add("Echo dedicated empty", "custom_maps\\hugeass", 1, 0, 32, 1, 0); +} + +static struct p2p_lobby_choices all_choices(void) +{ + struct p2p_lobby_choices choices; + + memset(&choices, 0, sizeof(choices)); + choices.source = P2P_LOBBY_SOURCE_ALL; + choices.show_empty = choices.show_full = 1; + choices.maps = P2P_LOBBY_MAPS_YES; + return choices; +} + +static int has(int shown, const char *name) +{ + int index; + + for (index = 0; index < shown; index++) + { + if (!strcmp(games[index].name, name)) + return 1; + } + return 0; +} + +/* a Custom Edition map is missing; Hugeass can be downloaded */ +static int map_state(const char *map) +{ + if (!strcmp(map, "custom_maps\\hugeass")) + return P2P_LOBBY_MAP_DOWNLOADABLE; + if (!strncmp(map, "custom_maps\\", 12)) + return P2P_LOBBY_MAP_MISSING; + return P2P_LOBBY_MAP_INSTALLED; +} + +static int order_is(const char *const *names, int shown) +{ + int index; + + for (index = 0; index < shown; index++) + { + if (strcmp(games[index].name, names[index])) + { + fprintf(stderr, "place %d: %s, not %s\n", index, games[index].name, names[index]); + return 0; + } + } + return 1; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + struct p2p_lobby_choices choices = all_choices(); + int shown; + + standard(); + CASE("source") { + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 5, "ALL GAMES: every game"); + standard(); + choices.source = P2P_LOBBY_SOURCE_DEDICATED; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 2 && has(shown, "Bravo dedicated") && has(shown, "Echo dedicated empty"), "DEDICATED: the servers"); + CHECK(!strcmp(games[0].name, "Bravo dedicated"), "kept in their order"); + standard(); + choices.source = P2P_LOBBY_SOURCE_PLAYERS; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 3 && !has(shown, "Bravo dedicated"), "P2P GAMES: the player-hosted"); + } else CASE("empty_full") { + choices.show_empty = 0; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 3 && !has(shown, "Charlie empty") && !has(shown, "Echo dedicated empty"), "no empty servers"); + standard(); + choices = all_choices(); + choices.show_full = 0; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 4 && !has(shown, "delta full"), "no full servers"); + CHECK(has(shown, "Bravo dedicated"), "12 of 16 is not full"); + } else CASE("game_type") { + choices.engine_type = 2; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 2 && has(shown, "Charlie empty") && has(shown, "delta full"), "Slayer's alone"); + } else CASE("maps") { + add("Foxtrot", "custom_maps\\lockout", 1, 3, 16, 0, 0); + choices.maps = P2P_LOBBY_MAPS_NO; + shown = p2p_lobby_filter(games, count, &choices, map_state); + CHECK(shown == 4 && !has(shown, "Echo dedicated empty") && !has(shown, "Foxtrot"), "NO: installed maps only"); + standard(); + add("Foxtrot", "custom_maps\\lockout", 1, 3, 16, 0, 0); + choices.maps = P2P_LOBBY_MAPS_DOWNLOADABLE; + shown = p2p_lobby_filter(games, count, &choices, map_state); + CHECK(shown == 5 && has(shown, "Echo dedicated empty") && !has(shown, "Foxtrot"), "IF DOWNLOADABLE"); + standard(); + add("Foxtrot", "custom_maps\\lockout", 1, 3, 16, 0, 0); + choices.maps = P2P_LOBBY_MAPS_YES; + shown = p2p_lobby_filter(games, count, &choices, map_state); + CHECK(shown == 6, "YES: every map"); + } else CASE("servers_default") { + static const char *const expected[] = { "Bravo dedicated", "Echo dedicated empty", "delta full", + "alpha player", "Charlie empty" }; + + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_SERVERS, 0); + CHECK(order_is(expected, count), "dedicated servers first, then the most players, then by name"); + } else CASE("sort_each_way") { + static const char *const names[] = { "alpha player", "Bravo dedicated", "Charlie empty", "delta full", + "Echo dedicated empty" }; + static const char *const names_reversed[] = { "Echo dedicated empty", "delta full", "Charlie empty", + "Bravo dedicated", "alpha player" }; + static const char *const maps[] = { "Bravo dedicated", "alpha player", "Echo dedicated empty", + "Charlie empty", "delta full" }; + static const char *const players[] = { "Bravo dedicated", "delta full", "alpha player", "Charlie empty", + "Echo dedicated empty" }; + static const char *const players_reversed[] = { "Echo dedicated empty", "Charlie empty", "alpha player", + "delta full", "Bravo dedicated" }; + + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_NAME, 0); + CHECK(order_is(names, count), "by name, whatever the case"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_NAME, 1); + CHECK(order_is(names_reversed, count), "by name, the other way"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_MAP, 0); + CHECK(order_is(maps, count), "by map"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_PLAYERS, 0); + CHECK(order_is(players, count), "the most players first (equal ones by name)"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_PLAYERS, 1); + CHECK(order_is(players_reversed, count), "the fewest first"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_LOCKED, 0); + CHECK(!strcmp(games[0].name, "Charlie empty"), "a password's first"); + p2p_lobby_sort(games, count, P2P_LOBBY_SORT_TYPE, 0); + CHECK(games[0].engine_type == 1 && games[count - 1].engine_type == 3, "by type"); + } else CASE("other_version") { + add("Golf newer", "bloodgulch", 1, 15, 16, 1, 0); + games[count - 1].network_version = HALO_PORT_NETWORK_VERSION + 1; + shown = p2p_lobby_filter(games, count, &choices, NULL); + CHECK(shown == 6 && has(shown, "Golf newer"), "kept: shown greyed"); + p2p_lobby_sort(games, shown, P2P_LOBBY_SORT_SERVERS, 0); + CHECK(!strcmp(games[shown - 1].name, "Golf newer"), "last, though it has the most players"); + p2p_lobby_sort(games, shown, P2P_LOBBY_SORT_PLAYERS, 1); + CHECK(!strcmp(games[shown - 1].name, "Golf newer"), "last the other way too"); + CHECK(!strcmp(games[0].name, "Charlie empty") || !strcmp(games[0].name, "Echo dedicated empty"), + "the fewest first"); + } else CASE("sort_text") { + int reverse; + char text[32]; + + CHECK(p2p_lobby_sort_parse("players", &reverse) == P2P_LOBBY_SORT_PLAYERS && !reverse, "players"); + CHECK(p2p_lobby_sort_parse("-map", &reverse) == P2P_LOBBY_SORT_MAP && reverse, "-map: reversed"); + CHECK(p2p_lobby_sort_parse("nonsense", &reverse) == P2P_LOBBY_SORT_SERVERS && !reverse, "unknown: servers"); + CHECK(p2p_lobby_sort_parse("-", &reverse) == P2P_LOBBY_SORT_SERVERS && !reverse, "a lone -: servers"); + p2p_lobby_sort_text(P2P_LOBBY_SORT_TYPE, 1, text, sizeof(text)); + CHECK(!strcmp(text, "-type"), "written back"); + p2p_lobby_sort_text(99, 0, text, sizeof(text)); + CHECK(!strcmp(text, "servers"), "an unknown order written as servers"); + } else return 2; + return 0; +} diff --git a/tools/harness/tests/lobby_countdown.c b/tools/harness/tests/lobby_countdown.c index 5cd25997..b12e21fb 100644 --- a/tools/harness/tests/lobby_countdown.c +++ b/tools/harness/tests/lobby_countdown.c @@ -4,7 +4,10 @@ LOBBY_COUNTDOWN.C (test) The real pregame countdown of source/networking/network_server_manager.c: whether the lobby's START NOW (the "faster" request, _network_game_server_countdown_event_player_joined) starts the countdown, -for a host alone, a host with split screen players, and machines together. +for a host alone, a host with split screen players, and machines together; +and a dedicated server's rule, its director's +(port/linux/game/server_director.c's server_director_ok_to_countdown: +server.min_players, server.team_start, and its own state). test_lobby_countdown.py takes the constants, enums and timer structures (config.inc) and the code (under_test.inc). */ @@ -13,6 +16,8 @@ test_lobby_countdown.py takes the constants, enums and timer structures #define NETWORK_SERVER_MANAGER_FILE "server" #define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define CEILING(n, ceiling) ((n) > (ceiling) ? (ceiling) : (n)) +#define PIN(n, floor, ceiling) ((n) < (floor) ? (floor) : CEILING((n), (ceiling))) #include "config.inc" @@ -64,6 +69,28 @@ static boolean network_game_server_client_machine_is_joined_to_game( } static void network_game_server_countdown_started(struct network_game_server *s) { } +/* a dedicated server (port/linux/game/server_director.c): its settings, and +its director's state (test_lobby_countdown.py names it director: this +file's server is the game's) */ +static int dedicated; +static long dedicated_minimum_players = 1; +static char const *dedicated_team_start = "one"; +static int halo_server_mode(void) { return dedicated; } +static long config_integer(char const *name) +{ + return !strcmp(name, "server.min_players") ? dedicated_minimum_players : 0; +} +static char const *config_string(char const *name) +{ + return !strcmp(name, "server.team_start") ? dedicated_team_start : ""; +} +static struct +{ + boolean quitting; + short state; + boolean entry_applied; +} director = { FALSE, _server_state_lobby, TRUE }; + #include "under_test.inc" static void join_machine( @@ -176,6 +203,80 @@ int main(int argc, char **argv) CHECK(server.countdown_state.active, "one machine's players of one team never start the countdown"); return 0; } + /* a dedicated server's host machine has no player: two machines' players + of one team start (server.team_start "one"), as Halo PC's servers do */ + CASE("dedicated-one-team-starts") + { + dedicated = 1; + lobby(3); + server.game.variant.universal_variant.teams = TRUE; + add_player(1, 0); + add_player(2, 0); + CHECK(server_ok_to_countdown(&server), "a dedicated server's players of one team may not count down"); + return 0; + } + CASE("dedicated-both-teams-waits") + { + dedicated = 1; + dedicated_team_start = "both"; + lobby(3); + server.game.variant.universal_variant.teams = TRUE; + add_player(1, 0); + add_player(2, 0); + CHECK(!server_ok_to_countdown(&server), "server.team_start both counted down with one team"); + server.game.players[1].team_index = 1; + CHECK(server_ok_to_countdown(&server), "server.team_start both did not count down with both teams"); + return 0; + } + CASE("dedicated-below-minimum-waits") + { + dedicated = 1; + dedicated_minimum_players = 2; + lobby(2); + add_player(1, 0); + CHECK(!server_ok_to_countdown(&server), "a dedicated server below server.min_players counted down"); + add_player(1, 1); + CHECK(server_ok_to_countdown(&server), "a dedicated server at server.min_players did not count down"); + return 0; + } + /* server.min_players is held to 1 to 16 */ + CASE("dedicated-minimum-clamped") + { + short index; + + dedicated = 1; + dedicated_minimum_players = 0; + lobby(3); + CHECK(!server_ok_to_countdown(&server), "server.min_players 0 counted down with no player"); + add_player(1, 0); + CHECK(server_ok_to_countdown(&server), "server.min_players 0 did not count down with one player"); + dedicated_minimum_players = 17; + for (index = 1; index < 16; index++) + add_player(1 + index % 2, 0); + CHECK(server.game.player_count == 16, "%d players", server.game.player_count); + CHECK(server_ok_to_countdown(&server), "server.min_players 17 did not count down with 16 players"); + return 0; + } + /* the director's own state: stopping, not in its lobby, or its entry not + set yet, the lobby never counts down */ + CASE("dedicated-director-refuses") + { + dedicated = 1; + lobby(2); + add_player(1, 0); + CHECK(server_ok_to_countdown(&server), "the lobby did not count down"); + director.quitting = TRUE; + CHECK(!server_ok_to_countdown(&server), "a server stopping counted down"); + director.quitting = FALSE; + director.state = _server_state_loading; + CHECK(!server_ok_to_countdown(&server), "a server out of its lobby counted down"); + director.state = _server_state_lobby; + director.entry_applied = FALSE; + CHECK(!server_ok_to_countdown(&server), "a lobby with no entry set counted down"); + director.entry_applied = TRUE; + CHECK(server_ok_to_countdown(&server), "the lobby did not count down again"); + return 0; + } fprintf(stderr, "unknown case: %s\n", case_name); return 2; } diff --git a/tools/harness/tests/map_share_trust.c b/tools/harness/tests/map_share_trust.c index 49a1209d..2023cfaf 100644 --- a/tools/harness/tests/map_share_trust.c +++ b/tools/harness/tests/map_share_trust.c @@ -24,6 +24,7 @@ typedef unsigned short word; #define MIN(a, b) ((a) < (b) ? (a) : (b)) #define _error_silent 0 #define error(level, ...) ((void)0) +#define server_event_map_share(machine_index, map, state, done, total) ((void)0) #define _distributed_message_map_share_offer 0x40 /* the fake world: a clock, the host's game and its machines, a catalog of one diff --git a/tools/harness/tests/master_protocol.c b/tools/harness/tests/master_protocol.c new file mode 100644 index 00000000..d826a109 --- /dev/null +++ b/tools/harness/tests/master_protocol.c @@ -0,0 +1,1330 @@ +/* +MASTER_PROTOCOL.C (test) + +The game's side of the master servers' protocol (docs/master_protocol.md), +byte for byte against the master's test vectors (vectors.inc, from its +testdata/vectors.json): the server browser's check of a signed list, the +entries it takes from one and its masters.txt (port/linux/src/ +master_list.c); the host's listing read and its signature checked +(port/linux/src/p2p_lobby.c); a dedicated server's publisher key, instance, +server id, signed requests, its publishes and what it makes of their +answers, and its answer to a master's probe and the probes' limits +(port/linux/src/server_master.c); on the real SHA-256 and Ed25519 +(port/linux/src/p2p_crypto.c, Monocypher) and base64 and JSON +(server_json.c): test_master_protocol.py takes the code (under_test.inc). +*/ + +#include "harness.h" + +#include +#include + +/* ---------- the crypto, base64 and JSON, as whole files */ + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Weverything" +/* (compiled quickly: each negative control builds it again) */ +#pragma clang optimize off +#include "monocypher.inc" +/* (both files have a hash_reduce of their own) */ +#define hash_reduce ed25519_hash_reduce +#include "monocypher_ed25519.inc" +#undef hash_reduce +#pragma clang optimize on +#pragma clang diagnostic pop +#undef FOR +#undef COPY +#undef ZERO +#undef WIPE_CTX +#undef WIPE_BUFFER +#undef MIN + +#include "p2p_crypto.inc" +#include "server_json.inc" + +/* bytes the code asks for at random: the test's */ +static unsigned char random_bytes[64]; + +void posix_random_bytes(void *buffer, posix_ulong size) +{ + memcpy(buffer, random_bytes, size); +} + +#include "master_list.h" +#include "server_master.h" + +/* ---------- the fake world */ + +struct vector_listing +{ + const char *description; + const char *host_seed; + const char *host_public_key; + const char *bytes; + const char *base64; + int size; + int signed_size; + const char *signed_message; + const char *signature; + int valid; +}; + +#include "vectors.inc" + +typedef struct SDL_Mutex SDL_Mutex; +typedef struct SDL_Condition SDL_Condition; +typedef struct SDL_Thread SDL_Thread; +#define SDL_CreateMutex() NULL +#define SDL_CreateCondition() NULL +#define SDL_CreateThread(function, name, data) NULL +#define SDL_LockMutex(lock) ((void)0) +#define SDL_UnlockMutex(lock) ((void)0) +/* a dedicated server's publisher (server_mode.c) unless a case says it is +a player's host; which the game's host started as the process exits +(atexit) never is here: no thread */ +static int server_mode = 1; +static int halo_server_mode(void) { return server_mode; } +void server_master_stop(void) {} +#define SDL_GetBasePath() ((const char *)NULL) +#define server_log(...) ((void)0) +static const char *server_mode_build(void) { return "build-206"; } + +/* the settings and files the code reads */ +static const char *setting_instance = ""; +static const char *setting_name = ""; +static const char *setting_region = ""; +static int setting_publish_address; +static const char *file_masters = NULL; +static const char *file_key = NULL; +static unsigned long now; + +static const char *config_string(const char *name) +{ + if (!strcmp(name, "server.instance")) + return setting_instance; + if (!strcmp(name, "server.name")) + return setting_name; + if (!strcmp(name, "server.region")) + return setting_region; + if (!strcmp(name, "server.masters_file") || !strcmp(name, "network.masters_file")) + return "masters.txt"; + return ""; +} + +static int config_boolean(const char *name) +{ + return !strcmp(name, "server.master_publish_address") && setting_publish_address; +} + +/* the hosted game (p2p.c, p2p_lobby.c): its listing (none: not hosting for +the internet), the tunnel's port (network byte order), its public address */ +static unsigned char host_listing[400]; +static int host_listing_size; +static unsigned short tunnel_port; + +static int p2p_lobby_host_listing(unsigned char *bytes, int size) +{ + if (!host_listing_size || size < host_listing_size) + return 0; + memcpy(bytes, host_listing, (size_t)host_listing_size); + return host_listing_size; +} + +static unsigned short p2p_tunnel_local_port(void) { return tunnel_port; } + +static int p2p_public_address(char *text, int size) +{ + snprintf(text, (size_t)size, "203.0.113.5:2302"); + return 1; +} + +/* the events of the master's outcomes (server_events.c) */ +static int events_added; + +static unsigned long server_event_add(const char *group, const char *object) +{ + (void)group; + (void)object; + return (unsigned long)++events_added; +} + +/* a master's entries handed to the server browser's games (p2p_lobby.c): +the first few kept */ +static struct +{ + int size; + char publisher[64]; + char region[64]; + int version; + int dedicated; +} entries_taken[4]; +static int entries_count; + +static int p2p_lobby_take_master_listing(const unsigned char *payload, int size, const char *publisher, + const char *region, int network_version_shown, int dedicated) +{ + (void)payload; + if (entries_count < 4) + { + entries_taken[entries_count].size = size; + snprintf(entries_taken[entries_count].publisher, sizeof(entries_taken[entries_count].publisher), "%s", publisher); + snprintf(entries_taken[entries_count].region, sizeof(entries_taken[entries_count].region), "%s", region); + entries_taken[entries_count].version = network_version_shown; + entries_taken[entries_count].dedicated = dedicated; + } + entries_count++; + return 1; +} + +static void config_folder(char *path, size_t size) +{ + snprintf(path, size, "/config/"); +} + +static char *config_file_read(const char *path, size_t *size) +{ + const char *text = !strcmp(path, "/config/masters.txt") ? file_masters : + !strcmp(path, "/config/publisher.key") ? file_key : NULL; + char *copy; + + if (!text) + return NULL; + *size = strlen(text); + copy = malloc(*size + 1); + memcpy(copy, text, *size + 1); + return copy; +} + +static unsigned long posix_server_unix_seconds(void) +{ + return now; +} + +/* what the code signs, kept */ +static unsigned char signed_message[4096]; +static int signed_size; + +static void test_sign(const unsigned char *seed, const unsigned char *public_key, const void *message, int size, + unsigned char *signature) +{ + memcpy(signed_message, message, (size_t)size < sizeof(signed_message) ? (size_t)size : sizeof(signed_message)); + signed_size = size; + p2p_ed25519_sign(seed, public_key, message, size, signature); +} +#define p2p_ed25519_sign(seed, public_key, message, size, signature) test_sign(seed, public_key, message, size, signature) + +#include "config.inc" + +/* the publishes made (update.h's update_https_request), and the master's +answer to them */ +static struct +{ + int count; + char method[8]; + char url[512]; + char headers[1024]; + char body[16384]; +} requested; +static int answer_status = 200; +static const char *answer_headers = ""; +static const char *answer_body = "{\"ok\":true,\"listed\":true,\"probe\":\"ok\"}"; + +static int update_https_request(const char *method, const char *url, const char *headers, const void *body, + unsigned long body_size, unsigned long maximum, struct update_response *response, char *error, int error_size) +{ + (void)maximum; + (void)error; + (void)error_size; + requested.count++; + snprintf(requested.method, sizeof(requested.method), "%s", method); + snprintf(requested.url, sizeof(requested.url), "%s", url); + snprintf(requested.headers, sizeof(requested.headers), "%s", headers ? headers : ""); + snprintf(requested.body, sizeof(requested.body), "%.*s", (int)body_size, body ? (const char *)body : ""); + memset(response, 0, sizeof(*response)); + response->status = answer_status; + snprintf(response->headers, sizeof(response->headers), "%s", answer_headers); + response->body = strdup(answer_body); + response->body_size = (unsigned long)strlen(answer_body); + return 1; +} + +#include "under_test.inc" + +/* ---------- the test's own */ + +static int hex_decode(const char *text, unsigned char *bytes, int maximum) +{ + int count = 0; + + while (text[0] && text[1] && count < maximum) + { + unsigned int value; + + if (sscanf(text, "%2x", &value) != 1) + return -1; + bytes[count++] = (unsigned char)value; + text += 2; + } + return count; +} + +static const char *hex_of(const void *bytes, int size) +{ + static char text[2][2048]; + static int which; + + which ^= 1; + p2p_hex(bytes, size, text[which]); + return text[which]; +} + +static void key_of(const char *hex, unsigned char *key) +{ + CHECK(hex_decode(hex, key, 32) == 32, "a vector's key is not 32 bytes: %s", hex); +} + +/* the publisher started as the vectors have it: its key, instance "main", +the vectors' master in masters.txt */ +static void start_publisher(void) +{ + static char masters[256]; + + snprintf(masters, sizeof(masters), "# the vectors' master\nhttps://master.example.org %s\n", + VECTOR_MASTER_PUBLIC_KEY_TEXT); + file_masters = masters; + setenv("HALO_SERVER_PUBLISHER_KEY", VECTOR_PUBLISHER_SEED, 1); + setting_instance = VECTOR_INSTANCE; + memset(&master, 0, sizeof(master)); + server_master_start(); + CHECK(master.has_key, "the publisher key was not taken from HALO_SERVER_PUBLISHER_KEY"); + CHECK(master.master_count == 1, "%d masters read from masters.txt", master.master_count); +} + +/* a request signed as the vectors' was: the Authorization header and the +string signed */ +static void check_request(const char *master_key_id, const char *method, const char *path, const char *body, + unsigned long size, const char *ts, const char *nonce, const char *canonical, const char *authorization) +{ + char header[512], expected[512]; + unsigned char master_key_id_bytes[8]; + + start_publisher(); + now = strtoul(ts, NULL, 10); + CHECK(hex_decode(nonce, random_bytes, 16) == 16, "the vector's nonce is not 16 bytes"); + CHECK(hex_decode(master_key_id, master_key_id_bytes, 8) == 8, "the vector's master key id is not 8 bytes"); + signed_size = -1; + sign_request(master_key_id_bytes, method, path, body, size, header, sizeof(header)); + CHECK(signed_size == (int)strlen(canonical) && !memcmp(signed_message, canonical, strlen(canonical)), + "signed \"%.*s\", not the vector's \"%s\"", signed_size, (const char *)signed_message, canonical); + snprintf(expected, sizeof(expected), "Authorization: %s\r\nContent-Type: application/json\r\n", authorization); + CHECK(!strcmp(header, expected), "the header is\n%s\nnot\n%s", header, expected); +} + +static int verify_list(const char *body, unsigned long size, const char *signature_header, const char *key_header, + const char *key_hex, unsigned long when) +{ + unsigned char key[32]; + char why[128] = ""; + + key_of(key_hex, key); + return master_list_verify(body, size, signature_header, key_header, key, when, why, sizeof(why)); +} + +/* a list body signed by the vectors' master: its Signature header */ +static const char *master_signs(const char *body) +{ + static char header[128]; + unsigned char seed[32], public_key[32], signature[64]; + + key_of(VECTOR_MASTER_SEED, seed); + p2p_ed25519_public(seed, public_key, NULL); + p2p_ed25519_sign(seed, public_key, body, (int)strlen(body), signature); + memcpy(header, "ed25519=", 8); + server_base64_encode(signature, 64, header + 8); + return header; +} + +/* a probe from an address (network byte order) answered: 1 */ +static int probe_from(const unsigned char *probe, int size, unsigned long address, unsigned char *answer) +{ + int answer_size = -1; + int answered = server_master_probe(probe, size, address, answer, &answer_size); + + CHECK(!answered || answer_size == PROBE_ANSWER_SIZE, "an answer of %d bytes", answer_size); + return answered; +} + +/* ... from the vectors' master's address */ +static int probe_answered(const unsigned char *probe, int size, unsigned char *answer) +{ + return probe_from(probe, size, 0x0100A8C0UL, answer); +} + +/* a listing as listing_make_token writes one (flags, the names' lengths; +a password's token sealed), signed by the vectors' host: its size */ +static int make_listing(unsigned char *bytes, int format, int flags, int name_length, int map_length, + int gametype_length) +{ + unsigned char seed[32], public_key[32], data[512]; + int lengths[3], size = 0, index, text; + + lengths[0] = name_length; + lengths[1] = map_length; + lengths[2] = gametype_length; + key_of(VECTOR_HOST_SEED, seed); + p2p_ed25519_public(seed, public_key, NULL); + bytes[size++] = 'H'; + bytes[size++] = 'L'; + bytes[size++] = (unsigned char)format; + bytes[size++] = 0; + bytes[size++] = 39; + bytes[size++] = (unsigned char)flags; + for (index = 0; index < 4; index++) + bytes[size++] = (unsigned char)(index == 3 ? 9 : 0); + for (index = 0; index < 4; index++) + bytes[size++] = (unsigned char)(1760900000UL >> (24 - 8 * index)); + memcpy(bytes + size, public_key, 32); + size += 32; + for (index = 0; index < ((flags & 16) ? P2P_SEALED_TOKEN_SIZE : P2P_TOKEN_SIZE); index++) + bytes[size++] = (unsigned char)(0xB0 + index); + bytes[size++] = 3; + bytes[size++] = 16; + bytes[size++] = 2; + for (text = 0; text < 3; text++) + { + bytes[size++] = (unsigned char)lengths[text]; + for (index = 0; index < lengths[text]; index++) + bytes[size++] = (unsigned char)('a' + text); + } + memset(bytes + size, 0, 8); + bytes[size] = 3; + size += 8; + memcpy(data, "hceu-lobby-1", 12); + memcpy(data + 12, bytes, (size_t)size); + p2p_ed25519_sign(seed, public_key, data, 12 + size, bytes + size); + return size + 64; +} + +/* whether listing_read takes the bytes, and listing_signed their +signature */ +static int listing_good(const unsigned char *bytes, int size) +{ + struct listing listing; + + return listing_read(bytes, size, &listing) && listing_signed(bytes, &listing); +} + +/* the vectors' first listing as the hosted game's */ +static void host_vector_listing(void) +{ + host_listing_size = hex_decode(vector_listings[0].bytes, host_listing, sizeof(host_listing)); + CHECK(host_listing_size == vector_listings[0].size, "the vectors' first listing is %d bytes", host_listing_size); +} + +/* a JSON body's member, as text: its string's value, or its number or +boolean as written */ +static const char *body_member(const char *body, const char *name) +{ + static char value[1024]; + struct server_json_document document; + int token; + + value[0] = 0; + if (!server_json_parse(&document, body, (int)strlen(body), 8)) + return "(not JSON)"; + token = server_json_member(&document, 0, name); + if (token < 0) + snprintf(value, sizeof(value), "(none)"); + else if (document.tokens[token].type == _server_json_string_token) + server_json_get_string(&document, token, value, sizeof(value)); + else + snprintf(value, sizeof(value), "%.*s", document.tokens[token].end - document.tokens[token].start, + body + document.tokens[token].start); + server_json_document_free(&document); + return value; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* the vectors' keys, ids and hashes from the seeds; a small-order key + refused */ + CASE("crypto") + { + const char *seeds[] = { VECTOR_PUBLISHER_SEED, VECTOR_MASTER_SEED, VECTOR_HOST_SEED }; + const char *keys[] = { VECTOR_PUBLISHER_PUBLIC_KEY, VECTOR_MASTER_PUBLIC_KEY, VECTOR_HOST_PUBLIC_KEY }; + const char *ids[] = { VECTOR_PUBLISHER_KEY_ID, VECTOR_MASTER_KEY_ID, VECTOR_HOST_KEY_ID }; + unsigned char seed[32], public_key[32], digest[32], x25519[32], signature[64]; + int index; + + for (index = 0; index < 3; index++) + { + key_of(seeds[index], seed); + p2p_ed25519_public(seed, public_key, NULL); + CHECK(!strcmp(hex_of(public_key, 32), keys[index]), "the key of seed %d is %s", index, + hex_of(public_key, 32)); + p2p_sha256(public_key, 32, digest); + CHECK(!strcmp(hex_of(digest, 8), ids[index]), "key id %s, not %s", hex_of(digest, 8), ids[index]); + } + CHECK(!strcmp(VECTOR_MASTER_KEY_ID_BYTES, VECTOR_MASTER_KEY_ID), "the master's key id bytes are its id"); + p2p_sha256("", 0, digest); + CHECK(!strcmp(hex_of(digest, 32), VECTOR_ME_BODY_SHA256), "SHA-256 of nothing is %s", hex_of(digest, 32)); + p2p_sha256(VECTOR_PUBLISH_BODY, (int)strlen(VECTOR_PUBLISH_BODY), digest); + CHECK(!strcmp(hex_of(digest, 32), VECTOR_PUBLISH_BODY_SHA256), "the body's SHA-256 is %s", + hex_of(digest, 32)); + p2p_sha256(VECTOR_SERVER_ID_INPUT, (int)strlen(VECTOR_SERVER_ID_INPUT), digest); + CHECK(!strcmp(hex_of(digest, 8), VECTOR_SERVER_ID), "the server id input hashes to %s", hex_of(digest, 8)); + /* (the vectors' signatures are the keys' over the vectors' strings) */ + key_of(VECTOR_PUBLISHER_PUBLIC_KEY, public_key); + CHECK(server_base64_decode(VECTOR_PUBLISH_SIGNATURE, signature, sizeof(signature)) == 64, "not 64 bytes"); + CHECK(p2p_ed25519_verify(public_key, VECTOR_PUBLISH_CANONICAL, (int)strlen(VECTOR_PUBLISH_CANONICAL), + signature), "the publish's signature does not verify"); + /* (a key of small order: refused, whatever it signs) */ + key_of(VECTOR_SMALL_ORDER_KEY, public_key); + CHECK(!p2p_ed25519_to_x25519(public_key, x25519), "a key of small order was taken"); + memset(signature, 0, sizeof(signature)); + CHECK(!p2p_ed25519_verify(public_key, "", 0, signature), "a small-order key's signature verified"); + signature[0] = 1; + CHECK(!p2p_ed25519_verify(public_key, "", 0, signature), "a small-order key's signature verified"); + return 0; + } + /* the listings' bytes from their base64, their host's key, and their + signatures over "hceu-lobby-1" and their signed bytes */ + CASE("listing-signatures") + { + int index; + + for (index = 0; index < (int)(sizeof(vector_listings) / sizeof(vector_listings[0])); index++) + { + struct vector_listing const *vector = &vector_listings[index]; + unsigned char bytes[400], decoded[400], seed[32], public_key[32], message[512], signature[64]; + int size = hex_decode(vector->bytes, bytes, sizeof(bytes)); + + CHECK(size == vector->size, "%s: %d bytes", vector->description, size); + CHECK(server_base64_decode(vector->base64, decoded, sizeof(decoded)) == size && + !memcmp(decoded, bytes, (size_t)size), "%s: its base64 is other bytes", vector->description); + key_of(vector->host_seed, seed); + p2p_ed25519_public(seed, public_key, NULL); + CHECK(!strcmp(hex_of(public_key, 32), vector->host_public_key) && !memcmp(bytes + 14, public_key, 32), + "%s: another host key", vector->description); + CHECK(!strcmp(hex_of(bytes + size - 64, 64), vector->signature), "%s: the signature is not its last 64 " + "bytes", vector->description); + CHECK(vector->signed_size == size - 64, + "%s: signed %d of %d bytes", vector->description, vector->signed_size, size); + memcpy(message, "hceu-lobby-1", 12); + memcpy(message + 12, bytes, (size_t)vector->signed_size); + CHECK((strcmp(hex_of(message, 12 + vector->signed_size), vector->signed_message) == 0) == vector->valid, + "%s: the signed message %s the vector's", vector->description, vector->valid ? "is not" : "is"); + CHECK(p2p_ed25519_verify(public_key, message, 12 + vector->signed_size, bytes + size - 64) == + vector->valid, "%s: its signature %s", vector->description, vector->valid ? "fails" : "verifies"); + if (vector->valid) + { + p2p_ed25519_sign(seed, public_key, message, 12 + vector->signed_size, signature); + CHECK(!memcmp(signature, bytes + size - 64, 64), "%s: signed otherwise", vector->description); + } + } + return 0; + } + /* --sign: the publisher key's signature of an application's nonce */ + CASE("proof") + { + unsigned char signature[64]; + + setenv("HALO_SERVER_PUBLISHER_KEY", VECTOR_PUBLISHER_SEED, 1); + CHECK(publisher_sign(VECTOR_PROOF_NONCE, (int)strlen(VECTOR_PROOF_NONCE), signature), "no publisher key"); + CHECK(!strcmp(hex_of(signature, 64), VECTOR_PROOF_SIGNATURE), "the proof is %s", hex_of(signature, 64)); + return 0; + } + /* the server's key id and server id, and its master's key id */ + CASE("server-id") + { + unsigned char master_id[8]; + + start_publisher(); + CHECK(!strcmp(master.key_id, VECTOR_PUBLISHER_KEY_ID), "key id %s", master.key_id); + CHECK(!strcmp(master.instance, VECTOR_INSTANCE), "instance %s", master.instance); + CHECK(!strcmp(master.server_id, VECTOR_SERVER_ID), "server id %s, not %s", master.server_id, VECTOR_SERVER_ID); + CHECK(hex_decode(VECTOR_MASTER_KEY_ID_BYTES, master_id, 8) == 8 && + !memcmp(master.masters[0].key_id, master_id, 8), "the master's key id is %s", + hex_of(master.masters[0].key_id, 8)); + CHECK(!strcmp(master.masters[0].url, "https://master.example.org"), "url %s", master.masters[0].url); + return 0; + } + /* publisher.key: one line of 64 hex digits, comments and blank lines + skipped; HALO_SERVER_PUBLISHER_KEY first */ + CASE("key-file") + { + unsigned char public_key[32]; + char key_id[17], text[256], upper[65]; + int index; + + unsetenv("HALO_SERVER_PUBLISHER_KEY"); + file_key = NULL; + CHECK(!server_master_publisher(public_key, key_id), "a key without a file"); + snprintf(text, sizeof(text), "# the operator's key\n\n %s # mine\n", VECTOR_PUBLISHER_SEED); + file_key = text; + CHECK(server_master_publisher(public_key, key_id), "the key file was refused"); + CHECK(!strcmp(hex_of(public_key, 32), VECTOR_PUBLISHER_PUBLIC_KEY) && !strcmp(key_id, VECTOR_PUBLISHER_KEY_ID), + "the file's key is %s (%s)", hex_of(public_key, 32), key_id); + snprintf(text, sizeof(text), "%s\r\n", VECTOR_PUBLISHER_SEED); + CHECK(server_master_publisher(public_key, key_id), "a key file with CRLF was refused"); + for (index = 0; index < 64; index++) + upper[index] = (char)toupper((unsigned char)VECTOR_PUBLISHER_SEED[index]); + upper[64] = 0; + snprintf(text, sizeof(text), "%s\n", upper); + CHECK(server_master_publisher(public_key, key_id) && !strcmp(key_id, VECTOR_PUBLISHER_KEY_ID), + "a key in capitals was read otherwise"); + snprintf(text, sizeof(text), "%.63s\n", VECTOR_PUBLISHER_SEED); + CHECK(!server_master_publisher(public_key, key_id), "63 digits were taken"); + snprintf(text, sizeof(text), "%s0\n", VECTOR_PUBLISHER_SEED); + CHECK(!server_master_publisher(public_key, key_id), "65 digits were taken"); + snprintf(text, sizeof(text), "%.62sxy\n", VECTOR_PUBLISHER_SEED); + CHECK(!server_master_publisher(public_key, key_id), "a key that is not hex was taken"); + snprintf(text, sizeof(text), "# %s\n", VECTOR_PUBLISHER_SEED); + CHECK(!server_master_publisher(public_key, key_id), "a commented key was taken"); + /* (the environment's over the file's) */ + snprintf(text, sizeof(text), "%s\n", VECTOR_MASTER_SEED); + setenv("HALO_SERVER_PUBLISHER_KEY", VECTOR_PUBLISHER_SEED, 1); + CHECK(server_master_publisher(public_key, key_id) && !strcmp(key_id, VECTOR_PUBLISHER_KEY_ID), + "the file's key was taken over the environment's"); + setenv("HALO_SERVER_PUBLISHER_KEY", "not a key", 1); + CHECK(!server_master_publisher(public_key, key_id), "a bad HALO_SERVER_PUBLISHER_KEY was taken"); + return 0; + } + /* the instance: server.instance, else a slug of the server's name; 1 to + 32 of a-z 0-9 - _, the first a letter or digit */ + CASE("instance") + { + static const struct instance_vector { const char *instance, *name, *expected; } names[] = + { + { "main", "Anything", "main" }, + { "Main", "", "main" }, + { "", "OpenCE Dedicated", "opence-dedicated" }, + { "", " --My.Server__ ", "my-server__" }, + { "", "EU #1 CTF / Slayer!", "eu-1-ctf-slayer" }, + { "", "", "main" }, + { "", "!!! ???", "main" }, + { "", "_-_", "main" }, + { "", "\xc3\x9c" "ber Server", "ber-server" }, + { "", "abcdefghijklmnopqrstuvwxyz0123456789", "abcdefghijklmnopqrstuvwxyz012345" }, + { "", "a b", "a-b" }, + }; + int index; + + for (index = 0; index < (int)(sizeof(names) / sizeof(names[0])); index++) + { + char instance[33]; + int character; + + setting_instance = names[index].instance; + setting_name = names[index].name; + memset(instance, 'z', sizeof(instance)); + instance_name(instance, sizeof(instance)); + CHECK(!strcmp(instance, names[index].expected), "\"%s\" / \"%s\" made %s, not %s", names[index].instance, + names[index].name, instance, names[index].expected); + /* (docs/master_protocol.md's form) */ + CHECK(strlen(instance) >= 1 && strlen(instance) <= 32 && isalnum((unsigned char)instance[0]), + "%s is no instance", instance); + for (character = 0; instance[character]; character++) + { + CHECK(islower((unsigned char)instance[character]) || isdigit((unsigned char)instance[character]) || + instance[character] == '-' || instance[character] == '_', "%s is no instance", instance); + } + } + return 0; + } + /* the vectors' signed publish */ + CASE("publish-request") + { + CHECK(!strcmp(&VECTOR_PUBLISH_PATH[strlen("/v1/servers/")], VECTOR_SERVER_ID), "the path is another server's"); + check_request(VECTOR_PUBLISH_MASTER_KEY_ID, VECTOR_PUBLISH_METHOD, VECTOR_PUBLISH_PATH, VECTOR_PUBLISH_BODY, strlen(VECTOR_PUBLISH_BODY), + VECTOR_PUBLISH_TS, VECTOR_PUBLISH_NONCE, VECTOR_PUBLISH_CANONICAL, VECTOR_PUBLISH_AUTHORIZATION); + return 0; + } + /* the vectors' signed GET /v1/me: an empty body ("" or none) */ + CASE("me-request") + { + check_request(VECTOR_ME_MASTER_KEY_ID, VECTOR_ME_METHOD, VECTOR_ME_PATH, VECTOR_ME_BODY, 0, VECTOR_ME_TS, VECTOR_ME_NONCE, + VECTOR_ME_CANONICAL, VECTOR_ME_AUTHORIZATION); + check_request(VECTOR_ME_MASTER_KEY_ID, VECTOR_ME_METHOD, VECTOR_ME_PATH, NULL, 0, VECTOR_ME_TS, VECTOR_ME_NONCE, VECTOR_ME_CANONICAL, + VECTOR_ME_AUTHORIZATION); + return 0; + } + /* the vectors' probe answered as they have it */ + CASE("probe") + { + unsigned char probe[64], expected[128], answer[128], message[64], public_key[32]; + int size, expected_size, message_size; + + start_publisher(); + now = 1760900000; + size = hex_decode(VECTOR_PROBE_PACKET, probe, sizeof(probe)); + expected_size = hex_decode(VECTOR_PROBE_ANSWER, expected, sizeof(expected)); + message_size = hex_decode(VECTOR_PROBE_SIGNED_MESSAGE, message, sizeof(message)); + CHECK(size == 35 && expected_size == 103 && message_size == 29, "the vectors' probe is %d, %d, %d bytes", size, + expected_size, message_size); + CHECK(!strcmp(hex_of(probe + 11, 8), VECTOR_PROBE_MASTER_KEY_ID) && !strcmp(hex_of(probe + 19, 16), + VECTOR_PROBE_NONCE), "the vectors' probe holds another key id or nonce"); + signed_size = -1; + CHECK(probe_answered(probe, size, answer), "the vectors' probe was not answered"); + CHECK(signed_size == message_size && !memcmp(signed_message, message, (size_t)message_size), + "signed %s, not the vector's %s", hex_of(signed_message, signed_size > 0 ? signed_size : 0), + VECTOR_PROBE_SIGNED_MESSAGE); + CHECK(!memcmp(answer, expected, 39), "answered %s", hex_of(answer, 39)); + CHECK(!memcmp(answer + 39, expected + 39, 64), "signed %s", hex_of(answer + 39, 64)); + key_of(VECTOR_PUBLISHER_PUBLIC_KEY, public_key); + CHECK(p2p_ed25519_verify(public_key, message, message_size, answer + 39), + "the answer's signature is not the publisher's"); + return 0; + } + /* a probe answered only as it is, from a master of masters.txt, with a + publisher key, a nonce once a second */ + CASE("probe-refusals") + { + unsigned char probe[64], other[64], answer[128]; + int size, index; + + start_publisher(); + now = 1760900000; + size = hex_decode(VECTOR_PROBE_PACKET, probe, sizeof(probe)); + memcpy(other, probe, (size_t)size); + CHECK(!probe_answered(probe, size - 1, answer), "a probe of 34 bytes was answered"); + CHECK(!probe_answered(probe, size + 1, answer), "a probe of 36 bytes was answered"); + other[10] = 2; + CHECK(!probe_answered(other, size, answer), "a probe of version 2 was answered"); + other[10] = 1; + other[0] = 'o'; + CHECK(!probe_answered(other, size, answer), "a probe with another magic was answered"); + other[0] = probe[0]; + other[11] ^= 1; + CHECK(!probe_answered(other, size, answer), "a probe of a master not in masters.txt was answered"); + other[11] = probe[11]; + CHECK(probe_answered(probe, size, answer), "the vectors' probe was not answered"); + CHECK(!probe_answered(probe, size, answer), "a nonce was answered twice in a second"); + /* (a master's retry, two seconds later, with the same nonce) */ + now += 2; + CHECK(probe_answered(probe, size, answer), "the master's retry was not answered"); + (void)index; + /* (no publisher key: nothing answered) */ + now += 1; + master.has_key = 0; + other[34] = 0xEF; + CHECK(!probe_answered(other, size, answer), "a probe was answered without a key"); + return 0; + } + /* the vectors' signed list, taken while it is fresh */ + CASE("list-verify") + { + unsigned long time_value = strtoul(VECTOR_LIST_TIME, NULL, 10); + unsigned long size = (unsigned long)strlen(VECTOR_LIST_BODY); + + CHECK(verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value), "the vectors' list was refused"); + CHECK(verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value + 300), "a list 300 seconds old was refused"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value + 301), "a list 301 seconds old was taken"); + CHECK(verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value - 60), "a list of a master's clock ahead was refused"); + /* (dated ahead of this machine's clock: 300 seconds at most) */ + CHECK(verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value - 300), "a list 300 seconds ahead was refused"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, VECTOR_LIST_SIGNATURE_HEADER, VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, time_value - 301), "a list 301 seconds ahead was taken"); + return 0; + } + /* a list altered, signed by another key, or with a malformed header */ + CASE("list-refusals") + { + unsigned long time_value = strtoul(VECTOR_LIST_TIME, NULL, 10); + unsigned long size = (unsigned long)strlen(VECTOR_LIST_BODY); + const char *signature = VECTOR_LIST_SIGNATURE_HEADER; + char body[512], header[256]; + unsigned long offsets[3]; + int index; + + offsets[0] = 0; + offsets[1] = size / 2; + offsets[2] = size - 1; + for (index = 0; index < 3; index++) + { + snprintf(body, sizeof(body), "%s", VECTOR_LIST_BODY); + body[offsets[index]] ^= 1; + CHECK(!verify_list(body, size, signature, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, time_value), + "a list with byte %lu changed was taken", offsets[index]); + } + CHECK(!verify_list(VECTOR_LIST_BODY, size - 1, signature, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a list without its last byte was taken"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature, NULL, VECTOR_PUBLISHER_PUBLIC_KEY, time_value), + "a list was taken with another master's key"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature, VECTOR_LIST_KEY_HEADER, VECTOR_PUBLISHER_PUBLIC_KEY, + time_value), "a list was taken with another master's key"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature, VECTOR_PUBLISHER_KEY_ID, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a list was taken whose Signature-Key is another key's"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature, "39f713d0a644253", VECTOR_MASTER_PUBLIC_KEY, + time_value), "a list was taken whose Signature-Key is cut short"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature, NULL, VECTOR_MASTER_PUBLIC_KEY, time_value) && + !verify_list(VECTOR_LIST_BODY, size, signature, "", VECTOR_MASTER_PUBLIC_KEY, time_value), + "a list was taken without its Signature-Key"); + /* (the Signature header malformed) */ + CHECK(!verify_list(VECTOR_LIST_BODY, size, "", VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, time_value), + "a list without a signature was taken"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, "ed25519=", VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "an empty signature was taken"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, signature + 8, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a signature without ed25519= was taken"); + snprintf(header, sizeof(header), "ed25519:%s", signature + 8); + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "ed25519: was taken for ed25519="); + snprintf(header, sizeof(header), "Ed25519=%s", signature + 8); + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "Ed25519= was taken for ed25519="); + snprintf(header, sizeof(header), "%.*s", (int)strlen(signature) - 4, signature); + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a signature cut short was taken"); + snprintf(header, sizeof(header), "%sAAAA", signature); + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a signature too long was taken"); + snprintf(header, sizeof(header), "%s", signature); + header[10] = header[10] == 'A' ? 'B' : 'A'; + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a signature changed was taken"); + snprintf(header, sizeof(header), "ed25519=%s", "!!!!"); + CHECK(!verify_list(VECTOR_LIST_BODY, size, header, VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, + time_value), "a signature that is not base64 was taken"); + return 0; + } + /* a body the master signed, but not a list of protocol 1 by it */ + CASE("list-body") + { + static const char *refused[] = + { + "{\"protocol\":2,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000},\"servers\":[]}", + "{\"protocol\":\"1\",\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000},\"servers\":[]}", + "{\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000},\"servers\":[]}", + "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"21fe31dfa154a261\",\"time\":1760900000},\"servers\":[]}", + "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\"},\"servers\":[]}", + "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":\"1760900000\"}}", + "{\"protocol\":1,\"servers\":[]}", + "[{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000}}]", + "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000}", + "not json", + }; + static const char *taken[] = + { + "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000},\"servers\":[]," + "\"later\":{\"field\":[1,2,3]}}", + "{\"servers\":[{\"id\":\"4c5f15237c866f6f\"}],\"master\":{\"time\":1760900000,\"key_id\":\"39f713d0a644253f\"}," + "\"protocol\":1}", + }; + int index; + + for (index = 0; index < (int)(sizeof(refused) / sizeof(refused[0])); index++) + { + CHECK(!verify_list(refused[index], strlen(refused[index]), master_signs(refused[index]), + VECTOR_LIST_KEY_HEADER, VECTOR_MASTER_PUBLIC_KEY, 1760900000), "taken: %s", refused[index]); + } + for (index = 0; index < (int)(sizeof(taken) / sizeof(taken[0])); index++) + { + CHECK(verify_list(taken[index], strlen(taken[index]), master_signs(taken[index]), VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, 1760900000), "refused: %s", taken[index]); + } + return 0; + } + /* masters.txt: a URL and its key a line, comments, https or this + machine's http, the first four */ + CASE("masters-file") + { + char urls[MASTER_LIST_MAXIMUM_MASTERS + 2][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS + 2][MASTER_LIST_KEY_SIZE]; + unsigned char key[32]; + char text[4096]; + int count; + + key_of(VECTOR_MASTER_PUBLIC_KEY, key); + snprintf(text, sizeof(text), + "# OpenCE's masters\n" + "\n" + "https://master1.example.org/ %s # the first\r\n" + "http://master2.example.org %s\n" + "https://master3.example.org ed25519:%.62s\n" + "https://master4.example.org ed25519:%.62sxy\n" + "https://master5.example.org %s0\n" + "https://master6.example.org\n" + "#https://master7.example.org %s\n" + "http://127.0.0.1.example.net %s\n" + " http://127.0.0.1:8080 %s\n" + "https://master9.example.org/opence// %s\n" + "http://localhost:9000 %s\n" + "https://master11.example.org %s\n", + VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY, + VECTOR_MASTER_PUBLIC_KEY, VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT, + VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT, + VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT); + file_masters = text; + memset(keys, 0, sizeof(keys)); + count = master_list_read_file("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + CHECK(count == MASTER_LIST_MAXIMUM_MASTERS, "%d masters read", count); + CHECK(!strcmp(urls[0], "https://master1.example.org"), "the first is %s", urls[0]); + CHECK(!strcmp(urls[1], "http://127.0.0.1:8080"), "the second is %s", urls[1]); + CHECK(!strcmp(urls[2], "https://master9.example.org/opence"), "the third is %s", urls[2]); + CHECK(!strcmp(urls[3], "http://localhost:9000"), "the fourth is %s", urls[3]); + for (count = 0; count < MASTER_LIST_MAXIMUM_MASTERS; count++) + CHECK(!memcmp(keys[count], key, 32), "master %d's key is %s", count, hex_of(keys[count], 32)); + CHECK(master_list_read_file("masters.txt", urls, keys, 1) == 1, "more than the most were read"); + file_masters = NULL; + CHECK(master_list_read_file("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 0, + "masters read from no file"); + return 0; + } + /* the official master, built in: asked when masters.txt names none */ + CASE("masters-default") + { + char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; + unsigned char official[32]; + static const char *const none[] = { NULL, "", "# comments alone\n\n#https://master1.example.org\n", + "https://master1.example.org ed25519:abc\n" }; + static char named[256]; + int index; + + key_of("89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa", official); + for (index = 0; index < (int)(sizeof(none) / sizeof(none[0])); index++) + { + file_masters = none[index]; + memset(urls, 0, sizeof(urls)); + CHECK(master_list_read("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 1 && + !strcmp(urls[0], "https://master.opencommunityedition.com"), + "masters.txt %d naming none: %s", index, urls[0]); + CHECK(!memcmp(keys[0], official, 32), "the official master's key is %s", hex_of(keys[0], 32)); + } + snprintf(named, sizeof(named), "https://master1.example.org %s\n", VECTOR_MASTER_PUBLIC_KEY_TEXT); + file_masters = named; + CHECK(master_list_read("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 1 && + !strcmp(urls[0], "https://master1.example.org"), "masters.txt's master was not alone: %s", urls[0]); + file_masters = NULL; + return 0; + } + /* the web site's masters.json */ + CASE("masters-json") + { + /* (room past the most: a fault that reads more is seen, not a crash) */ + char urls[MASTER_LIST_MAXIMUM_MASTERS + 4][MASTER_LIST_URL_SIZE]; + unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS + 4][MASTER_LIST_KEY_SIZE]; + unsigned char key[32]; + char text[2048]; + int count, index, length; + + key_of(VECTOR_MASTER_PUBLIC_KEY, key); + snprintf(text, sizeof(text), + "{\"masters\":[{\"url\":\"https://master1.example.org/\",\"key\":\"%s\"}," + "{\"url\":\"http://master2.example.org\",\"key\":\"%s\"}," + "{\"url\":\"https://master3.example.org\",\"key\":\"ed25519:%.62s\"}," + "{\"url\":\"https://master4.example.org\"}," + "{\"url\":1,\"key\":\"%s\"}," + "\"https://master6.example.org\"," + "{\"key\":\"%s\",\"url\":\"https://master7.example.org\",\"name\":\"later\"}]}", + VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY, + VECTOR_MASTER_PUBLIC_KEY_TEXT, VECTOR_MASTER_PUBLIC_KEY_TEXT); + count = master_list_read_json(text, strlen(text), urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + CHECK(count == 2, "%d masters read", count); + CHECK(!strcmp(urls[0], "https://master1.example.org") && !strcmp(urls[1], "https://master7.example.org"), + "read %s and %s", urls[0], urls[1]); + CHECK(!memcmp(keys[0], key, 32) && !memcmp(keys[1], key, 32), "another key was read"); + CHECK(master_list_read_json("{}", 2, urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 0, "masters read from {}"); + CHECK(master_list_read_json("not json", 8, urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 0, + "masters read from what is not JSON"); + /* (six good ones: the first four) */ + length = snprintf(text, sizeof(text), "{\"masters\":["); + for (index = 0; index < MASTER_LIST_MAXIMUM_MASTERS + 2; index++) + { + length += snprintf(text + length, sizeof(text) - (size_t)length, "%s{\"url\":\"https://m%d.example.org\"," + "\"key\":\"%s\"}", index ? "," : "", index, VECTOR_MASTER_PUBLIC_KEY_TEXT); + } + length += snprintf(text + length, sizeof(text) - (size_t)length, "]}"); + count = master_list_read_json(text, (unsigned long)length, urls, keys, MASTER_LIST_MAXIMUM_MASTERS); + CHECK(count == MASTER_LIST_MAXIMUM_MASTERS && !strcmp(urls[3], "https://m3.example.org"), + "%d masters read of %d", count, MASTER_LIST_MAXIMUM_MASTERS + 2); + return 0; + } + + /* the vectors' listings as p2p_lobby.c reads them: each field, and the + signature over "hceu-lobby-1" and the signed bytes */ + CASE("listing-read") + { + unsigned char bytes[400], host_key[32]; + struct listing listing; + int index, size; + + key_of(VECTOR_HOST_PUBLIC_KEY, host_key); + for (index = 0; index < (int)(sizeof(vector_listings) / sizeof(vector_listings[0])); index++) + { + struct vector_listing const *vector = &vector_listings[index]; + + size = hex_decode(vector->bytes, bytes, sizeof(bytes)); + memset(&listing, 0, sizeof(listing)); + CHECK(listing_read(bytes, size, &listing), "%s: not read", vector->description); + CHECK(listing.signed_size == vector->signed_size && !memcmp(listing.key, host_key, 32), + "%s: signed %d bytes, by %s", vector->description, listing.signed_size, hex_of(listing.key, 32)); + CHECK(listing_signed(bytes, &listing) == vector->valid, "%s: its signature %s", vector->description, + vector->valid ? "fails" : "checks"); + } + /* (the first: an open team game of a dedicated server) */ + size = hex_decode(vector_listings[0].bytes, bytes, sizeof(bytes)); + listing_read(bytes, size, &listing); + CHECK(listing.version == 38 && listing.flags == (_listing_open | _listing_has_teams | _listing_dedicated) && + listing.sequence == 412 && listing.time == 1760900000UL, "version %d, flags %d, sequence %lu, time %lu", + listing.version, listing.flags, listing.sequence, listing.time); + CHECK(!strcmp(hex_of(listing.token, 16), "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf"), "token %s", hex_of(listing.token, 16)); + CHECK(listing.player_count == 5 && listing.maximum_player_count == 16 && listing.engine_type == 1 && + listing.kind == 0, "%d of %d, engine %d, kind %d", listing.player_count, listing.maximum_player_count, + listing.engine_type, listing.kind); + CHECK(!strcmp(listing.name, "OpenCE Dedicated") && !strcmp(listing.map, "bloodgulch") && + !strcmp(listing.gametype, "CTF"), "named %s, %s, %s", listing.name, listing.map, listing.gametype); + /* (the second: a password's game in progress, its token sealed, SWAT + with Hardcore) */ + size = hex_decode(vector_listings[1].bytes, bytes, sizeof(bytes)); + listing_read(bytes, size, &listing); + CHECK(listing.flags == (_listing_open | _listing_in_progress | _listing_password) && listing.kind == 0x81 && + !memcmp(listing.sealed_token, bytes + 46, P2P_SEALED_TOKEN_SIZE) && + listing.player_count == bytes[46 + P2P_SEALED_TOKEN_SIZE], "the password's game was read as flags %d, kind %d", + listing.flags, listing.kind); + /* (the third: a tombstone) */ + size = hex_decode(vector_listings[2].bytes, bytes, sizeof(bytes)); + listing_read(bytes, size, &listing); + CHECK((listing.flags & _listing_closed) && listing.sequence == 413, "the tombstone was read as flags %d, " + "sequence %lu", listing.flags, listing.sequence); + /* (a byte changed anywhere in the signed part, or the signature) */ + size = hex_decode(vector_listings[0].bytes, bytes, sizeof(bytes)); + for (index = 0; index < size; index += 7) + { + bytes[index] ^= 0x20; + CHECK(!listing_good(bytes, size), "the first listing with byte %d changed was taken", index); + bytes[index] ^= 0x20; + } + CHECK(listing_good(bytes, size), "the first listing was refused"); + return 0; + } + /* a listing is 140 to 268 bytes, of format 1, its names within their + fields and its stamp and signature ending it exactly */ + CASE("listing-bounds") + { + unsigned char bytes[600]; + struct listing listing; + int size; + + CHECK(MINIMUM_LISTING_SIZE == 140 && MAXIMUM_LISTING_SIZE == 268, "listings of %d to %d bytes", + MINIMUM_LISTING_SIZE, MAXIMUM_LISTING_SIZE); + size = make_listing(bytes, 1, _listing_open, 0, 0, 0); + CHECK(size == 140 && listing_good(bytes, size), "the shortest listing (%d bytes) was refused", size); + size = make_listing(bytes, 1, _listing_open | _listing_password, P2P_LISTING_NAME_SIZE, P2P_LISTING_MAP_SIZE, + P2P_LISTING_GAMETYPE_SIZE); + CHECK(size == 268 && listing_good(bytes, size), "the longest listing (%d bytes) was refused", size); + listing_read(bytes, size, &listing); + CHECK(strlen(listing.name) == 32 && strlen(listing.map) == 32 && strlen(listing.gametype) == 24 && + listing.kind == 3 && listing.engine_type == 2, "the longest listing was read as %s / %s / %s", + listing.name, listing.map, listing.gametype); + CHECK(!listing_read(bytes, size - 1, &listing) && !listing_read(bytes, 139, &listing), + "a listing cut short was read"); + size = make_listing(bytes, 1, _listing_open, 4, 4, 4); + bytes[size] = 0; + CHECK(!listing_read(bytes, size + 1, &listing), "a listing with a byte after its signature was read"); + size = make_listing(bytes, 2, _listing_open, 4, 4, 4); + CHECK(!listing_read(bytes, size, &listing), "a listing of format 2 was read"); + size = make_listing(bytes, 1, _listing_open, 4, 4, 4); + bytes[0] = 'h'; + CHECK(!listing_read(bytes, size, &listing), "a listing without HL was read"); + /* (a name past its field, which ends the listing exactly) */ + size = make_listing(bytes, 1, _listing_open, P2P_LISTING_NAME_SIZE + 1, 0, 0); + CHECK(!listing_read(bytes, size, &listing), "a name of 33 was read"); + size = make_listing(bytes, 1, _listing_open, 0, 0, P2P_LISTING_GAMETYPE_SIZE + 1); + CHECK(!listing_read(bytes, size, &listing), "a gametype of 25 was read"); + /* (a length past the end) */ + size = make_listing(bytes, 1, _listing_open, 0, 0, 0); + bytes[65] = 200; + CHECK(!listing_read(bytes, size, &listing), "a name's length past the end was read"); + /* (a name's bytes kept to printable ASCII, a zero ending it) */ + size = make_listing(bytes, 1, _listing_open, 6, 0, 0); + bytes[66] = 0x01; + bytes[67] = 0xC3; + bytes[69] = 0; + CHECK(listing_read(bytes, size, &listing) && !strcmp(listing.name, "??a"), "a name was read as %s", listing.name); + return 0; + } + /* a publish: its body (the listing, the tunnel's port, dedicated or not, + closed or not), its URL and the path signed under a master's base path; + a listing withdrawn once */ + CASE("publish-body") + { + static const struct + { + unsigned short network_order; + const char *port; + } ports[] = { { 0xFE08, "2302" }, { 0x0100, "1" }, { 0xFFFF, "65535" }, { 0x0001, "256" } }; + char base64[400]; + int index; + + start_publisher(); + now = 1760900000; + host_vector_listing(); + server_base64_encode(host_listing, host_listing_size, base64); + snprintf(master.masters[0].url, sizeof(master.masters[0].url), "https://m.example.org/opence"); + tunnel_port = 0xFE08; + setting_region = "eu"; + memset(&requested, 0, sizeof(requested)); + publish_to(&master.masters[0], 0); + CHECK(requested.count == 1 && !strcmp(requested.method, "PUT") && + !strcmp(requested.url, "https://m.example.org/opence/v1/servers/" VECTOR_SERVER_ID), "published %s %s", + requested.method, requested.url); + CHECK(strstr((const char *)signed_message, "\nPUT\n/opence/v1/servers/" VECTOR_SERVER_ID "\n") != NULL, + "signed %.*s", signed_size, (const char *)signed_message); + CHECK(!strncmp(requested.headers, "Authorization: OpenCE-Ed25519 keyid=" VECTOR_PUBLISHER_KEY_ID ", ", 52), + "the headers were %s", requested.headers); + CHECK(!strcmp(body_member(requested.body, "protocol"), "1") && !strcmp(body_member(requested.body, "instance"), + "main") && !strcmp(body_member(requested.body, "listing"), base64), "the body was %s", requested.body); + CHECK(!strcmp(body_member(requested.body, "tunnel_port"), "2302") && + !strcmp(body_member(requested.body, "dedicated"), "true") && + !strcmp(body_member(requested.body, "closed"), "false") && + !strcmp(body_member(requested.body, "region"), "eu") && + !strcmp(body_member(requested.body, "build"), "build-206") && + !strcmp(body_member(requested.body, "address"), "(none)"), "the body was %s", requested.body); + CHECK(master.masters[0].published && master.masters[0].listed, "the master's listing was not kept"); + /* (a master at its origin signs the path alone) */ + snprintf(master.masters[0].url, sizeof(master.masters[0].url), "https://m.example.org"); + publish_to(&master.masters[0], 0); + CHECK(!strcmp(requested.url, "https://m.example.org/v1/servers/" VECTOR_SERVER_ID) && + strstr((const char *)signed_message, "\nPUT\n/v1/servers/" VECTOR_SERVER_ID "\n") != NULL, + "published %s, signed %.*s", requested.url, signed_size, (const char *)signed_message); + /* (the port as the socket has it, in network byte order) */ + for (index = 0; index < (int)(sizeof(ports) / sizeof(ports[0])); index++) + { + tunnel_port = ports[index].network_order; + publish_to(&master.masters[0], 0); + CHECK(!strcmp(body_member(requested.body, "tunnel_port"), ports[index].port), "port %04x was sent as %s", + ports[index].network_order, body_member(requested.body, "tunnel_port")); + } + /* (a player's host's, and its public address when asked) */ + server_mode = 0; + setting_publish_address = 1; + publish_to(&master.masters[0], 0); + CHECK(!strcmp(body_member(requested.body, "dedicated"), "false") && + !strcmp(body_member(requested.body, "address"), "203.0.113.5:2302"), "the body was %s", requested.body); + server_mode = 1; + setting_publish_address = 0; + /* (a tombstone) */ + publish_to(&master.masters[0], 1); + CHECK(!strcmp(body_member(requested.body, "closed"), "true") && !master.masters[0].published, + "a tombstone was %s", requested.body); + /* (no listing: nothing, unless one was published, which is withdrawn + once) */ + host_listing_size = 0; + requested.count = 0; + publish_to(&master.masters[0], 0); + CHECK(!requested.count, "a publish was made with no listing to withdraw"); + master.masters[0].published = 1; + publish_to(&master.masters[0], 0); + CHECK(requested.count == 1 && !strcmp(body_member(requested.body, "closed"), "true") && + !strcmp(body_member(requested.body, "listing"), "(none)") && !master.masters[0].published, + "a listing was not withdrawn (%d: %s)", requested.count, requested.body); + publish_to(&master.masters[0], 0); + CHECK(requested.count == 1, "a listing was withdrawn twice"); + return 0; + } + /* what a master's answers make the publisher do: wait as Retry-After + says (its name in any case), stop after a revocation, say how far off + the clock is */ + CASE("publish-answers") + { + start_publisher(); + now = 1760900000; + host_vector_listing(); + tunnel_port = 0xFE08; + answer_status = 429; + answer_headers = "Content-Type: application/json\r\nretry-after: 120\r\n"; + answer_body = "{\"ok\":false,\"error\":{\"code\":\"rate_limited\",\"message\":\"slow down\"}}"; + memset(&requested, 0, sizeof(requested)); + publish_to(&master.masters[0], 0); + CHECK(master.masters[0].retry_at == now + 120, "Retry-After: 120 waited until %lu", master.masters[0].retry_at); + now += 119; + publish_to(&master.masters[0], 0); + CHECK(requested.count == 1, "a publish was made before Retry-After"); + now += 1; + answer_headers = "RETRY-AFTER: 999999999\r\n"; + publish_to(&master.masters[0], 0); + CHECK(requested.count == 2 && master.masters[0].retry_at == now + 86400, "Retry-After past a day waited until %lu", + master.masters[0].retry_at); + now += 86400; + answer_headers = ""; + publish_to(&master.masters[0], 0); + CHECK(master.masters[0].retry_at == now + 30, "no Retry-After waited until %lu", master.masters[0].retry_at); + now += 30; + /* (the clock: the difference, from the answer's master_time: this + machine's 450 seconds behind) */ + answer_status = 401; + answer_body = "{\"ok\":false,\"error\":{\"code\":\"clock\",\"message\":\"ts\"},\"master_time\":1760987000}"; + publish_to(&master.masters[0], 0); + CHECK(!strcmp(master.masters[0].code, "clock") && strstr(master.masters[0].message, "seconds behind") && + strstr(master.masters[0].message, " 450 "), "the clock's answer was kept as %s: %s", master.masters[0].code, + master.masters[0].message); + /* (a revoked key: not published there again, nor withdrawn) */ + answer_status = 403; + answer_body = "{\"ok\":false,\"error\":{\"code\":\"key_revoked\",\"message\":\"revoked\"}}"; + master.masters[0].retry_at = 0; + publish_to(&master.masters[0], 0); + CHECK(master.masters[0].revoked && !master.masters[0].published, "a revoked key was not stopped"); + requested.count = 0; + now += 3600; + publish_to(&master.masters[0], 0); + publish_to(&master.masters[0], 1); + CHECK(!requested.count, "a revoked key published again (%d)", requested.count); + answer_status = 200; + answer_headers = ""; + answer_body = "{\"ok\":true,\"listed\":true,\"probe\":\"ok\"}"; + return 0; + } + /* a list's entries: one that is not an entry is skipped, not the list; + the publisher's name and region kept to what the ticker shows; the + entry's word on a dedicated server passed on */ + CASE("take-list") + { + char base64[400], body[8192]; + unsigned char bytes[400], large[600]; + char large_base64[900]; + int size; + + size = hex_decode(vector_listings[0].bytes, bytes, sizeof(bytes)); + server_base64_encode(bytes, size, base64); + memset(large, 'x', sizeof(large)); + server_base64_encode(large, (int)sizeof(large) - 150, large_base64); + snprintf(body, sizeof(body), "{\"protocol\":1,\"servers\":[" + "{\"id\":\"a\",\"listing\":\"%s\",\"publisher\":{\"name\":\"Mil\\u0001enko's \\u00e9 servers, a name past " + "thirty-two\"},\"region\":\"eu west!\",\"network_version\":38,\"dedicated\":true}," + "{\"id\":\"b\",\"listing\":7}," + "{\"id\":\"c\"}," + "\"not an entry\"," + "{\"id\":\"d\",\"listing\":\"not base64!\"}," + "{\"id\":\"e\",\"listing\":\"%s\"}," + "{\"id\":\"f\",\"listing\":\"%s\",\"region\":\"us-east-1\",\"dedicated\":\"yes\"}]}", + base64, large_base64, base64); + entries_count = 0; + CHECK(take_list(body, (unsigned long)strlen(body)) == 2 && entries_count == 2, + "%d entries taken of the 2 good ones", entries_count); + CHECK(entries_taken[0].size == size && entries_taken[0].dedicated == 1 && entries_taken[0].version == 38, + "the first entry was taken as %d bytes, dedicated %d, version %d", entries_taken[0].size, + entries_taken[0].dedicated, entries_taken[0].version); + CHECK(!strcmp(entries_taken[0].publisher, "Mil?enko's ?? servers, a name pa") && + !strcmp(entries_taken[0].region, "eu?west?"), "the publisher %s, the region %s", entries_taken[0].publisher, + entries_taken[0].region); + CHECK(entries_taken[1].dedicated == 0 && !strcmp(entries_taken[1].region, "us-east-1") && + !entries_taken[1].publisher[0], "the last entry was taken as dedicated %d, region %s", + entries_taken[1].dedicated, entries_taken[1].region); + entries_count = 0; + CHECK(!take_list("{\"servers\":7}", 13) && !take_list("not json", 8) && !entries_count, + "entries taken from what is no list"); + return 0; + } + /* a list of 512 entries and more JSON tokens than a body of the API + may have: checked and taken, its first 512 entries */ + CASE("list-large") + { + static char body[600 * 1024]; + char base64[400]; + unsigned char bytes[400]; + int size, index, member, length; + + size = hex_decode(vector_listings[0].bytes, bytes, sizeof(bytes)); + server_base64_encode(bytes, size, base64); + length = snprintf(body, sizeof(body), "{\"protocol\":1,\"master\":{\"name\":\"m\",\"key_id\":\"%s\",\"time\":1760900000}," + "\"servers\":[", VECTOR_MASTER_KEY_ID); + for (index = 0; index < MAXIMUM_ENTRIES + 3; index++) + { + length += snprintf(body + length, sizeof(body) - (size_t)length, "%s{\"id\":\"s%d\",\"listing\":\"%s\"", + index ? "," : "", index, base64); + for (member = 0; member < 34; member++) + length += snprintf(body + length, sizeof(body) - (size_t)length, ",\"f%d\":%d", member, member); + length += snprintf(body + length, sizeof(body) - (size_t)length, "}"); + } + length += snprintf(body + length, sizeof(body) - (size_t)length, "]}"); + CHECK(length < (int)sizeof(body) - 1, "the test's list does not fit"); + CHECK(verify_list(body, (unsigned long)length, master_signs(body), VECTOR_LIST_KEY_HEADER, + VECTOR_MASTER_PUBLIC_KEY, 1760900000), "a list of %d entries was refused", MAXIMUM_ENTRIES + 3); + entries_count = 0; + CHECK(take_list(body, (unsigned long)length) == MAXIMUM_ENTRIES && entries_count == MAXIMUM_ENTRIES, + "%d entries were taken of %d", entries_count, MAXIMUM_ENTRIES + 3); + return 0; + } + /* a nonce answered once a second, whatever came between: A, B, A in one + second answers A once; the remembered nonces are a ring */ + CASE("probe-nonces") + { + unsigned char a[64], b[64], answer[128]; + int size, index; + + start_publisher(); + now = 1760900100; + size = hex_decode(VECTOR_PROBE_PACKET, a, sizeof(a)); + memcpy(b, a, (size_t)size); + b[34] ^= 0x55; + CHECK(probe_from(a, size, 0x01000001UL, answer), "nonce A was not answered"); + CHECK(probe_from(b, size, 0x01000002UL, answer), "nonce B was not answered"); + CHECK(!probe_from(a, size, 0x01000003UL, answer), "nonce A was answered again after B in one second"); + now++; + CHECK(probe_from(a, size, 0x01000003UL, answer), "nonce A was not answered the next second"); + /* (the ring: the oldest of REMEMBERED_NONCES + 1 is forgotten, the + newest are not) */ + now++; + for (index = 0; index < REMEMBERED_NONCES; index++) + { + memcpy(b, a, (size_t)size); + b[19] = (unsigned char)index; + b[20] = 0xEE; + master.probe_answers = 0; + memset(master.source_seconds, 0, sizeof(master.source_seconds)); + CHECK(probe_from(b, size, 0x02000000UL + (unsigned long)index, answer), "nonce %d was not answered", index); + } + b[19] = (unsigned char)(REMEMBERED_NONCES - 1); + master.probe_answers = 0; + CHECK(!probe_from(b, size, 0x03000000UL, answer), "the newest nonce was answered twice"); + return 0; + } + /* two answers a second to an address, ten in all: probes forged from + one address do not use up the answers a master's probe needs; a nonce + resent counts against neither */ + CASE("probe-sources") + { + unsigned char probe[64], answer[128]; + int size, index, sources; + + start_publisher(); + now = 1760900200; + size = hex_decode(VECTOR_PROBE_PACKET, probe, sizeof(probe)); + /* (one address flooding) */ + for (index = 0; index < 20; index++) + { + probe[34] = (unsigned char)index; + CHECK(probe_from(probe, size, 0x0A0A0A0AUL, answer) == (index < PROBE_ANSWERS_PER_ADDRESS), + "probe %d from one address was %s", index + 1, index < PROBE_ANSWERS_PER_ADDRESS ? "refused" : "answered"); + } + probe[34] = 0xF0; + CHECK(probe_from(probe, size, 0x0B0B0B0BUL, answer), "a master's probe was refused after another's flood"); + CHECK(!probe_from(probe, size, 0x0C0C0C0CUL, answer), "a nonce resent was answered"); + probe[34] = 0xF1; + CHECK(probe_from(probe, size, 0x0B0B0B0BUL, answer), "a nonce resent counted against its address"); + /* (ten in all a second, whatever the addresses) */ + now++; + for (index = 0, sources = 0; index < 20; index++) + { + probe[34] = (unsigned char)(0x40 + index); + sources += probe_from(probe, size, 0x0D000000UL + (unsigned long)index, answer); + } + CHECK(sources == PROBE_ANSWERS_PER_SECOND, "%d probes of 20 addresses were answered in a second", sources); + /* (the next second, an address's allowance again) */ + now++; + probe[34] = 0x7F; + CHECK(probe_from(probe, size, 0x0A0A0A0AUL, answer), "a flooding address was not answered the next second"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/master_vectors.json b/tools/harness/tests/master_vectors.json new file mode 100644 index 00000000..4549051d --- /dev/null +++ b/tools/harness/tests/master_vectors.json @@ -0,0 +1,164 @@ +{ + "comment": "OpenCE master protocol v1 test vectors (docs/master_protocol.md). Hex is lowercase; strings are ASCII. Made by github.com/OpenCommunityEdition/opence-master-server/internal/vectors.", + "protocol": 1, + "publisher": { + "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "public_key": "ed25519:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "key_id": "21fe31dfa154a261", + "key_id_bytes": "21fe31dfa154a261" + }, + "master": { + "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "public_key": "ed25519:3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "key_id": "39f713d0a644253f", + "key_id_bytes": "39f713d0a644253f" + }, + "host": { + "seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "public_key": "ed25519:fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "key_id": "dac073e0123bdea5", + "key_id_bytes": "dac073e0123bdea5" + }, + "instance": "main", + "server_id_input": "21fe31dfa154a261main", + "server_id": "4c5f15237c866f6f", + "publish_request": { + "master_key_id": "39f713d0a644253f", + "method": "PUT", + "path": "/v1/servers/4c5f15237c866f6f", + "ts": "1760900000", + "nonce": "000102030405060708090a0b0c0d0e0f", + "body": "{\"build\":\"build-206\",\"closed\":false,\"dedicated\":true,\"gametype\":\"CTF\",\"in_progress\":false,\"instance\":\"main\",\"listing\":\"SEwBACYlAAABnGj1M6D8Uc2OYhiho42kftACMPBYCBbtE7ozA6xd65EVSJCAJaChoqOkpaanqKmqq6ytrq8FEAEQT3BlbkNFIERlZGljYXRlZApibG9vZGd1bGNoA0NURgAAAAAAAAAAYz3GPrJ4KNblieNNxDNtuy1RSyAY5L9KOt9a63dLalI/5mLD0pJBxvkchxSoyiteSTqssyvK65vOcV8Vt20jDw==\",\"map\":\"bloodgulch\",\"max_players\":16,\"name\":\"OpenCE Dedicated\",\"network_version\":38,\"password\":false,\"players\":5,\"protocol\":1,\"region\":\"eu\",\"serial\":412,\"teams\":true,\"tunnel_port\":2302,\"variant\":\"CTF\"}", + "body_sha256": "f9c46f7703b94e8530a48252b19b372bb20e46a1f5270dc029a9582ee1eedfec", + "canonical": "OpenCE-Master-v1\n39f713d0a644253f\nPUT\n/v1/servers/4c5f15237c866f6f\n1760900000\n000102030405060708090a0b0c0d0e0f\nf9c46f7703b94e8530a48252b19b372bb20e46a1f5270dc029a9582ee1eedfec", + "signature": "21c7zV0RuW7zoePIeW1IzqzVdr4uFDzg6wBQnNq8rpMne1mR/7EfwhTPcZx87Tm/LhqAmB+cj3EWoZ1zZDqeDg==", + "authorization": "OpenCE-Ed25519 keyid=21fe31dfa154a261, ts=1760900000, nonce=000102030405060708090a0b0c0d0e0f, sig=21c7zV0RuW7zoePIeW1IzqzVdr4uFDzg6wBQnNq8rpMne1mR/7EfwhTPcZx87Tm/LhqAmB+cj3EWoZ1zZDqeDg==" + }, + "me_request": { + "master_key_id": "39f713d0a644253f", + "method": "GET", + "path": "/v1/me", + "ts": "1760900001", + "nonce": "f0e0d0c0b0a090807060504030201000", + "body": "", + "body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "canonical": "OpenCE-Master-v1\n39f713d0a644253f\nGET\n/v1/me\n1760900001\nf0e0d0c0b0a090807060504030201000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "duRZ+WKAI8hbvidtC63U1XHxOSoeRN5mcrZvw4qihhlilhZjbVBCLYjsQLr4+F2C1gy6lN9/ixqevFX7dfNGDA==", + "authorization": "OpenCE-Ed25519 keyid=21fe31dfa154a261, ts=1760900001, nonce=f0e0d0c0b0a090807060504030201000, sig=duRZ+WKAI8hbvidtC63U1XHxOSoeRN5mcrZvw4qihhlilhZjbVBCLYjsQLr4+F2C1gy6lN9/ixqevFX7dfNGDA==" + }, + "probe": { + "master_key_id": "39f713d0a644253f", + "nonce": "101112131415161718191a1b1c1d1e1f", + "packet": "4f5043452d50524f42450139f713d0a644253f101112131415161718191a1b1c1d1e1f", + "signed_message": "70726f626539f713d0a644253f101112131415161718191a1b1c1d1e1f", + "answer": "4f5043452d50524f42452d41434b0139f713d0a644253f101112131415161718191a1b1c1d1e1fa63af355db038dff90e6c82abc0e252eddbd1ed3562580d7becaf3e4edbdd39cef7ef3163b91849ba57f6e83240b92df1efbce15dfa42f4a12981590a0234109" + }, + "listings": [ + { + "description": "an open team game of a dedicated server (flags open, teams, dedicated)", + "host_seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "host_public_key": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "network_version": 38, + "flags": 37, + "sequence": 412, + "time": 1760900000, + "token": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "players": 5, + "max_players": 16, + "engine": 1, + "name": "OpenCE Dedicated", + "map": "bloodgulch", + "gametype": "CTF", + "kind": 0, + "bytes": "484c010026250000019c68f533a0fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025a0a1a2a3a4a5a6a7a8a9aaabacadaeaf051001104f70656e4345204465646963617465640a626c6f6f6467756c6368034354460000000000000000633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f", + "base64": "SEwBACYlAAABnGj1M6D8Uc2OYhiho42kftACMPBYCBbtE7ozA6xd65EVSJCAJaChoqOkpaanqKmqq6ytrq8FEAEQT3BlbkNFIERlZGljYXRlZApibG9vZGd1bGNoA0NURgAAAAAAAAAAYz3GPrJ4KNblieNNxDNtuy1RSyAY5L9KOt9a63dLalI/5mLD0pJBxvkchxSoyiteSTqssyvK65vOcV8Vt20jDw==", + "size": 169, + "signed_size": 105, + "signed_message": "686365752d6c6f6262792d31484c010026250000019c68f533a0fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025a0a1a2a3a4a5a6a7a8a9aaabacadaeaf051001104f70656e4345204465646963617465640a626c6f6f6467756c6368034354460000000000000000", + "signature": "633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f", + "valid": true + }, + { + "description": "a password game in progress: its token sealed (56 bytes); kind 0x81 is SWAT with Hardcore", + "host_seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "host_public_key": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "network_version": 38, + "flags": 19, + "sequence": 7, + "time": 1760900030, + "sealed_token": "020202020202020202020202020202020202020202020202830b7b34e4bfb2066a5a2b270ac905b26cc7944c0ed25aea08c8cb3adefdd731", + "players": 3, + "max_players": 8, + "engine": 2, + "name": "Hardcore SWAT", + "map": "prisoner", + "gametype": "Team Slayer", + "kind": 129, + "bytes": "484c010026130000000768f533befc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025020202020202020202020202020202020202020202020202830b7b34e4bfb2066a5a2b270ac905b26cc7944c0ed25aea08c8cb3adefdd7310308020d48617264636f7265205357415408707269736f6e65720b5465616d20536c6179657281000000000000004eec35187da028138c2cb4c28951f02fae8e4986ed4bf5ec0ccd5ba465f29b3ae9fda23271d3528fc119868bd303e73f75335ea09fa113d293c5f2af141e990a", + "base64": "SEwBACYTAAAAB2j1M778Uc2OYhiho42kftACMPBYCBbtE7ozA6xd65EVSJCAJQICAgICAgICAgICAgICAgICAgICAgICAoMLezTkv7IGalorJwrJBbJsx5RMDtJa6gjIyzre/dcxAwgCDUhhcmRjb3JlIFNXQVQIcHJpc29uZXILVGVhbSBTbGF5ZXKBAAAAAAAAAE7sNRh9oCgTjCy0wolR8C+ujkmG7Uv17AzNW6Rl8ps66f2iMnHTUo/BGYaL0wPnP3UzXqCfoRPSk8XyrxQemQo=", + "size": 212, + "signed_size": 148, + "signed_message": "686365752d6c6f6262792d31484c010026130000000768f533befc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025020202020202020202020202020202020202020202020202830b7b34e4bfb2066a5a2b270ac905b26cc7944c0ed25aea08c8cb3adefdd7310308020d48617264636f7265205357415408707269736f6e65720b5465616d20536c617965728100000000000000", + "signature": "4eec35187da028138c2cb4c28951f02fae8e4986ed4bf5ec0ccd5ba465f29b3ae9fda23271d3528fc119868bd303e73f75335ea09fa113d293c5f2af141e990a", + "valid": true + }, + { + "description": "a tombstone: the host stopped (flags closed, token zero)", + "host_seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "host_public_key": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "network_version": 38, + "flags": 8, + "sequence": 413, + "time": 1760900100, + "token": "00000000000000000000000000000000", + "players": 0, + "max_players": 16, + "engine": 1, + "name": "OpenCE Dedicated", + "map": "bloodgulch", + "gametype": "CTF", + "kind": 0, + "bytes": "484c010026080000019d68f53404fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb91154890802500000000000000000000000000000000001001104f70656e4345204465646963617465640a626c6f6f6467756c636803435446000000000000000046260bbe15b8aa617724442e82a164f2ebdb093d3866c0c09496aa483121dd8f1ba3591aba35dce2d61389a48d384aac9ea079576810e836b7e9f4a15b6b5308", + "base64": "SEwBACYIAAABnWj1NAT8Uc2OYhiho42kftACMPBYCBbtE7ozA6xd65EVSJCAJQAAAAAAAAAAAAAAAAAAAAAAEAEQT3BlbkNFIERlZGljYXRlZApibG9vZGd1bGNoA0NURgAAAAAAAAAARiYLvhW4qmF3JEQugqFk8uvbCT04ZsDAlJaqSDEh3Y8bo1kaujXc4tYTiaSNOEqsnqB5V2gQ6Da36fShW2tTCA==", + "size": 169, + "signed_size": 105, + "signed_message": "686365752d6c6f6262792d31484c010026080000019d68f53404fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb91154890802500000000000000000000000000000000001001104f70656e4345204465646963617465640a626c6f6f6467756c6368034354460000000000000000", + "signature": "46260bbe15b8aa617724442e82a164f2ebdb093d3866c0c09496aa483121dd8f1ba3591aba35dce2d61389a48d384aac9ea079576810e836b7e9f4a15b6b5308", + "valid": true + }, + { + "description": "a listing whose name was changed after signing: it must be refused", + "host_seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "host_public_key": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "network_version": 38, + "flags": 37, + "sequence": 412, + "time": 1760900000, + "token": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "players": 5, + "max_players": 16, + "engine": 1, + "name": "oPenCE Dedicated", + "map": "bloodgulch", + "gametype": "CTF", + "kind": 0, + "bytes": "484c010026250000019c68f533a0fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025a0a1a2a3a4a5a6a7a8a9aaabacadaeaf051001106f70656e4345204465646963617465640a626c6f6f6467756c6368034354460000000000000000633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f", + "base64": "SEwBACYlAAABnGj1M6D8Uc2OYhiho42kftACMPBYCBbtE7ozA6xd65EVSJCAJaChoqOkpaanqKmqq6ytrq8FEAEQb3BlbkNFIERlZGljYXRlZApibG9vZGd1bGNoA0NURgAAAAAAAAAAYz3GPrJ4KNblieNNxDNtuy1RSyAY5L9KOt9a63dLalI/5mLD0pJBxvkchxSoyiteSTqssyvK65vOcV8Vt20jDw==", + "size": 169, + "signed_size": 105, + "signed_message": "686365752d6c6f6262792d31484c010026250000019c68f533a0fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025a0a1a2a3a4a5a6a7a8a9aaabacadaeaf051001104f70656e4345204465646963617465640a626c6f6f6467756c6368034354460000000000000000", + "signature": "633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f", + "valid": false + } + ], + "list": { + "body": "{\"protocol\":1,\"master\":{\"name\":\"OpenCE Master 1\",\"key_id\":\"39f713d0a644253f\",\"time\":1760900000},\"servers\":[]}", + "signature_header": "ed25519=18dmQnIfmRnC+wUwRJ/loxAH1VfASRBVnZgCfRuHrAw/2uo4thTibWnNQFScgRju+5lQtt709blK86BLT0fYCw==", + "signature_key_header": "39f713d0a644253f", + "signature_time_header": "1760900000", + "etag": "\"cd755a50c5cfc457\"" + }, + "small_order_key_refused": "ed25519:0100000000000000000000000000000000000000000000000000000000000000", + "proof_nonce": "opence-apply-1760900000-0123456789abcdef", + "proof_signature_hex": "8e7d8fcafeb3763625557028360d959a3f9f586ff22f5744546bba5a82567dfb9480188076e7c179334b85781929da9b3dce3035dc77ac588bd62d1236b35209" +} diff --git a/tools/harness/tests/server_command_words.c b/tools/harness/tests/server_command_words.c new file mode 100644 index 00000000..c44e7d6d --- /dev/null +++ b/tools/harness/tests/server_command_words.c @@ -0,0 +1,377 @@ +/* +SERVER_COMMAND_WORDS.C (test) + +The words of a dedicated server's commands (port/linux/game/server_commands.c): +a line split into words (a quoted one whole), the text after some of them, +a reason kept to printable ASCII, a ban's duration, a number in range, a +field of a bans.txt line, and a player found by slot or the start of their +name, with the name read from the game (server_status.c, +player_name_character_ascii of source/game/players.c) and checked against +the web page's (server_status_player_matches); and how long a role may ban +for (ban_duration_allowed). test_server_command_words.py takes the limits +(config.inc) and the code (under_test.inc). +*/ + +#include "harness.h" + +#include +#include +#include +#include + +#define csstrcasecmp strcasecmp +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define NUMBEROF(array) (sizeof(array) / sizeof(array[0])) + +#include "config.inc" + +struct network_player +{ + boolean valid; + wchar_t name[12]; +}; + +struct network_game +{ + struct network_player players[MAXIMUM_SLOTS]; +}; + +static struct network_game game; +static boolean hosting = TRUE; + +static struct network_game *server_game(void) +{ + return hosting ? &game : NULL; +} + +static boolean network_player_is_valid(struct network_player *player) +{ + return player->valid; +} + +/* what the commands said last */ +static char said[1024]; + +static void say(char const *format, ...) +{ + va_list arguments; + + va_start(arguments, format); + vsnprintf(said, sizeof(said), format, arguments); + va_end(arguments); +} + +#include "under_test.inc" + +static void add_player(long slot, wchar_t const *name) +{ + game.players[slot].valid = TRUE; + wcsncpy(game.players[slot].name, name, NUMBEROF(game.players[slot].name)); +} + +static long find(char const *text) +{ + said[0] = 0; + return find_player("kick", text); +} + +static long split(char const *line, char *copy, char **words, long maximum) +{ + strcpy(copy, line); + return split_words(copy, words, maximum); +} + +static char const *printable(char const *source, long size) +{ + static char text[256]; + + memset(text, '#', sizeof(text)); + printable_text(text, size, source); + return text; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* words between spaces and tabs, a quoted one whole (no escapes); no + more than the maximum, the rest of the line left as it was */ + CASE("split-words") + { + char copy[256]; + char *words[32]; + long count; + + count = split("sv_map bloodgulch ctf", copy, words, MAXIMUM_WORDS); + CHECK(count == 3 && !strcmp(words[0], "sv_map") && !strcmp(words[1], "bloodgulch") && !strcmp(words[2], "ctf"), + "%ld words", count); + count = split(" \tsv_kick \t 3 ", copy, words, MAXIMUM_WORDS); + CHECK(count == 2 && !strcmp(words[0], "sv_kick") && !strcmp(words[1], "3"), "%ld words: %s", count, words[1]); + CHECK(split("", copy, words, MAXIMUM_WORDS) == 0, "an empty line had words"); + CHECK(split(" \t ", copy, words, MAXIMUM_WORDS) == 0, "spaces had words"); + count = split("ban \"Master Chief\" 1d being rude", copy, words, MAXIMUM_WORDS); + CHECK(count == 5 && !strcmp(words[1], "Master Chief") && !strcmp(words[2], "1d") && !strcmp(words[4], "rude"), + "%ld words: %s, %s", count, words[1], words[2]); + count = split("say \"\"", copy, words, MAXIMUM_WORDS); + CHECK(count == 2 && !words[1][0], "an empty quoted word: %ld words", count); + count = split("kick \"Master Chief", copy, words, MAXIMUM_WORDS); + CHECK(count == 2 && !strcmp(words[1], "Master Chief"), "an unclosed quote: %s", words[1]); + count = split("say \"a\"b \"c\"\"d\"", copy, words, MAXIMUM_WORDS); + CHECK(count == 5 && !strcmp(words[1], "a") && !strcmp(words[2], "b") && !strcmp(words[3], "c") && + !strcmp(words[4], "d"), "quotes against words: %ld words", count); + count = split("say ab\"c d\"", copy, words, MAXIMUM_WORDS); + CHECK(count == 3 && !strcmp(words[1], "ab\"c") && !strcmp(words[2], "d\""), "a quote inside a word: %s", + words[1]); + count = split("say \"a\\\"b\"", copy, words, MAXIMUM_WORDS); + CHECK(count == 3 && !strcmp(words[1], "a\\") && !strcmp(words[2], "b\""), "a backslash escaped a quote: %s", + words[1]); + count = split("1 2 3 4 5 6 7 8 9 10", copy, words, MAXIMUM_WORDS); + CHECK(count == MAXIMUM_WORDS && !strcmp(words[MAXIMUM_WORDS - 1], "8"), "%ld words, the last %s", count, + words[count - 1]); + CHECK(!strcmp(words[MAXIMUM_WORDS - 1] + 2, "9 10"), "the rest of the line was split: %s", + words[MAXIMUM_WORDS - 1] + 2); + count = split("a b c", copy, words, 2); + CHECK(count == 2 && !strcmp(words[1], "b"), "%ld words of 2", count); + CHECK(split("a b", copy, words, 0) == 0, "words past a maximum of none"); + return 0; + } + /* the text after count words: where the next word begins (its quote) */ + CASE("rest-after-words") + { + static char const *const lines[] = + { + "say hello world ", "ban \"Master Chief\" 1d being rude", " tell\t3 psst", "kick \"unclosed", + "say \"a b\" c", "sv_map", "", + }; + char copy[256]; + char *words[32]; + long line, count, index; + + CHECK(!strcmp(rest_after_words("say hello world ", 1), "hello world "), "%s", + rest_after_words("say hello world ", 1)); + CHECK(!strcmp(rest_after_words("ban \"Master Chief\" 1d being rude", 3), "being rude"), "%s", + rest_after_words("ban \"Master Chief\" 1d being rude", 3)); + CHECK(!strcmp(rest_after_words("ban \"Master Chief\" 1d being rude", 2), "1d being rude"), "%s", + rest_after_words("ban \"Master Chief\" 1d being rude", 2)); + CHECK(!strcmp(rest_after_words("ban joe", 3), ""), "past the words: %s", rest_after_words("ban joe", 3)); + CHECK(!strcmp(rest_after_words("say", 0), "say"), "after no words: %s", rest_after_words("say", 0)); + /* (as the words were split) */ + for (line = 0; line < (long)NUMBEROF(lines); line++) + { + count = split(lines[line], copy, words, 32); + for (index = 0; index < count; index++) + { + char const *rest = rest_after_words(lines[line], index); + long at = (long)(words[index] - copy); + + /* (a quoted word's: its quote) */ + if (at > 0 && lines[line][at - 1] == '"' && + (at == 1 || lines[line][at - 2] == ' ' || lines[line][at - 2] == '\t')) + { + at--; + } + CHECK(rest == lines[line] + at, "\"%s\": after %ld words at %ld, not %ld", lines[line], index, + (long)(rest - lines[line]), at); + } + CHECK(!*rest_after_words(lines[line], count), "\"%s\": something after every word", lines[line]); + } + return 0; + } + /* a text kept to printable ASCII, its spaces after and its quotes taken off */ + CASE("printable-text") + { + CHECK(!strcmp(printable("being rude", 96), "being rude"), "%s", printable("being rude", 96)); + CHECK(!strcmp(printable("tab\there\x01\x7f!", 96), "tabhere!"), "%s", printable("tab\there\x01\x7f!", 96)); + CHECK(!strcmp(printable("caf\xc3\xa9", 96), "caf"), "%s", printable("caf\xc3\xa9", 96)); + CHECK(!strcmp(printable("spaces after ", 96), "spaces after"), "\"%s\"", printable("spaces after ", 96)); + CHECK(!strcmp(printable(" spaces before", 96), "spaces before"), "\"%s\"", printable(" spaces before", 96)); + /* (the quotes go before the text is cut to its room) */ + CHECK(!strcmp(printable("\"a long quoted reason\"", 8), "a long"), "\"%s\"", + printable("\"a long quoted reason\"", 8)); + CHECK(!strcmp(printable("\"a quoted reason\"", 96), "a quoted reason"), "%s", + printable("\"a quoted reason\"", 96)); + CHECK(!strcmp(printable("\"quoted\" ", 96), "quoted"), "%s", printable("\"quoted\" ", 96)); + CHECK(!strcmp(printable("\"\"", 96), ""), "%s", printable("\"\"", 96)); + CHECK(!strcmp(printable("\"", 96), "\""), "%s", printable("\"", 96)); + CHECK(!strcmp(printable("\"half", 96), "\"half"), "%s", printable("\"half", 96)); + CHECK(!strcmp(printable("say \"it\"", 96), "say \"it\""), "%s", printable("say \"it\"", 96)); + CHECK(!strcmp(printable("abcdefgh", 6), "abcde"), "%s", printable("abcdefgh", 6)); + CHECK(!strcmp(printable("abc", 1), ""), "a room of one held %s", printable("abc", 1)); + CHECK(!strcmp(printable(NULL, 96), ""), "no text was %s", printable(NULL, 96)); + CHECK(printable("abcdefgh", 6)[6] == '#', "written past the room"); + return 0; + } + /* 30m, 2h, 1d, 1w, 45s, 1y, minutes alone, forever: seconds; else -1 */ + CASE("durations") + { + CHECK(parse_duration("30m") == 30 * 60, "30m is %ld", parse_duration("30m")); + CHECK(parse_duration("2h") == 2 * 60 * 60, "2h is %ld", parse_duration("2h")); + CHECK(parse_duration("1d") == 24 * 60 * 60, "1d is %ld", parse_duration("1d")); + CHECK(parse_duration("7d") == 7 * 24 * 60 * 60, "7d is %ld", parse_duration("7d")); + CHECK(parse_duration("1w") == 7 * 24 * 60 * 60, "1w is %ld", parse_duration("1w")); + CHECK(parse_duration("45s") == 45, "45s is %ld", parse_duration("45s")); + CHECK(parse_duration("1y") == 365L * 24 * 60 * 60, "1y is %ld", parse_duration("1y")); + CHECK(parse_duration("10") == 10 * 60, "10 is %ld", parse_duration("10")); + CHECK(parse_duration("forever") == 0 && parse_duration("FOREVER") == 0 && parse_duration("Permanent") == 0 && + parse_duration("0") == 0, "forever is not 0"); + CHECK(parse_duration("10y") == 10L * 365 * 24 * 60 * 60, "10y is %ld", parse_duration("10y")); + CHECK(parse_duration("11y") == -1, "11y is %ld", parse_duration("11y")); + CHECK(parse_duration("3650d") == 3650L * 24 * 60 * 60 && parse_duration("3651d") == -1, "3651d is %ld", + parse_duration("3651d")); + CHECK(parse_duration("521w") > 0 && parse_duration("522w") == -1, "522w is %ld", parse_duration("522w")); + CHECK(parse_duration("100000") == 100000L * 60 && parse_duration("100001") == -1, "100001 is %ld", + parse_duration("100001")); + CHECK(parse_duration("99999999999h") == -1, "a number past a long was %ld", parse_duration("99999999999h")); + CHECK(parse_duration("") == -1, "nothing is %ld", parse_duration("")); + CHECK(parse_duration("m") == -1, "m is %ld", parse_duration("m")); + CHECK(parse_duration("-5m") == -1, "-5m is %ld", parse_duration("-5m")); + CHECK(parse_duration("+5m") == -1, "+5m is %ld", parse_duration("+5m")); + CHECK(parse_duration(" 5m") == -1, "\" 5m\" is %ld", parse_duration(" 5m")); + CHECK(parse_duration("0m") == -1, "0m is %ld", parse_duration("0m")); + CHECK(parse_duration("30x") == -1, "30x is %ld", parse_duration("30x")); + CHECK(parse_duration("30mm") == -1, "30mm is %ld", parse_duration("30mm")); + CHECK(parse_duration("30m5") == -1, "30m5 is %ld", parse_duration("30m5")); + CHECK(parse_duration("1.5h") == -1, "1.5h is %ld", parse_duration("1.5h")); + CHECK(parse_duration("spam") == -1, "a reason is %ld", parse_duration("spam")); + CHECK(parse_duration("forevers") == -1, "forevers is %ld", parse_duration("forevers")); + return 0; + } + /* a whole number within a range */ + CASE("integers") + { + long value = NONE; + + CHECK(parse_integer("5", 0, 9, &value) && value == 5, "5: %ld", value); + CHECK(parse_integer("0", 0, 9, &value) && value == 0, "0: %ld", value); + CHECK(parse_integer("9", 0, 9, &value) && value == 9, "9: %ld", value); + value = NONE; + CHECK(!parse_integer("10", 0, 9, &value) && value == NONE, "10 was taken"); + CHECK(!parse_integer("-1", 0, 9, &value), "-1 was taken"); + CHECK(parse_integer("-1", -5, 9, &value) && value == -1, "-1 within -5 to 9 was refused"); + CHECK(!parse_integer("5x", 0, 9, &value), "5x was taken"); + CHECK(!parse_integer("", 0, 9, &value), "nothing was taken"); + CHECK(!parse_integer(NULL, 0, 9, &value), "no text was taken"); + CHECK(!parse_integer("0", 0, -1, &value), "a number of an empty range was taken"); + CHECK(!parse_integer("99999999999", 0, 2147483646, &value), "a number past a long was taken"); + return 0; + } + /* a field of a bans.txt line (network_distributed.c writes them) */ + CASE("ban-fields") + { + static char const line[] = + "2026-10-24 12:00:00\tip=203.0.113.7\thwid=0123abcd\tdiscord_username=none\tdiscord_id=none\t" + "players=Chief, Bob\treason=spam (by the moderator)\tuntil=1792839600"; + char field[128]; + char small[8]; + + CHECK(ban_field(line, "ip", field, sizeof(field)) && !strcmp(field, "203.0.113.7"), "ip %s", field); + CHECK(ban_field(line, "hwid", field, sizeof(field)) && !strcmp(field, "0123abcd"), "hwid %s", field); + CHECK(ban_field(line, "players", field, sizeof(field)) && !strcmp(field, "Chief, Bob"), "players %s", field); + CHECK(ban_field(line, "reason", field, sizeof(field)) && !strcmp(field, "spam (by the moderator)"), "reason %s", + field); + CHECK(ban_field(line, "until", field, sizeof(field)) && strtoul(field, NULL, 10) == 1792839600UL, "until %s", + field); + CHECK(!ban_field("2026-10-24 12:00:00\tip=1.2.3.4\treason=x", "until", field, sizeof(field)), + "a ban for ever has an end"); + CHECK(!ban_field(line, "id", field, sizeof(field)), "id was found in discord_id: %s", field); + CHECK(!ban_field(line, "username", field, sizeof(field)), "username was found in discord_username"); + CHECK(!ban_field(line, "2026-10-24 12:00:00", field, sizeof(field)), "the date was taken for a field"); + CHECK(ban_field(line, "ip", small, sizeof(small)) && !strcmp(small, "203.0.1"), "cut to %s", small); + CHECK(ban_field("x\tip=\treason=y", "ip", field, sizeof(field)) && !field[0], "an empty field: %s", field); + return 0; + } + /* a player by slot, else by the start of their name in either case (a + whole name first); one that is no one's, or more than one's, is refused */ + CASE("find-player") + { + add_player(0, L"Chief"); + add_player(1, L"Chieftain"); + add_player(2, L"bob"); + add_player(3, L"Bob"); + add_player(5, L"Ramón"); + add_player(7, L"Spartan-117"); + add_player(MAXIMUM_SLOTS - 1, L"Last"); + CHECK(find("chief") == 0, "chief found %ld: %s", find("chief"), said); + CHECK(find("CHIEF") == 0, "CHIEF found %ld: %s", find("CHIEF"), said); + CHECK(find("chieft") == 1, "chieft found %ld: %s", find("chieft"), said); + CHECK(find("Chieftain") == 1, "Chieftain found %ld", find("Chieftain")); + CHECK(find("Chieftains") == NONE && strstr(said, "no player's name begins with \"Chieftains\""), + "more than a name found someone: %s", said); + CHECK(find("chi") == NONE && strstr(said, "2 players' names begin with \"chi\""), "chi: %s", said); + CHECK(find("bob") == NONE && strstr(said, "2 players are named \"bob\""), "bob: %s", said); + CHECK(find("ramon") == 5 && find("RAM") == 5, "a name past ASCII was not found: %s", said); + CHECK(find("spartan-1") == 7, "spartan-1 found %ld", find("spartan-1")); + CHECK(find("last") == MAXIMUM_SLOTS - 1, "the last slot's player was not found"); + CHECK(find("3") == 3, "slot 3 found %ld", find("3")); + CHECK(find("007") == 7, "slot 007 found %ld", find("007")); + CHECK(find("4") == NONE && strstr(said, "no player in slot 4"), "an empty slot: %s", said); + CHECK(find("200") == NONE && strstr(said, "no player in slot 200"), "slot 200: %s", said); + { + char text[16]; + + snprintf(text, sizeof(text), "%d", MAXIMUM_SLOTS); + CHECK(find(text) == NONE, "slot %s past the last found someone", text); + snprintf(text, sizeof(text), "%d", MAXIMUM_SLOTS - 1); + CHECK(find(text) == MAXIMUM_SLOTS - 1, "the last slot was not found"); + } + CHECK(find("99999999999") == NONE, "a number past a long found someone"); + CHECK(find("-1") == NONE && strstr(said, "no player's name begins with \"-1\""), "-1: %s", said); + CHECK(find("3x") == NONE && strstr(said, "no player's name begins with"), "3x: %s", said); + CHECK(find("") == NONE && strstr(said, "give a player's slot"), "nothing: %s", said); + CHECK(find("x") == NONE, "x found someone"); + hosting = FALSE; + CHECK(find("chief") == NONE && strstr(said, "not hosting"), "a server not hosting found someone: %s", said); + return 0; + } + /* a slot's player's name in ASCII; whether it is still the page's */ + CASE("player-matches") + { + char current[16]; + + add_player(0, L"Chief"); + add_player(5, L"Ramón"); + add_player(6, L"中文"); + add_player(9, L"ElevenChars"); + CHECK(server_status_player_matches(0, "Chief", current, sizeof(current)) && !strcmp(current, "Chief"), + "Chief is not slot 0's: %s", current); + CHECK(!server_status_player_matches(0, "chief", current, sizeof(current)) && !strcmp(current, "Chief"), + "a name in another case matched: %s", current); + CHECK(!server_status_player_matches(0, "Chie", current, sizeof(current)), "the start of a name matched"); + CHECK(!server_status_player_matches(0, "Chiefs", current, sizeof(current)), "more than the name matched"); + CHECK(server_status_player_matches(5, "Ramon", current, sizeof(current)), "Ramón's name read %s", current); + CHECK(server_status_player_matches(6, "??", current, sizeof(current)), "a name of no Latin letters read %s", + current); + CHECK(server_status_player_matches(9, "ElevenChars", current, sizeof(current)), "eleven characters read %s", + current); + strcpy(current, "stale"); + CHECK(!server_status_player_matches(4, "", current, sizeof(current)) && !current[0], + "an empty slot matched, or kept \"%s\"", current); + CHECK(!server_status_player_matches(-1, "Chief", current, sizeof(current)), "slot -1 matched"); + CHECK(!server_status_player_matches(MAXIMUM_SLOTS, "Chief", current, sizeof(current)), "a slot past the last matched"); + memset(current, '#', sizeof(current)); + CHECK(server_status_player_name(0, current, 4) && !strcmp(current, "Chi") && current[4] == '#', + "a room of 4 held %s", current); + CHECK(!server_status_player_matches(0, "Chief", current, 4), "a name cut to its room matched"); + hosting = FALSE; + CHECK(!server_status_player_matches(0, "Chief", current, sizeof(current)) && !current[0], + "a server not hosting matched"); + return 0; + } + /* a moderator bans for a day at most, never for ever; an admin and the + owner for as long as they like */ + CASE("ban-limits") + { + CHECK(ban_duration_allowed(_server_role_moderator, 30 * 60), "a moderator's 30 minutes were refused"); + CHECK(ban_duration_allowed(_server_role_moderator, 24 * 60 * 60), "a moderator's day was refused"); + CHECK(!ban_duration_allowed(_server_role_moderator, 24 * 60 * 60 + 1), "a moderator banned for over a day"); + CHECK(!ban_duration_allowed(_server_role_moderator, 7 * 24 * 60 * 60), "a moderator banned for a week"); + CHECK(!ban_duration_allowed(_server_role_moderator, 0), "a moderator banned for ever"); + CHECK(!ban_duration_allowed(_server_role_viewer, 0), "a viewer banned for ever"); + CHECK(ban_duration_allowed(_server_role_admin, 0), "an admin's ban for ever was refused"); + CHECK(ban_duration_allowed(_server_role_admin, 7 * 24 * 60 * 60), "an admin's week was refused"); + CHECK(ban_duration_allowed(_server_role_owner, 0), "the owner's ban for ever was refused"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/server_http.c b/tools/harness/tests/server_http.c new file mode 100644 index 00000000..1c698833 --- /dev/null +++ b/tools/harness/tests/server_http.c @@ -0,0 +1,1932 @@ +/* +SERVER_HTTP.C (test) + +The real parsing of a dedicated server's web port +(port/linux/src/server_http.c): a request's head (its line, headers, body's +length, cookie), a connection's bytes taken a request at a time, the +WebSocket handshake (its SHA-1 and accept key, its refusals), a client's +frames, and the small checks (Origin, addresses, query numbers, passwords +compared). test_server_http.py takes the limits, roles and structures +(config.inc), the JSON writer (server_json.inc) and the functions +(under_test.inc); what they call besides is the test's. Also a +connection's output (its compaction, its pause and its most), its +requests held while its answers wait, the addresses' table (its +allowance, the record that makes room), the connection a new one evicts, +and a connection's deadlines; and the sign-in's decisions (handle_request, +serve_login, the sessions, the passwords' checks, their budget and +lockouts), on stand-ins for the HMAC, the random bytes and the settings. +*/ + +#include "harness.h" + +#include +#include + +typedef uint64_t Uint64; +typedef struct SDL_Mutex SDL_Mutex; +typedef struct SDL_Thread SDL_Thread; + +#define HALO_PORT_NETWORK_VERSION 1 +#define SDL_LockMutex(lock) ((void)0) +#define SDL_UnlockMutex(lock) ((void)0) +#define server_log(...) ((void)0) + +#include "config.inc" +#include "server_json.inc" + +static Uint64 SDL_GetTicksNS(void) { return 1000; } + +static unsigned long test_now = 1000; + +static unsigned long now_seconds(void) { return test_now; } + +/* the settings the code reads, and their changes (config_changes) */ +static const char *trusted_proxy = ""; +static const char *setting_admin = ""; +static const char *setting_moderator = ""; +static const char *setting_viewer = ""; +static const char *setting_owner = ""; +static unsigned long setting_changes; + +static const char *config_string(const char *name) +{ + if (!strcmp(name, "server.web_trusted_proxy")) + return trusted_proxy; + if (!strcmp(name, "server.web_password")) + return setting_admin; + if (!strcmp(name, "server.web_moderator_password")) + return setting_moderator; + if (!strcmp(name, "server.web_viewer_password")) + return setting_viewer; + if (!strcmp(name, "server.web_owner_password")) + return setting_owner; + return "a server"; +} + +static int config_boolean(const char *name) +{ + (void)name; + return 0; +} + +static unsigned long config_changes(void) { return setting_changes; } + +static const char *server_mode_build(void) { return "build-0"; } + +static const char *server_role_name(int role) +{ + static const char *const names[] = { "viewer", "moderator", "admin", "owner" }; + + return role >= _server_role_viewer && role <= _server_role_owner ? names[role] : "none"; +} + +/* a stand-in for HMAC-SHA-256: a digest that differs for each key and +text, which is all the sessions and passwords need of it */ +static void p2p_hmac_sha256(const unsigned char *key, int key_size, const void *data, int size, unsigned char *digest) +{ + unsigned long long state = 1469598103934665603ULL; + int index; + + for (index = 0; index < key_size; index++) + state = (state ^ key[index]) * 1099511628211ULL; + state = (state ^ 0xFF) * 1099511628211ULL; + for (index = 0; index < size; index++) + state = (state ^ ((const unsigned char *)data)[index]) * 1099511628211ULL; + for (index = 0; index < P2P_SHA256_SIZE; index++) + { + state = (state ^ (unsigned long long)index) * 1099511628211ULL; + digest[index] = (unsigned char)(state >> 40); + } +} + +static int p2p_equal(const void *first, const void *second, int size) +{ + return !memcmp(first, second, (size_t)size); +} + +/* random bytes: a generator's, the same each run */ +static unsigned long long random_state = 12345; + +static void posix_random_bytes(void *buffer, unsigned long size) +{ + unsigned char *bytes = buffer; + + while (size--) + { + random_state = random_state * 6364136223846793005ULL + 1442695040888963407ULL; + *bytes++ = (unsigned char)(random_state >> 56); + } +} + +static void out_append(struct connection *connection, const void *data, int size); +static void ws_close(struct connection *connection, int code, const char *reason); + +/* what was answered (respond and respond_error close a connection that is +not kept alive, as they do) */ +static struct +{ + int count; + int status; + const char *code; + int retry_after; + char extra_headers[256]; + char body[512]; +} answered; + +static void respond(struct connection *connection, int status, const char *content_type, const void *body, int size, + const char *extra_headers, int no_store) +{ + answered.count++; + answered.status = status; + answered.code = NULL; + answered.retry_after = 0; + snprintf(answered.extra_headers, sizeof(answered.extra_headers), "%s", extra_headers ? extra_headers : ""); + snprintf(answered.body, sizeof(answered.body), "%.*s", size, (const char *)body); + if (!connection->request.keep_alive) + connection->close_after_write = 1; +} + +static void respond_error(struct connection *connection, int status, const char *code, const char *message, + int retry_after) +{ + respond(connection, status, "application/json", "", 0, NULL, 1); + answered.code = code; + answered.retry_after = retry_after; +} + +static void respond_json(struct connection *connection, int status, const char *json, const char *extra_headers) +{ + respond(connection, status, "application/json", json, (int)strlen(json), extra_headers, 1); +} + +/* what handle_request handed on: the snapshot or page served, the ticket +for the game's thread */ +static const char *served; + +static void serve_healthz(struct connection *connection) { served = "healthz"; respond(connection, 200, "", "", 0, NULL, 1); } +static void serve_metrics(struct connection *connection) { served = "metrics"; respond(connection, 200, "", "", 0, NULL, 1); } +static void serve_asset(struct connection *connection) { served = "asset"; respond(connection, 200, "", "", 0, NULL, 1); } +static void serve_snapshot(struct connection *connection, const char *text) +{ + served = "snapshot"; + respond(connection, 200, "", "", 0, NULL, 1); +} +static void serve_log(struct connection *connection, int role) { served = "log"; respond(connection, 200, "", "", 0, NULL, 1); } +static void serve_events(struct connection *connection, int role) +{ + served = "events"; + respond(connection, 200, "", "", 0, NULL, 1); +} + +static struct server_ticket ticket_made; +static int tickets; + +static struct server_ticket *ticket_new(int kind, int role, const char *by) +{ + memset(&ticket_made, 0, sizeof(ticket_made)); + ticket_made.kind = kind; + ticket_made.role = role; + snprintf(ticket_made.by, sizeof(ticket_made.by), "%s", by ? by : ""); + return &ticket_made; +} + +static void ticket_submit(struct server_ticket *ticket) +{ + tickets++; + served = "ticket"; +} + +/* the requests handed on, the WebSocket messages taken and sent */ +static struct +{ + int count; + char path[256]; + char body[256]; + int length; +} handled; + +/* the bytes each request handed on answers, if any */ +static int answer_size; +static unsigned char answer[256 * 1024]; + +/* (the test's, which connection_take calls; the real one, which the +sign-in's cases call, is real_handle_request) */ +static void handle_request(struct connection *connection, const char *body, int length) +{ + if (answer_size > 0) + out_append(connection, answer, answer_size); + handled.count++; + snprintf(handled.path, sizeof(handled.path), "%s", connection->request.path); + snprintf(handled.body, sizeof(handled.body), "%.*s", length, body); + handled.length = length; +} + +static struct +{ + int count; + char text[256]; + int length; +} message_taken; + +static void ws_message(struct connection *connection, const char *text, int length) +{ + message_taken.count++; + snprintf(message_taken.text, sizeof(message_taken.text), "%.*s", length, text); + message_taken.length = length; +} + +static int sent_count; +static char sent[512]; + +static void ws_send(struct connection *connection, const char *text) +{ + sent_count++; + snprintf(sent, sizeof(sent), "%s", text); +} + +static int auth_answered; +static char auth_role[16]; + +static void ws_auth_answer(struct connection *connection, int ok, const char *code, const char *message, int retry_after) +{ + auth_answered = ok ? 1 : -1; + snprintf(auth_role, sizeof(auth_role), "%s", ok ? server_role_name(connection->role) : ""); +} + +#include "under_test.inc" + +/* ---------- the test's */ + +static unsigned char input[400 * 1024]; +static struct connection connection; + +static void connection_reset(int websocket) +{ + free(connection.out); + free(connection.message); + memset(&connection, 0, sizeof(connection)); + connection.socket = 5; + connection.in = input; + connection.websocket = websocket; + connection.address = 0x0A000002UL; + connection.real_address = connection.address; + connection.role = _server_role_none; + memset(&answered, 0, sizeof(answered)); + memset(&handled, 0, sizeof(handled)); + memset(&message_taken, 0, sizeof(message_taken)); + sent_count = 0; + auth_answered = 0; + auth_role[0] = 0; + answer_size = 0; + served = NULL; + tickets = 0; +} + +/* the sign-in's state as the server starts: no sessions, no addresses +seen, the passwords as set */ +static void sign_in_reset(const char *admin, const char *moderator, const char *viewer) +{ + int index; + + memset(http.sessions, 0, sizeof(http.sessions)); + memset(http.addresses, 0, sizeof(http.addresses)); + memset(http.session_key, 7, sizeof(http.session_key)); + http.budget_minute = 0; + http.budget_used = 0; + http.passwords_known = 0; + setting_admin = admin; + setting_moderator = moderator; + setting_viewer = viewer; + setting_owner = ""; + setting_changes++; + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + http.connections[index].socket = -1; + passwords_check(); +} + +/* request_from's requests come through the trusted proxy (connection_take +marks them so) */ +static int request_forwarded; + +/* a request handed to handle_request as connection_take would: its head +read from text (CRLF lines), from the address */ +static void request_from(unsigned long address, const char *head, const char *body) +{ + static char text[4096]; + int status; + const char *code; + + connection_reset(0); + connection.address = connection.real_address = address; + connection.forwarded = request_forwarded; + snprintf(text, sizeof(text), "%s", head); + CHECK(server_http_parse_head(text, (int)strlen(text), &connection.request, &status, &code), + "the test's request was refused (%d %s): %s", status, code, head); + real_handle_request(&connection, body ? body : "", body ? (int)strlen(body) : 0); +} + +/* a sign in: POST /api/login with the password, from the address; the +token made, if one was */ +static int sign_in(unsigned long address, const char *password, char *token) +{ + char body[256]; + const char *found; + + snprintf(body, sizeof(body), "{\"password\":\"%s\"}", password); + request_from(address, "POST /api/login HTTP/1.1\r\nHost: server.example\r\n", body); + token[0] = 0; + if (answered.status != 200) + return 0; + found = strstr(answered.body, "\"token\":\""); + CHECK(found && sscanf(found + 9, "%64[0-9a-f]", token) == 1, "a sign in answered no token: %s", answered.body); + return 1; +} + +static void feed(const void *bytes, int size) +{ + memcpy(connection.in + connection.in_length, bytes, (size_t)size); + connection.in_length += size; +} + +static void feed_text(const char *text) +{ + feed(text, (int)strlen(text)); +} + +/* a client's frame: its length in the shortest form, or in form (16 or 64 +bits) if asked; masked with the RFC 6455's example key unless not */ +static int client_frame(unsigned char *frame, int first, int opcode, const void *payload, unsigned long long size, + int form, int masked) +{ + static const unsigned char mask[4] = { 0x37, 0xFA, 0x21, 0x3D }; + int length = 0, index; + unsigned long long byte; + + frame[length++] = (unsigned char)opcode | (first ? 0x80 : 0); + if (size < 126 && !form) + { + frame[length++] = (unsigned char)size; + } + else if (size < 65536 && form != 64) + { + frame[length++] = 126; + frame[length++] = (unsigned char)(size >> 8); + frame[length++] = (unsigned char)size; + } + else + { + frame[length++] = 127; + for (index = 7; index >= 0; index--) + frame[length++] = (unsigned char)(size >> (8 * index)); + } + if (masked) + { + frame[1] |= 0x80; + memcpy(frame + length, mask, 4); + length += 4; + } + for (byte = 0; payload && byte < size; byte++) + frame[length++] = ((const unsigned char *)payload)[byte] ^ (masked ? mask[byte & 3] : 0); + return length; +} + +static unsigned char frame[400 * 1024]; + +static void feed_frame(int fin, int opcode, const void *payload, unsigned long long size) +{ + feed(frame, client_frame(frame, fin, opcode, payload, size, 0, 1)); +} + +/* the server's frames written: the opcode and payload of the which'th, 0 +if there are not that many */ +static int server_frame(int which, int *opcode, const unsigned char **payload, int *size) +{ + int at = 0; + + while (at + 2 <= connection.out_length) + { + const unsigned char *data = connection.out + at; + int length = data[1] & 0x7F, head = 2; + + if (length == 126) + { + length = data[2] << 8 | data[3]; + head = 4; + } + else if (length == 127) + { + length = data[6] << 24 | data[7] << 16 | data[8] << 8 | data[9]; + head = 10; + } + if (!which--) + { + *opcode = data[0] & 0x0F; + *payload = data + head; + *size = length; + return 1; + } + at += head + length; + } + return 0; +} + +/* the close frame's code the server wrote, 0 for none */ +static int close_code(void) +{ + const unsigned char *payload; + int opcode, size, which; + + for (which = 0; server_frame(which, &opcode, &payload, &size); which++) + { + if (opcode == 0x8 && size >= 2) + return payload[0] << 8 | payload[1]; + } + return 0; +} + +static int parse(const char *head, struct http_request *request, int *status, const char **code) +{ + return server_http_parse_head(head, (int)strlen(head), request, status, code); +} + +/* a request's head taken, or refused with the status and code */ +static int head_refused(const char *head, int expected_status, const char *expected_code) +{ + static struct http_request request; + int status; + const char *code; + + return !parse(head, &request, &status, &code) && status == expected_status && !strcmp(code, expected_code); +} + +static long content_length(const char *value, int *status) +{ + static struct http_request request; + char head[256]; + const char *code; + + snprintf(head, sizeof(head), "POST /api/x HTTP/1.1\r\nContent-Length: %s\r\n", value); + *status = 0; + return parse(head, &request, status, &code) ? request.content_length : -1; +} + +static const char *cookie(const char *value) +{ + static struct http_request request; + char head[512]; + int status; + const char *code; + + snprintf(head, sizeof(head), "GET / HTTP/1.1\r\nCookie: %s\r\n", value); + if (!parse(head, &request, &status, &code)) + return "(refused)"; + return request.session_cookie; +} + +static void hex(unsigned char const *digest, char *text) +{ + int index; + + for (index = 0; index < 20; index++) + snprintf(text + 2 * index, 3, "%02x", digest[index]); +} + +static void upgrade_request(const char *extra_headers) +{ + connection_reset(0); + connection.request.keep_alive = 1; + feed_text("GET /ws HTTP/1.1\r\nHost: server.example:8080\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"); + feed_text(extra_headers); + feed_text("\r\n"); +} + +static const char *accept_header(void) +{ + static char text[64]; + const char *at; + + text[0] = 0; + if (connection.out && (at = strstr((const char *)connection.out, "Sec-WebSocket-Accept: ")) != NULL) + sscanf(at + 22, "%63[^\r]", text); + return text; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + int slot; + + /* (the connections' slots free, as server_http_start leaves them) */ + for (slot = 0; slot < MAXIMUM_CONNECTIONS; slot++) + http.connections[slot].socket = -1; + + /* FIPS 180's examples, and every length across a block's padding (the + digests are Python's) */ + CASE("sha1") + { + static const char *const examples[][2] = { + { "abc", "a9993e364706816aba3e25717850c26c9cd0d89d" }, + { "", "da39a3ee5e6b4b0d3255bfef95601890afd80709" }, + { "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "84983e441c3bd26ebaae4aa1f95129e5e54670f1" }, + }; + unsigned char message[SHA1_LENGTHS], digest[20]; + char text[41]; + int index; + + for (index = 0; index < (int)(sizeof(examples) / sizeof(examples[0])); index++) + { + sha1((const unsigned char *)examples[index][0], (unsigned long)strlen(examples[index][0]), digest); + hex(digest, text); + CHECK(!strcmp(text, examples[index][1]), "\"%s\" hashed to %s", examples[index][0], text); + } + for (index = 0; index < SHA1_LENGTHS; index++) + message[index] = (unsigned char)(index * 7 + 3); + for (index = 0; index < SHA1_LENGTHS; index++) + { + sha1(message, (unsigned long)index, digest); + hex(digest, text); + CHECK(!strcmp(text, sha1_digests[index]), "%d bytes hashed to %s, not %s", index, text, sha1_digests[index]); + } + return 0; + } + /* RFC 6455's handshake (1.3's example key and accept): the subprotocol, the hello */ + CASE("accept-key") + { + upgrade_request("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: chat, opence-rcon-1\r\nOrigin: https://Server.Example:8080\r\n"); + CHECK(connection_take(&connection), "the connection was closed"); + CHECK(!answered.count && connection.websocket, "the upgrade was refused (%d %s)", answered.status, + answered.code ? answered.code : ""); + CHECK(!strncmp((const char *)connection.out, "HTTP/1.1 101 Switching Protocols\r\n", 34), "no 101 was written"); + CHECK(!strcmp(accept_header(), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="), "the accept key was %s", accept_header()); + CHECK(strstr((const char *)connection.out, "\r\nSec-WebSocket-Protocol: opence-rcon-1\r\n\r\n") != NULL, + "the subprotocol was not answered"); + CHECK(sent_count == 1 && strstr(sent, "\"type\":\"hello\"") && strstr(sent, "\"protocol\":1"), + "the hello was %s", sent); + CHECK(connection.role == _server_role_none && !auth_answered, "a connection signed in by nothing"); + CHECK(connection.in_length == 0, "the request's bytes were left"); + /* (signed in already: a Bearer token anywhere, a cookie from the + page's own origin) */ + { + char token[2 * TOKEN_BYTES + 1], headers[512]; + struct session *session; + + sign_in_reset("", "", ""); + session = session_new(_server_role_admin, "admin", token); + CHECK(session && strlen(token) == 2 * TOKEN_BYTES, "no session was made"); + snprintf(headers, sizeof(headers), "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nOrigin: http://server.example:8080\r\n" + "Cookie: opence_session=%s\r\n", token); + upgrade_request(headers); + connection_take(&connection); + CHECK(auth_answered == 1 && connection.role == _server_role_admin && !strcmp(connection.by, "admin"), + "a cookie from the page did not sign in"); + snprintf(headers, sizeof(headers), "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nCookie: opence_session=%s\r\n", token); + upgrade_request(headers); + connection_take(&connection); + CHECK(auth_answered == 0 && connection.role == _server_role_none, "a cookie without an Origin signed in"); + snprintf(headers, sizeof(headers), "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nAuthorization: Bearer %s\r\n", token); + upgrade_request(headers); + connection_take(&connection); + CHECK(auth_answered == 1 && connection.role == _server_role_admin, "a Bearer token did not sign in"); + snprintf(headers, sizeof(headers), "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nAuthorization: Bearer %.63s0\r\n", token); + upgrade_request(headers); + connection_take(&connection); + CHECK(auth_answered == 0 && connection.role == _server_role_none, "a wrong Bearer token signed in"); + } + return 0; + } + /* a handshake that is not one this server takes */ + CASE("upgrade-refusals") + { + static const char good[] = "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\n"; + static const struct + { + const char *headers; + int status; + const char *code; + } refusals[] = { + { "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: opence-rcon-1\r\n", 400, "bad_request" }, + { "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\n", 426, NULL }, + { "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n", 400, "bad_request" }, + { "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nOrigin: https://elsewhere.example\r\n", 403, "origin" }, + { "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nOrigin: https://server.example:8081\r\n", 403, "origin" }, + { "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nOrigin: null\r\n", 403, "origin" }, + { "Sec-WebSocket-Key: 0123456789012345678901234567890123456789012345678901234567890\r\n" + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: opence-rcon-1\r\n", 400, "bad_request" }, + }; + int index; + + for (index = 0; index < (int)(sizeof(refusals) / sizeof(refusals[0])); index++) + { + upgrade_request(refusals[index].headers); + connection_take(&connection); + CHECK(answered.count == 1 && answered.status == refusals[index].status && !connection.websocket && + (!refusals[index].code || !strcmp(answered.code, refusals[index].code)), + "refusal %d was answered %d %s", index, answered.status, answered.code ? answered.code : ""); + } + upgrade_request("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\n"); + connection_take(&connection); + CHECK(!strcmp(answered.extra_headers, "Sec-WebSocket-Version: 13\r\n"), "426 did not name version 13"); + /* (a POST, and a GET with no Upgrade) */ + connection_reset(0); + feed_text("POST /ws HTTP/1.1\r\nUpgrade: websocket\r\n"); + feed_text(good); + feed_text("\r\n"); + connection_take(&connection); + CHECK(answered.status == 400 && !connection.websocket, "a POST was upgraded"); + connection_reset(0); + feed_text("GET /ws HTTP/1.1\r\n"); + feed_text(good); + feed_text("\r\n"); + connection_take(&connection); + CHECK(answered.status == 400 && !connection.websocket, "a GET without Upgrade was upgraded"); + /* (no more than MAXIMUM_WEBSOCKETS at once) */ + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + http.connections[index].socket = -1; + for (index = 0; index < MAXIMUM_WEBSOCKETS; index++) + { + http.connections[index].socket = 10 + index; + http.connections[index].websocket = 1; + } + upgrade_request(good); + connection_take(&connection); + CHECK(answered.status == 503 && !strcmp(answered.code, "busy") && !connection.websocket, + "a remote console past the most was upgraded"); + http.connections[0].websocket = 0; + upgrade_request(good); + connection_take(&connection); + CHECK(!answered.count && connection.websocket, "a remote console below the most was refused"); + return 0; + } + /* a request's line and headers read (names in any case, values trimmed) */ + CASE("head") + { + struct http_request request; + int status; + const char *code; + + CHECK(parse("GET /api/log?after=5&limit=10 HTTP/1.1\r\nhOST: Server:8080 \r\nx-csrf-token:\tabc\t\r\n" + "ORIGIN: http://server:8080\r\nsec-fetch-site: same-origin\r\nUnknown: kept out\r\n\r\n", &request, &status, + &code), "a request was refused"); + CHECK(!strcmp(request.fetch_site, "same-origin"), "Sec-Fetch-Site was read as \"%s\"", request.fetch_site); + CHECK(!strcmp(request.method, "GET") && !strcmp(request.path, "/api/log") && + !strcmp(request.query, "after=5&limit=10") && request.minor_version == 1 && request.keep_alive, + "read %s %s ? %s (1.%d, %d)", request.method, request.path, request.query, request.minor_version, + request.keep_alive); + CHECK(!strcmp(request.host, "Server:8080") && !strcmp(request.csrf, "abc") && + !strcmp(request.origin, "http://server:8080") && request.content_length == 0, + "read host %s, csrf %s, origin %s", request.host, request.csrf, request.origin); + CHECK(parse("POST /api/login HTTP/1.0\r\nContent-Length: 12\r\nAuthorization: Bearer x\r\n", &request, &status, + &code) && request.minor_version == 0 && !request.keep_alive && request.content_length == 12 && + !strcmp(request.authorization, "Bearer x"), "an HTTP/1.0 request was not read"); + CHECK(parse("GET / HTTP/1.0\r\nConnection: Keep-Alive\r\n", &request, &status, &code) && request.keep_alive, + "HTTP/1.0 with Keep-Alive was not kept alive"); + CHECK(parse("GET / HTTP/1.1\r\nConnection: close\r\n", &request, &status, &code) && !request.keep_alive, + "Connection: close was kept alive"); + CHECK(parse("HEAD /index.html HTTP/1.1\r\n", &request, &status, &code) && !strcmp(request.method, "HEAD"), + "HEAD was refused"); + CHECK(parse("DELETE /api/bans/3 HTTP/1.1\r\n", &request, &status, &code) && parse("PUT /a?b HTTP/1.1", &request, + &status, &code) && !strcmp(request.query, "b"), "DELETE or PUT was refused"); + CHECK(parse("GET /ws HTTP/1.1\r\nUpgrade: websocket\r\nSec-WebSocket-Key: k==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\nX-Forwarded-For: 1.2.3.4\r\nX-Forwarded-Host: h\r\n" + "X-Forwarded-Proto: https\r\nIf-None-Match: \"e\"\r\n", &request, &status, &code) && + !strcmp(request.upgrade, "websocket") && !strcmp(request.websocket_key, "k==") && + !strcmp(request.websocket_version, "13") && !strcmp(request.websocket_protocol, "opence-rcon-1") && + !strcmp(request.forwarded_for, "1.2.3.4") && !strcmp(request.forwarded_host, "h") && + !strcmp(request.forwarded_proto, "https") && !strcmp(request.if_none_match, "\"e\""), + "a header was not read"); + /* (a value longer than its field is cut to it) */ + { + char head[700]; + + snprintf(head, sizeof(head), "GET / HTTP/1.1\r\nHost: %0400d\r\n", 0); + CHECK(parse(head, &request, &status, &code) && strlen(request.host) == sizeof(request.host) - 1, + "a long Host was kept as %d", (int)strlen(request.host)); + } + return 0; + } + /* a request line, path or header this server does not take */ + CASE("head-refusals") + { + static const char *const bad[] = { + "", "\r\n", "GET\r\n", "GET /\r\n", "GET / HTTP/2.0\r\n", "GET / HTTP/1.10\r\n", "GET / HTTP/1.2\r\n", + "GET / http/1.1\r\n", "GET api HTTP/1.1\r\n", "GET /a/../b HTTP/1.1\r\n", "GET /a//b HTTP/1.1\r\n", + "GET /a\\b HTTP/1.1\r\n", "GET /%2e%2e/ HTTP/1.1\r\n", "GET /caf\xc3\xa9 HTTP/1.1\r\n", + "GET /a\x01 HTTP/1.1\r\n", "GET /a\x7f HTTP/1.1\r\n", + "GET / HTTP/1.1\r\nNo colon\r\n", "GET / HTTP/1.1\r\n: no name\r\n", + "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n", "POST / HTTP/1.1\r\nexpect: 100-continue\r\n", NULL, + }; + char head[1200]; + int index; + + /* (shown to the first byte that is not plain) */ + for (index = 0; bad[index]; index++) + { + CHECK(head_refused(bad[index], 400, "bad_request"), "\"%.*s\" was taken", + (int)strcspn(bad[index], "\r\x7f\x01\xc3"), bad[index]); + } + CHECK(!head_refused("GET /?x=..%2F HTTP/1.1\r\n", 400, "bad_request"), "dots in a query were refused"); + CHECK(head_refused("PATCH / HTTP/1.1\r\n", 405, "method") && head_refused("OPTIONS * HTTP/1.1\r\n", 405, "method") + && head_refused("get / HTTP/1.1\r\n", 405, "method"), "a method this server does not take was taken"); + /* (a path of 255 characters, and 256) */ + head[0] = 0; + strcat(head, "GET /"); + memset(head + 5, 'p', 254); + strcpy(head + 5 + 254, " HTTP/1.1\r\n"); + CHECK(!head_refused(head, 400, "bad_request"), "a path of 255 characters was refused"); + strcpy(head + 5 + 254, "p HTTP/1.1\r\n"); + CHECK(head_refused(head, 400, "bad_request"), "a path of 256 characters was taken"); + return 0; + } + /* Content-Length: digits alone, nine at most, MAXIMUM_BODY at most */ + CASE("content-length") + { + static const struct + { + const char *value; + long length; + int status; + } lengths[] = { + { "0", 0, 0 }, { "17", 17, 0 }, { " 42\t", 42, 0 }, { "65536", MAXIMUM_BODY, 0 }, { "000000005", 5, 0 }, + { "65537", -1, 413 }, { "999999999", -1, 413 }, { "1234567890", -1, 413 }, { "0000000005", -1, 413 }, + { "-1", -1, 400 }, { "+5", -1, 400 }, { "", -1, 400 }, { "0x10", -1, 400 }, { "5 5", -1, 400 }, + { "5,5", -1, 400 }, { "99999999999999999999", -1, 413 }, + }; + int index, status; + + for (index = 0; index < (int)(sizeof(lengths) / sizeof(lengths[0])); index++) + { + long length = content_length(lengths[index].value, &status); + + CHECK(length == lengths[index].length && (length >= 0 || status == lengths[index].status), + "Content-Length \"%s\" was read as %ld (%d)", lengths[index].value, length, status); + } + return 0; + } + /* two lengths that differ are refused (RFC 9112 6.3: a request + smuggled past a proxy that reads the other) */ + CASE("content-length-duplicate") + { + CHECK(head_refused("POST /api/x HTTP/1.1\r\nContent-Length: 5\r\nContent-Length: 0\r\n", 400, "bad_request"), + "two Content-Lengths were taken"); + return 0; + } + /* the session cookie among others, whole names only */ + CASE("cookie") + { + static const char token[] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + char value[256]; + + CHECK(!strcmp(cookie("opence_session=abc"), "abc"), "read %s", cookie("opence_session=abc")); + CHECK(!strcmp(cookie("theme=dark; opence_session=abc; other=1"), "abc"), "read %s", + cookie("theme=dark; opence_session=abc; other=1")); + CHECK(!strcmp(cookie("theme=dark;opence_session=abc"), "abc"), "read %s", cookie("theme=dark;opence_session=abc")); + CHECK(!strcmp(cookie("xopence_session=evil"), ""), "read %s", cookie("xopence_session=evil")); + CHECK(!strcmp(cookie("a=opence_session=evil; opence_session=good"), "good"), "read %s", + cookie("a=opence_session=evil; opence_session=good")); + CHECK(!strcmp(cookie("theme=dark"), ""), "read %s", cookie("theme=dark")); + snprintf(value, sizeof(value), "opence_session=%s", token); + CHECK(!strcmp(cookie(value), token), "a whole token was not read"); + snprintf(value, sizeof(value), "opence_session=%s0", token); + CHECK(!strcmp(cookie(value), ""), "a token too long was read as %s", cookie(value)); + return 0; + } + /* a connection's bytes: a head waited for, its body waited for, each + request handed on, heads too long answered 413 */ + CASE("connection") + { + static char large[MAXIMUM_HEAD + 64]; + int index; + + connection_reset(0); + feed_text("POST /api/x HTTP/1.1\r\nContent-Length: 5\r\n\r"); + CHECK(connection_take(&connection) && !handled.count && !answered.count, "part of a head was taken"); + feed_text("\nab"); + CHECK(connection_take(&connection) && !handled.count && connection.have_head, + "part of a body was taken (%d)", handled.count); + feed_text("cde"); + feed_text("GET /next HTTP/1.1\r\n\r\nGET /third"); + CHECK(connection_take(&connection) && handled.count == 2 && !strcmp(handled.path, "/next"), + "the requests were handed on %d times, the last %s", handled.count, handled.path); + CHECK(connection.in_length == 10 && !memcmp(connection.in, "GET /third", 10) && !connection.have_head, + "the bytes after the requests were not kept"); + /* (each byte at a time) */ + connection_reset(0); + { + static const char request[] = "PUT /api/y HTTP/1.1\r\ncontent-length: 2\r\n\r\nhi"; + + for (index = 0; request[index]; index++) + { + feed(request + index, 1); + connection_take(&connection); + CHECK(handled.count == !request[index + 1], "the request was handed on after %d bytes", index + 1); + } + CHECK(!strcmp(handled.body, "hi") && handled.length == 2, "the body was %s", handled.body); + } + /* (a head that does not end within MAXIMUM_HEAD) */ + connection_reset(0); + memset(large, 'a', sizeof(large)); + memcpy(large, "GET / HTTP/1.1\r\nX: ", 19); + feed(large, MAXIMUM_HEAD); + connection_take(&connection); + CHECK(!answered.count, "a head of MAXIMUM_HEAD bytes was refused before it ended"); + feed(large, 1); + connection_take(&connection); + CHECK(answered.status == 413 && connection.close_after_write && !handled.count, + "a head that did not end was answered %d", answered.status); + /* (a head that ends past MAXIMUM_HEAD) */ + connection_reset(0); + memcpy(large + MAXIMUM_HEAD - 2, "\r\n\r\n", 4); + feed(large, MAXIMUM_HEAD + 2); + connection_take(&connection); + CHECK(answered.status == 413 && !handled.count, "a head ending past the most was answered %d", answered.status); + /* (one ending at the most is read) */ + connection_reset(0); + memcpy(large + MAXIMUM_HEAD - 4, "\r\n\r\n", 4); + feed(large, MAXIMUM_HEAD); + connection_take(&connection); + CHECK(!answered.count && handled.count == 1, "a head of MAXIMUM_HEAD bytes was answered %d", answered.status); + /* (a request refused closes the connection) */ + connection_reset(0); + feed_text("GET /../etc HTTP/1.1\r\n\r\nGET / HTTP/1.1\r\n\r\n"); + connection_take(&connection); + CHECK(answered.count == 1 && answered.status == 400 && connection.close_after_write && !handled.count, + "a refused request was answered %d times, %d", answered.count, answered.status); + connection_reset(0); + feed_text("POST /api/x HTTP/1.1\r\nContent-Length: 70000\r\n\r\n"); + connection_take(&connection); + CHECK(answered.status == 413 && !strcmp(answered.code, "too_large"), "a body too large was answered %d", + answered.status); + return 0; + } + /* behind the trusted proxy alone, the client's address is its word */ + CASE("forwarded") + { + connection_reset(0); + connection.address = 0x0A000002UL; + trusted_proxy = "10.0.0.2"; + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 203.0.113.9\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == 0xCB007109UL, "the proxy's word was not taken: %lx", connection.real_address); + /* (the entry the proxy appended is the last: those before it are the + client's, forged or not) */ + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 198.51.100.7, 192.0.2.1,\t203.0.113.9\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == 0xCB007109UL, "an entry before the proxy's was taken: %lx", + connection.real_address); + /* (a list longer than the field: its end is kept) */ + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 1.1.1.1, 1.1.1.2, 1.1.1.3, 1.1.1.4, 1.1.1.5, 1.1.1.6, 1.1.1.7, " + "1.1.1.8, 1.1.1.9, 203.0.113.9\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == 0xCB007109UL, "a long list's last entry was not taken: %lx", + connection.real_address); + /* (a last entry that is no address: the proxy's own) */ + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 203.0.113.9, unknown\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == connection.address, "a bad last entry gave %lx", connection.real_address); + connection_reset(0); + connection.address = 0x0A000003UL; + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 203.0.113.9\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == 0x0A000003UL && !connection.forwarded, "another address's word was taken"); + /* (an entry with its port, IPv6 with and without brackets, IPv4 in + IPv6) */ + { + static const struct + { + const char *entry; + unsigned long address; + const char *text; + } entries[] = { + { "203.0.113.9:4711", 0xCB007109UL, "203.0.113.9" }, + { "::ffff:203.0.113.9", 0xCB007109UL, "203.0.113.9" }, + { "[::FFFF:cb00:7109]:80", 0xCB007109UL, "203.0.113.9" }, + { "[2001:db8::1]:443", 1, "2001:db8::1" }, + { "2001:db8::1", 1, "2001:db8::1" }, + { "[2001:db8:0:0:ffff::2]", 1, "2001:db8:0:0:ffff::2" }, + { "2001:0db8:0000:0000:0000:0000:0000:0001", 1, "2001:0db8:0000:0000:0000:0000:0000:0001" }, + { "2001:db8:0:1::1", 2, "2001:db8:0:1::1" }, + { "::", 3, "::" }, + }; + static const char *const refused[] = { + "203.0.113.9:", "203.0.113.9:65536", "203.0.113.9:4x", "203.0.113.9:1:2", "[2001:db8::1", "2001:db8:::1", + "2001:db8::1::2", "1:2:3:4:5:6:7:8:9", "1:2:3:4:5:6:7", "2001:db8::1%eth0", "[2001:db8::1]:x", + "[2001:db8::1]x", ":1::2", "1::2:", "12345::1", "::ffff:1.2.3", "::1.2.3.4.5", "[]", "unknown", + "2001:db8::g", NULL, + }; + unsigned long keys[4] = { 0, 0, 0, 0 }; + int index; + + for (index = 0; index < (int)(sizeof(entries) / sizeof(entries[0])); index++) + { + char head[256]; + + connection_reset(0); + snprintf(head, sizeof(head), "GET / HTTP/1.1\r\nX-Forwarded-For: 198.51.100.7, %s\r\n\r\n", + entries[index].entry); + feed_text(head); + connection_take(&connection); + CHECK(connection.forwarded && connection.real_address != connection.address && + !strcmp(connection.real_text, entries[index].text), "%s gave %lx (%s)", entries[index].entry, + connection.real_address, connection.real_text); + if (entries[index].address > 4) + { + CHECK(connection.real_address == entries[index].address, "%s was read as %lx", entries[index].entry, + connection.real_address); + } + else if (!keys[entries[index].address]) + { + keys[entries[index].address] = connection.real_address; + } + else + { + CHECK(keys[entries[index].address] == connection.real_address, "%s is another /64's key", + entries[index].entry); + } + } + CHECK(keys[1] != keys[2] && keys[1] != keys[3] && keys[2] != keys[3], + "two /64s had one key (%lx, %lx, %lx)", keys[1], keys[2], keys[3]); + for (index = 0; refused[index]; index++) + { + char head[256]; + + connection_reset(0); + snprintf(head, sizeof(head), "GET / HTTP/1.1\r\nX-Forwarded-For: 203.0.113.9, %s\r\n\r\n", + refused[index]); + feed_text(head); + connection_take(&connection); + CHECK(connection.real_address == connection.address && !strcmp(connection.real_text, "10.0.0.2"), + "%s was taken (as %lx, %s)", refused[index], connection.real_address, connection.real_text); + } + } + /* (the trusted proxy's X-Forwarded-Host and -Proto, with no + X-Forwarded-For too, or one for the proxy's own address) */ + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-Host: panel.example\r\n\r\n"); + connection_take(&connection); + CHECK(connection.forwarded && connection.real_address == connection.address, "the trusted proxy was not taken"); + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 10.0.0.2\r\n\r\n"); + connection_take(&connection); + CHECK(connection.forwarded, "the trusted proxy forwarding for its own address was not taken"); + trusted_proxy = ""; + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX-Forwarded-For: 203.0.113.9\r\n\r\n"); + connection_take(&connection); + CHECK(connection.real_address == connection.address && !connection.forwarded, + "a word was taken with no proxy trusted"); + return 0; + } + /* dotted addresses: four parts of 0 to 255, then the end, a comma or a + space */ + CASE("addresses") + { + static const char *const refused[] = { "", "1.2.3", "1.2.3.256", "1.2.3.4.5", "1..3.4", "a.b.c.d", "1.2.3.4x", + "1234.1.1.1", "-1.2.3.4", " 1.2.3.4", "1.2.3.", NULL }; + unsigned long address; + int index; + + CHECK(parse_address("192.168.0.1", &address) && address == 0xC0A80001UL, "192.168.0.1 was %lx", address); + CHECK(parse_address("0.0.0.0", &address) && address == 0, "0.0.0.0 was %lx", address); + CHECK(parse_address("255.255.255.255", &address) && address == 0xFFFFFFFFUL, "the last address was refused"); + CHECK(parse_address("1.2.3.4, 5.6.7.8", &address) && address == 0x01020304UL, "a list's first was not read"); + CHECK(parse_address("1.2.3.4 ", &address) && parse_address("001.002.003.004", &address) && + address == 0x01020304UL, "an address before a space, or with leading zeros, was refused"); + for (index = 0; refused[index]; index++) + CHECK(!parse_address(refused[index], &address), "\"%s\" was taken", refused[index]); + return 0; + } + /* a page's Origin is the host's (the proxy's word for it, from the + proxy alone), in any case, with its port */ + CASE("origin") + { + connection_reset(0); + snprintf(connection.request.host, sizeof(connection.request.host), "Server.Example:8080"); + snprintf(connection.request.origin, sizeof(connection.request.origin), "http://server.example:8080"); + CHECK(origin_matches(&connection), "the host's own origin did not match"); + snprintf(connection.request.origin, sizeof(connection.request.origin), "https://SERVER.EXAMPLE:8080"); + CHECK(origin_matches(&connection), "another scheme or case did not match"); + snprintf(connection.request.origin, sizeof(connection.request.origin), "http://server.example"); + CHECK(!origin_matches(&connection), "another port matched"); + snprintf(connection.request.origin, sizeof(connection.request.origin), "http://server.example:8080.evil"); + CHECK(!origin_matches(&connection), "a longer host matched"); + snprintf(connection.request.origin, sizeof(connection.request.origin), "server.example:8080"); + CHECK(!origin_matches(&connection), "an origin without a scheme matched"); + connection.request.origin[0] = 0; + CHECK(!origin_matches(&connection), "no origin matched"); + /* (a page's own post under a referrer policy that hides it: Origin + null, which the browser's Sec-Fetch-Site alone vouches for) */ + snprintf(connection.request.origin, sizeof(connection.request.origin), "null"); + CHECK(!origin_matches(&connection), "Origin null matched with no Sec-Fetch-Site"); + snprintf(connection.request.fetch_site, sizeof(connection.request.fetch_site), "cross-site"); + CHECK(!origin_matches(&connection), "Origin null matched from another site"); + snprintf(connection.request.fetch_site, sizeof(connection.request.fetch_site), "same-origin"); + CHECK(origin_matches(&connection), "the page's own post with Origin null did not match"); + connection.request.fetch_site[0] = 0; + snprintf(connection.request.origin, sizeof(connection.request.origin), "https://panel.example"); + connection.request.host[0] = 0; + CHECK(!origin_matches(&connection), "an origin matched no host"); + snprintf(connection.request.host, sizeof(connection.request.host), "10.0.0.5:8080"); + snprintf(connection.request.forwarded_host, sizeof(connection.request.forwarded_host), "panel.example"); + connection.forwarded = 0; + connection.real_address = 0xCB007109UL; + CHECK(!origin_matches(&connection), "a forwarded host was taken from a client"); + connection.forwarded = 1; + connection.real_address = connection.address; + CHECK(origin_matches(&connection), "the trusted proxy's forwarded host was not taken"); + return 0; + } + /* a query's numbers, by whole names */ + CASE("query") + { + CHECK(query_number("after=5&limit=10", "after", 0) == 5 && query_number("after=5&limit=10", "limit", 0) == 10, + "after or limit was not read"); + CHECK(query_number("xafter=5&after=7", "after", 0) == 7, "a name was found inside another"); + CHECK(query_number("after=x", "after", 3) == 3 && query_number("after=", "after", 3) == 3 && + query_number("after", "after", 3) == 3 && query_number("", "after", 3) == 3 && + query_number("limit=1", "after", 3) == 3, "a missing number was not the default"); + CHECK(query_number("after=-4", "after", 0) == -4 && query_number("after=12abc", "after", 0) == 12, + "a number was not read as strtol reads it"); + return 0; + } + /* passwords compared whole, and names in any case */ + CASE("compare") + { + CHECK(constant_time_equal("secret", "secret") && constant_time_equal("", ""), "the same text was not equal"); + CHECK(!constant_time_equal("secret", "secre") && !constant_time_equal("secre", "secret") && + !constant_time_equal("secret", "") && !constant_time_equal("", "secret") && + !constant_time_equal("secret", "Secret") && !constant_time_equal("abab", "ab"), + "different text was equal"); + CHECK(text_equal_case("WebSocket", "websocket") && !text_equal_case("websocket", "websockets") && + !text_equal_case("websockets", "websocket") && text_equal_case("", ""), "names were compared wrong"); + return 0; + } + /* a client's frames: masked, each length's form, waited for whole, + pings answered, the limits kept */ + CASE("frames") + { + /* (RFC 6455 5.7's masked "Hello") */ + static const unsigned char hello[] = { 0x81, 0x85, 0x37, 0xFA, 0x21, 0x3D, 0x7F, 0x9F, 0x4D, 0x51, 0x58 }; + static unsigned char payload[WS_FRAME + 1]; + const unsigned char *pong; + int opcode, size, length, index; + + connection_reset(1); + feed(hello, sizeof(hello)); + ws_read_frames(&connection); + CHECK(message_taken.count == 1 && !strcmp(message_taken.text, "Hello") && connection.in_length == 0, + "RFC 6455's example was taken as %s", message_taken.text); + /* (each byte at a time, in each length's form) */ + memset(payload, 'x', sizeof(payload)); + for (index = 0; index < 3; index++) + { + int byte; + + connection_reset(1); + length = client_frame(frame, 1, 0x1, "abc", 3, index == 0 ? 0 : index == 1 ? 16 : 64, 1); + for (byte = 0; byte < length; byte++) + { + feed(frame + byte, 1); + ws_read_frames(&connection); + CHECK(message_taken.count == (byte == length - 1), "a frame of %d bytes was taken after %d", length, byte + 1); + } + CHECK(!strcmp(message_taken.text, "abc") && !connection.closing, "the frame's text was %s", message_taken.text); + } + connection_reset(1); + feed_frame(1, 0x1, payload, 300); + feed_frame(1, 0x1, payload, WS_FRAME); + ws_read_frames(&connection); + CHECK(message_taken.count == 2 && message_taken.length == WS_FRAME && !connection.closing, + "frames of 300 and %d bytes were taken %d times", WS_FRAME, message_taken.count); + /* (two frames in one read, and a ping between them) */ + connection_reset(1); + feed_frame(1, 0x1, "one", 3); + feed_frame(1, 0x9, "beat", 4); + feed_frame(1, 0xA, "unasked", 7); + feed_frame(1, 0x1, "two", 3); + ws_read_frames(&connection); + CHECK(message_taken.count == 2 && !strcmp(message_taken.text, "two"), "two frames were taken %d times", + message_taken.count); + CHECK(server_frame(0, &opcode, &pong, &size) && opcode == 0xA && size == 4 && !memcmp(pong, "beat", 4) && + !server_frame(1, &opcode, &pong, &size), "the ping was not answered once with its payload"); + CHECK(connection.out[0] == 0x8A, "the pong was not one final frame"); + /* (a close is answered with one) */ + connection_reset(1); + feed_frame(1, 0x8, "\x03\xe8", 2); + feed_frame(1, 0x1, "after", 5); + ws_read_frames(&connection); + CHECK(close_code() == 1000 && connection.closing && !message_taken.count, "a close was answered %d", close_code()); + return 0; + } + /* frames that close the connection, and why */ + CASE("frame-refusals") + { + static unsigned char payload[200]; + static const struct + { + int fin, opcode; + unsigned long long size; + int form, masked; + int code; + } refusals[] = { + { 1, 0x1, 5, 0, 0, 1002 }, + { 1, 0x1 | 0x40, 5, 0, 1, 1002 }, + { 1, 0x1 | 0x10, 5, 0, 1, 1002 }, + { 1, 0x1, WS_FRAME + 1, 64, 1, 1009 }, + { 1, 0x1, 0x8000000000000000ULL, 64, 1, 1009 }, + { 1, 0x1, 0x100000000ULL, 64, 1, 1009 }, + { 1, 0x9, 126, 0, 1, 1002 }, + { 0, 0x9, 4, 0, 1, 1002 }, + { 1, 0x8, 126, 0, 1, 1002 }, + { 1, 0x2, 5, 0, 1, 1003 }, + { 1, 0x3, 5, 0, 1, 1002 }, + { 1, 0xB, 5, 0, 1, 1002 }, + { 1, 0x0, 5, 0, 1, 1002 }, + }; + int index; + + for (index = 0; index < (int)(sizeof(refusals) / sizeof(refusals[0])); index++) + { + int length; + + connection_reset(1); + length = client_frame(frame, refusals[index].fin, refusals[index].opcode, + refusals[index].size <= sizeof(payload) ? payload : NULL, refusals[index].size, refusals[index].form, + refusals[index].masked); + feed(frame, length); + ws_read_frames(&connection); + CHECK(close_code() == refusals[index].code && connection.closing && connection.close_after_write && + !message_taken.count, "frame %d was closed %d, not %d", index, close_code(), refusals[index].code); + /* (nothing after the close is read) */ + feed_frame(1, 0x1, "more", 4); + ws_read_frames(&connection); + CHECK(!message_taken.count, "frame %d's next frame was taken", index); + } + return 0; + } + /* a message in fragments: one message, control frames between them, + WS_MESSAGE at most */ + CASE("fragments") + { + static unsigned char payload[WS_FRAME]; + int index; + + connection_reset(1); + feed_frame(0, 0x1, "frag", 4); + feed_frame(1, 0x9, "", 0); + feed_frame(0, 0x0, "men", 3); + ws_read_frames(&connection); + CHECK(!message_taken.count && connection.message_open, "part of a message was taken"); + feed_frame(1, 0x0, "ted", 3); + feed_frame(1, 0x1, "whole", 5); + ws_read_frames(&connection); + CHECK(message_taken.count == 2 && !strcmp(message_taken.text, "whole") && !connection.message_open && + !connection.closing, "the messages were taken %d times, the last %s", message_taken.count, + message_taken.text); + connection_reset(1); + feed_frame(0, 0x1, "frag", 4); + feed_frame(1, 0x0, "mented", 6); + ws_read_frames(&connection); + CHECK(message_taken.count == 1 && !strcmp(message_taken.text, "fragmented"), "a fragmented message was %s", + message_taken.text); + /* (a continuation of nothing, a message inside another) */ + connection_reset(1); + feed_frame(1, 0x0, "lost", 4); + ws_read_frames(&connection); + CHECK(close_code() == 1002 && !message_taken.count, "a continuation of nothing was closed %d", close_code()); + connection_reset(1); + feed_frame(0, 0x1, "one", 3); + feed_frame(1, 0x1, "two", 3); + ws_read_frames(&connection); + CHECK(close_code() == 1002 && !message_taken.count, "a message inside another was closed %d", close_code()); + /* (WS_MESSAGE in fragments, and a byte more) */ + connection_reset(1); + memset(payload, 'm', sizeof(payload)); + for (index = 0; index < WS_MESSAGE / WS_FRAME; index++) + { + feed_frame(index == WS_MESSAGE / WS_FRAME - 1, index ? 0x0 : 0x1, payload, WS_FRAME); + ws_read_frames(&connection); + } + CHECK(message_taken.count == 1 && message_taken.length == WS_MESSAGE && !connection.closing, + "a message of WS_MESSAGE bytes was not taken"); + connection_reset(1); + for (index = 0; index < WS_MESSAGE / WS_FRAME; index++) + { + feed_frame(0, index ? 0x0 : 0x1, payload, WS_FRAME); + ws_read_frames(&connection); + } + feed_frame(1, 0x0, "!", 1); + ws_read_frames(&connection); + CHECK(close_code() == 1009 && !message_taken.count, "a message past WS_MESSAGE was closed %d", close_code()); + return 0; + } + /* a connection's output: what was sent makes room, one answer however + long is taken below OUTPUT_PAUSE, and past it OUTPUT_MOST closes the + connection */ + CASE("output") + { + static unsigned char large[3 * 1024 * 1024]; + static unsigned char tail[5000]; + + connection_reset(0); + out_append(&connection, "abcdef", 6); + connection.out_sent = 3; + memset(tail, 'x', sizeof(tail)); + out_append(&connection, tail, sizeof(tail)); + CHECK(connection.out_sent == 0 && connection.out_length == 3 + (int)sizeof(tail) && + !memcmp(connection.out, "def", 3) && connection.out[3] == 'x' && connection.out[connection.out_length - 1] == 'x', + "the output was not kept in order (%d sent of %d)", connection.out_sent, connection.out_length); + /* (one answer longer than OUTPUT_MOST, onto output below the pause) */ + connection_reset(0); + out_append(&connection, large, sizeof(large)); + CHECK(!connection.out_overflowed && connection.out_length == (int)sizeof(large), "a long answer was refused"); + out_append(&connection, "more", 4); + CHECK(connection.out_overflowed && connection.close_after_write && out_pending(&connection) == 0, + "output past OUTPUT_MOST was taken (%d)", out_pending(&connection)); + out_append(&connection, "more", 4); + CHECK(out_pending(&connection) == 0, "output was taken after the overflow"); + /* (past the pause, answers up to OUTPUT_MOST in all) */ + connection_reset(1); + out_append(&connection, large, OUTPUT_PAUSE + 1); + out_append(&connection, large, OUTPUT_MOST - OUTPUT_PAUSE - 1); + CHECK(!connection.out_overflowed && out_pending(&connection) == OUTPUT_MOST, "output to OUTPUT_MOST was refused"); + out_append(&connection, "!", 1); + CHECK(connection.out_overflowed && connection.closing && connection.close_after_write, + "a WebSocket's output past OUTPUT_MOST was taken"); + return 0; + } + /* pipelined requests are taken while their answers wait below + OUTPUT_PAUSE, and the rest as the answers go */ + CASE("pipelining") + { + int index; + + connection_reset(0); + answer_size = 100 * 1024; + for (index = 0; index < 6; index++) + feed_text("GET /app.js HTTP/1.1\r\n\r\n"); + connection_take(&connection); + CHECK(handled.count == 3 && connection.in_length == 3 * 24, "%d requests were taken past the pause", + handled.count); + connection_take(&connection); + CHECK(handled.count == 3, "a request was taken while the answers waited"); + connection.out_sent += 200 * 1024; + connection_take(&connection); + CHECK(handled.count == 5, "the answers went and %d requests were taken", handled.count); + connection.out_sent = connection.out_length; + connection_take(&connection); + CHECK(handled.count == 6 && connection.in_length == 0, "the last request was not taken (%d)", handled.count); + CHECK(!connection.out_overflowed, "pipelined requests overflowed"); + /* (a WebSocket's frames as well) */ + connection_reset(1); + connection.out_length = OUTPUT_PAUSE; + feed_frame(1, 0x1, "held", 4); + ws_read_frames(&connection); + CHECK(!message_taken.count, "a frame was taken while the answers waited"); + connection.out_sent = connection.out_length; + ws_read_frames(&connection); + CHECK(message_taken.count == 1 && !strcmp(message_taken.text, "held"), "a held frame was not taken"); + connection.out_length = connection.out_sent = 0; + return 0; + } + /* the addresses' table: a minute's allowance each, and room for a new + address made by the least lately seen that is not locked, else by the + lock that ends soonest */ + CASE("address-table") + { + int index, found; + + memset(http.addresses, 0, sizeof(http.addresses)); + test_now = 5000; + for (index = 0; index < REQUESTS_UNAUTHENTICATED; index++) + CHECK(address_allows_request(0x0A000001UL, 0), "request %d was refused", index + 1); + CHECK(!address_allows_request(0x0A000001UL, 0), "a request past the allowance was taken"); + CHECK(address_allows_request(0x0A000001UL, 1), "a signed-in request was refused"); + CHECK(address_allows_request(0x0A000002UL, 0), "another address was refused"); + test_now += 60; + CHECK(address_allows_request(0x0A000001UL, 0), "a request a minute later was refused"); + /* (a table full: its records seen in the reverse of their order) */ + memset(http.addresses, 0, sizeof(http.addresses)); + for (index = 0; index < MAXIMUM_ADDRESSES; index++) + { + test_now = 1000 + (unsigned long)(MAXIMUM_ADDRESSES - index); + address_allows_request(0x0A000000UL + (unsigned long)index, 0); + } + CHECK(http.addresses[MAXIMUM_ADDRESSES - 1].address == 0x0A000000UL + MAXIMUM_ADDRESSES - 1 && + http.addresses[MAXIMUM_ADDRESSES - 1].last_seen == 1001, "the table was not filled in order"); + test_now = 2000; + http.addresses[MAXIMUM_ADDRESSES - 1].locked_until = test_now + LOGIN_LOCK_SECONDS; + CHECK(address_allows_request(0x0B000001UL, 0), "a new address was refused with the oldest locked"); + CHECK(http.addresses[MAXIMUM_ADDRESSES - 2].address == 0x0B000001UL, + "the least lately seen unlocked record did not make room"); + CHECK(http.addresses[MAXIMUM_ADDRESSES - 1].address == 0x0A000000UL + MAXIMUM_ADDRESSES - 1 && + address_locked(0x0A000000UL + MAXIMUM_ADDRESSES - 1) == LOGIN_LOCK_SECONDS, "the locked record went"); + /* (every one locked: the lock that ends soonest goes) */ + for (index = 0; index < MAXIMUM_ADDRESSES; index++) + http.addresses[index].locked_until = test_now + LOGIN_LOCK_SECONDS + (unsigned long)(index == 7 ? 0 : 60); + CHECK(address_allows_request(0x0C000001UL, 0), "a new address was refused with every record locked"); + for (index = 0, found = -1; index < MAXIMUM_ADDRESSES; index++) + { + if (http.addresses[index].address == 0x0C000001UL) + found = index; + } + CHECK(found == 7 && !address_locked(0x0C000001UL), "the new address took record %d", found); + return 0; + } + /* when every slot is taken, the oldest connection that waits for a + request makes room: no WebSocket, ticket or answer in hand */ + CASE("eviction") + { + static unsigned char pending[16]; + int index; + + memset(http.connections, 0, sizeof(http.connections)); + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + http.connections[index].socket = 10 + index; + http.connections[index].head_started = 100 + (Uint64)index; + } + http.connections[0].websocket = 1; + http.connections[0].head_started = 1; + http.connections[1].ticket = (struct server_ticket *)pending; + http.connections[1].head_started = 2; + http.connections[2].out = pending; + http.connections[2].out_length = 8; + http.connections[2].head_started = 3; + http.connections[9].head_started = 50; + http.connections[5].head_started = 40; + http.connections[5].have_head = 1; + CHECK(connection_to_evict() == 5, "connection %d was evicted, not the oldest waiting", connection_to_evict()); + /* (a socket 0 is a connection: -1 is a free slot) */ + http.connections[6].socket = 0; + http.connections[6].head_started = 30; + CHECK(connection_to_evict() == 6, "a connection on socket 0 was taken for a free slot (%d)", connection_to_evict()); + http.connections[6].socket = -1; + CHECK(connection_to_evict() == 5, "a free slot was evicted (%d)", connection_to_evict()); + for (index = 3; index < MAXIMUM_CONNECTIONS; index++) + http.connections[index].websocket = 1; + CHECK(connection_to_evict() == -1, "a busy connection was evicted (%d)", connection_to_evict()); + memset(http.connections, 0, sizeof(http.connections)); + return 0; + } + /* a connection's deadlines: a request within HEAD_SECONDS of its + opening or its last answer (one that sends nothing too), and silence + within IDLE_SECONDS but for a signed-in WebSocket */ + CASE("deadlines") + { + static unsigned char pending[16]; + const Uint64 second = 1000000000ULL; + + connection_reset(0); + CHECK(!connection_overdue(&connection, HEAD_SECONDS * second), "a connection was closed in time"); + CHECK(connection_overdue(&connection, HEAD_SECONDS * second + 1), "a silent connection was kept past its head's time"); + feed_text("GET / HTTP/1.1\r\n"); + CHECK(connection_overdue(&connection, HEAD_SECONDS * second + 1), "a slow head was kept"); + connection.ticket = (struct server_ticket *)pending; + CHECK(!connection_overdue(&connection, 2 * IDLE_SECONDS * second), "a connection waiting for its ticket was closed"); + connection.ticket = NULL; + connection.out = pending; + connection.out_length = 8; + CHECK(!connection_overdue(&connection, IDLE_SECONDS * second), "an answer being sent was closed"); + CHECK(connection_overdue(&connection, IDLE_SECONDS * second + 1), "an answer unread too long was kept"); + connection.out = NULL; + connection.out_length = 0; + connection_reset(1); + connection.role = _server_role_admin; + CHECK(!connection_overdue(&connection, 10 * IDLE_SECONDS * second), "a signed-in WebSocket was closed"); + connection.closing = 1; + CHECK(connection_overdue(&connection, IDLE_SECONDS * second + 1), "a closing WebSocket was kept unread"); + connection_reset(1); + connection.role = _server_role_none; + connection.last_activity = 20 * second; + CHECK(!connection_overdue(&connection, 20 * second), "a WebSocket was held to a request's time"); + CHECK(connection_overdue(&connection, (20 + IDLE_SECONDS) * second + 1), "a silent WebSocket was kept"); + /* (a time later than now, as a request answered since it was read) */ + connection_reset(0); + connection.head_started = connection.last_activity = 100 * second; + CHECK(!connection_overdue(&connection, 50 * second), "a connection was closed for a time to come"); + return 0; + } + /* the messages queued before a close go before its frame */ + CASE("close-order") + { + static const char *const texts[] = { "one", "two" }; + const unsigned char *payload; + int opcode, size, index; + + connection_reset(1); + for (index = 0; index < 2; index++) + { + struct ws_message *message = malloc(sizeof(*message) + 4); + + message->next = NULL; + message->length = 3; + memcpy(message->text, texts[index], 4); + if (connection.queue_tail) + connection.queue_tail->next = message; + else + connection.queue_head = message; + connection.queue_tail = message; + connection.queued++; + } + ws_close(&connection, 1008, "denied"); + CHECK(!connection.queue_head && !connection.queue_tail && !connection.queued, "the queue was left"); + for (index = 0; index < 2; index++) + { + CHECK(server_frame(index, &opcode, &payload, &size) && opcode == 0x1 && size == 3 && + !memcmp(payload, texts[index], 3), "frame %d was not the queued message", index); + } + CHECK(server_frame(2, &opcode, &payload, &size) && opcode == 0x8 && close_code() == 1008, + "the close did not follow the queued messages"); + return 0; + } + /* a head's lines end with CRLF: a bare LF (an empty line of one + included, which a proxy in front may not see end the head), a CR inside + a line, and anything after the empty line are refused */ + CASE("bare-lf") + { + static const char *const refused[] = { + "GET / HTTP/1.1\nHost: x\r\n", "GET / HTTP/1.1\r\nHost: x\n", "GET / HTTP/1.1\r\n\n", + "GET / HTTP/1.1\r\nX: a\n\r\n", NULL, + }; + int index; + + for (index = 0; refused[index]; index++) + CHECK(head_refused(refused[index], 400, "bad_request"), "bare LF %d was taken", index); + CHECK(head_refused("GET / HTTP/1.1\r\nX: a\rb\r\n", 400, "bad_request"), "a CR inside a line was taken"); + CHECK(head_refused("GET / HTTP/1.1\r\nX: a\r\n\r\nGET /evil HTTP/1.1\r\n", 400, "bad_request"), + "a line after the empty one was taken"); + CHECK(!head_refused("GET / HTTP/1.1\r\nX: a\r\n\r\n", 400, "bad_request") && + !head_refused("GET / HTTP/1.1\r\nX: a\r\n", 400, "bad_request"), "a head of CRLF lines was refused"); + /* (through a connection: what ends at CRLFCRLF, with a bare-LF + empty line before it) */ + connection_reset(0); + feed_text("GET / HTTP/1.1\r\nX: a\n\nPOST /api/say HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + connection_take(&connection); + CHECK(answered.count == 1 && answered.status == 400 && !handled.count && connection.close_after_write, + "a head with a bare-LF empty line was answered %d, handed on %d times", answered.status, handled.count); + return 0; + } + /* a change by the page's cookie needs its Origin (403 origin) and its + X-CSRF-Token (403 csrf); a read needs neither, nor a change by a Bearer + token, which no browser sends unasked */ + CASE("auth-csrf") + { + char token[2 * TOKEN_BYTES + 1], other_token[2 * TOKEN_BYTES + 1], csrf[2 * CSRF_BYTES + 1]; + char wrong[2 * CSRF_BYTES + 1], head[768]; + struct session *session; + + sign_in_reset("", "", ""); + session = session_new(_server_role_admin, "admin", token); + snprintf(csrf, sizeof(csrf), "%s", session->csrf); + snprintf(wrong, sizeof(wrong), "%s", csrf); + wrong[0] = wrong[0] == 'a' ? 'b' : 'a'; + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nCookie: opence_session=%s\r\n" + "X-CSRF-Token: %s\r\n", token, csrf); + request_from(0x0A000009UL, head, "{\"text\":\"hi\"}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "origin") && !tickets, + "a change by the cookie without its Origin was answered %d %s", answered.status, + answered.code ? answered.code : ""); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: https://server.example\r\n" + "Cookie: opence_session=%s\r\n", token); + request_from(0x0A000009UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "csrf") && !tickets, + "a change by the cookie without its CSRF token was answered %d", answered.status); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: https://server.example\r\n" + "Cookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, wrong); + request_from(0x0A000009UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "csrf") && !tickets, + "a change by the cookie with a wrong CSRF token was answered %d", answered.status); + /* (another session's CSRF token is not this one's) */ + session_new(_server_role_admin, "other", other_token); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: https://server.example\r\n" + "Cookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", other_token, csrf); + request_from(0x0A000009UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "csrf"), "another session's CSRF token was taken"); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: https://server.example\r\n" + "Cookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, csrf); + request_from(0x0A000009UL, head, "{}"); + CHECK(!answered.count && tickets == 1 && !strcmp(ticket_made.by, "admin") && + ticket_made.role == _server_role_admin, "a change by the cookie with both was answered %d", answered.status); + /* (an Origin of another site: refused, whatever the session) */ + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: https://evil.example\r\n" + "Cookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, csrf); + request_from(0x0A000009UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "origin") && !tickets, "another site's change was taken"); + /* (a read by the cookie) */ + snprintf(head, sizeof(head), "GET /api/status HTTP/1.1\r\nHost: server.example\r\nCookie: opence_session=%s\r\n", + token); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 200 && served && !strcmp(served, "snapshot"), "a read by the cookie was answered %d", + answered.status); + /* (a change by a Bearer token) */ + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %s\r\n", token); + request_from(0x0A000009UL, head, "{}"); + CHECK(!answered.count && tickets == 1, "a change by a Bearer token was answered %d %s", answered.status, + answered.code ? answered.code : ""); + /* (a sign out by the cookie is a change too) */ + snprintf(head, sizeof(head), "POST /api/logout HTTP/1.1\r\nHost: server.example\r\nOrigin: https://server.example\r\n" + "Cookie: opence_session=%s\r\n", token); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 403 && !strcmp(answered.code, "csrf") && session_find(token), + "a sign out without the CSRF token was taken"); + return 0; + } + /* no session: 401 denied, but for the health, the page and the sign in */ + CASE("auth-denied") + { + static const char unknown[] = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + char token[2 * TOKEN_BYTES + 1], head[512]; + + sign_in_reset("secret", "", ""); + session_new(_server_role_admin, "admin", token); + request_from(0x0A000009UL, "GET /api/status HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(answered.status == 401 && !strcmp(answered.code, "denied"), "a read without a session was answered %d", + answered.status); + snprintf(head, sizeof(head), "GET /api/status HTTP/1.1\r\nHost: server.example\r\nCookie: opence_session=%s\r\n", + unknown); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 401, "an unknown cookie was answered %d", answered.status); + snprintf(head, sizeof(head), "GET /api/status HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %s\r\n", + unknown); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 401, "an unknown Bearer token was answered %d", answered.status); + snprintf(head, sizeof(head), "GET /api/status HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %.32s\r\n", + token); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 401, "half a token was answered %d", answered.status); + snprintf(head, sizeof(head), "GET /api/status HTTP/1.1\r\nHost: server.example\r\nAuthorization: Basic %s\r\n", + token); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 401, "a token not Bearer was answered %d", answered.status); + request_from(0x0A000009UL, "POST /api/logout HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(answered.status == 401, "a sign out without a session was answered %d", answered.status); + request_from(0x0A000009UL, "GET /api/logout HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(answered.status == 405, "GET /api/logout was answered %d", answered.status); + request_from(0x0A000009UL, "GET /api/login HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(answered.status == 405, "GET /api/login was answered %d", answered.status); + request_from(0x0A000009UL, "GET /healthz HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(served && !strcmp(served, "healthz"), "the health was not served"); + request_from(0x0A000009UL, "GET /index.html HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(served && !strcmp(served, "asset"), "the page was not served"); + request_from(0x0A000009UL, "POST /index.html HTTP/1.1\r\nHost: server.example\r\n", NULL); + CHECK(answered.status == 405, "a POST to the page was answered %d", answered.status); + snprintf(head, sizeof(head), "GET /api/session HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %s\r\n", + token); + request_from(0x0A000009UL, head, NULL); + CHECK(answered.status == 200 && strstr(answered.body, "\"role\":\"admin\""), "the session was answered %d %s", + answered.status, answered.body); + return 0; + } + /* each route's role: a session's below it is answered 403 forbidden */ + CASE("route-roles") + { + static const struct + { + const char *method, *path; + int role; + } routes[] = { + { "GET", "/api/status", _server_role_viewer }, { "GET", "/api/players", _server_role_viewer }, + { "GET", "/api/log", _server_role_viewer }, { "GET", "/api/events", _server_role_viewer }, + { "GET", "/api/rotation", _server_role_viewer }, { "GET", "/api/gametypes", _server_role_viewer }, + { "GET", "/api/maps", _server_role_viewer }, { "POST", "/api/command", _server_role_viewer }, + { "GET", "/api/session", _server_role_viewer }, + { "POST", "/api/players/3/kick", _server_role_moderator }, { "POST", "/api/say", _server_role_moderator }, + { "POST", "/api/rotation/next", _server_role_moderator }, + { "POST", "/api/rotation/play", _server_role_moderator }, + { "PUT", "/api/rotation", _server_role_admin }, { "POST", "/api/gametypes/ctf.txt", _server_role_admin }, + { "DELETE", "/api/gametypes/ctf.txt", _server_role_admin }, { "GET", "/api/bans", _server_role_admin }, + { "DELETE", "/api/bans/1", _server_role_admin }, { "GET", "/api/settings", _server_role_admin }, + { "PUT", "/api/settings", _server_role_admin }, { "POST", "/api/bans", _server_role_admin }, + { "POST", "/api/stop", _server_role_owner }, + }; + char tokens[_server_role_admin + 1][2 * TOKEN_BYTES + 1]; + int index, role; + + sign_in_reset("", "", ""); + for (role = _server_role_viewer; role <= _server_role_admin; role++) + session_new(role, server_role_name(role), tokens[role]); + for (index = 0; index < (int)(sizeof(routes) / sizeof(routes[0])); index++) + { + CHECK(route_role(routes[index].method, routes[index].path) == routes[index].role, "%s %s needs %d, not %d", + routes[index].method, routes[index].path, route_role(routes[index].method, routes[index].path), + routes[index].role); + for (role = _server_role_viewer; role <= _server_role_admin; role++) + { + char head[512]; + int forbidden; + + snprintf(head, sizeof(head), "%s %s HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %s\r\n", + routes[index].method, routes[index].path, tokens[role]); + request_from(0x0A000009UL, head, "{}"); + forbidden = answered.status == 403 && answered.code && !strcmp(answered.code, "forbidden"); + CHECK(forbidden == (role < routes[index].role), "%s %s by a %s was answered %d", routes[index].method, + routes[index].path, server_role_name(role), answered.status); + CHECK(forbidden || tickets || served || answered.status == 200, "%s %s by a %s was not handed on (%d)", + routes[index].method, + routes[index].path, server_role_name(role), answered.status); + } + } + return 0; + } + /* a session lasts 12 hours at most, and 30 minutes unused */ + CASE("session-expiry") + { + char token[2 * TOKEN_BYTES + 1]; + unsigned long created; + int index, used; + + sign_in_reset("", "", ""); + test_now = created = 100000; + session_new(_server_role_viewer, "v", token); + test_now += SESSION_IDLE_SECONDS; + CHECK(session_find(token), "a session was closed after exactly 30 minutes unused"); + while (test_now + SESSION_IDLE_SECONDS <= created + SESSION_SECONDS) + { + test_now += SESSION_IDLE_SECONDS; + CHECK(session_find(token), "a session used every 30 minutes was closed at %lu s", test_now - created); + } + test_now = created + SESSION_SECONDS; + CHECK(session_find(token), "a session was closed at exactly 12 hours"); + test_now++; + CHECK(!session_find(token), "a session was kept past 12 hours"); + for (index = 0, used = 0; index < MAXIMUM_SESSIONS; index++) + used += http.sessions[index].used; + CHECK(!used, "a lapsed session kept its slot"); + CHECK(!session_find(token), "a lapsed session came back"); + session_new(_server_role_viewer, "v", token); + test_now += SESSION_IDLE_SECONDS + 1; + CHECK(!session_find(token), "a session unused past 30 minutes was kept"); + return 0; + } + /* MAXIMUM_SESSIONS at once: a new one takes the place of the one used + least lately */ + CASE("session-cap") + { + char tokens[MAXIMUM_SESSIONS + 1][2 * TOKEN_BYTES + 1]; + int index, used; + + sign_in_reset("", "", ""); + test_now = 200000; + for (index = 0; index < MAXIMUM_SESSIONS; index++) + { + test_now++; + session_new(_server_role_viewer, "v", tokens[index]); + } + for (index = 0, used = 0; index < MAXIMUM_SESSIONS; index++) + used += http.sessions[index].used; + CHECK(used == MAXIMUM_SESSIONS, "%d sessions of %d", used, MAXIMUM_SESSIONS); + test_now++; + CHECK(session_find(tokens[0]), "the first session was not found"); + test_now++; + session_new(_server_role_viewer, "v", tokens[MAXIMUM_SESSIONS]); + CHECK(!session_find(tokens[1]), "the session used least lately was kept"); + CHECK(session_find(tokens[0]) && session_find(tokens[MAXIMUM_SESSIONS]), "a session used lately went"); + for (index = 2; index < MAXIMUM_SESSIONS; index++) + CHECK(session_find(tokens[index]), "session %d went", index); + return 0; + } + /* five wrong passwords from an address in ten minutes lock it for ten + (429 locked, with when to try again), its right password too; others' + are not */ + CASE("lockout") + { + char token[2 * TOKEN_BYTES + 1]; + int index; + + sign_in_reset("right", "", ""); + test_now = 300000; + for (index = 0; index < LOGIN_FAILURES; index++) + { + CHECK(!sign_in(0x0A000011UL, "wrong", token) && answered.status == 401 && !strcmp(answered.code, "denied"), + "wrong password %d was answered %d", index + 1, answered.status); + } + CHECK(!sign_in(0x0A000011UL, "right", token) && answered.status == 429 && !strcmp(answered.code, "locked") && + answered.retry_after == LOGIN_LOCK_SECONDS, "a locked address was answered %d %s (%d)", answered.status, + answered.code ? answered.code : "", answered.retry_after); + test_now += 60; + CHECK(!sign_in(0x0A000011UL, "right", token) && answered.retry_after == LOGIN_LOCK_SECONDS - 60, + "a minute into the lock, Retry-After was %d", answered.retry_after); + CHECK(sign_in(0x0A000012UL, "right", token), "another address was locked out"); + test_now += LOGIN_LOCK_SECONDS - 60; + CHECK(sign_in(0x0A000011UL, "right", token), "the lock did not end after ten minutes (%d)", answered.status); + /* (failures further apart than the window) */ + for (index = 0; index < LOGIN_FAILURES - 1; index++) + sign_in(0x0A000013UL, "wrong", token); + test_now += LOGIN_WINDOW_SECONDS; + for (index = 0; index < LOGIN_FAILURES - 1; index++) + sign_in(0x0A000013UL, "wrong", token); + CHECK(sign_in(0x0A000013UL, "right", token), "failures in two windows locked the address (%d)", answered.status); + return 0; + } + /* 30 passwords a minute, from every address in all: past them, 429 + rate_limited until the minute's end */ + CASE("login-budget") + { + char token[2 * TOKEN_BYTES + 1]; + int index; + + sign_in_reset("right", "", ""); + test_now = 400020; + for (index = 0; index < LOGIN_BUDGET; index++) + CHECK(sign_in(0x0B000000UL + (unsigned long)index, "right", token), "password %d was refused", index + 1); + test_now += 15; + CHECK(!sign_in(0x0C000001UL, "right", token) && answered.status == 429 && !strcmp(answered.code, "rate_limited") + && answered.retry_after == 45, "a password past the budget was answered %d %s (%d)", answered.status, + answered.code ? answered.code : "", answered.retry_after); + test_now += 45; + CHECK(sign_in(0x0C000001UL, "right", token), "a password of the next minute was refused"); + return 0; + } + /* an empty password is no password: it never signs in, whichever are + set */ + CASE("empty-password") + { + char token[2 * TOKEN_BYTES + 1]; + + sign_in_reset("", "", ""); + CHECK(!sign_in(0x0A000021UL, "", token) && answered.status == 401, "an empty password signed in with none set"); + CHECK(!sign_in(0x0A000021UL, "x", token) && answered.status == 401, "a password signed in with none set"); + sign_in_reset("", "", "view"); + CHECK(!sign_in(0x0A000022UL, "", token) && answered.status == 401, "an empty password signed in"); + CHECK(sign_in(0x0A000022UL, "view", token) && strstr(answered.body, "\"role\":\"viewer\""), + "the viewer's password was answered %d", answered.status); + sign_in_reset("admin", "mod", "view"); + CHECK(sign_in(0x0A000023UL, "mod", token) && strstr(answered.body, "\"role\":\"moderator\"") && + sign_in(0x0A000023UL, "admin", token) && strstr(answered.body, "\"role\":\"admin\""), + "the moderator's or admin's password gave another role"); + CHECK(!sign_in(0x0A000023UL, "admi", token) && !sign_in(0x0A000023UL, "adminx", token), + "part of a password, or more, signed in"); + request_from(0x0A000024UL, "POST /api/login HTTP/1.1\r\nHost: server.example\r\n", "{\"by\":\"x\"}"); + CHECK(answered.status == 400 && !strcmp(answered.code, "schema"), "a sign in without a password was answered %d", + answered.status); + return 0; + } + /* a password changed ends its role's sessions and closes its remote + consoles (1008); the other roles' are kept, and a change of other + settings ends nothing */ + CASE("password-change") + { + char admin_token[2 * TOKEN_BYTES + 1], viewer_token[2 * TOKEN_BYTES + 1]; + struct connection *admin_console = &http.connections[0], *viewer_console = &http.connections[1]; + + sign_in_reset("admin1", "mod1", "view1"); + CHECK(sign_in(0x0A000031UL, "admin1", admin_token) && sign_in(0x0A000032UL, "view1", viewer_token), + "the passwords did not sign in"); + memset(admin_console, 0, sizeof(*admin_console)); + admin_console->socket = 20; + admin_console->websocket = 1; + admin_console->role = _server_role_admin; + memset(viewer_console, 0, sizeof(*viewer_console)); + viewer_console->socket = 21; + viewer_console->websocket = 1; + viewer_console->role = _server_role_viewer; + setting_changes++; + passwords_check(); + CHECK(session_find(admin_token) && session_find(viewer_token) && !admin_console->closing && + !viewer_console->closing, "a change of other settings ended a session"); + setting_admin = "admin2"; + setting_changes++; + passwords_check(); + CHECK(!session_find(admin_token), "the admin's session was kept after its password changed"); + CHECK(session_find(viewer_token) && !viewer_console->closing && viewer_console->role == _server_role_viewer, + "the viewer's session or console ended with the admin's password"); + CHECK(admin_console->closing && admin_console->role == _server_role_none && admin_console->out_length >= 4 && + admin_console->out[0] == 0x88 && (admin_console->out[2] << 8 | admin_console->out[3]) == 1008, + "the admin's console was not closed 1008"); + CHECK(!sign_in(0x0A000031UL, "admin1", admin_token) && sign_in(0x0A000031UL, "admin2", admin_token), + "the old password signed in, or the new one did not"); + free(admin_console->out); + memset(admin_console, 0, sizeof(*admin_console)); + memset(viewer_console, 0, sizeof(*viewer_console)); + admin_console->socket = viewer_console->socket = -1; + return 0; + } + /* the owner's password signs in from this machine alone (127.0.0.0/8, + not through the trusted proxy): from anywhere else it is refused + "forbidden" and counted as a wrong password; its session is used from + this machine alone, and ends when the password changes */ + CASE("owner-local") + { + char token[2 * TOKEN_BYTES + 1], other[2 * TOKEN_BYTES + 1], head[512]; + int index; + + sign_in_reset("admin-pass", "", ""); + setting_owner = "owner-pass"; + setting_changes++; + test_now = 700000; + CHECK(sign_in(0x7F000001UL, "owner-pass", token) && strstr(answered.body, "\"role\":\"owner\""), + "the owner's password from this machine was answered %d: %s", answered.status, answered.body); + snprintf(head, sizeof(head), "GET /api/session HTTP/1.1\r\nHost: server.example\r\nAuthorization: Bearer %s\r\n", + token); + request_from(0x7F000002UL, head, NULL); + CHECK(answered.status == 200, "the owner's session from this machine was answered %d", answered.status); + request_from(0x0A000051UL, head, NULL); + CHECK(answered.status == 401, "the owner's session from another address was answered %d", answered.status); + request_forwarded = 1; + request_from(0x7F000001UL, head, NULL); + CHECK(answered.status == 401, "the owner's session through the proxy was answered %d", answered.status); + CHECK(!sign_in(0x7F000001UL, "owner-pass", other) && answered.status == 403 && !strcmp(answered.code, "forbidden"), + "the owner's password through the proxy was answered %d", answered.status); + request_forwarded = 0; + for (index = 0; index < LOGIN_FAILURES; index++) + { + CHECK(!sign_in(0x0A000052UL, "owner-pass", other) && answered.status == 403 && + !strcmp(answered.code, "forbidden"), "the owner's password from another address was answered %d (%d)", + answered.status, index + 1); + } + CHECK(!sign_in(0x0A000052UL, "admin-pass", other) && answered.status == 429, + "the owner's password from elsewhere was not counted as a wrong one (%d)", answered.status); + CHECK(sign_in(0x7F000003UL, "admin-pass", other) && strstr(answered.body, "\"role\":\"admin\""), + "the admin's password from this machine gave another role"); + setting_owner = "owner-pass-2"; + setting_changes++; + passwords_check(); + request_from(0x7F000002UL, head, NULL); + CHECK(answered.status == 401, "the owner's session was kept after its password changed (%d)", answered.status); + return 0; + } + /* Origin: null from the page itself, which only the browser's + Sec-Fetch-Site: same-origin vouches for */ + CASE("fetch-site") + { + char token[2 * TOKEN_BYTES + 1], head[768]; + struct session *session; + static const char *const sites[] = { "cross-site", "same-site", "none", "Same-Origin", NULL }; + int index; + + sign_in_reset("", "", ""); + session = session_new(_server_role_admin, "admin", token); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: null\r\n" + "Sec-Fetch-Site: same-origin\r\nCookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, session->csrf); + request_from(0x0A000041UL, head, "{}"); + CHECK(!answered.count && tickets == 1, "the page's own change with Origin null was answered %d", answered.status); + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: null\r\n" + "sec-fetch-site: same-origin \r\nCookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, session->csrf); + request_from(0x0A000041UL, head, "{}"); + CHECK(!answered.count && tickets == 1, "Sec-Fetch-Site in lower case was not read"); + for (index = 0; sites[index]; index++) + { + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: null\r\n" + "Sec-Fetch-Site: %s\r\nCookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", sites[index], token, + session->csrf); + request_from(0x0A000041UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "origin") && !tickets, + "Origin null with Sec-Fetch-Site %s was answered %d", sites[index], answered.status); + } + snprintf(head, sizeof(head), "POST /api/say HTTP/1.1\r\nHost: server.example\r\nOrigin: null\r\n" + "Cookie: opence_session=%s\r\nX-CSRF-Token: %s\r\n", token, session->csrf); + request_from(0x0A000041UL, head, "{}"); + CHECK(answered.status == 403 && !strcmp(answered.code, "origin"), "Origin null alone was answered %d", + answered.status); + return 0; + } + /* a /ws upgrade counts against its address's allowance, as other + requests do: 429 past it */ + CASE("ws-allowance") + { + static const char good[] = "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: opence-rcon-1\r\n"; + char token[2 * TOKEN_BYTES + 1], headers[512]; + int index; + + sign_in_reset("", "", ""); + test_now = 500000; + for (index = 0; index < REQUESTS_UNAUTHENTICATED; index++) + { + upgrade_request(good); + connection_take(&connection); + CHECK(connection.websocket, "upgrade %d was refused (%d)", index + 1, answered.status); + } + upgrade_request(good); + connection_take(&connection); + CHECK(!connection.websocket && answered.status == 429 && !strcmp(answered.code, "rate_limited"), + "an upgrade past the allowance was answered %d", answered.status); + /* (a signed-in one's allowance, a Bearer token's) */ + session_new(_server_role_admin, "admin", token); + snprintf(headers, sizeof(headers), "%sAuthorization: Bearer %s\r\n", good, token); + upgrade_request(headers); + connection_take(&connection); + CHECK(connection.websocket && auth_answered == 1, "a signed-in upgrade was refused (%d)", answered.status); + test_now += 60; + upgrade_request(good); + connection_take(&connection); + CHECK(connection.websocket, "an upgrade a minute later was refused (%d)", answered.status); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/server_json.c b/tools/harness/tests/server_json.c new file mode 100644 index 00000000..b7d72969 --- /dev/null +++ b/tools/harness/tests/server_json.c @@ -0,0 +1,598 @@ +/* +SERVER_JSON.C (test) + +The real JSON of a dedicated server (port/linux/src/server_json.c): the +writer's escaping, nesting and values, the reader over the vendored jsmn +(its limits on depth and tokens, members, elements, strings unescaped) and +base64. test_server_json.py takes the whole file (under_test.inc) and its +header (server_json.h). +*/ + +#include "harness.h" + +#include +#include +#include + +#include "under_test.inc" + +/* what a writer wrote: one call's text, into a static copy */ +static const char *written(struct server_json *json) +{ + static char text[4096]; + + snprintf(text, sizeof(text), "%s", server_json_text(json)); + server_json_free(json); + return text; +} + +/* text as ASCII, for a failure's message (the harness reads it as UTF-8) */ +static const char *visible(const char *text) +{ + static char shown[4096]; + int length = 0; + + for (; *text && length < (int)sizeof(shown) - 5; text++) + { + if ((unsigned char)*text >= 0x20 && (unsigned char)*text < 0x7F) + shown[length++] = *text; + else + length += snprintf(shown + length, 5, "\\x%02x", (unsigned char)*text); + } + shown[length] = 0; + return shown; +} + +static const char *escaped(const char *value) +{ + struct server_json json; + + server_json_begin(&json); + server_json_string(&json, NULL, value); + return written(&json); +} + +static const char *escaped_length(const char *value, int length) +{ + struct server_json json; + + server_json_begin(&json); + server_json_string_length(&json, NULL, value, length); + return written(&json); +} + +static int parse(struct server_json_document *document, const char *text, int maximum_depth) +{ + return server_json_parse(document, text, (int)strlen(text), maximum_depth); +} + +/* whether text parses (at a depth of 16, the API's) */ +static int parses(const char *text) +{ + struct server_json_document document; + int ok = parse(&document, text, 16); + + server_json_document_free(&document); + return ok; +} + +/* the string at the top of a document ("..." as text), unescaped into a +buffer of size bytes: 1 if it fits */ +static int unescaped(const char *text, char *value, int size) +{ + struct server_json_document document; + char wrapped[256]; + int ok; + + snprintf(wrapped, sizeof(wrapped), "[%s]", text); + if (!parse(&document, wrapped, 16)) + return -1; + ok = server_json_get_string(&document, 1, value, size); + server_json_document_free(&document); + return ok; +} + +static int same_string(const char *text, const char *expected) +{ + char value[256]; + + return unescaped(text, value, sizeof(value)) == 1 && !strcmp(value, expected); +} + +static int decoded(const char *text, const char *expected, int maximum) +{ + unsigned char bytes[64]; + int count = server_base64_decode(text, bytes, maximum); + + return count == (int)strlen(expected) && !memcmp(bytes, expected, (size_t)count); +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* quotes, backslashes and control characters escaped; UTF-8 kept as it + is, anything else U+FFFD */ + CASE("escaping") + { + CHECK(!strcmp(escaped("plain text"), "\"plain text\""), "wrote %s", visible(escaped("plain text"))); + CHECK(!strcmp(escaped("say \"hi\" \\o/"), "\"say \\\"hi\\\" \\\\o/\""), "wrote %s", visible(escaped("say \"hi\" \\o/"))); + CHECK(!strcmp(escaped("a\nb\tc\rd"), "\"a\\nb\\tc\\rd\""), "wrote %s", visible(escaped("a\nb\tc\rd"))); + CHECK(!strcmp(escaped("\x01\x1f\x08"), "\"\\u0001\\u001f\\u0008\""), "wrote %s", visible(escaped("\x01\x1f\x08"))); + CHECK(!strcmp(escaped("del\x7f"), "\"del\x7f\""), "DEL was not written as it is: %s", visible(escaped("del\x7f"))); + CHECK(!strcmp(escaped("caf\xc3\xa9 \xe2\x82\xac \xf0\x9f\x98\x80"), "\"caf\xc3\xa9 \xe2\x82\xac \xf0\x9f\x98\x80\""), + "UTF-8 was changed: %s", visible(escaped("caf\xc3\xa9 \xe2\x82\xac \xf0\x9f\x98\x80"))); + /* (a lone continuation byte, an overlong lead, a sequence cut off, + a lead after a lead) */ + CHECK(!strcmp(escaped("a\x80z"), "\"a\\ufffdz\""), "wrote %s", visible(escaped("a\x80z"))); + CHECK(!strcmp(escaped("\xc0\xaf"), "\"\\ufffd\\ufffd\""), "wrote %s", visible(escaped("\xc0\xaf"))); + CHECK(!strcmp(escaped("\xe2\x82"), "\"\\ufffd\\ufffd\""), "wrote %s", visible(escaped("\xe2\x82"))); + CHECK(!strcmp(escaped("\xc3\xc3\xa9"), "\"\\ufffd\xc3\xa9\""), "wrote %s", visible(escaped("\xc3\xc3\xa9"))); + CHECK(!strcmp(escaped("\xf8"), "\"\\ufffd\""), "wrote %s", visible(escaped("\xf8"))); + /* (a sequence that runs past the length given is not taken whole) */ + CHECK(!strcmp(escaped_length("\xc3\xa9", 1), "\"\\ufffd\""), "wrote %s", visible(escaped_length("\xc3\xa9", 1))); + CHECK(!strcmp(escaped_length("abcdef", 3), "\"abc\""), "wrote %s", visible(escaped_length("abcdef", 3))); + CHECK(!strcmp(escaped_length("ab\0cd", 5), "\"ab\""), "wrote past a NUL: %s", visible(escaped_length("ab\0cd", 5))); + CHECK(!strcmp(escaped(NULL), "null"), "wrote %s", visible(escaped(NULL))); + CHECK(!strcmp(escaped(""), "\"\""), "wrote %s", visible(escaped(""))); + { + struct server_json json; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "k\"ey\n", "v"); + server_json_object_end(&json); + CHECK(!strcmp(written(&json), "{\"k\\\"ey\\n\":\"v\"}"), "a key was not escaped"); + } + return 0; + } + /* bytes that can never begin UTF-8 (0xF5 to 0xFF) are not written as + they are */ + CASE("utf8-invalid-leads") + { + CHECK(!strcmp(escaped("\xf5\x80\x80\x80"), "\"\\ufffd\\ufffd\\ufffd\\ufffd\""), "wrote %s", + visible(escaped("\xf5\x80\x80\x80"))); + CHECK(!strcmp(escaped("\xff\x80\x80"), "\"\\ufffd\\ufffd\\ufffd\""), "wrote %s", visible(escaped("\xff\x80\x80"))); + CHECK(!strcmp(escaped("\xf8\x88\x80\x80\x80"), "\"\\ufffd\\ufffd\\ufffd\\ufffd\\ufffd\""), "wrote %s", + visible(escaped("\xf8\x88\x80\x80\x80"))); + return 0; + } + /* overlong forms and surrogates are not UTF-8 either */ + CASE("utf8-overlong-surrogates") + { + CHECK(!strcmp(escaped("\xe0\x80\xaf"), "\"\\ufffd\\ufffd\\ufffd\""), "wrote %s", visible(escaped("\xe0\x80\xaf"))); + CHECK(!strcmp(escaped("\xed\xa0\x80"), "\"\\ufffd\\ufffd\\ufffd\""), "wrote %s", visible(escaped("\xed\xa0\x80"))); + CHECK(!strcmp(escaped("\xf4\x90\x80\x80"), "\"\\ufffd\\ufffd\\ufffd\\ufffd\""), "wrote %s", + visible(escaped("\xf4\x90\x80\x80"))); + return 0; + } + /* members separated by commas, keys in objects, none in arrays */ + CASE("nesting") + { + struct server_json json; + const char *text; + + server_json_begin(&json); + server_json_object_begin(&json, NULL); + server_json_string(&json, "name", "x"); + server_json_object_begin(&json, "empty"); + server_json_object_end(&json); + server_json_array_begin(&json, "list"); + server_json_integer(&json, NULL, 1); + server_json_array_begin(&json, NULL); + server_json_array_end(&json); + server_json_object_begin(&json, NULL); + server_json_boolean(&json, "b", 1); + server_json_null(&json, "n"); + server_json_object_end(&json); + server_json_string(&json, NULL, "s"); + server_json_array_end(&json); + server_json_raw(&json, "raw", "{\"x\":[1,2]}"); + server_json_object_end(&json); + text = written(&json); + CHECK(!strcmp(text, "{\"name\":\"x\",\"empty\":{},\"list\":[1,[],{\"b\":true,\"n\":null},\"s\"]," + "\"raw\":{\"x\":[1,2]}}"), "wrote %s", text); + /* (what it writes, it reads) */ + CHECK(parses(text), "its own text did not parse: %s", text); + + server_json_begin(&json); + server_json_array_begin(&json, NULL); + server_json_array_end(&json); + CHECK(!strcmp(written(&json), "[]"), "an empty array was not []"); + server_json_begin(&json); + CHECK(!strcmp(server_json_text(&json), ""), "nothing written was not \"\""); + server_json_free(&json); + return 0; + } + /* integers, reals (no infinity nor NaN), booleans, null and raw text */ + CASE("values") + { + struct server_json json; + const char *text; + + server_json_begin(&json); + server_json_array_begin(&json, NULL); + server_json_integer(&json, NULL, 0); + server_json_integer(&json, NULL, -42); + server_json_integer(&json, NULL, LLONG_MAX); + server_json_integer(&json, NULL, LLONG_MIN); + server_json_real(&json, NULL, 1.5); + server_json_real(&json, NULL, -0.1); + server_json_real(&json, NULL, 1e-7); + server_json_real(&json, NULL, NAN); + server_json_real(&json, NULL, INFINITY); + server_json_real(&json, NULL, -INFINITY); + server_json_real(&json, NULL, DBL_MAX); + server_json_boolean(&json, NULL, 0); + server_json_boolean(&json, NULL, 7); + server_json_null(&json, NULL); + server_json_raw(&json, NULL, ""); + server_json_raw(&json, NULL, NULL); + server_json_raw(&json, NULL, "\"ready\""); + server_json_array_end(&json); + text = written(&json); + CHECK(!strcmp(text, "[0,-42,9223372036854775807,-9223372036854775808,1.5,-0.1,1e-07,null,null,null,null," + "false,true,null,null,null,\"ready\"]"), "wrote %s", text); + CHECK(parses(text), "its own text did not parse: %s", text); + return 0; + } + /* 24 deep is written; deeper, nothing is */ + CASE("write-depth") + { + struct server_json json; + int index; + + server_json_begin(&json); + for (index = 0; index < SERVER_JSON_MAXIMUM_DEPTH; index++) + server_json_array_begin(&json, NULL); + for (index = 0; index < SERVER_JSON_MAXIMUM_DEPTH; index++) + server_json_array_end(&json); + CHECK(!json.failed && (int)strlen(server_json_text(&json)) == 2 * SERVER_JSON_MAXIMUM_DEPTH, + "%d deep was not written", SERVER_JSON_MAXIMUM_DEPTH); + server_json_free(&json); + + server_json_begin(&json); + for (index = 0; index <= SERVER_JSON_MAXIMUM_DEPTH; index++) + server_json_object_begin(&json, index ? "k" : NULL); + for (index = 0; index <= SERVER_JSON_MAXIMUM_DEPTH; index++) + server_json_object_end(&json); + CHECK(json.failed && !strcmp(server_json_text(&json), ""), "%d deep was written", + SERVER_JSON_MAXIMUM_DEPTH + 1); + server_json_free(&json); + return 0; + } + /* text that would grow past SERVER_JSON_MAXIMUM_SIZE is not written */ + CASE("write-size") + { + struct server_json json; + int size = SERVER_JSON_MAXIMUM_SIZE / 2; + char *large = malloc((size_t)size + 1); + + memset(large, 'a', (size_t)size); + large[size] = 0; + server_json_begin(&json); + server_json_array_begin(&json, NULL); + server_json_string(&json, NULL, large); + CHECK(!json.failed && json.length == size + 3, "half the most was not written"); + server_json_string(&json, NULL, large); + CHECK(json.failed && !strcmp(server_json_text(&json), ""), "more than the most was written"); + /* (and once failed, nothing more is) */ + server_json_array_end(&json); + CHECK(!strcmp(server_json_text(&json), ""), "a failed text was written to"); + server_json_free(&json); + free(large); + return 0; + } + /* members, elements and values found, others refused */ + CASE("read") + { + static const char text[] = "{\"ab\":1,\"a\":{\"x\":[10,{\"y\":null},\"s\"]},\"t\":true,\"f\":false," + "\"n\":null,\"r\":-2.5e2,\"s\":\"str\"}trailing"; + struct server_json_document document; + int a, x, element, value; + double number; + char string[16]; + + /* (the length given ends the text: what follows is not read) */ + CHECK(server_json_parse(&document, text, (int)(strstr(text, "trailing") - text), 8), "a document was refused"); + CHECK(document.tokens[0].type == _server_json_object, "the top is not an object"); + CHECK(server_json_get_number(&document, server_json_member(&document, 0, "ab"), &number) && number == 1, + "ab was not 1"); + a = server_json_member(&document, 0, "a"); + CHECK(a > 0 && document.tokens[a].type == _server_json_object, "a was not found after ab"); + CHECK(server_json_member(&document, 0, "") == -1 && server_json_member(&document, 0, "abc") == -1 && + server_json_member(&document, 0, "y") == -1, "a member that is not there (or not here) was found"); + x = server_json_member(&document, a, "x"); + CHECK(x > 0 && document.tokens[x].type == _server_json_array && document.tokens[x].size == 3, "x was not found"); + CHECK(server_json_get_number(&document, server_json_element(&document, x, 0), &number) && number == 10, + "x[0] was not 10"); + element = server_json_element(&document, x, 1); + CHECK(server_json_is_null(&document, server_json_member(&document, element, "y")), "x[1].y was not null"); + CHECK(server_json_get_string(&document, server_json_element(&document, x, 2), string, sizeof(string)) && + !strcmp(string, "s"), "x[2] was not \"s\""); + CHECK(server_json_element(&document, x, 3) == -1 && server_json_element(&document, x, -1) == -1, + "an element past the array was found"); + CHECK(server_json_element(&document, 0, 0) == -1 && server_json_member(&document, x, "x") == -1, + "an object was read as an array, or an array as an object"); + CHECK(server_json_member(&document, -1, "a") == -1 && server_json_member(&document, 999, "a") == -1 && + server_json_element(&document, 999, 0) == -1, "a token past the document was read"); + /* (skipping a member's key skips its value with it) */ + CHECK(server_json_skip(&document, a) == server_json_member(&document, 0, "t") - 1, "a was not skipped whole"); + CHECK(server_json_skip(&document, 0) == document.count, "the document was not skipped whole"); + CHECK(server_json_get_boolean(&document, server_json_member(&document, 0, "t"), &value) && value == 1, + "t was not true"); + CHECK(server_json_get_boolean(&document, server_json_member(&document, 0, "f"), &value) && value == 0, + "f was not false"); + CHECK(!server_json_get_boolean(&document, server_json_member(&document, 0, "n"), &value) && + !server_json_get_boolean(&document, server_json_member(&document, 0, "ab"), &value), + "null or a number was a boolean"); + CHECK(server_json_get_number(&document, server_json_member(&document, 0, "r"), &number) && number == -250, + "r was not -250"); + CHECK(!server_json_get_number(&document, server_json_member(&document, 0, "t"), &number) && + !server_json_get_number(&document, server_json_member(&document, 0, "s"), &number) && + !server_json_get_number(&document, a, &number) && !server_json_get_number(&document, -1, &number), + "something that is not a number was one"); + CHECK(!server_json_get_string(&document, server_json_member(&document, 0, "ab"), string, sizeof(string)) && + !server_json_get_string(&document, -1, string, sizeof(string)), "a number was a string"); + CHECK(!server_json_is_null(&document, server_json_member(&document, 0, "s")) && + !server_json_is_null(&document, -1), "a string was null"); + server_json_document_free(&document); + CHECK(!document.tokens && !document.count, "a freed document kept its tokens"); + return 0; + } + /* strings unescaped, \uXXXX (and surrogate pairs) to UTF-8, into what fits */ + CASE("strings") + { + char value[16]; + + CHECK(same_string("\"a\\nb\\tc\\rd\\be\\ff\"", "a\nb\tc\rd\be\ff"), "the short escapes were not read"); + CHECK(same_string("\"\\\"\\\\\\/\"", "\"\\/"), "a quote, backslash or slash was not read"); + CHECK(same_string("\"\\u0041\\u00e9\\u20AC\"", "A\xc3\xa9\xe2\x82\xac"), "\\u was not read to UTF-8"); + CHECK(same_string("\"\\ud83d\\ude00!\"", "\xf0\x9f\x98\x80!"), "a surrogate pair was not read as one"); + CHECK(same_string("\"\\uDBFF\\uDFFF\"", "\xf4\x8f\xbf\xbf"), "the last code point was not read"); + CHECK(same_string("\"\\ud83dx\"", "\xef\xbf\xbdx"), "a lone high surrogate was not U+FFFD"); + CHECK(same_string("\"\\ude00\"", "\xef\xbf\xbd"), "a lone low surrogate was not U+FFFD"); + CHECK(same_string("\"\\ud83d\\u0041\"", "\xef\xbf\xbd" "A"), "a high surrogate before a letter was not U+FFFD"); + CHECK(same_string("\"\\ud83d\\ud83d\\ude00\"", "\xef\xbf\xbd\xf0\x9f\x98\x80"), + "a high surrogate before a pair was not U+FFFD and the pair"); + CHECK(same_string("\"caf\xc3\xa9\"", "caf\xc3\xa9"), "UTF-8 was changed"); + CHECK(same_string("\"\"", ""), "an empty string was not read"); + /* (exactly what fits, and a byte too many) */ + CHECK(unescaped("\"1234567\"", value, 8) == 1 && !strcmp(value, "1234567"), "a string that fits was refused"); + memset(value, '#', sizeof(value)); + CHECK(unescaped("\"12345678\"", value, 8) == 0, "a string that does not fit was taken"); + CHECK(value[8] == '#', "a string that does not fit was written past its room"); + CHECK(unescaped("\"\\u20ac\"", value, 4) == 1 && unescaped("\"\\u20ac\"", value, 3) == 0, + "a character's bytes were split at the end of the room"); + CHECK(unescaped("\"\"", value, 0) == 0, "a string was read into no room"); + return 0; + } + /* text that is not JSON (as jsmn reads it strictly) is refused */ + CASE("malformed") + { + static const char *const refused[] = { + "", "{", "[", "{\"a\":1", "[1,2", "{\"a\":\"b", "{\"a\" 1}", "{\"a\",1}", "{1:2}", "[1}", "{]", + "\"open", "[\"a\\x\"]", "[\"\\u12\"]", "[\"\\u12zz\"]", "{} x", + /* (a number, true or null at the top: jsmn's strict mode wants a + delimiter after one) */ + "1", "true", "null", + /* (what jsmn's strict mode takes, the grammar's check does not) */ + "[1,]", "{\"a\":1,}", "[1 2]", "{\"a\":1 \"b\":2}", "[,1]", "[\"a\":1]", "{\"a\":}", "{}{}", "[01]", + "[\"a\x01\"]", "[\"a\"\"b\"]", "[1e]", "[.5]", "[+1]", "[True]", + NULL, + }; + struct server_json_document document; + int index; + + for (index = 0; refused[index]; index++) + CHECK(!parses(refused[index]), "%s was taken", refused[index]); + CHECK(!server_json_parse(&document, NULL, 4, 8) && !server_json_parse(&document, "[1]", 0, 8) && + !server_json_parse(&document, "[1]", -1, 8), "nothing, or a length of none, was taken"); + /* (cut off anywhere, a document is refused) */ + { + static const char whole[] = "{\"a\":[1,{\"b\":\"c\\u00e9\"}],\"d\":true}"; + int length; + + for (length = 1; length < (int)sizeof(whole) - 1; length++) + { + CHECK(!server_json_parse(&document, whole, length, 8), "%.*s was taken", length, whole); + CHECK(!document.tokens, "a refused document kept tokens"); + } + CHECK(server_json_parse(&document, whole, length, 8), "the whole document was refused"); + server_json_document_free(&document); + } + return 0; + } + /* what jsmn takes as a primitive (it reads only the first character) is + refused unless it is a number, boolean or null */ + CASE("primitives") + { + static const char *const neither[] = { "[tru]", "[nul]", "[-]", "[nan]", "[truex]", "[nullx]", "[falsey]", "[1_0]", + "[0.5.5]", NULL }; + struct server_json_document document; + double number; + int index, value; + + for (index = 0; neither[index]; index++) + { + CHECK(!parse(&document, neither[index], 8), "%s was taken", neither[index]); + } + CHECK(parse(&document, "[-0.5e+1,1E2]", 8), "numbers were refused"); + CHECK(server_json_get_number(&document, 1, &number) && number == -5, "-0.5e+1 was not -5"); + CHECK(server_json_get_number(&document, 2, &number) && number == 100, "1E2 was not 100"); + server_json_document_free(&document); + return 0; + } + /* only JSON's numbers are numbers: not strtod's NaN, infinities or hex, + which pass every range check a caller makes ("minutes", "time") */ + CASE("numbers-json-only") + { + static const char *const refused[] = { "[-nan]", "[-inf]", "[-infinity]", "[0x10]", "[0x1p3]", "[1e999]", NULL }; + struct server_json_document document; + double number; + int index; + + for (index = 0; refused[index]; index++) + { + CHECK(!parse(&document, refused[index], 8) || !server_json_get_number(&document, 1, &number), + "%s was read as %g", refused[index], number); + server_json_document_free(&document); + } + return 0; + } + /* nesting deeper than asked is refused (each key a level, as jsmn makes + a key its value's parent) */ + CASE("read-depth") + { + struct server_json_document document; + char deep[4096]; + int index; + + CHECK(parse(&document, "[[1]]", 2), "[[1]] was refused at a depth of 2"); + server_json_document_free(&document); + CHECK(!parse(&document, "[[[1]]]", 2), "[[[1]]] was taken at a depth of 2"); + CHECK(parse(&document, "{\"a\":1}", 2), "{\"a\":1} was refused at a depth of 2"); + server_json_document_free(&document); + CHECK(!parse(&document, "{\"a\":[1]}", 2), "{\"a\":[1]} was taken at a depth of 2"); + CHECK(parse(&document, "[]", 0), "[] was refused at a depth of 0"); + server_json_document_free(&document); + CHECK(!parse(&document, "[1]", 0), "[1] was taken at a depth of 0"); + /* (a thousand open brackets) */ + for (index = 0; index < 1000; index++) + { + deep[index] = '['; + deep[1000 + index] = ']'; + } + deep[2000] = 0; + CHECK(!parse(&document, deep, 16), "a thousand deep was taken"); + CHECK(parse(&document, deep, 1000), "a thousand deep was refused at a depth of 1000"); + server_json_document_free(&document); + return 0; + } + /* a document of SERVER_JSON_MAXIMUM_TOKENS is read, one of more is not */ + CASE("tokens") + { + struct server_json_document document; + int elements = SERVER_JSON_MAXIMUM_TOKENS - 1; + char *text = malloc((size_t)(2 * elements + 8)); + int index, length = 0; + + text[length++] = '['; + for (index = 0; index < elements; index++) + { + text[length++] = '0'; + text[length++] = ','; + } + text[length - 1] = ']'; + CHECK(server_json_parse(&document, text, length, 8) && document.count == SERVER_JSON_MAXIMUM_TOKENS, + "%d tokens were refused", SERVER_JSON_MAXIMUM_TOKENS); + CHECK(server_json_element(&document, 0, elements - 1) == SERVER_JSON_MAXIMUM_TOKENS - 1, + "the last element was not found"); + server_json_document_free(&document); + text[length - 1] = ','; + text[length++] = '0'; + text[length++] = ']'; + CHECK(!server_json_parse(&document, text, length, 8), "%d tokens were taken", SERVER_JSON_MAXIMUM_TOKENS + 1); + free(text); + return 0; + } + /* a master's list reads up to SERVER_JSON_MAXIMUM_LIST_TOKENS, a body + of the API still SERVER_JSON_MAXIMUM_TOKENS */ + CASE("list-tokens") + { + struct server_json_document document; + int elements = SERVER_JSON_MAXIMUM_LIST_TOKENS - 1; + char *text = malloc((size_t)(2 * elements + 8)); + int index, length = 0; + + CHECK(SERVER_JSON_MAXIMUM_LIST_TOKENS > SERVER_JSON_MAXIMUM_TOKENS, "a list reads no more than a body"); + text[length++] = '['; + for (index = 0; index < elements; index++) + { + text[length++] = '0'; + text[length++] = ','; + } + text[length - 1] = ']'; + CHECK(server_json_parse_tokens(&document, text, length, 8, SERVER_JSON_MAXIMUM_LIST_TOKENS) && + document.count == SERVER_JSON_MAXIMUM_LIST_TOKENS, "a list of %d tokens was refused", + SERVER_JSON_MAXIMUM_LIST_TOKENS); + server_json_document_free(&document); + CHECK(!server_json_parse(&document, text, length, 8), "a body of %d tokens was taken", + SERVER_JSON_MAXIMUM_LIST_TOKENS); + text[length - 1] = ','; + text[length++] = '0'; + text[length++] = ']'; + CHECK(!server_json_parse_tokens(&document, text, length, 8, SERVER_JSON_MAXIMUM_LIST_TOKENS), + "a list of %d tokens was taken", SERVER_JSON_MAXIMUM_LIST_TOKENS + 1); + CHECK(!server_json_parse_tokens(&document, "[]", 2, 8, 0), "a document was read into no tokens"); + free(text); + return 0; + } + /* \u0000 is refused (the text read would end there, past the checks of + what follows it); the other control characters are read, for the callers + to refuse */ + CASE("nul-refused") + { + char value[16]; + + memset(value, '#', sizeof(value)); + CHECK(unescaped("\"\\u0000x\"", value, sizeof(value)) == 0 && !value[0], "\\u0000x was read as \"%s\"", + visible(value)); + CHECK(unescaped("\"x\\u0000\"", value, sizeof(value)) == 0 && !value[0], "x\\u0000 was read as \"%s\"", + visible(value)); + CHECK(same_string("\"\\u0001\\u001f\\u0030\"", "\x01\x1f" "0"), "\\u0001, \\u001f or \\u0030 was not read"); + return 0; + } + /* RFC 4648's examples, every length round trip, bad text refused, the + most bytes kept to */ + CASE("base64") + { + static const char *const examples[][2] = { + { "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" }, { "foob", "Zm9vYg==" }, + { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" }, + }; + static const char *const refused[] = { + "Zg=", "Zm9", "Zm9vY", "Zg=a", "Z===", "====", "Zg==Zg==", "Zm9v!A==", "Zm9v YQ=", "Zm9v\nYQ=", "Zm-v", + "Zm_v", "\xc3\xa9Zg", NULL, + }; + unsigned char bytes[96], back[96]; + char text[160]; + int index, size; + + for (index = 0; index < (int)(sizeof(examples) / sizeof(examples[0])); index++) + { + server_base64_encode((const unsigned char *)examples[index][0], (int)strlen(examples[index][0]), text); + CHECK(!strcmp(text, examples[index][1]), "%s was written %s", examples[index][0], text); + CHECK(decoded(examples[index][1], examples[index][0], 64), "%s was not read", examples[index][1]); + } + for (index = 0; index < (int)sizeof(bytes); index++) + bytes[index] = (unsigned char)(index * 151 + 7); + for (size = 0; size <= (int)sizeof(bytes); size++) + { + server_base64_encode(bytes, size, text); + CHECK((int)strlen(text) == 4 * ((size + 2) / 3), "%d bytes were written in %d characters", size, + (int)strlen(text)); + CHECK(server_base64_decode(text, back, (int)sizeof(back)) == size && !memcmp(bytes, back, (size_t)size), + "%d bytes did not come back", size); + } + server_base64_encode((const unsigned char *)"\xfb\xff\xbf", 3, text); + CHECK(!strcmp(text, "+/+/"), "the last two characters were not + and /: %s", text); + for (index = 0; refused[index]; index++) + { + CHECK(server_base64_decode(refused[index], back, (int)sizeof(back)) == -1, "%s was read", + visible(refused[index])); + } + /* (the most is kept to exactly, padding counted out) */ + memset(back, '#', sizeof(back)); + CHECK(decoded("Zm9vYmFy", "foobar", 6) && server_base64_decode("Zm9vYmFy", back, 5) == -1, + "six bytes were not read into six, or were into five"); + CHECK(decoded("Zm9vYg==", "foob", 4) && server_base64_decode("Zm9vYg==", back, 3) == -1, + "four bytes were not read into four, or were into three"); + CHECK(decoded("Zm9vYmE=", "fooba", 5) && server_base64_decode("Zm9vYmE=", back, 4) == -1, + "five bytes were not read into five, or were into four"); + CHECK(back[4] == '#', "bytes were written past the most"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/server_log.c b/tools/harness/tests/server_log.c new file mode 100644 index 00000000..06cf05c4 --- /dev/null +++ b/tools/harness/tests/server_log.c @@ -0,0 +1,275 @@ +/* +SERVER_LOG.C (test) + +The real log of a dedicated server (port/linux/src/server_log.c): its ring +of the last SERVER_LOG_RING_SIZE lines, numbered, read after a cursor, and +its lines on standard output as text or JSON. test_server_log.py takes the +whole file with its headers and the JSON writer (server_json.c), and +posix_server.c's UTC text; the clock is the test's. +*/ + +#include "harness.h" + +#include +#include + +#include "posix_server.h" + +static long long clock_milliseconds = 1000000000123LL; + +long long posix_server_unix_milliseconds(void) +{ + return clock_milliseconds; +} + +#include "under_test.inc" + +static void log_lines(int count) +{ + int index; + + for (index = 0; index < count; index++) + server_log_ring(_server_log_info, "game", "a line"); +} + +/* a line's text, as the ring keeps it: "line " */ +static void log_numbered(int count) +{ + int index; + + for (index = 0; index < count; index++) + { + char text[32]; + + snprintf(text, sizeof(text), "line %lu", server_log_latest() + 1); + server_log_ring(_server_log_info, "game", text); + } +} + +/* lines read in order, each its own number, from first */ +static int lines_from(struct server_log_line const *lines, int count, unsigned long first) +{ + int index; + + for (index = 0; index < count; index++) + { + char text[32]; + + snprintf(text, sizeof(text), "line %lu", first + (unsigned long)index); + if (lines[index].number != first + (unsigned long)index || strcmp(lines[index].text, text)) + return 0; + } + return 1; +} + +static struct server_log_line heard; +static int heard_count; + +static void listener(struct server_log_line const *line) +{ + heard = *line; + heard_count++; +} + +static struct server_log_line lines[SERVER_LOG_RING_SIZE + 8]; + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + unsigned long next = 99, missed = 99; + int count; + + /* lines numbered from 1; each read starts after the cursor */ + CASE("numbering") + { + CHECK(server_log_latest() == 0, "an empty log has a latest line"); + count = server_log_read(0, lines, 16, &next, &missed); + CHECK(count == 0 && next == 0 && missed == 0, "an empty log read %d, next %lu, missed %lu", count, next, missed); + log_numbered(3); + CHECK(server_log_latest() == 3, "the latest is %lu, not 3", server_log_latest()); + count = server_log_read(0, lines, 16, &next, &missed); + CHECK(count == 3 && lines_from(lines, count, 1) && next == 3 && missed == 0, + "from the oldest read %d, next %lu, missed %lu", count, next, missed); + count = server_log_read(2, lines, 16, &next, &missed); + CHECK(count == 1 && lines_from(lines, count, 3) && next == 3, "after 2 read %d from %lu", count, lines[0].number); + count = server_log_read(3, lines, 16, &next, &missed); + CHECK(count == 0 && next == 3 && missed == 0, "after the latest read %d, next %lu", count, next); + return 0; + } + /* no more than the most asked for; next is the last read */ + CASE("maximum") + { + log_numbered(10); + count = server_log_read(0, lines, 4, &next, &missed); + CHECK(count == 4 && lines_from(lines, count, 1) && next == 4, "the first four read %d, next %lu", count, next); + count = server_log_read(next, lines, 4, &next, &missed); + CHECK(count == 4 && lines_from(lines, count, 5) && next == 8, "the next four read %d, next %lu", count, next); + count = server_log_read(next, lines, 4, &next, &missed); + CHECK(count == 2 && lines_from(lines, count, 9) && next == 10, "the last two read %d, next %lu", count, next); + count = server_log_read(6, lines, 0, &next, &missed); + CHECK(count == 0 && next == 6, "none asked for read %d, next %lu", count, next); + return 0; + } + /* past SERVER_LOG_RING_SIZE lines, the newest are kept */ + CASE("wrap") + { + log_numbered(SERVER_LOG_RING_SIZE + 100); + count = server_log_read(0, lines, SERVER_LOG_RING_SIZE + 8, &next, &missed); + CHECK(count == SERVER_LOG_RING_SIZE, "%d lines were read of a full ring", count); + CHECK(lines_from(lines, count, 101), "the ring did not keep 101 to %d: it began at %lu", + SERVER_LOG_RING_SIZE + 100, lines[0].number); + CHECK(next == SERVER_LOG_RING_SIZE + 100 && missed == 100, "next %lu, missed %lu", next, missed); + /* (the ring's last place, and its first after it) */ + log_numbered(SERVER_LOG_RING_SIZE - 100 - 1); + count = server_log_read(2 * SERVER_LOG_RING_SIZE - 3, lines, 8, &next, &missed); + CHECK(count == 2 && lines_from(lines, count, 2 * SERVER_LOG_RING_SIZE - 2) && next == 2 * SERVER_LOG_RING_SIZE - 1, + "around the ring's end read %d from %lu, next %lu", count, lines[0].number, next); + log_numbered(2); + count = server_log_read(2 * SERVER_LOG_RING_SIZE - 2, lines, 8, &next, &missed); + CHECK(count == 3 && lines_from(lines, count, 2 * SERVER_LOG_RING_SIZE - 1) && missed == 0, + "across the ring's end read %d from %lu", count, lines[0].number); + return 0; + } + /* a reader behind the ring's start is told how many it missed, and + reads from the oldest kept */ + CASE("behind") + { + log_numbered(SERVER_LOG_RING_SIZE + 100); + count = server_log_read(50, lines, 4, &next, &missed); + CHECK(count == 4 && lines_from(lines, count, 101) && missed == 50 && next == 104, + "after 50 read %d from %lu, missed %lu, next %lu", count, lines[0].number, missed, next); + count = server_log_read(100, lines, 4, &next, &missed); + CHECK(count == 4 && lines_from(lines, count, 101) && missed == 0, "after 100 read from %lu, missed %lu", + lines[0].number, missed); + count = server_log_read(next, lines, 4, &next, &missed); + CHECK(count == 4 && lines_from(lines, count, 105) && missed == 0, "the next read from %lu", lines[0].number); + return 0; + } + /* a cursor past the latest (the server restarted) reads nothing, and + next is the latest */ + CASE("past-latest") + { + log_lines(20); + count = server_log_read(5000, lines, 8, &next, &missed); + CHECK(count == 0 && next == 20 && missed == 0, "after 5000 read %d, next %lu, missed %lu", count, next, missed); + count = server_log_read(21, lines, 8, &next, &missed); + CHECK(count == 0 && next == 20 && missed == 0, "after 21 read %d, next %lu, missed %lu", count, next, missed); + return 0; + } + /* the largest cursor too */ + CASE("past-latest-largest") + { + log_lines(20); + count = server_log_read(ULONG_MAX, lines, 8, &next, &missed); + CHECK(count == 0 && next == 20 && missed == 0, "after %lu read %d, next %lu, missed %lu", ULONG_MAX, count, next, + missed); + return 0; + } + /* a line's text kept to printable characters and its size, its category + to its size ("server" for none), its level and time */ + CASE("text") + { + char long_text[1000]; + + server_log_ring(_server_log_warning, "chat", "a\nb\rc\td\x1b[31me\x7f" "f\xc3\xa9"); + count = server_log_read(0, lines, 8, &next, &missed); + CHECK(count == 1 && !strcmp(lines[0].text, "a b c d [31me f\xc3\xa9"), "kept \"%s\"", lines[0].text); + CHECK(!strcmp(lines[0].category, "chat") && lines[0].level == _server_log_warning && + lines[0].time == clock_milliseconds, "kept %s, level %d, time %lld", lines[0].category, lines[0].level, + lines[0].time); + memset(long_text, 'x', sizeof(long_text) - 1); + long_text[sizeof(long_text) - 1] = 0; + server_log_ring(_server_log_error, NULL, long_text); + server_log_ring(_server_log_info, "a category too long to keep", "y"); + count = server_log_read(1, lines, 8, &next, &missed); + CHECK(count == 2 && strlen(lines[0].text) == SERVER_LOG_TEXT_SIZE - 1, "a long text was kept as %d characters", + (int)strlen(lines[0].text)); + CHECK(!strcmp(lines[0].category, "server"), "no category was kept as %s", lines[0].category); + CHECK(strlen(lines[1].category) == SERVER_LOG_CATEGORY_SIZE - 1 && !strncmp(lines[1].category, "a category", + 10), "a long category was kept as %s", lines[1].category); + /* (server_log's text is formatted, to the same size) */ + server_log(_server_log_info, "net", "%s %d", long_text, 5); + count = server_log_read(3, lines, 8, &next, &missed); + CHECK(count == 1 && strlen(lines[0].text) == SERVER_LOG_TEXT_SIZE - 1, "a long formatted text was kept as %d", + (int)strlen(lines[0].text)); + CHECK(!strcmp(server_log_level_name(_server_log_info), "info") && + !strcmp(server_log_level_name(_server_log_warning), "warning") && + !strcmp(server_log_level_name(_server_log_error), "error"), "a level's name was wrong"); + return 0; + } + /* the listener hears every line, as kept, until it is taken away */ + CASE("listener") + { + server_log_set_listener(listener); + server_log_ring(_server_log_info, "platform", "ring\none"); + CHECK(heard_count == 1 && heard.number == 1 && !strcmp(heard.text, "ring one") && + !strcmp(heard.category, "platform"), "the ring's line was heard as %d %lu %s", heard_count, heard.number, + heard.text); + server_log(_server_log_error, "server", "logged %d", 2); + CHECK(heard_count == 2 && heard.number == 2 && !strcmp(heard.text, "logged 2") && + heard.level == _server_log_error, "a logged line was heard as %d %lu %s", heard_count, heard.number, + heard.text); + server_log_set_listener(NULL); + server_log_ring(_server_log_info, "server", "unheard"); + CHECK(heard_count == 2, "a line was heard after the listener was taken away"); + return 0; + } + /* standard output's lines, as text and as JSON; the ring's alone are not + there */ + CASE("stdout") + { + int ends[2]; + char output[2048]; + int length; + + CHECK(!pipe(ends) && dup2(ends[1], STDOUT_FILENO) >= 0, "standard output could not be read"); + server_log(_server_log_info, "lobby", "%s joined", "Chief"); + server_log(_server_log_warning, "net", "slow"); + server_log_ring(_server_log_info, "platform", "the ring's alone"); + server_log_set_json(1); + server_log(_server_log_error, "chat", "say \"hi\"\tnow"); + server_log_set_json(0); + server_log(_server_log_info, NULL, "back to text"); + close(ends[1]); + close(STDOUT_FILENO); + length = (int)read(ends[0], output, sizeof(output) - 1); + CHECK(length > 0, "nothing was written"); + output[length] = 0; + CHECK(!strcmp(output, + "2001-09-09T01:46:40Z INFO lobby: Chief joined\n" + "2001-09-09T01:46:40Z WARN net: slow\n" + "{\"time\":\"2001-09-09T01:46:40Z\",\"level\":\"error\",\"cat\":\"chat\",\"msg\":\"say \\\"hi\\\" now\"}\n" + "2001-09-09T01:46:40Z INFO server: back to text\n"), "wrote:\n%s", output); + return 0; + } + /* a private line (a secret's) on standard output alone: not in the + ring, not numbered, not heard */ + CASE("private") + { + int ends[2]; + char output[2048]; + int length; + + CHECK(!pipe(ends) && dup2(ends[1], STDOUT_FILENO) >= 0, "standard output could not be read"); + server_log_set_listener(listener); + server_log(_server_log_info, "server", "the password was made"); + server_log_private(_server_log_info, "server", "the password: %s", "hunter22"); + close(ends[1]); + close(STDOUT_FILENO); + length = (int)read(ends[0], output, sizeof(output) - 1); + CHECK(length > 0, "nothing was written"); + output[length] = 0; + CHECK(!strcmp(output, + "2001-09-09T01:46:40Z INFO server: the password was made\n" + "2001-09-09T01:46:40Z INFO server: the password: hunter22\n"), "wrote:\n%s", output); + CHECK(server_log_latest() == 1, "the latest is %lu, not 1: the private line was numbered", server_log_latest()); + count = server_log_read(0, lines, 16, &next, &missed); + CHECK(count == 1 && !strcmp(lines[0].text, "the password was made") && next == 1, + "the ring read %d lines, the first \"%s\"", count, count ? lines[0].text : ""); + CHECK(heard_count == 1 && !strcmp(heard.text, "the password was made"), + "the listener heard %d lines, the last \"%s\"", heard_count, heard.text); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/server_rotation.c b/tools/harness/tests/server_rotation.c new file mode 100644 index 00000000..e1bfbd94 --- /dev/null +++ b/tools/harness/tests/server_rotation.c @@ -0,0 +1,785 @@ +/* +SERVER_ROTATION.C (test) + +A dedicated server's rotation (port/linux/src/server_rotation.c), taken +whole: the entries' grammar (map, gametype, +rule=value), the rules' table +and values, the rotation's separators and limits, its text written back, +and the maps found on disk, with small cache file headers this test writes +in a folder beside it. The platform's paths and folders are stubs over that +folder. test_server_rotation.py takes the code (under_test.inc). +*/ + +#include "harness.h" + +#include +#include +#include + +/* the folder that stands for the data root (d:\), and the settings read */ +static char data_root[512]; +static char custom_maps_setting[512]; +static char custom_edition_root[512]; + +/* d:\ as data_root/, with forward slashes */ +void platform_translate_path(const char *xbox_path, char *host_path, unsigned long host_path_size) +{ + int length = snprintf(host_path, (size_t)host_path_size, "%s/%s", data_root, + !strncmp(xbox_path, "d:\\", 3) ? xbox_path + 3 : xbox_path); + int index; + + for (index = 0; index < length && host_path[index]; index++) + { + if (host_path[index] == '\\') + host_path[index] = '/'; + } +} + +const char *config_string(const char *name) +{ + return !strcmp(name, "paths.custom_maps") ? custom_maps_setting : ""; +} + +const char *platform_custom_edition_root(void) +{ + return custom_edition_root; +} + +void *posix_directory_open(const char *path) +{ + return opendir(path); +} + +int posix_directory_next(void *directory, char *name, unsigned long name_size) +{ + struct dirent *entry; + + while ((entry = readdir(directory))) + { + if (entry->d_name[0] == '.') + continue; + snprintf(name, (size_t)name_size, "%s", entry->d_name); + return 1; + } + return 0; +} + +void posix_directory_close(void *directory) +{ + closedir(directory); +} + +#include "under_test.inc" + +static struct server_entry entry; +static struct server_rotation rotation; +static struct server_rotation again; + +static int parse(char const *text) +{ + return server_rotation_parse_entry(text, (int)strlen(text), &entry); +} + +static int rejected_with(char const *text, char const *reason) +{ + return parse(text) && entry.rejected[0] && strstr(entry.rejected, reason); +} + +static int playable(char const *text) +{ + return parse(text) && !entry.rejected[0]; +} + +static long rule_of(char const *key) +{ + int index; + + for (index = 0; index < entry.rule_count; index++) + { + if (!strcmp(entry.rules[index].key, key)) + return entry.rules[index].value; + } + return NONE; +} + +static int value_of(char const *key, char const *text, long *value, char *reason) +{ + const struct server_rule_definition *rule = server_rule_find(key); + + *value = NONE; + reason[0] = 0; + return rule && server_rule_value(rule, text, value, reason, SERVER_REASON_SIZE); +} + +/* a text of count times the character */ +static char const *repeated(char character, int count) +{ + static char text[8192]; + + memset(text, character, (size_t)count); + text[count] = 0; + return text; +} + +/* ---------- map files */ + +/* a folder of the case beside the executable: the data root */ +static void make_root(char const *executable, char const *case_name) +{ + char const *slash = strrchr(executable, '/'); + + snprintf(data_root, sizeof(data_root), "%.*s/root-%s-%d", slash ? (int)(slash - executable) : 1, + slash ? executable : ".", case_name, (int)getpid()); + mkdir(data_root, 0755); +} + +static void make_folder(char const *relative) +{ + char path[1024]; + + snprintf(path, sizeof(path), "%s/%s", data_root, relative); + mkdir(path, 0755); +} + +/* a cache file's header of size bytes: its signatures, version and +scenario type where server_map_header reads them */ +static void write_map(char const *relative, long version, long type, size_t size, char const *head, char const *foot) +{ + char path[1024]; + unsigned char *bytes = calloc(1, size + 4); + FILE *file; + + snprintf(path, sizeof(path), "%s/%s", data_root, relative); + memcpy(bytes, head, 4); + bytes[4] = (unsigned char)version; + bytes[5] = (unsigned char)(version >> 8); + bytes[6] = (unsigned char)(version >> 16); + bytes[7] = (unsigned char)(version >> 24); + bytes[0x60] = (unsigned char)type; + bytes[0x61] = (unsigned char)(type >> 8); + if (size >= 0x800) + memcpy(bytes + 0x7FC, foot, 4); + file = fopen(path, "wb"); + fwrite(bytes, 1, size, file); + fclose(file); + free(bytes); +} + +static void write_maps(void) +{ + make_folder("maps"); + make_folder("custom_maps"); + write_map("maps/bloodgulch.map", 5, 1, 0x800, "daeh", "toof"); + write_map("maps/sidewinder.map", 5, 1, 0x1000, "daeh", "toof"); + write_map("maps/a10.map", 5, 0, 0x800, "daeh", "toof"); + write_map("maps/ui.map", 5, 2, 0x800, "daeh", "toof"); + write_map("maps/bitmaps.map", 5, 1, 0x800, "daeh", "toof"); + write_map("maps/broken.map", 5, 1, 0x400, "daeh", "toof"); + write_map("maps/pcmap.map", 7, 1, 0x800, "daeh", "toof"); + write_map("custom_maps/hugeass.map", 609, 1, 0x800, "daeh", "toof"); + write_map("custom_maps/c_campaign.map", 609, 0, 0x800, "daeh", "toof"); + write_map("custom_maps/xboxmap.map", 5, 1, 0x800, "daeh", "toof"); +} + +/* server_maps_each's maps, as "name level kind;" in the order told */ +static char maps_told[2048]; + +static void tell_map(void *context, const char *name, const char *level, const char *kind) +{ + (*(int *)context)++; + snprintf(maps_told + strlen(maps_told), sizeof(maps_told) - strlen(maps_told), "%s %s %s;", name, level, kind); +} + +static int resolved(char const *text) +{ + parse(text); + server_rotation_resolve(&entry); + return !entry.rejected[0]; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* a map's name: the Xbox's level of it; slayer when no gametype is given */ + CASE("names") + { + CHECK(playable("bloodgulch"), "a map's name was rejected: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_name, "kind %d", entry.map_kind); + CHECK(!strcmp(entry.map, "bloodgulch") && !strcmp(entry.map_name, "bloodgulch"), "map %s", entry.map); + CHECK(!strcmp(entry.level, "levels\\test\\bloodgulch\\bloodgulch"), "level %s", entry.level); + CHECK(!strcmp(entry.gametype, "slayer") && !entry.rule_count, "gametype %s", entry.gametype); + CHECK(playable("BloodGulch:CTF"), "a name in capitals was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.map_name, "bloodgulch") && !strcmp(entry.gametype, "ctf"), + "read %s:%s, not in lower case", entry.map_name, entry.gametype); + CHECK(!strcmp(entry.text, "BloodGulch:CTF"), "the text kept was %s", entry.text); + CHECK(playable("dangercanyon-2.v1"), "a name of '-' and '.' was rejected: %s", entry.rejected); + CHECK(rejected_with("blood gulch", "is not a map's name"), "a name with a space was taken"); + CHECK(rejected_with(".hidden", "is not a map's name"), "a name beginning with a dot was taken"); + CHECK(rejected_with("blood*gulch", "is not a map's name"), "a name with a '*' was taken"); + CHECK(rejected_with(":ctf", "is not a map's name"), "an entry with no map was taken"); + return 0; + } + /* custom_maps\: a Custom Edition map by its name */ + CASE("custom-maps") + { + CHECK(playable("custom_maps\\hugeass:team_slayer"), "a Custom Edition map was rejected: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_custom, "kind %d", entry.map_kind); + CHECK(!strcmp(entry.map_name, "hugeass") && !strcmp(entry.level, "custom_maps\\hugeass"), + "map %s, level %s", entry.map_name, entry.level); + CHECK(playable("CUSTOM_MAPS/HugeAss"), "custom_maps in capitals with a slash was rejected: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_custom && !strcmp(entry.map, "custom_maps\\HugeAss"), "map %s", entry.map); + CHECK(!strcmp(entry.map_name, "hugeass"), "map name %s", entry.map_name); + CHECK(rejected_with("custom_maps\\", "is not a map's name"), "custom_maps\\ alone was taken"); + CHECK(rejected_with("custom_maps\\a\\b", "is not a map's name"), "a folder in custom_maps was taken"); + CHECK(rejected_with("custom_maps\\..", "is not a map's name"), "custom_maps\\.. was taken"); + CHECK(rejected_with("custom_maps/../maps/x", "is not a map's name"), "a path out of custom_maps was taken"); + return 0; + } + /* a level's path: taken as is, its last part the map's file; nothing + that leaves the maps folder */ + CASE("paths") + { + CHECK(playable("levels\\a10\\a10:coop"), "a level's path was rejected: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_path, "kind %d", entry.map_kind); + CHECK(!strcmp(entry.level, "levels\\a10\\a10") && !strcmp(entry.map_name, "a10"), + "level %s, map %s", entry.level, entry.map_name); + CHECK(!strcmp(entry.gametype, "coop"), "gametype %s", entry.gametype); + CHECK(playable("levels/test/wizard/wizard:ctf"), "a path with slashes was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.level, "levels\\test\\wizard\\wizard"), "slashes kept: %s", entry.level); + CHECK(playable("levels\\test\\v1.2\\map.x"), "dots inside a part were rejected: %s", entry.rejected); + CHECK(rejected_with("levels\\..\\..\\etc\\passwd", "is not a level's path"), "a path with .. was taken"); + CHECK(rejected_with("levels/.../x", "is not a level's path"), "a part of dots only was taken"); + CHECK(rejected_with("levels\\test\\..", "is not a level's path"), "a path ending in .. was taken"); + CHECK(rejected_with("\\levels\\x", "is not a level's path"), "a path from the root was taken"); + CHECK(rejected_with("/etc/passwd", "is not a level's path"), "an absolute path was taken"); + CHECK(rejected_with("levels\\\\x", "is not a level's path"), "an empty part was taken"); + CHECK(rejected_with("levels\\x\\", "is not a level's path"), "a path ending in a backslash was taken"); + CHECK(rejected_with("levels\\a b\\c", "is not a level's path"), "a part with a space was taken"); + return 0; + } + /* the gametype: a name, file=, lowercased and trimmed */ + CASE("gametypes") + { + char text[256]; + + CHECK(playable("bloodgulch: Team_Slayer "), "a gametype with spaces was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.gametype, "team_slayer"), "gametype %s", entry.gametype); + CHECK(playable("bloodgulch:file=My_CTF"), "file= was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.gametype, "file=my_ctf"), "gametype %s", entry.gametype); + CHECK(playable("bloodgulch:btb-slayer.v2"), "a gametype file's name was rejected: %s", entry.rejected); + CHECK(rejected_with("bloodgulch:", "is not a gametype's name"), "an empty gametype was taken"); + CHECK(rejected_with("bloodgulch: ", "is not a gametype's name"), "a gametype of spaces was taken"); + CHECK(rejected_with("bloodgulch:file=", "is not a gametype's name"), "file= with no name was taken"); + CHECK(rejected_with("bloodgulch:file=../x", "is not a gametype's name"), "file=../x was taken"); + CHECK(rejected_with("bloodgulch:file=.x", "is not a gametype's name"), "file=.x was taken"); + CHECK(rejected_with("bloodgulch:ctf:slayer", "is not a gametype's name"), "a second colon was taken"); + CHECK(rejected_with("bloodgulch:team slayer", "is not a gametype's name"), "a gametype with a space was taken"); + CHECK(rejected_with("bloodgulch:x=y", "is not a gametype's name"), "a gametype with '=' was taken"); + snprintf(text, sizeof(text), "bloodgulch:%s", repeated('g', SERVER_GAMETYPE_SIZE - 1)); + CHECK(playable(text), "the longest gametype was rejected: %s", entry.rejected); + CHECK(strlen(entry.gametype) == SERVER_GAMETYPE_SIZE - 1, "kept %d characters", (int)strlen(entry.gametype)); + snprintf(text, sizeof(text), "bloodgulch:%s", repeated('g', SERVER_GAMETYPE_SIZE)); + CHECK(rejected_with(text, "is not a gametype's name"), "a gametype longer than its room was taken"); + return 0; + } + /* +rule=value: by key or alias, in any case, trimmed; the last of a rule + given twice; an unknown rule, one with no value or out of range rejected */ + CASE("rules") + { + CHECK(playable("sidewinder:team_slayer+score=50+time=15"), "two rules were rejected: %s", entry.rejected); + CHECK(entry.rule_count == 2 && rule_of("score_to_win") == 50 && rule_of("time_limit") == 15, + "%d rules, score %ld, time %ld", entry.rule_count, rule_of("score_to_win"), rule_of("time_limit")); + CHECK(playable("bloodgulch:ctf + FF = Off +Weapons=rocket_launchers+teams=yes"), + "rules with spaces and capitals were rejected: %s", entry.rejected); + CHECK(rule_of("friendly_fire") == 1 && rule_of("weapon_set") == 6 && rule_of("teams") == 1, + "ff %ld, weapons %ld, teams %ld", rule_of("friendly_fire"), rule_of("weapon_set"), rule_of("teams")); + CHECK(!strcmp(entry.rules[0].key, "friendly_fire"), "an alias was kept as the key: %s", entry.rules[0].key); + CHECK(playable("bloodgulch:slayer+score=5+lives=3+score_to_win=7"), "a rule given twice was rejected: %s", + entry.rejected); + CHECK(entry.rule_count == 2 && rule_of("score_to_win") == 7 && rule_of("lives") == 3, + "%d rules, score %ld: not the last given", entry.rule_count, rule_of("score_to_win")); + CHECK(playable("bloodgulch:oddball+ball_spawn_count=16+speed_with_ball=0"), "an engine's rules were rejected: %s", + entry.rejected); + CHECK(rejected_with("bloodgulch:ctf+score", "the rule \"score\" has no value"), "a rule with no value was taken"); + CHECK(rejected_with("bloodgulch:ctf+", "has no value"), "an empty rule was taken"); + CHECK(rejected_with("bloodgulch:ctf+=5", "no rule is named \"\""), "a rule with no key was taken"); + CHECK(rejected_with("bloodgulch:ctf+bogus=1", "no rule is named \"bogus\""), "an unknown rule was taken"); + CHECK(rejected_with("bloodgulch:ctf+score_to=1", "no rule is named"), "the start of a key was taken"); + CHECK(rejected_with("bloodgulch:ctf+score=1000", "score_to_win takes 0 to 999, not 1000"), + "a score out of range was taken: %s", entry.rejected); + CHECK(rejected_with("bloodgulch:ctf+score=", "score_to_win takes a whole number, not \"\""), + "an empty value was taken: %s", entry.rejected); + CHECK(rejected_with("bloodgulch:ctf+score=5=6", "takes a whole number"), "a value with '=' was taken"); + CHECK(rejected_with("bloodgulch:ctf+score=5+health=10", "health takes 25 to 400, not 10"), + "a second rule out of range was taken: %s", entry.rejected); + CHECK(rejected_with("bloodgulch+score=5", "is not a map's name"), "rules with no gametype were taken"); + return 0; + } + /* spaces around an entry, an empty entry and a comment say nothing; a + character that is not printable ASCII inside one rejects it */ + CASE("whitespace-comments") + { + CHECK(!parse(""), "nothing said something"); + CHECK(!parse(" \t\r\n "), "spaces said something"); + CHECK(!parse("# prisoner:slayer"), "a comment said something"); + CHECK(!parse(" #prisoner"), "a comment after spaces said something"); + CHECK(playable("\t bloodgulch:ctf \r\n"), "an entry in spaces was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.text, "bloodgulch:ctf"), "the text kept its spaces: \"%s\"", entry.text); + CHECK(rejected_with("bloodgulch:\tctf", "not printable ASCII"), "a tab inside an entry was taken"); + CHECK(rejected_with("bl\xc3\xb6odgulch", "not printable ASCII"), "a character past ASCII was taken"); + CHECK(rejected_with("blood\x7fgulch", "not printable ASCII"), "a delete character was taken"); + CHECK(parse("bloodgulch#ctf") && entry.rejected[0], "a '#' inside a name was taken"); + /* (only the length given is read) */ + CHECK(server_rotation_parse_entry("bloodgulch:ctf", 10, &entry) && !strcmp(entry.text, "bloodgulch") && + !strcmp(entry.gametype, "slayer"), "read past the length: %s", entry.text); + return 0; + } + /* the rotation has no quoting: a quote is no map's or gametype's */ + CASE("quotes") + { + CHECK(rejected_with("\"bloodgulch\"", "is not a map's name"), "a quoted map was taken"); + CHECK(rejected_with("'bloodgulch'", "is not a map's name"), "a map in single quotes was taken"); + CHECK(rejected_with("bloodgulch:\"ctf\"", "is not a gametype's name"), "a quoted gametype was taken"); + CHECK(rejected_with("bloodgulch:ctf+weapons=\"plasma\"", "weapon_set takes"), "a quoted value was taken"); + server_rotation_parse("\"bloodgulch:ctf, sidewinder:slayer\"", &rotation); + CHECK(rotation.count == 2 && !server_rotation_playable(&rotation), + "a quoted rotation's commas were not separators: %d entries", rotation.count); + return 0; + } + /* an entry or a map longer than its room is rejected, its text cut */ + CASE("long") + { + char text[600]; + + CHECK(rejected_with(repeated('a', SERVER_ENTRY_TEXT_SIZE), "longer than 255 characters"), + "a 256 character entry was taken: %s", entry.rejected); + CHECK(strlen(entry.text) == SERVER_ENTRY_TEXT_SIZE - 1, "its text is %d long", (int)strlen(entry.text)); + CHECK(!strcmp(entry.text + SERVER_ENTRY_TEXT_SIZE - 4, "..."), "its text does not end in ...: %s", + entry.text + SERVER_ENTRY_TEXT_SIZE - 8); + CHECK(rejected_with(repeated('a', 500), "longer than"), "a 500 character entry was taken"); + CHECK(rejected_with(repeated('a', SERVER_ENTRY_TEXT_SIZE - 1), "the map's name is too long"), + "a 255 character name was taken: %s", entry.rejected); + CHECK(rejected_with(repeated('a', SERVER_MAP_SIZE), "the map's name is too long"), + "a %d character name was taken: %s", SERVER_MAP_SIZE, entry.rejected); + snprintf(text, sizeof(text), "custom_maps\\%s", repeated('a', SERVER_MAP_SIZE - 12)); + CHECK(rejected_with(text, "the map's name is too long"), "a %d character custom map was taken: %s", + (int)strlen(text), entry.rejected); + snprintf(text, sizeof(text), "custom_maps\\%s", repeated('a', SERVER_MAP_SIZE - 13)); + CHECK(playable(text), "the longest custom map was rejected: %s", entry.rejected); + CHECK(!strcmp(entry.level, text), "level %s", entry.level); + /* (spaces around an entry do not count) */ + snprintf(text, sizeof(text), "%sbloodgulch%s", repeated(' ', 300), ""); + snprintf(text + strlen(text), sizeof(text) - strlen(text), "%s", repeated(' ', 200)); + CHECK(playable(text), "an entry in many spaces was rejected: %s", entry.rejected); + return 0; + } + /* a map's name whose Xbox level is longer than the level's room is + rejected, not played as a cut level */ + CASE("long-map-level") + { + char const *name = repeated('m', 60); + + CHECK(parse(name), "a 60 character name said nothing"); + CHECK(entry.rejected[0] || strlen(entry.level) == 13 + 2 * strlen(name), + "its level was cut to \"%s\" (%d characters) and the entry taken", entry.level, (int)strlen(entry.level)); + return 0; + } + /* ui is the main menu's, however it is given */ + CASE("ui") + { + CHECK(rejected_with("ui", "main menu"), "ui was taken"); + CHECK(rejected_with("UI:ctf", "main menu"), "UI was taken"); + CHECK(rejected_with("levels\\ui\\ui", "main menu"), "ui's level was taken"); + CHECK(rejected_with("custom_maps\\ui", "main menu"), "custom_maps\\ui was taken"); + CHECK(playable("uil"), "a name beginning with ui was rejected: %s", entry.rejected); + return 0; + } + /* entries are separated by commas, newlines and semicolons; empty ones + and comments are left out, rejected ones kept */ + CASE("rotation-separators") + { + server_rotation_parse("bloodgulch:ctf, sidewinder:team_slayer+score=50\nprisoner;wizard:king\r\n", &rotation); + CHECK(rotation.count == 4 && !rotation.truncated, "%d entries", rotation.count); + CHECK(!strcmp(rotation.entries[0].map_name, "bloodgulch") && !strcmp(rotation.entries[1].map_name, "sidewinder") && + !strcmp(rotation.entries[2].map_name, "prisoner") && !strcmp(rotation.entries[3].map_name, "wizard"), + "the entries are not in order"); + CHECK(!strcmp(rotation.entries[1].text, "sidewinder:team_slayer+score=50"), "text %s", rotation.entries[1].text); + CHECK(server_rotation_playable(&rotation) == 4, "%d playable", server_rotation_playable(&rotation)); + server_rotation_parse(",,, ,\n\n;", &rotation); + CHECK(rotation.count == 0 && !rotation.truncated, "separators alone made %d entries", rotation.count); + server_rotation_parse("", &rotation); + CHECK(rotation.count == 0 && !server_rotation_playable(&rotation), "nothing made %d entries", rotation.count); + server_rotation_parse("# the rotation\nbloodgulch\n # prisoner\nwizard", &rotation); + CHECK(rotation.count == 2, "comments made %d entries", rotation.count); + server_rotation_parse("bloodgulch, nowhere:bogus+x, ui, prisoner", &rotation); + CHECK(rotation.count == 4, "rejected entries were left out: %d entries", rotation.count); + CHECK(server_rotation_playable(&rotation) == 2, "%d playable", server_rotation_playable(&rotation)); + CHECK(rotation.entries[1].rejected[0] && rotation.entries[2].rejected[0] && !rotation.entries[3].rejected[0], + "the wrong entries were rejected"); + return 0; + } + /* 64 entries and 4096 characters at most: the rest left out, and said */ + CASE("rotation-limits") + { + char text[8192]; + int index; + + text[0] = 0; + for (index = 0; index < SERVER_ROTATION_MAXIMUM_ENTRIES; index++) + snprintf(text + strlen(text), sizeof(text) - strlen(text), "%smap%d", index ? "," : "", index); + server_rotation_parse(text, &rotation); + CHECK(rotation.count == SERVER_ROTATION_MAXIMUM_ENTRIES && !rotation.truncated, "%d entries, truncated %d", + rotation.count, rotation.truncated); + snprintf(text + strlen(text), sizeof(text) - strlen(text), ", ,\n# the end\n"); + server_rotation_parse(text, &rotation); + CHECK(rotation.count == SERVER_ROTATION_MAXIMUM_ENTRIES && !rotation.truncated, + "empty entries and comments after the last counted as more (truncated %d)", rotation.truncated); + snprintf(text + strlen(text), sizeof(text) - strlen(text), ",extra"); + server_rotation_parse(text, &rotation); + CHECK(rotation.count == SERVER_ROTATION_MAXIMUM_ENTRIES && rotation.truncated, + "a 65th entry: %d entries, truncated %d", rotation.count, rotation.truncated); + CHECK(!strcmp(rotation.entries[SERVER_ROTATION_MAXIMUM_ENTRIES - 1].map_name, "map63"), "the last entry is %s", + rotation.entries[SERVER_ROTATION_MAXIMUM_ENTRIES - 1].map_name); + /* (4096 characters of entries, then more) */ + snprintf(text, sizeof(text), "bloodgulch,%s", repeated(' ', SERVER_ROTATION_MAXIMUM_TEXT - 11)); + server_rotation_parse(text, &rotation); + CHECK(rotation.count == 1 && !rotation.truncated, "4096 characters were too many"); + snprintf(text, sizeof(text), "bloodgulch,%s,prisoner", repeated(' ', SERVER_ROTATION_MAXIMUM_TEXT)); + server_rotation_parse(text, &rotation); + CHECK(rotation.count == 1 && rotation.truncated, "text past 4096 characters: %d entries, truncated %d", + rotation.count, rotation.truncated); + return 0; + } + /* an entry that runs past the 4096th character is left out, not read cut */ + CASE("rotation-cut-entry") + { + char text[8192]; + + snprintf(text, sizeof(text), "%s,bloodgulch:ctf+score=50", repeated(' ', SERVER_ROTATION_MAXIMUM_TEXT - 23)); + CHECK(strlen(text) == SERVER_ROTATION_MAXIMUM_TEXT + 1, "the text is %d long", (int)strlen(text)); + server_rotation_parse(text, &rotation); + CHECK(rotation.truncated, "the text was not cut"); + CHECK(rotation.count == 0 || rotation.entries[0].rejected[0], + "the entry cut at the 4096th character was taken as \"%s\" (score %ld)", rotation.entries[0].text, + rotation.entries[0].rules[0].value); + return 0; + } + /* the rotation written as text reads back the same */ + CASE("round-trip") + { + char text[SERVER_ROTATION_MAXIMUM_TEXT * 2]; + char second[SERVER_ROTATION_MAXIMUM_TEXT * 2]; + char source[2048]; + int index; + + snprintf(source, sizeof(source), + "bloodgulch:ctf\n sidewinder : team_slayer + score = 50 + time=15 ;custom_maps/hugeass:file=mine," + "levels\\a10\\a10:coop, # a comment\n nowhere:bogus+x, ui,%s, prisoner:oddball+ball_spawn_count=3", + repeated('z', 300)); + server_rotation_parse(source, &rotation); + server_rotation_text(&rotation, text, sizeof(text)); + server_rotation_parse(text, &again); + CHECK(again.count == rotation.count && rotation.count == 8, "%d entries, then %d", rotation.count, again.count); + for (index = 0; index < rotation.count; index++) + { + struct server_entry const *first = &rotation.entries[index]; + struct server_entry const *read_again = &again.entries[index]; + + CHECK(!strcmp(first->text, read_again->text), "entry %d: \"%s\", then \"%s\"", index, first->text, + read_again->text); + CHECK(!first->rejected[0] == !read_again->rejected[0], "entry %d: rejected \"%s\", then \"%s\"", index, + first->rejected, read_again->rejected); + if (first->rejected[0]) + continue; + CHECK(!strcmp(first->map, read_again->map) && !strcmp(first->level, read_again->level) && + !strcmp(first->gametype, read_again->gametype) && first->map_kind == read_again->map_kind, + "entry %d read back otherwise", index); + CHECK(first->rule_count == read_again->rule_count && + !memcmp(first->rules, read_again->rules, sizeof(first->rules[0]) * (size_t)first->rule_count), + "entry %d's rules read back otherwise", index); + } + server_rotation_text(&again, second, sizeof(second)); + CHECK(!strcmp(text, second), "the text changed when written again:\n%s\n%s", text, second); + CHECK(!strncmp(text, "bloodgulch:ctf, sidewinder : team_slayer + score = 50 + time=15, custom_maps/hugeass:", + strlen("bloodgulch:ctf, sidewinder : team_slayer + score = 50 + time=15, custom_maps/hugeass:")), + "written as %s", text); + /* (a small room: cut, ended, nothing past it) */ + memset(second, '#', 64); + server_rotation_text(&rotation, second, 20); + CHECK(strlen(second) == 19 && second[20] == '#', "a 20 character room held %d, the next '%c'", + (int)strlen(second), second[20]); + server_rotation_text(&rotation, second, 1); + CHECK(!second[0], "a room of one held something"); + return 0; + } + /* every rule by its key and alias, in any case, and nothing else */ + CASE("rule-find") + { + static char const *const aliases[][2] = + { + { "score", "score_to_win" }, { "respawn", "respawn_time" }, { "weapons", "weapon_set" }, + { "vehicles", "vehicle_set" }, { "time", "time_limit" }, { "ff", "friendly_fire" }, + { "balance", "auto_team_balance" }, + }; + static char const *const tables[] = { "universal", "options", "ctf", "slayer", "king", "oddball", "race" }; + int index, other; + + CHECK(server_rule_definition_count() > 40, "%d rules", server_rule_definition_count()); + for (index = 0; index < server_rule_definition_count(); index++) + { + const struct server_rule_definition *rule = &server_rule_definitions[index]; + char upper[64]; + int table, letter; + + CHECK(server_rule_find(rule->key) == rule, "%s found another rule", rule->key); + for (letter = 0; rule->key[letter]; letter++) + upper[letter] = (char)toupper((unsigned char)rule->key[letter]); + upper[letter] = 0; + CHECK(server_rule_find(upper) == rule, "%s not found in capitals", upper); + CHECK(!rule->alias || server_rule_find(rule->alias) == rule, "%s's alias found another rule", rule->key); + CHECK(strlen(rule->key) < SERVER_RULE_KEY_SIZE, "%s is longer than a rule's key", rule->key); + for (table = 0; table < (int)(sizeof(tables) / sizeof(tables[0])) && strcmp(rule->table, tables[table]); + table++) + { + } + CHECK(table < (int)(sizeof(tables) / sizeof(tables[0])), "%s is in the table %s", rule->key, rule->table); + CHECK(rule->engines && !(rule->engines & ~_server_engine_all), "%s's engines are %x", rule->key, rule->engines); + CHECK(rule->minimum <= rule->maximum, "%s's range is %ld to %ld", rule->key, rule->minimum, rule->maximum); + CHECK(rule->type != _server_rule_integer || !rule->choices, "%s has words", rule->key); + CHECK(rule->type == _server_rule_integer || (rule->choices && rule->choices[0] && rule->choices[1]), + "%s has no words", rule->key); + CHECK(rule->description && rule->description[0], "%s has no description", rule->key); + for (other = 0; other < index; other++) + { + const struct server_rule_definition *before = &server_rule_definitions[other]; + + CHECK(strcmp(before->key, rule->key) && (!rule->alias || strcmp(before->key, rule->alias)) && + (!before->alias || strcmp(before->alias, rule->key)), + "%s and %s share a name", before->key, rule->key); + } + } + for (index = 0; index < (int)(sizeof(aliases) / sizeof(aliases[0])); index++) + { + const struct server_rule_definition *rule = server_rule_find(aliases[index][0]); + + CHECK(rule && !strcmp(rule->key, aliases[index][1]), "%s found %s", aliases[index][0], rule ? rule->key : "none"); + } + CHECK(server_rule_find("FF") == server_rule_find("friendly_fire"), "FF did not find friendly_fire"); + CHECK(!server_rule_find(""), "an empty key found a rule"); + CHECK(!server_rule_find("scor"), "the start of an alias found a rule"); + CHECK(!server_rule_find("score_to_win_"), "a key with more after it found a rule"); + CHECK(!server_rule_find("bogus"), "an unknown key found a rule"); + CHECK(!strcmp(server_rule_find("assault")->table, "ctf") && server_rule_find("assault")->engines == _server_engine_ctf, + "assault is not ctf's"); + CHECK(!strcmp(server_rule_find("moving_hill")->table, "king"), "moving_hill is not king's"); + return 0; + } + /* numbers in range, booleans' words, choices' words and indices; why not */ + CASE("rule-values") + { + char reason[SERVER_REASON_SIZE]; + long value; + + CHECK(value_of("score", "0", &value, reason) && value == 0, "0: %s", reason); + CHECK(value_of("score", "999", &value, reason) && value == 999, "999: %s", reason); + CHECK(value_of("score", "050", &value, reason) && value == 50, "050: %ld", value); + CHECK(!value_of("score", "1000", &value, reason) && !strcmp(reason, "score_to_win takes 0 to 999, not 1000"), + "1000: %s", reason); + CHECK(value == NONE, "a rejected value was written: %ld", value); + CHECK(!value_of("score", "-1", &value, reason) && strstr(reason, "not -1"), "-1: %s", reason); + CHECK(!value_of("health", "24", &value, reason) && value_of("health", "25", &value, reason) && + value_of("health", "400", &value, reason) && !value_of("health", "401", &value, reason), + "health's range is not 25 to 400"); + CHECK(!value_of("score", "+5", &value, reason) && strstr(reason, "takes a whole number, not \"+5\""), "+5: %s", + reason); + CHECK(!value_of("score", "5x", &value, reason), "5x was taken"); + CHECK(!value_of("score", "1e3", &value, reason), "1e3 was taken"); + CHECK(!value_of("score", "", &value, reason), "nothing was taken"); + CHECK(!value_of("score", "-", &value, reason), "- was taken"); + CHECK(!value_of("score", "1234567890", &value, reason), "ten digits were taken"); + CHECK(!value_of("score", "99999999999999999999", &value, reason), "a number past a long was taken"); + CHECK(!value_of("score", "five", &value, reason), "a word was taken for a number"); + CHECK(value_of("ball_spawn_count", "1", &value, reason) && !value_of("ball_spawn_count", "0", &value, reason), + "ball_spawn_count's range is not from 1"); + /* (booleans) */ + CHECK(value_of("teams", "true", &value, reason) && value == 1, "true: %ld", value); + CHECK(value_of("teams", "TRUE", &value, reason) && value == 1, "TRUE: %ld", value); + CHECK(value_of("teams", "Yes", &value, reason) && value == 1, "Yes: %ld", value); + CHECK(value_of("teams", "on", &value, reason) && value == 1, "on: %ld", value); + CHECK(value_of("teams", "1", &value, reason) && value == 1, "1: %ld", value); + CHECK(value_of("teams", "false", &value, reason) && value == 0, "false: %ld", value); + CHECK(value_of("teams", "NO", &value, reason) && value == 0, "NO: %ld", value); + CHECK(value_of("teams", "Off", &value, reason) && value == 0, "Off: %ld", value); + CHECK(value_of("teams", "0", &value, reason) && value == 0, "0: %ld", value); + CHECK(!value_of("teams", "2", &value, reason) && !strcmp(reason, "teams takes true or false, not \"2\""), + "2: %s", reason); + CHECK(!value_of("teams", "y", &value, reason), "y was taken"); + CHECK(!value_of("teams", "", &value, reason), "nothing was taken"); + CHECK(!value_of("teams", "truer", &value, reason), "truer was taken"); + CHECK(!value_of("teams", "01", &value, reason), "01 was taken"); + /* (choices) */ + CHECK(value_of("ff", "on", &value, reason) && value == 0, "on: %ld", value); + CHECK(value_of("ff", "OFF", &value, reason) && value == 1, "OFF: %ld", value); + CHECK(value_of("ff", "explosives_only", &value, reason) && value == 3, "explosives_only: %ld", value); + CHECK(value_of("ff", "3", &value, reason) && value == 3, "3: %ld", value); + CHECK(!value_of("ff", "4", &value, reason), "an index past the words was taken"); + CHECK(!value_of("ff", "-1", &value, reason), "-1 was taken"); + CHECK(!value_of("ff", "", &value, reason), "nothing was taken"); + CHECK(!value_of("ff", "of", &value, reason), "the start of a word was taken"); + CHECK(!value_of("ff", "bogus", &value, reason) && + !strcmp(reason, "friendly_fire takes on, off, shields_only or explosives_only, not \"bogus\""), "%s", reason); + CHECK(value_of("weapons", "heavy", &value, reason) && value == 13, "heavy: %ld", value); + CHECK(value_of("vehicles", "scorpion", &value, reason) && value == 4, "scorpion: %ld", value); + CHECK(value_of("goal_radar", "nav_point", &value, reason) && value == 1, "nav_point: %ld", value); + CHECK(value_of("primary_weapon", "needler", &value, reason) && value == 9, "needler: %ld", value); + /* (a long reason, cut to its room) */ + CHECK(!value_of("weapons", "bogus", &value, reason), "bogus was taken"); + CHECK(strlen(reason) < SERVER_REASON_SIZE && !strncmp(reason, "weapon_set takes normal, pistols, ", 34), + "%s", reason); + { + char small[40]; + const struct server_rule_definition *rule = server_rule_find("weapons"); + + memset(small, '#', sizeof(small)); + CHECK(!server_rule_value(rule, "bogus", &value, small, 24), "bogus was taken"); + CHECK(strlen(small) == 23 && small[24] == '#', "a 24 character room held %d, the next '%c'", + (int)strlen(small), small[24]); + } + return 0; + } + /* a campaign level only as co-op, a multiplayer map never so, the main + menu's not at all */ + CASE("check-kind") + { + CHECK(parse("bloodgulch:ctf"), "nothing read"); + server_rotation_check_kind(&entry, 1, 5); + CHECK(!entry.rejected[0], "a multiplayer map was rejected: %s", entry.rejected); + server_rotation_check_kind(&entry, 0, 5); + CHECK(strstr(entry.rejected, "bloodgulch is a campaign level (as co-op: bloodgulch:coop)"), "%s", entry.rejected); + parse("levels\\a10\\a10:coop"); + server_rotation_check_kind(&entry, 0, 5); + CHECK(!entry.rejected[0], "a campaign level as co-op was rejected: %s", entry.rejected); + server_rotation_check_kind(&entry, 1, 5); + CHECK(strstr(entry.rejected, "is not a campaign level"), "a multiplayer map as co-op: %s", entry.rejected); + parse("menu"); + server_rotation_check_kind(&entry, 2, 5); + CHECK(strstr(entry.rejected, "main menu"), "the main menu's map: %s", entry.rejected); + return 0; + } + /* a cache file's header: both signatures, its version and scenario type */ + CASE("map-header") + { + char path[1024]; + long version, type; + + make_root(argv[0], case_name); + write_map("xbox.map", 5, 1, 0x800, "daeh", "toof"); + write_map("ce.map", 609, 0, 0x2000, "daeh", "toof"); + write_map("negative.map", 0x12345678, 0xFFFF, 0x800, "daeh", "toof"); + write_map("short.map", 5, 1, 0x7FF, "daeh", "toof"); + write_map("head.map", 5, 1, 0x800, "head", "toof"); + write_map("foot.map", 5, 1, 0x800, "daeh", "foot"); + snprintf(path, sizeof(path), "%s/xbox.map", data_root); + CHECK(server_map_header(path, &version, &type) && version == 5 && type == 1, "version %ld, type %ld", version, + type); + snprintf(path, sizeof(path), "%s/ce.map", data_root); + CHECK(server_map_header(path, &version, &type) && version == 609 && type == 0, "version %ld, type %ld", + version, type); + snprintf(path, sizeof(path), "%s/negative.map", data_root); + CHECK(server_map_header(path, &version, &type) && version == 0x12345678 && type == -1, + "version %lx, type %ld", version, type); + snprintf(path, sizeof(path), "%s/short.map", data_root); + CHECK(!server_map_header(path, &version, &type) && version == 0 && type == -1, "a short file was read"); + snprintf(path, sizeof(path), "%s/head.map", data_root); + CHECK(!server_map_header(path, &version, &type), "a file with 'head' backwards was read"); + snprintf(path, sizeof(path), "%s/foot.map", data_root); + CHECK(!server_map_header(path, &version, &type), "a file with 'foot' backwards was read"); + snprintf(path, sizeof(path), "%s/none.map", data_root); + CHECK(!server_map_header(path, &version, &type) && version == 0 && type == -1, "a missing file was read"); + CHECK(!server_map_header(data_root, &version, &type), "a folder was read"); + return 0; + } + /* an entry's map found in maps, custom_maps or Custom Edition's folder */ + CASE("resolve") + { + char path[1024]; + + make_root(argv[0], case_name); + write_maps(); + CHECK(resolved("bloodgulch:ctf"), "bloodgulch: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_name && !strcmp(entry.level, "levels\\test\\bloodgulch\\bloodgulch"), + "level %s", entry.level); + CHECK(resolved("BloodGulch"), "BloodGulch: %s", entry.rejected); + CHECK(resolved("hugeass:team_slayer"), "a Custom Edition map by its name: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_custom && !strcmp(entry.level, "custom_maps\\hugeass"), + "kind %d, level %s", entry.map_kind, entry.level); + CHECK(resolved("custom_maps\\hugeass"), "custom_maps\\hugeass: %s", entry.rejected); + CHECK(!resolved("nowhere") && entry.map_missing && strstr(entry.rejected, "there is no map nowhere"), + "a missing map: %s", entry.rejected); + CHECK(!resolved("custom_maps\\bloodgulch") && entry.map_missing && + strstr(entry.rejected, "no Custom Edition map bloodgulch"), "an Xbox map as Custom Edition's: %s", + entry.rejected); + CHECK(!resolved("levels\\test\\nowhere\\nowhere") && entry.map_missing, "a missing level: %s", entry.rejected); + CHECK(!resolved("broken") && entry.map_missing, "a file too short for a header: %s", entry.rejected); + CHECK(!resolved("a10") && !entry.map_missing && strstr(entry.rejected, "campaign level"), "a10: %s", + entry.rejected); + CHECK(resolved("a10:coop"), "a10 as co-op: %s", entry.rejected); + CHECK(resolved("levels\\a10\\a10:coop"), "a10's level as co-op: %s", entry.rejected); + CHECK(!resolved("custom_maps\\c_campaign:slayer") && strstr(entry.rejected, "campaign level"), + "a Custom Edition campaign level: %s", entry.rejected); + CHECK(!resolved("bloodgulch:coop") && strstr(entry.rejected, "not a campaign level"), "bloodgulch:coop: %s", + entry.rejected); + /* (an entry already rejected stays as it is) */ + CHECK(!resolved("nowhere+x") && !entry.map_missing && strstr(entry.rejected, "is not a map's name"), + "a rejected entry was resolved: %s", entry.rejected); + /* (paths.custom_maps, then Custom Edition's own maps folder) */ + make_folder("elsewhere"); + write_map("elsewhere/other.map", 609, 1, 0x800, "daeh", "toof"); + snprintf(custom_maps_setting, sizeof(custom_maps_setting), "%s/elsewhere", data_root); + CHECK(resolved("custom_maps\\other"), "a map in paths.custom_maps: %s", entry.rejected); + CHECK(!resolved("custom_maps\\hugeass") && entry.map_missing, "custom_maps was searched past the setting"); + custom_maps_setting[0] = 0; + make_folder("ce"); + make_folder("ce/maps"); + write_map("ce/maps/ceonly.map", 609, 1, 0x800, "daeh", "toof"); + CHECK(!resolved("ceonly"), "a map in no folder was found"); + snprintf(custom_edition_root, sizeof(custom_edition_root), "%s/ce", data_root); + CHECK(resolved("ceonly"), "a map in Custom Edition's folder: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_custom && !strcmp(entry.level, "custom_maps\\ceonly"), "level %s", + entry.level); + CHECK(resolved("custom_maps\\ceonly"), "custom_maps\\ceonly: %s", entry.rejected); + snprintf(path, sizeof(path), "%s/maps/bloodgulch.map", data_root); + CHECK(!access(path, R_OK), "the maps were not written"); + return 0; + } + /* a map file of another game's version is not played as the Xbox's or + Custom Edition's (server_maps_each does not list it) */ + CASE("resolve-version") + { + make_root(argv[0], case_name); + write_maps(); + CHECK(!resolved("pcmap"), "a Halo PC map (version 7) in maps was taken"); + CHECK(!resolved("custom_maps\\xboxmap"), "an Xbox map (version 5) in custom_maps was taken"); + return 0; + } + /* the multiplayer maps there are: the Xbox's of version 5, Custom + Edition's of 609, not the resource maps', the main menu's or a campaign's */ + CASE("maps-each") + { + int count = 0, told = 0; + + make_root(argv[0], case_name); + write_maps(); + count = server_maps_each(tell_map, &told); + CHECK(count == 3 && told == 3, "%d maps (%d told): %s", count, told, maps_told); + CHECK(strstr(maps_told, "bloodgulch levels\\test\\bloodgulch\\bloodgulch xbox;"), "%s", maps_told); + CHECK(strstr(maps_told, "sidewinder levels\\test\\sidewinder\\sidewinder xbox;"), "%s", maps_told); + CHECK(strstr(maps_told, "hugeass custom_maps\\hugeass custom;"), "%s", maps_told); + snprintf(data_root, sizeof(data_root), "/nonexistent-halo-harness"); + maps_told[0] = 0; + CHECK(!server_maps_each(tell_map, &told) && !maps_told[0], "maps were found in no folder"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/server_toml.c b/tools/harness/tests/server_toml.c new file mode 100644 index 00000000..1a47af88 --- /dev/null +++ b/tools/harness/tests/server_toml.c @@ -0,0 +1,359 @@ +/* +SERVER_TOML.C (test) + +A dedicated server's gametype files (port/linux/src/server_toml.c): the +TOML read by the vendored tomlc17 (taken whole, toml.inc) into a base, a +name and the rules of server_rotation.c's table (taken whole with no file +access, rotation.inc), each checked to be in its table and range, with the +reason and line of what is refused; and the names a file may have. +test_server_toml.py takes the code (under_test.inc). +*/ + +#include "harness.h" + +#include + +/* (tomlc17 is C11's; its warnings are its own) */ +#ifndef static_assert +#define static_assert _Static_assert +#endif +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Weverything" +#include "toml.inc" +#pragma clang diagnostic pop + +#define SERVER_ROTATION_NO_FILES +#include "rotation.inc" + +#include "server_toml.h" + +#include "under_test.inc" + +static struct server_gametype_file file; + +static int parse(char const *text) +{ + return server_gametype_file_parse(text, (int)strlen(text), &file); +} + +/* refused, with a reason that says this, on that line (0: any) */ +static int refused(char const *text, char const *reason, int line) +{ + return !parse(text) && file.error[0] && strstr(file.error, reason) && (!line || file.error_line == line); +} + +static long rule_of(char const *key) +{ + int index; + + for (index = 0; index < file.rule_count; index++) + { + if (!strcmp(file.rules[index].key, key)) + return file.rules[index].value; + } + return NONE; +} + +/* a text of count times the character */ +static char const *repeated(char character, int count) +{ + static char text[256]; + + memset(text, character, (size_t)count); + text[count] = 0; + return text; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* server_toml.h's example */ + CASE("example") + { + CHECK(parse( + "base = \"team_slayer\" # a built-in or OpenCE gametype: its rules are the defaults\n" + "name = \"BTB Slayer\" # 11 characters, shown in the lobby and the server browser\n" + "[universal] # any game engine's rules\n" + "score_to_win = 50\n" + "[options] # the PC version's options\n" + "time_limit = 15\n" + "friendly_fire = \"off\"\n" + "[slayer] # the base's game engine's: ctf, slayer, king, oddball, race\n" + "no_death_bonus = true\n"), "the example was refused: %s (line %d)", file.error, file.error_line); + CHECK(!strcmp(file.base, "team_slayer") && !strcmp(file.name, "BTB Slayer"), "base %s, name %s", file.base, + file.name); + CHECK(!strcmp(file.engine_table, "slayer"), "engine table %s", file.engine_table); + CHECK(file.rule_count == 4, "%d rules", file.rule_count); + CHECK(rule_of("score_to_win") == 50 && rule_of("time_limit") == 15 && rule_of("friendly_fire") == 1 && + rule_of("no_death_bonus") == 1, "score %ld, time %ld, ff %ld, bonus %ld", rule_of("score_to_win"), + rule_of("time_limit"), rule_of("friendly_fire"), rule_of("no_death_bonus")); + CHECK(!file.error[0] && !file.error_line, "an error was kept: %s", file.error); + return 0; + } + /* base and name: a base is needed, lowercased; a name of 11 printable + ASCII characters at most */ + CASE("base-name") + { + CHECK(parse("base = \"CTF\""), "a base alone was refused: %s", file.error); + CHECK(!strcmp(file.base, "ctf") && !file.name[0] && !file.engine_table[0] && !file.rule_count, + "base %s, name %s", file.base, file.name); + CHECK(parse("base = 'ctf'\nname = 'Literal'"), "literal strings were refused: %s", file.error); + CHECK(!strcmp(file.name, "Literal"), "name %s", file.name); + CHECK(refused("name = \"No base\"", "there is no base", 1), "a file with no base was taken: %s", file.error); + CHECK(refused("", "there is no base", 1), "an empty file was taken"); + CHECK(refused("[universal]\nlives = 3\n", "there is no base", 1), "rules with no base were taken"); + CHECK(parse("base = \"slayer\"\nname = \"12345678901\""), "an 11 character name was refused: %s", file.error); + CHECK(!strcmp(file.name, "12345678901"), "name %s", file.name); + CHECK(refused("base = \"slayer\"\nname = \"123456789012\"", "name is longer than 11 characters", 2), + "a 12 character name was taken: %s (line %d)", file.error, file.error_line); + CHECK(parse("base = \"slayer\"\nname = \"A\\\"B\\\\C\""), "escapes were refused: %s", file.error); + CHECK(!strcmp(file.name, "A\"B\\C"), "name %s", file.name); + CHECK(parse("base = \"slayer\"\nname = \"\\u0041\\u0062\""), "\\u escapes were refused: %s", file.error); + CHECK(!strcmp(file.name, "Ab"), "name %s", file.name); + CHECK(refused("base = \"slayer\"\nname = \"Tab\\there\"", "not printable ASCII", 2), "a tab was taken: %s", + file.error); + CHECK(refused("base = \"slayer\"\nname = \"caf\\u00e9\"", "not printable ASCII", 2), "an accent was taken: %s", + file.error); + CHECK(refused("base = \"slayer\"\nname = \"x\\u007f\"", "not printable ASCII", 2), "a delete was taken"); + CHECK(refused("base = 5", "base is a string", 1), "a base that is a number was taken: %s", file.error); + CHECK(refused("base = \"slayer\"\nname = true", "name is a string", 2), "a name that is true was taken"); + { + char text[256]; + + snprintf(text, sizeof(text), "base = \"%s\"", repeated('b', SERVER_GAMETYPE_SIZE - 1)); + CHECK(parse(text) && strlen(file.base) == SERVER_GAMETYPE_SIZE - 1, "the longest base was refused: %s", + file.error); + snprintf(text, sizeof(text), "base = \"%s\"", repeated('b', SERVER_GAMETYPE_SIZE)); + CHECK(!parse(text) && file.error[0], "a base longer than its room was taken"); + } + return 0; + } + /* top-level keys are base and name; tables are universal, options and + one engine's, each rule in its own */ + CASE("sections") + { + CHECK(parse("base = \"ctf\"\n[universal]\nlives = 3\n[options]\nhardcore = true\n[ctf]\nassault = true\n"), + "three tables were refused: %s", file.error); + CHECK(!strcmp(file.engine_table, "ctf") && file.rule_count == 3, "engine %s, %d rules", file.engine_table, + file.rule_count); + CHECK(parse("base = \"oddball\"\n[oddball]\nball_spawn_count = 2\n[universal]\n[options]\n"), + "empty tables were refused: %s", file.error); + CHECK(parse("base = \"ctf\"\nuniversal.lives = 2\noptions = { time_limit = 5 }\n"), + "dotted keys and inline tables were refused: %s", file.error); + CHECK(rule_of("lives") == 2 && rule_of("time_limit") == 5, "lives %ld, time %ld", rule_of("lives"), + rule_of("time_limit")); + CHECK(refused("base = \"ctf\"\nscore_to_win = 5\n", "score_to_win is not base or name", 2), + "a rule outside a table was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[bogus]\nlives = 1\n", "[bogus] is no table of a gametype file", 0), + "an unknown table was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[ctf]\nassault = true\n[slayer]\nkill_in_order = true\n", + "[ctf] and [slayer]: a gametype has one game engine", 0), "two engines' tables were taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[options]\nscore_to_win = 5\n", "score_to_win goes in [universal], not [options]", + 3), "a rule in another table was taken: %s (line %d)", file.error, file.error_line); + CHECK(refused("base = \"slayer\"\n[slayer]\nassault = true\n", "assault goes in [ctf], not [slayer]", 3), + "an engine's rule in another engine's table was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nbogus = 1\n", "no rule is named bogus (in [universal])", 3), + "an unknown rule was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nscore = 5\n", "no rule is named score", 3), + "an alias was taken for a key: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nLives = 5\n", "no rule is named Lives", 3), + "a key in capitals was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nbase = \"slayer\"\n", "no rule is named base", 3), + "base in a table was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal.more]\nlives = 1\n", "no rule is named more", 0), + "a table in a table was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[[universal]]\nlives = 1\n", "universal is not base or name", 0), + "an array of tables was taken: %s", file.error); + return 0; + } + /* a value as its rule takes it: numbers, booleans, words; why not */ + CASE("values") + { + CHECK(parse("base = \"ctf\"\n[universal]\nteams = false\nradar = 1\nweapon_set = \"Plasma\"\n" + "vehicle_set = 2\nscore_to_win = \"50\"\nlives = 0\nhealth = 400\n"), "values were refused: %s", file.error); + CHECK(rule_of("teams") == 0 && rule_of("radar") == 1 && rule_of("weapon_set") == 3 && + rule_of("vehicle_set") == 2 && rule_of("score_to_win") == 50 && rule_of("lives") == 0 && + rule_of("health") == 400, "teams %ld, radar %ld, weapons %ld, vehicles %ld, score %ld", + rule_of("teams"), rule_of("radar"), rule_of("weapon_set"), rule_of("vehicle_set"), rule_of("score_to_win")); + CHECK(parse("base = \"ctf\"\n[options]\nfriendly_fire = \"shields_only\"\ntime_limit = 0x10\n"), + "a word and a hexadecimal number were refused: %s", file.error); + CHECK(rule_of("friendly_fire") == 2 && rule_of("time_limit") == 16, "ff %ld, time %ld", rule_of("friendly_fire"), + rule_of("time_limit")); + CHECK(refused("base = \"ctf\"\n[universal]\nscore_to_win = 1000\n", "score_to_win takes 0 to 999, not 1000", 3), + "a score out of range was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nscore_to_win = -1\n", "not -1", 3), "-1 was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nscore_to_win = 99999999999\n", "takes a whole number", 3), + "a number past a long was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nhealth = 24\n", "health takes 25 to 400", 3), "24 was taken"); + CHECK(refused("base = \"ctf\"\n[universal]\nteams = 2\n", "teams takes true or false", 3), "teams = 2 was taken"); + CHECK(refused("base = \"ctf\"\n[universal]\nteams = \"maybe\"\n", "teams takes true or false, not \"maybe\"", 3), + "teams = maybe was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[options]\nfriendly_fire = \"bogus\"\n", + "friendly_fire takes on, off, shields_only or explosives_only, not \"bogus\"", 3), "%s", file.error); + CHECK(refused("base = \"ctf\"\n[options]\nfriendly_fire = 4\n", "friendly_fire takes", 3), + "an index past the words was taken"); + CHECK(refused("base = \"ctf\"\n[universal]\nscore_to_win = 1.5\n", + "score_to_win takes a number, true or false, or a word", 3), "a fraction was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nlives = [1, 2]\n", "lives takes a number", 3), + "an array was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nlives = 2026-10-24\n", "lives takes a number", 3), + "a date was taken: %s", file.error); + CHECK(refused("base = \"ctf\"\n[universal]\nlives = { a = 1 }\n", "lives takes a number", 3), + "a table was taken: %s", file.error); + return 0; + } + /* comments, blank lines, spaces and CRLF line ends */ + CASE("comments") + { + CHECK(parse("# a gametype\r\n\r\n base = \"king\" # the base\r\n\t[king] # its table\r\n" + "moving_hill = true # it moves\r\n# lives = 3\r\n"), "comments were refused: %s", file.error); + CHECK(!strcmp(file.base, "king") && file.rule_count == 1 && rule_of("moving_hill") == 1, + "base %s, %d rules", file.base, file.rule_count); + CHECK(parse("base = \"ctf\" # name = \"not read\"\n"), "a comment after a value was refused: %s", file.error); + CHECK(!file.name[0], "a comment's name was read: %s", file.name); + return 0; + } + /* a key or table given twice is TOML's error, with its line */ + CASE("duplicates") + { + CHECK(refused("base = \"ctf\"\n[universal]\nlives = 1\nlives = 2\n", "duplicate key", 4), + "a rule given twice was taken: %s (line %d)", file.error, file.error_line); + CHECK(refused("base = \"ctf\"\n[universal]\nlives = 1\n[universal]\nteams = true\n", "more than once", 4), + "a table given twice was taken: %s (line %d)", file.error, file.error_line); + CHECK(refused("base = \"ctf\"\nbase = \"slayer\"\n", "duplicate key", 2), "base given twice was taken: %s (line %d)", + file.error, file.error_line); + CHECK(refused("base = \"ctf\"\n[universal]\nlives = 1\n[options]\nuniversal.lives = 2\n", "no rule is named universal", 5), + "a dotted key in another table was taken: %s (line %d)", file.error, file.error_line); + return 0; + } + /* what is not TOML: refused with the line TOML names */ + CASE("malformed") + { + CHECK(refused("base = \"ctf\"\n[universal]\nlives =\n", "", 3), "a key with no value: %s (line %d)", file.error, + file.error_line); + CHECK(refused("base = \"ctf\"\n[universal\nlives = 1\n", "", 2), "an unclosed table: %s (line %d)", file.error, + file.error_line); + CHECK(refused("base = \"ctf\"\nname = \"unterminated\n", "", 0) && file.error_line, "an unclosed string: %s (line %d)", file.error, + file.error_line); + CHECK(refused("this is not toml\n", "", 1), "a sentence: %s (line %d)", file.error, file.error_line); + CHECK(refused("base = \"ctf\"\nname = \"bad \\q escape\"\n", "", 2), "an unknown escape: %s (line %d)", + file.error, file.error_line); + CHECK(refused("base = \"ctf\"\n\n\nlives 3\n", "", 4), "a key with no '=': %s (line %d)", file.error, + file.error_line); + return 0; + } + /* 64 KB at most; only the length given is read */ + CASE("size") + { + static char text[SERVER_GAMETYPE_FILE_MAXIMUM_SIZE + 64]; + int length; + + length = snprintf(text, sizeof(text), "base = \"ctf\"\n"); + memset(text + length, '#', (size_t)(SERVER_GAMETYPE_FILE_MAXIMUM_SIZE - length - 1)); + text[SERVER_GAMETYPE_FILE_MAXIMUM_SIZE - 1] = '\n'; + text[SERVER_GAMETYPE_FILE_MAXIMUM_SIZE] = 0; + CHECK(server_gametype_file_parse(text, SERVER_GAMETYPE_FILE_MAXIMUM_SIZE, &file), + "a 64 KB file was refused: %s", file.error); + text[SERVER_GAMETYPE_FILE_MAXIMUM_SIZE] = '\n'; + text[SERVER_GAMETYPE_FILE_MAXIMUM_SIZE + 1] = 0; + CHECK(!server_gametype_file_parse(text, SERVER_GAMETYPE_FILE_MAXIMUM_SIZE + 1, &file) && + strstr(file.error, "the file is larger than 64 KB"), "a larger file was taken: %s", file.error); + snprintf(text, sizeof(text), "base = \"ctf\"\n[bogus]\n"); + CHECK(server_gametype_file_parse(text, 13, &file), "read past the length: %s", file.error); + return 0; + } + /* every rule of universal, options and each engine's table in one file: + at their least and their most */ + CASE("every-rule") + { + static char const *const engines[] = { "ctf", "slayer", "king", "oddball", "race" }; + static char text[8192]; + int engine, most; + + for (most = 0; most <= 1; most++) + { + for (engine = 0; engine < (int)(sizeof(engines) / sizeof(engines[0])); engine++) + { + char const *tables[3] = { "universal", "options", engines[engine] }; + int table, index, expected = 0; + int length = snprintf(text, sizeof(text), "base = \"%s\"\n", engines[engine]); + + for (table = 0; table < 3; table++) + { + length += snprintf(text + length, sizeof(text) - (size_t)length, "[%s]\n", tables[table]); + for (index = 0; index < server_rule_definition_count(); index++) + { + const struct server_rule_definition *rule = &server_rule_definitions[index]; + int last = 0; + + if (strcmp(rule->table, tables[table])) + continue; + expected++; + if (rule->type == _server_rule_integer) + { + length += snprintf(text + length, sizeof(text) - (size_t)length, "%s = %ld\n", rule->key, + most ? rule->maximum : rule->minimum); + continue; + } + while (rule->choices[last + 1]) + last++; + if (rule->type == _server_rule_boolean) + { + length += snprintf(text + length, sizeof(text) - (size_t)length, "%s = %s\n", rule->key, + most ? "true" : "false"); + } + else + { + length += snprintf(text + length, sizeof(text) - (size_t)length, "%s = \"%s\"\n", rule->key, + rule->choices[most ? last : 0]); + } + } + } + CHECK(server_gametype_file_parse(text, length, &file), "every %s rule was refused: %s (line %d)", + engines[engine], file.error, file.error_line); + CHECK(file.rule_count == expected && expected <= SERVER_RULES_MAXIMUM, "%d of %d rules", file.rule_count, + expected); + for (index = 0; index < file.rule_count; index++) + { + const struct server_rule_definition *rule = server_rule_find(file.rules[index].key); + long want = most ? rule->maximum : rule->minimum; + + if (rule->type == _server_rule_choice) + { + for (want = 0; most && rule->choices[want + 1]; want++) + { + } + } + CHECK(file.rules[index].value == want, "%s is %ld, not %ld", rule->key, file.rules[index].value, want); + } + } + } + return 0; + } + /* a gametype file's name: letters, digits, '_' and '-', no path, and + room for file= before it in a rotation's gametype */ + CASE("name-valid") + { + CHECK(server_gametype_file_name_valid("btb_slayer"), "btb_slayer was refused"); + CHECK(server_gametype_file_name_valid("Team-CTF2"), "Team-CTF2 was refused"); + CHECK(server_gametype_file_name_valid("a"), "a was refused"); + CHECK(!server_gametype_file_name_valid(""), "an empty name was taken"); + CHECK(!server_gametype_file_name_valid("../config"), "../config was taken"); + CHECK(!server_gametype_file_name_valid(".."), ".. was taken"); + CHECK(!server_gametype_file_name_valid("a/b"), "a/b was taken"); + CHECK(!server_gametype_file_name_valid("a\\b"), "a\\b was taken"); + CHECK(!server_gametype_file_name_valid("/etc/passwd"), "/etc/passwd was taken"); + CHECK(!server_gametype_file_name_valid("c:x"), "c:x was taken"); + CHECK(!server_gametype_file_name_valid("x.toml"), "x.toml was taken"); + CHECK(!server_gametype_file_name_valid("a b"), "a b was taken"); + CHECK(!server_gametype_file_name_valid("caf\xc3\xa9"), "a name past ASCII was taken"); + CHECK(!server_gametype_file_name_valid("x\n"), "a new line was taken"); + CHECK(server_gametype_file_name_valid(repeated('n', SERVER_GAMETYPE_SIZE - 6)), "the longest name was refused"); + CHECK(!server_gametype_file_name_valid(repeated('n', SERVER_GAMETYPE_SIZE - 5)), "a name too long was taken"); + CHECK(!server_gametype_file_name_valid(repeated('n', 200)), "a 200 character name was taken"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_chat_logic.py b/tools/harness/tests/test_chat_logic.py index e612eaa6..07e755bd 100644 --- a/tools/harness/tests/test_chat_logic.py +++ b/tools/harness/tests/test_chat_logic.py @@ -1,6 +1,7 @@ """The rules of network_chat.c's text chat: a line's text (another machine's is untrusted), the lines the host takes -from a machine, who hears a team's line and a vehicle's, how long a line shows, and the host's /mute: what it -reads from a typed line, the player a name means, and how long a mute lasts.""" +from a machine, who hears a team's line and a vehicle's, how long a line shows, whether a public game has the chat (a +dedicated server's may), and the host's /mute: what it reads from a typed line, the player a name means, and how long a +mute lasts.""" import re import sys @@ -14,12 +15,13 @@ from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, CASES = ["text-printable", "text-control-characters", "text-pipe", "text-bounds", "text-empty", "append", "rate-burst", "rate-refill", "rate-wrap", "hears", "hears-vehicle", "flags", "alpha", "command-mute", "command-unmute", "command-not", "command-usage", "name-match", "name-bounds", "mute-time", "mute-wrap", "mute-again", - "mute-full", "link-found", "link-written-out", "link-spaced", "link-word-domains", "link-not", "refused", "keyboard-character", + "mute-full", "link-found", "link-written-out", "link-spaced", "link-word-domains", "link-not", "refused", "listing", "keyboard-character", "keyboard-move", "keyboard-tap"] FUNCTIONS = ("chat_text_clean", "chat_text_append", "chat_rate_take", "chat_hears", "chat_channel_from_flags", "chat_channel_flags", "chat_line_alpha", "chat_lower", "chat_command_word", "chat_command_parse", "chat_name_match", "chat_names_equal", "chat_mute_minutes_left", "chat_mute_find", "chat_mute_set", "chat_minutes_plural", - "chat_alphanumeric", "chat_link_domain", "chat_text_has_link", "chat_refused", "chat_keyboard_character", + "chat_alphanumeric", "chat_link_domain", "chat_text_has_link", "chat_refused", "chat_listing_allows", + "chat_keyboard_character", "chat_keyboard_move", "chat_keyboard_key_at") # a fault in network_chat.c, the function it is in, and the case that must catch it @@ -87,6 +89,8 @@ NEGATIVE_CONTROLS = { "chat_refused", "refused"), "chat-always-allowed": (("if (!TEST_FLAG(flags, _chat_config_allowed_bit))", "if (0)"), "chat_refused", "refused"), + "dedicated-chat-off": (("return !listed || dedicated;", "return !listed;"), "chat_listing_allows", "listing"), + "public-chat-on": (("return !listed || dedicated;", "return TRUE;"), "chat_listing_allows", "listing"), "shift-not-letters": (("if (shift && character >= 'a' && character <= 'z')", "if (shift)"), "chat_keyboard_character", "keyboard-character"), "last-row-read": (("row >= CHAT_KEYBOARD_ROWS - 1 ||", "row >= CHAT_KEYBOARD_ROWS ||"), diff --git a/tools/harness/tests/test_config_write.py b/tools/harness/tests/test_config_write.py new file mode 100644 index 00000000..0b871b58 --- /dev/null +++ b/tools/harness/tests/test_config_write.py @@ -0,0 +1,53 @@ +"""config_write (port/linux/src/port_config.c): a setting set for now and its line in config.toml changed, a string as +long as a server's rotation (escaped as a TOML basic string) included, longer ones refused, and nothing written by a run +that only answers its command line (halo --probe, --version).""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = ["long-string", "all-escaped", "too-long", "answer-only"] +NAMES = ["config_setting_index", "config_text_is_false", "config_set_from_text", "config_append", "config_copy", + "config_line_key", "config_line_section", "config_write"] + +# a fault in the code, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "strings-kept-short": (("CONFIG_MAXIMUM_WRITTEN_STRING : 256", "256 : 256"), "config_write", "long-string"), + "no-limit": (("strlen(value) > (config_settings", "strlen(value) > 99999 + 0 * (config_settings"), "config_write", + "too-long"), + "probe-writes": (("\tif (config_answer_only)\n", "\tif (0)\n"), "config_write", "answer-only"), +} + + +def generated(fault=None, faulty_function=None): + source = read("port/linux/src/port_config.c") + types = "\n\n".join([ + enum_with(source, "_config_boolean,"), enum_with(source, "_environment_set_is_true,"), + enum_with(source, "_platform_desktop = 1"), structure(source, "config_setting"), + structure(source, "config_value"), structure(source, "config_text"), + f"#define CONFIG_MAXIMUM_WRITTEN_STRING {constant(source, 'CONFIG_MAXIMUM_WRITTEN_STRING')}", + ]) + "\n" + text = "" + for name in NAMES: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("types.inc", types), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("config_write", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("config_write", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_console_lines.py b/tools/harness/tests/test_console_lines.py new file mode 100644 index 00000000..4eada7b9 --- /dev/null +++ b/tools/harness/tests/test_console_lines.py @@ -0,0 +1,50 @@ +"""A dedicated server's command lines (port/linux/src/server_console.c): the init file's and standard input's, a line +too long to run left out rather than run cut short, and server.console_exit_on_eof's "quit" after the standard input's +last line, unterminated or not.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, mutated, read, run # noqa: E402 + +CASES = ["eof-quit", "eof-quit-terminated", "eof-go-on", "input-limits", "init-limits"] + +HEADERS = ("server_console.h", "server_mode.h", "server_log.h", "posix_server.h") + +# a fault in server_console.c and the case that must catch it +NEGATIVE_CONTROLS = { + "quit-lost-after-last-line": (("\t\t\tif (!server_console.quit_owed)\n\t\t\t\treturn 0;\n", + "\t\t\treturn 0;\n"), "eof-quit"), + "input-line-cut": (("else if (length >= size)", "else if (length > size)"), "input-limits"), + "last-input-line-cut": (("if (last && server_console.input_length >= size)", + "if (last && server_console.input_length > size)"), "input-limits"), + "init-line-cut": (("if (strlen(line) >= SERVER_COMMAND_LINE_SIZE)", "if (0)"), "init-limits"), +} + + +def generated(fault=None): + console = read("port/linux/src/server_console.c") + if fault: + console = mutated(console, *fault) + files = tuple((name, read(f"port/linux/src/{name}")) for name in HEADERS) + stubs = ( + ("platform.h", "const char *platform_data_root(void);\n"), + ("port_config.h", "int config_boolean(const char *name);\nconst char *config_string(const char *name);\n"), + ) + return files + stubs + (("under_test.inc", console),) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("console_lines", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("console_lines", generated(fault)), case) + assert status == CHECK_FAILED, f"server_console.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_console_secret.py b/tools/harness/tests/test_console_secret.py new file mode 100644 index 00000000..0c68dd36 --- /dev/null +++ b/tools/harness/tests/test_console_secret.py @@ -0,0 +1,48 @@ +"""Which command lines the dedicated server keeps out of its log (port/linux/src/server_console.c): sv_password, and +sv_set of a password's or the publisher key's setting, matched in any case as the commands are.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, function, mutated, read, run # noqa: E402 + +CASES = ["secrets"] +NAMES = ["word_is", "holds_word", "next_word", "server_console_line_is_secret"] + +# a fault in the code, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "case-matters": (("tolower((unsigned char)word[index]) != tolower((unsigned char)text[index])", + "word[index] != text[index]"), "word_is", "secrets"), + "prefix-is-enough": (("return index == length && !text[index];", "return index == length;"), "word_is", + "secrets"), + "publisher-key-logged": ((' || holds_word(setting, setting_length, "publisher_key")', ""), + "server_console_line_is_secret", "secrets"), + "quotes-kept": (("\t\t*word = ++at;", "\t\t*word = at++;"), "next_word", "secrets"), +} + + +def generated(fault=None, faulty_function=None): + source = read("port/linux/src/server_console.c") + text = "" + for name in NAMES: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("under_test.inc", text),) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("console_secret", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("console_secret", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_lobby_browser.py b/tools/harness/tests/test_lobby_browser.py new file mode 100644 index 00000000..41cf6217 --- /dev/null +++ b/tools/harness/tests/test_lobby_browser.py @@ -0,0 +1,60 @@ +"""The server browser's choice and order of the games found: its source (ALL GAMES, DEDICATED, P2P GAMES), its +Filters (empty, full, game type, missing maps) and its column titles' orders, both ways; a game of another network +version kept, last (port/linux/src/p2p_lobby.c's p2p_lobby_filter and p2p_lobby_sort).""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run, structure + + +def generated(fault=None): + header = read('port/linux/src/p2p.h') + version = constant(read('port/linux/include/halo_port_limits.h'), 'HALO_PORT_NETWORK_VERSION') + enums = '\n'.join([f'#define HALO_PORT_NETWORK_VERSION {version}', + enum_with(header, 'P2P_LISTING_NAME_SIZE = 32'), + enum_with(header, 'P2P_LOBBY_SOURCE_ALL'), + enum_with(header, 'P2P_LOBBY_MAPS_YES'), + enum_with(header, 'P2P_LOBBY_MAP_INSTALLED'), + enum_with(header, 'P2P_LOBBY_SORT_SERVERS'), + structure(header, 'p2p_listing'), + structure(header, 'p2p_lobby_choices')]) + '\n' + source = read('port/linux/src/p2p_lobby.c') + names = re.search(r'static const char \*const sort_names\[NUMBER_OF_P2P_LOBBY_SORTS\] =.*?\};', source, + re.S).group(0) + code = '\n'.join([names, function(source, 'p2p_lobby_filter'), function(source, 'text_order'), + function(source, 'sort_order'), function(source, 'p2p_lobby_sort'), + function(source, 'p2p_lobby_sort_parse'), function(source, 'p2p_lobby_sort_text')]) + if fault: + code = mutated(code, *fault) + return (('enums.inc', enums), ('under_test.inc', code)) + + +CASES = ['source', 'empty_full', 'game_type', 'maps', 'servers_default', 'sort_each_way', 'other_version', + 'sort_text'] + + +@pytest.mark.parametrize('case', CASES) +def test_case(case): + status, output = run(build('lobby_browser', generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize('fault,case', [ + (('(choices->source == P2P_LOBBY_SOURCE_PLAYERS && game->dedicated)', + '(choices->source == P2P_LOBBY_SOURCE_PLAYERS && !game->dedicated)'), 'source'), + (('game->player_count >= game->maximum_player_count', 'game->player_count > game->maximum_player_count'), + 'empty_full'), + (('state == P2P_LOBBY_MAP_MISSING))', 'state != P2P_LOBBY_MAP_INSTALLED))'), 'maps'), + (('difference = second->dedicated - first->dedicated;', 'difference = 0;'), 'servers_default'), + (('(reverse ? -difference : difference) <= 0', 'difference <= 0'), 'sort_each_way'), + (('before_other != other_version ? !before_other :', 'before_other != other_version ? before_other :'), + 'other_version'), + (('*reverse = text && *text == \'-\';', '*reverse = 0;'), 'sort_text'), +]) +def test_negative_control(fault, case): + status, output = run(build('lobby_browser', generated(fault)), case) + assert status == CHECK_FAILED, output diff --git a/tools/harness/tests/test_lobby_countdown.py b/tools/harness/tests/test_lobby_countdown.py index 0bd69bcc..457b2dd2 100644 --- a/tools/harness/tests/test_lobby_countdown.py +++ b/tools/harness/tests/test_lobby_countdown.py @@ -1,6 +1,9 @@ """The real pregame countdown of network_server_manager.c: the lobby's START NOW starts it for a host alone, a host with -split screen players and machines together, and a machine joining without a player does not stop it.""" +split screen players and machines together, and a machine joining without a player does not stop it; and a dedicated +server's rule, its director's (server_director.c's server_director_ok_to_countdown): server.min_players held to 1 to +16, server.team_start, and its own state.""" +import re import sys from pathlib import Path @@ -12,7 +15,8 @@ from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, CASES = [ "split-screen-start-now", "alone-start-now", "empty-lobby-waits", "two-machines-start-now", "two-machines-team-game-needs-both-teams", "playerless-machine-keeps-countdown", - "split-screen-team-game-one-team", + "split-screen-team-game-one-team", "dedicated-one-team-starts", "dedicated-both-teams-waits", + "dedicated-below-minimum-waits", "dedicated-minimum-clamped", "dedicated-director-refuses", ] FUNCTIONS = ( "countdown_timer_update", "countdown_timer_get_time_remaining", "countdown_timer_increment", @@ -41,6 +45,10 @@ NEGATIVE_CONTROLS = { ("return first_machine_index != NONE;", "return TRUE;"), "server_players_on_one_machine", "empty-lobby-waits", ), + "dedicated-rule-ignored": ( + ("if (halo_server_mode())", "if (0)"), + "server_ok_to_countdown", "dedicated-one-team-starts", + ), "joined-machines-counted": ( ( "return first_machine_index != NONE;", @@ -48,11 +56,49 @@ NEGATIVE_CONTROLS = { ), "server_players_on_one_machine", "playerless-machine-keeps-countdown", ), + "teams-passed-as-none": ( + ("server_director_ok_to_countdown(server->game.player_count, server_needs_more_teams(server))", + "server_director_ok_to_countdown(server->game.player_count, FALSE)"), + "server_ok_to_countdown", "dedicated-both-teams-waits", + ), + "both-ignored": ( + ('if (needs_more_teams && !strcmp(team_start, "both"))', "if (0)"), + "server_director_ok_to_countdown", "dedicated-both-teams-waits", + ), + "minimum-unchecked": ( + ("|| player_count < minimum)", ")"), + "server_director_ok_to_countdown", "dedicated-below-minimum-waits", + ), + "minimum-not-floored": ( + ('PIN(config_integer("server.min_players"), 1, 16)', 'CEILING(config_integer("server.min_players"), 16)'), + "server_director_ok_to_countdown", "dedicated-minimum-clamped", + ), + "minimum-not-capped": ( + ('PIN(config_integer("server.min_players"), 1, 16)', 'MAX(config_integer("server.min_players"), 1)'), + "server_director_ok_to_countdown", "dedicated-minimum-clamped", + ), + "quitting-ignored": ( + ("if (director.quitting || ", "if ("), + "server_director_ok_to_countdown", "dedicated-director-refuses", + ), + "state-ignored": ( + ("director.state != _server_state_lobby || ", ""), + "server_director_ok_to_countdown", "dedicated-director-refuses", + ), + "entry-ignored": ( + ("!director.entry_applied || ", ""), + "server_director_ok_to_countdown", "dedicated-director-refuses", + ), } +# the director's rule (port/linux/game/server_director.c), its state named director: the test's server is the game's +DIRECTOR_FUNCTIONS = ("server_director_ok_to_countdown",) + + def generated(fault=None, faulty_function=None): source = read("source/networking/network_server_manager.c") + director = read("port/linux/game/server_director.c") # (the machine and player counts are halo_port_limits.h's, which constant() does not follow into) with_limits = source + read("port/linux/include/halo_port_limits.h") config = "" @@ -62,7 +108,14 @@ def generated(fault=None, faulty_function=None): config += enum_with(source, member) + "\n" for name in ("countdown_timer", "network_game_server_countdown_state"): config += structure(source, name) + "\n" + config += enum_with(read("port/linux/game/server_director.h"), "_server_state_lobby") + "\n" + config += "#define MAX(a, b) ((a) > (b) ? (a) : (b))\n" text = "" + for name in DIRECTOR_FUNCTIONS: + code = re.sub(r'(? FRESH_SECONDS", "0"), "master_list_verify", "list-verify"), + "list-any-master": (("!strcmp(text, key_id) &&", ""), "master_list_verify", "list-body"), + "instance-keeps-leading-dash": (("while (length && (instance[0] == '-' || instance[0] == '_'))", "while (0)"), + "instance_name", "instance"), + "seed-takes-more-digits": (("return !*text || *text == ' '", "return 1 || *text == ' '"), "seed_from_text", + "key-file"), + "masters-keep-slash": (("while (strlen(url) > 8 && url[strlen(url) - 1] == '/')", "while (0)"), + "master_list_read_file", "masters-file"), + "masters-json-any-scheme": (('if (!strncmp(url, "https://", 8))\n\t\treturn 1;', 'if (1)\n\t\treturn 1;'), + "master_url_allowed", "masters-json"), + "loopback-by-prefix": (("(!url[length] || url[length] == ':' || url[length] == '/')", "1"), "master_url_allowed", + "masters-file"), + "masters-file-any-url": (("if (!master_url_allowed(url) ||", "if ("), "master_list_read_file", "masters-file"), + "masters-file-not-default": (("return count ? count : master_list_read_defaults(urls, keys, maximum);", + "return master_list_read_defaults(urls, keys, maximum);"), "master_list_read", + "masters-default"), + "masters-never-default": (("return count ? count : master_list_read_defaults(urls, keys, maximum);", + "return count;"), "master_list_read", "masters-default"), + "masters-default-key": (('"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa"', + '"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225ab"'), + "master_list_read_defaults", "masters-default"), + "masters-json-unbounded": (("for (index = 0; count < maximum && server_json_element", + "for (index = 0; server_json_element"), "master_list_read_json", "masters-json"), + "list-taken-unchecked": (("p2p_sha256(key, MASTER_LIST_KEY_SIZE, digest);\n\tp2p_hex(digest, 8, key_id);", + "return 1;"), "master_list_verify", "list-refusals"), + "list-any-protocol": (("&protocol) && protocol == 1 &&", "&protocol) &&"), "master_list_verify", "list-body"), + "list-from-the-future": (("if (time_value - (double)now > FRESH_SECONDS)", "if (0)"), "master_list_verify", + "list-verify"), + "list-verify-few-tokens": (("server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS) ||", + "server_json_parse(&document, body, (int)size, 16) ||"), "master_list_verify", + "list-large"), + "take-list-few-tokens": (("server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS))", + "server_json_parse(&document, body, (int)size, 16))"), "take_list", "list-large"), + "take-list-unbounded": (("for (index = 0; index < MAXIMUM_ENTRIES && server_json_element", + "for (index = 0; server_json_element"), "take_list", "list-large"), + "take-list-bad-entry-ends-it": (("\t\t{\n\t\t\tcontinue;\n\t\t}\n\t\tlisting_size", "\t\t{\n\t\t\tbreak;\n\t\t}\n\t\tlisting_size"), + "take_list", "take-list"), + "take-list-publisher-raw": (("if ((unsigned char)publisher[character] < 0x20 ||", + "if ((unsigned char)publisher[character] < 0x01 ||"), "take_list", "take-list"), + "take-list-region-raw": (("if (!(isalnum((unsigned char)region[character]) || region[character] == '-'))", "if (0)"), + "take_list", "take-list"), + "take-list-publisher-long": (("publisher[P2P_LISTING_PUBLISHER_SIZE] = 0;", "publisher[sizeof(publisher) - 1] = 0;"), + "take_list", "take-list"), + "take-list-never-dedicated": (('server_json_member(&document, entry, "dedicated")', + 'server_json_member(&document, entry, "no such field")'), "take_list", "take-list"), + "listing-unlabelled": (("memcpy(data, signature_label, sizeof(signature_label) - 1);\n\tmemcpy(data + sizeof", + "memset(data, 0, sizeof(signature_label) - 1);\n\tmemcpy(data + sizeof"), "listing_signed", + "listing-read"), + "listing-signs-less": (("bytes, (size_t)listing->signed_size);", "bytes, (size_t)listing->signed_size - 1);"), + "listing_signed", "listing-read"), + "listing-any-format": (("bytes[2] != LISTING_FORMAT)", "0)"), "listing_read", "listing-bounds"), + "listing-names-long": (("if (length > maximum || *offset + length > size)", "if (*offset + length > size)"), + "get_text", "listing-bounds"), + "listing-trailing-taken": (("if (offset + P2P_SIGNATURE_SIZE != size)", "if (offset + P2P_SIGNATURE_SIZE > size)"), + "listing_read", "listing-bounds"), + "listing-kind-lost": (("listing->kind = offset < size ? bytes[offset] : 0;", "listing->kind = 0;"), "listing_read", + "listing-read"), + "publish-signs-bare-path": (('snprintf(signed_path, sizeof(signed_path), "%s%s", base_path ? base_path : "", path);', + 'snprintf(signed_path, sizeof(signed_path), "%s", path);'), "publish_to", + "publish-body"), + "publish-port-unswapped": (("(long long)(unsigned short)(port << 8 | port >> 8)", "(long long)port"), "publish_body", + "publish-body"), + "publish-never-dedicated": (('server_json_boolean(json, "dedicated", halo_server_mode() != 0);', + 'server_json_boolean(json, "dedicated", 0);'), "publish_body", "publish-body"), + "publish-never-closed": (('server_json_boolean(json, "closed", closed);', 'server_json_boolean(json, "closed", 0);'), + "publish_body", "publish-body"), + "withdrawn-never": (("if (!each->published)\n\t\t\treturn;", "return;"), "publish_to", "publish-body"), + "retry-after-case": (("tolower((unsigned char)line[index]) == tolower((unsigned char)name[index])", + "line[index] == name[index]"), "header_number", "publish-answers"), + "revoked-published-again": (("if (each->revoked || (!closed", "if ((!closed"), "publish_to", "publish-answers"), + "clock-difference-unlogged": (('if (!strcmp(code, "clock") && has_master_time)', "if (0 && has_master_time)"), + "publish_answer", + "publish-answers"), + "probe-any-master": (("if (!known)\n\t\treturn 0;", "if (0 && !known)\n\t\treturn 0;"), "server_master_probe", + "probe-refusals"), + "probe-without-key": (("|| !master.has_key)\n\t\treturn 0;", ")\n\t\treturn 0;"), "server_master_probe", + "probe-refusals"), + "probe-rate-unlimited": (("++master.probe_answers > PROBE_ANSWERS_PER_SECOND", "++master.probe_answers > 1000"), + "server_master_probe", "probe-sources"), + "probe-sources-unlimited": (("return ++master.source_answers[index] <= PROBE_ANSWERS_PER_ADDRESS;", "return 1;"), + "probe_source_allowed", "probe-sources"), + "probe-sources-one": (("if (master.source_seconds[index] != second)\n\t\t{", "if (1)\n\t\t{"), + "probe_source_allowed", "probe-sources"), + "probe-nonce-last-only": (("for (index = 0; index < REMEMBERED_NONCES; index++)\n\t{\n\t\tif (master.nonce_seconds", + "for (index = (master.next_nonce + REMEMBERED_NONCES - 1) % REMEMBERED_NONCES; " + "index == (master.next_nonce + REMEMBERED_NONCES - 1) % REMEMBERED_NONCES; index++)" + "\n\t{\n\t\tif (master.nonce_seconds"), "server_master_probe", "probe-nonces"), +} + +# a vector the code disagrees with: the case and why (docs/master_protocol.md) +DISAGREEMENTS = {} + + +def vectors(): + return json.loads(VECTORS_PATH.read_text(encoding="utf-8")) + + +def c_string(text): + """A C string literal of ASCII text.""" + out = '"' + for character in text: + if character in '"\\': + out += "\\" + character + elif character == "\n": + out += "\\n" + elif " " <= character <= "~": + out += character + else: + raise ValueError(f"not printable ASCII in a vector: {character!r}") + return out + '"' + + +def key_hex(text): + """The hex of an "ed25519:" public key.""" + assert text.startswith("ed25519:"), text + return text[8:] + + +def vectors_include(v): + """vectors.json as C: a #define a value, and the listings' table.""" + values = { + "PUBLISHER_SEED": v["publisher"]["seed"], "PUBLISHER_PUBLIC_KEY": key_hex(v["publisher"]["public_key"]), + "PUBLISHER_KEY_ID": v["publisher"]["key_id"], + "MASTER_SEED": v["master"]["seed"], "MASTER_PUBLIC_KEY_TEXT": v["master"]["public_key"], + "MASTER_PUBLIC_KEY": key_hex(v["master"]["public_key"]), "MASTER_KEY_ID": v["master"]["key_id"], + "MASTER_KEY_ID_BYTES": v["master"]["key_id_bytes"], + "HOST_SEED": v["host"]["seed"], "HOST_PUBLIC_KEY": key_hex(v["host"]["public_key"]), + "HOST_KEY_ID": v["host"]["key_id"], + "INSTANCE": v["instance"], "SERVER_ID_INPUT": v["server_id_input"], "SERVER_ID": v["server_id"], + "PROBE_MASTER_KEY_ID": v["probe"]["master_key_id"], "PROBE_NONCE": v["probe"]["nonce"], + "PROBE_PACKET": v["probe"]["packet"], "PROBE_SIGNED_MESSAGE": v["probe"]["signed_message"], + "PROBE_ANSWER": v["probe"]["answer"], + "LIST_BODY": v["list"]["body"], "LIST_SIGNATURE_HEADER": v["list"]["signature_header"], + "LIST_KEY_HEADER": v["list"]["signature_key_header"], "LIST_TIME": v["list"]["signature_time_header"], + "SMALL_ORDER_KEY": key_hex(v["small_order_key_refused"]), + "PROOF_NONCE": v["proof_nonce"], "PROOF_SIGNATURE": v["proof_signature_hex"], + } + for prefix, request in (("PUBLISH", v["publish_request"]), ("ME", v["me_request"])): + for field in ("master_key_id", "method", "path", "ts", "nonce", "body", "body_sha256", "canonical", "signature", + "authorization"): + values[f"{prefix}_{field.upper()}"] = request[field] + text = "".join(f"#define VECTOR_{name} {c_string(value)}\n" for name, value in values.items()) + text += "static const struct vector_listing vector_listings[] =\n{\n" + for listing in v["listings"]: + text += "\t{ " + ", ".join([c_string(listing["description"]), c_string(listing["host_seed"]), + c_string(listing["host_public_key"]), c_string(listing["bytes"]), + c_string(listing["base64"]), str(listing["size"]), str(listing["signed_size"]), + c_string(listing["signed_message"]), c_string(listing["signature"]), + "1" if listing["valid"] else "0"]) + " },\n" + return text + "};\n" + + +def crypto_includes(): + """p2p_crypto.c, Monocypher and server_json.c (jsmn) as whole files, with the headers they include: the parts + of p2p_internal.h and posix.h they need.""" + internal = read("port/linux/src/p2p_internal.h") + section = internal[internal.index("/* ---------- p2p_crypto.c */"):] + section = section[:section.index("/* ----------", 10)] + public = read("port/linux/src/p2p.h") + sizes = ", ".join(f"{name} = {constant(public, name)}" for name in ( + "P2P_LISTING_NAME_SIZE", "P2P_LISTING_MAP_SIZE", "P2P_LISTING_GAMETYPE_SIZE", "P2P_LISTING_PUBLISHER_SIZE", + "P2P_LISTING_REGION_SIZE")) + internal_stub = ("#include \n" + f"enum {{ P2P_TOKEN_SIZE = {constant(internal, 'P2P_TOKEN_SIZE')}, {sizes} }};\n" + "void p2p_hex(const unsigned char *bytes, int size, char *text);\n" + section) + posix = read("port/linux/src/posix.h") + posix_stub = "typedef unsigned long posix_ulong;\n" + \ + re.search(r"^void posix_random_bytes\([^;]*\);", posix, re.M).group(0) + "\n" + server_json = read("port/linux/src/server_json.c") + if server_json.count('#include "../../third_party/jsmn/jsmn.h"') != 1: + raise LookupError("server_json.c no longer includes jsmn as it did") + server_json = server_json.replace('#include "../../third_party/jsmn/jsmn.h"', '#include "jsmn.h"') + return ( + ("posix.h", posix_stub), ("p2p_internal.h", internal_stub), + ("monocypher.h", read("port/third_party/monocypher/monocypher.h")), + ("monocypher-ed25519.h", read("port/third_party/monocypher/monocypher-ed25519.h")), + ("monocypher.inc", read("port/third_party/monocypher/monocypher.c")), + ("monocypher_ed25519.inc", read("port/third_party/monocypher/monocypher-ed25519.c")), + ("p2p_crypto.inc", read("port/linux/src/p2p_crypto.c")), + ("jsmn.h", read("port/third_party/jsmn/jsmn.h")), + ("server_json.h", read("port/linux/src/server_json.h")), + ("server_json.inc", server_json), + ("master_list.h", read("port/linux/src/master_list.h")), + ("server_master.h", read("port/linux/src/server_master.h")), + ) + + +def generated(fault=None, faulty_function=None): + lists = read("port/linux/src/master_list.c") + publisher = read("port/linux/src/server_master.c") + lobby = read("port/linux/src/p2p_lobby.c") + state = re.search(r"^static struct\s*\{.*?^\} master;", publisher, re.M | re.S) + if not state: + raise LookupError("server_master.c's state (static struct { ... } master;) not found") + label = re.search(r"^static const char signature_label\[\] = \"[^\"]*\";", lobby, re.M) + if not label: + raise LookupError("p2p_lobby.c's signature_label not found") + config = "" + for name in ("FRESH_SECONDS", "MAXIMUM_ENTRIES", "MAXIMUM_LISTING"): + config += f"#define {name} {constant(lists, name)}\n" + for name in ("PROBE_ANSWERS_PER_SECOND", "PROBE_ANSWERS_PER_ADDRESS", "REMEMBERED_NONCES", "REMEMBERED_SOURCES", + "PROBE_SIZE", "PROBE_ANSWER_SIZE", "MAXIMUM_ANSWER"): + config += f"#define {name} {constant(publisher, name)}\n" + config += structure(read("port/linux/src/update.h"), "update_response") + "\n" + config += structure(publisher, "master") + "\n" + state.group(0) + "\n" + config += enum_with(lobby, "LISTING_FORMAT = 1") + "\n" + label.group(0) + "\n" + structure(lobby, "listing") + "\n" + text = "" + for path, names in SOURCES.items(): + source = read(path) + for name in names: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return crypto_includes() + (("vectors.inc", vectors_include(vectors())), ("config.inc", config), + ("under_test.inc", text)) + + +def test_vectors_copy(): + """The copy here is the master's vectors (when the master's repository is checked out beside this one).""" + if not CHECKOUT_VECTORS.exists(): + pytest.skip("the master's repository is not checked out beside this one") + assert json.loads(COPIED_VECTORS.read_text()) == json.loads(CHECKOUT_VECTORS.read_text()), \ + f"the master's vectors changed: copy {CHECKOUT_VECTORS} to {COPIED_VECTORS}" + + +@pytest.mark.parametrize("case", [pytest.param(case, marks=pytest.mark.xfail(reason=DISAGREEMENTS[case], strict=True)) + if case in DISAGREEMENTS else case for case in CASES]) +def test_case(case): + status, output = run(build("master_protocol", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("master_protocol", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_command_words.py b/tools/harness/tests/test_server_command_words.py new file mode 100644 index 00000000..2761aef9 --- /dev/null +++ b/tools/harness/tests/test_server_command_words.py @@ -0,0 +1,98 @@ +"""The words of a dedicated server's commands (port/linux/game/server_commands.c): the tokenizer (spaces, quotes, no +escapes, at most MAXIMUM_WORDS), the text after some words, a reason kept printable, ban durations, numbers in range, +bans.txt's fields, and a player found by slot or the start of their name, with the name as server_status.c reads it +and checks it against the web page's; and how long each role may ban for (a moderator a day at most).""" + +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run # noqa: E402 + +CASES = ["split-words", "rest-after-words", "printable-text", "durations", "integers", "ban-fields", "find-player", + "player-matches", "ban-limits"] + +SOURCES = { + "source/game/players.c": ["player_name_character_ascii"], + "port/linux/game/server_status.c": ["server_status_player_name", "server_status_player_matches"], + "port/linux/game/server_commands.c": ["split_words", "rest_after_words", "printable_text", "parse_duration", + "parse_integer", "name_begins_with", "find_player", "ban_field", + "ban_duration_allowed"], +} + +# a fault in the code, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "quotes-split": (("if (*cursor == '\"')", "if (0)"), "split_words", "split-words"), + "words-unbounded": (("while (*cursor && count < maximum)", "while (*cursor)"), "split_words", "split-words"), + "tabs-in-words": (("while (*cursor == ' ' || *cursor == '\\t')", "while (*cursor == ' ')"), "split_words", + "split-words"), + "rest-ignores-quotes": (("if (*line == '\"')", "if (0)"), "rest_after_words", "rest-after-words"), + "quotes-kept": (("if (last > first && source[first] == '\"' && source[last] == '\"')", "if (0)"), + "printable_text", "printable-text"), + "control-characters-kept": (("if ((unsigned char)source[index] >= 0x20 && (unsigned char)source[index] < 0x7F)", + "if (1)"), + "printable_text", "printable-text"), + "room-overrun": (("length < size - 1", "length < size"), "printable_text", "printable-text"), + "hours-as-minutes": (("case 'h': unit = 60 * 60; break;", "case 'h': unit = 60; break;"), "parse_duration", + "durations"), + "suffix-unchecked": (("if (*end && end[1])\n\t\treturn -1;", ""), "parse_duration", "durations"), + "no-ceiling": (("if ((double)number * unit > 10.0 * 365 * 24 * 60 * 60)", "if (0)"), "parse_duration", + "durations"), + "forever-unknown": (('!csstrcasecmp(text, "forever") || ', ""), "parse_duration", "durations"), + "range-unchecked": (("if (*end || number < minimum || number > maximum)", "if (*end)"), "parse_integer", + "integers"), + "fields-by-their-end": (('snprintf(key, sizeof(key), "\\t%s=", name);', 'snprintf(key, sizeof(key), "%s=", name);'), + "ban_field", "ban-fields"), + "field-runs-on": (('length = (long)strcspn(at, "\\t");', "length = (long)strlen(at);"), "ban_field", + "ban-fields"), + "exact-name-not-first": (("if (exact_count == 1)", "if (exact_count == 1 && 0)"), "find_player", "find-player"), + "ambiguous-taken": (("if (matches > 1)", "if (0)"), "find_player", "find-player"), + "slots-unchecked": (("|| !network_player_is_valid(&game->players[number])", ""), "find_player", "find-player"), + "names-by-case": (("tolower((unsigned char)name[index]) != tolower((unsigned char)text[index])", + "name[index] != text[index]"), "name_begins_with", "find-player"), + "matches-any-case": (("return !strcmp(current, name);", "return !strcasecmp(current, name);"), + "server_status_player_matches", "player-matches"), + "names-uncut": (("&&\n\t\tindex < size - 1; index++)", "; index++)"), "server_status_player_name", + "player-matches"), + "moderator-forever": (("(seconds > 0 && seconds <= MODERATOR_BAN_SECONDS)", "(seconds <= MODERATOR_BAN_SECONDS)"), + "ban_duration_allowed", "ban-limits"), + "moderator-unlimited": (("(seconds > 0 && seconds <= MODERATOR_BAN_SECONDS)", "(seconds > 0)"), + "ban_duration_allowed", "ban-limits"), + "admin-limited": (("role >= _server_role_admin || ", ""), "ban_duration_allowed", "ban-limits"), +} + + +def generated(fault=None, faulty_function=None): + commands = read("port/linux/game/server_commands.c") + # (MAXIMUM_SLOTS is halo_port_limits.h's player count, which constant() does not follow into) + with_limits = commands + read("port/linux/include/halo_port_limits.h") + config = f"#define MAXIMUM_WORDS {constant(commands, 'MAXIMUM_WORDS')}\n" + config += f"#define MAXIMUM_SLOTS {constant(with_limits, 'MAXIMUM_SLOTS')}\n" + # (a product of numbers, which constant() does not take whole) + config += f"#define MODERATOR_BAN_SECONDS ({re.search(r'MODERATOR_BAN_SECONDS = ([^,]+),', commands).group(1)})\n" + config += enum_with(read("port/linux/src/server_console.h"), "_server_role_moderator") + "\n" + text = "" + for path, names in SOURCES.items(): + source = read(path) + for name in names: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("config.inc", config), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_command_words", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_command_words", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_http.py b/tools/harness/tests/test_server_http.py new file mode 100644 index 00000000..463d724d --- /dev/null +++ b/tools/harness/tests/test_server_http.py @@ -0,0 +1,248 @@ +"""The real parsing of a dedicated server's web port (port/linux/src/server_http.c): a request's head, a connection's +bytes taken a request at a time, the WebSocket handshake (SHA-1, the accept key, its refusals), a client's frames, and +the Origin, address, query and password checks; a connection's output and its limits, the requests held while its +answers wait, the addresses' table, the connection a new one evicts, its deadlines, and a close after the queue; and +the sign-in's decisions (handle_request's Origin, CSRF and roles, the sessions' expiry and cap, the passwords' lockout, +budget and changes), on stand-ins for the HMAC, the random bytes and the settings.""" + +import hashlib +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = [ + "sha1", "accept-key", "upgrade-refusals", "head", "head-refusals", "content-length", "cookie", "connection", + "forwarded", "addresses", "origin", "query", "compare", "frames", "frame-refusals", "fragments", + "content-length-duplicate", "output", "pipelining", "address-table", "eviction", "deadlines", "close-order", + "bare-lf", "auth-csrf", "auth-denied", "route-roles", "session-expiry", "session-cap", "lockout", "login-budget", + "empty-password", "password-change", "fetch-site", "ws-allowance", "owner-local", +] +FUNCTIONS = ( + "sha1_rotate", "sha1", "text_equal_case", "constant_time_equal", "address_text", "parse_address", "hex_digit_value", + "parse_address6", "port_text", "forwarded_address", "out_pending", "out_append", "server_http_parse_head", + "query_number", "connection_is_local", "address_record", "address_allows_request", "address_locked", + "address_login_failed", + "session_hash", "session_find", "session_new", "passwords_check", "password_role", "request_host", + "origin_matches", "ws_frame", "ws_close", "ws_read_frames", "request_session", "body_string", "serve_login", + "route_needs_game", "route_role", "handle_request", "server_http_capabilities", "handle_upgrade", "connection_take", + "connection_to_evict", + "connection_overdue", +) +STRUCTURES = ("http_request", "ws_message", "connection", "server_ticket", "session", "address_record") +# the messages hashed against Python's SHA-1: every length across two blocks' padding +SHA1_LENGTHS = 200 + +# a fault in server_http.c, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "null-origin-taken": (('return !strcmp(connection->request.fetch_site, "same-origin");', "return 1;"), + "origin_matches", "origin"), + "sha1-bytes-not-bits": (("bits = (unsigned long long)size * 8;", "bits = (unsigned long long)size;"), "sha1", + "sha1"), + "sha1-block-short": (("unsigned long total = size + 9 + 63;", "unsigned long total = size + 8 + 63;"), "sha1", + "sha1"), + "accept-guid-wrong": (("258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "258EAFA5-E914-47DA-95CA-C5AB0DC85B12"), + "handle_upgrade", "accept-key"), + "cookie-from-anywhere": (("if (session && (!by_cookie || origin_matches(connection)))", "if (session)"), + "handle_upgrade", "accept-key"), + "upgrade-origin-unchecked": (("if (request->origin[0] && !origin_matches(connection))", "if (0)"), + "handle_upgrade", "upgrade-refusals"), + "websockets-unbounded": (("if (count >= MAXIMUM_WEBSOCKETS)", "if (count > MAXIMUM_WEBSOCKETS)"), + "handle_upgrade", "upgrade-refusals"), + "names-case-sensitive": (("if (tolower((unsigned char)*a) != tolower((unsigned char)*b))", "if (*a != *b)"), + "text_equal_case", "head"), + "transfer-encoding-taken": (("if (request->has_transfer_encoding || request->has_expect)", + "if (request->has_expect)"), "server_http_parse_head", "head-refusals"), + "dot-dot-taken": (("strstr(target, \"..\") ||", ""), "server_http_parse_head", "head-refusals"), + "length-signs-taken": (("if (!value[0] || !isdigit((unsigned char)value[0]) || has_content_length)", + "if (!value[0] || has_content_length)"), "server_http_parse_head", "content-length"), + "length-digits-unbounded": (("if (number < 0 || value_length > 9)", "if (number < 0)"), "server_http_parse_head", + "content-length"), + "length-junk-too-large": (("if (*number_end)\n\t\t\t\t\treturn 0;", "if (0)\n\t\t\t\t\treturn 0;"), + "server_http_parse_head", "content-length"), + "second-length-taken": (("has_content_length = 1;", "has_content_length = 0;"), "server_http_parse_head", "content-length-duplicate"), + "body-unbounded": (("if (request->content_length > MAXIMUM_BODY)", "if (0)"), "server_http_parse_head", + "content-length"), + "cookie-inside-names": (("while (found && found != value && found[-1] != ' ' && found[-1] != ';')", "while (0)"), + "server_http_parse_head", "cookie"), + "unended-head-waited-for": (("if (connection->in_length > MAXIMUM_HEAD)", + "if (connection->in_length > 2 * MAXIMUM_HEAD)"), "connection_take", "connection"), + "head-past-most-taken": (("if (connection->head_length > MAXIMUM_HEAD ||", + "if (connection->head_length > MAXIMUM_HEAD + 2 ||"), "connection_take", "connection"), + "proxy-unchecked": (("parse_address(config_string(\"server.web_trusted_proxy\"), &proxy) && " + "proxy == connection->address", "1"), "connection_take", "forwarded"), + "forwarded-flag-from-anyone": (("connection->forwarded = 0;", "connection->forwarded = 1;"), "connection_take", + "forwarded"), + "ipv6-unread": (("else if (colon && strchr(colon + 1, ':'))", "else if (0)"), "forwarded_address", "forwarded"), + "ipv6-whole-address-keyed": (("bytes, 8, digest);", "bytes, 16, digest);"), "forwarded_address", "forwarded"), + "ipv4-mapped-hashed": (('if (!memcmp(bytes, "\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\xff\\xff", 12))', "if (0)"), + "forwarded_address", "forwarded"), + "ipv6-two-gaps": (("if (gap >= 0)\n\t\t\t\treturn 0;", "if (0)\n\t\t\t\treturn 0;"), "parse_address6", "forwarded"), + "ports-unbounded": (("strtol(text + 1, NULL, 10) <= 65535", "strtol(text + 1, NULL, 10) <= 99999"), "port_text", + "forwarded"), + "bare-lf-taken": (("else if (line_end == line || line_end[-1] != '\\r')\n\t\t\treturn 0;", + "else if (0)\n\t\t\treturn 0;"), "server_http_parse_head", "bare-lf"), + "bare-cr-taken": (("if (memchr(line, '\\r', (size_t)line_length))", "if (0)"), "server_http_parse_head", "bare-lf"), + "after-empty-line-taken": (("if (line < end)\n\t\t\t\treturn 0;", "if (0)\n\t\t\t\treturn 0;"), + "server_http_parse_head", "bare-lf"), + "cookie-change-without-origin": (("if (!request->origin[0])\n\t\t{", "if (0)\n\t\t{"), "handle_request", + "auth-csrf"), + "cookie-change-without-csrf": (("if (!constant_time_equal(request->csrf, session->csrf))", "if (0)"), + "handle_request", "auth-csrf"), + "bearer-needs-csrf": (("if (mutation && by_cookie)", "if (mutation)"), "handle_request", "auth-csrf"), + "no-session-taken": (("return connection->request.session_cookie[0] ? session_find(connection->request.session_cookie) " + ": NULL;", "return http.sessions[0].used ? &http.sessions[0] : NULL;"), "request_session", + "auth-denied"), + "moderator-routes-viewer": (("return _server_role_moderator;", "return _server_role_viewer;"), "route_role", + "route-roles"), + "admin-routes-moderator": (("if (!strncmp(path, \"/api/bans\", 9) || !strcmp(path, \"/api/settings\"))\n\t\treturn " + "_server_role_admin;", "if (!strncmp(path, \"/api/bans\", 9) || !strcmp(path, " + "\"/api/settings\"))\n\t\treturn _server_role_moderator;"), "route_role", "route-roles"), + "roles-unchecked": (("if (session->role < route_role(request->method, path))", "if (0)"), "handle_request", + "route-roles"), + "sessions-forever": (("if (now - session->created > SESSION_SECONDS || now - session->used_at > SESSION_IDLE_SECONDS)", + "if (now - session->used_at > SESSION_IDLE_SECONDS)"), "session_find", "session-expiry"), + "sessions-never-idle": (("if (now - session->created > SESSION_SECONDS || now - session->used_at > SESSION_IDLE_SECONDS)", + "if (now - session->created > SESSION_SECONDS)"), "session_find", "session-expiry"), + "sessions-evict-newest": (("if (http.sessions[index].used_at < oldest->used_at)", + "if (http.sessions[index].used_at > oldest->used_at)"), "session_new", "session-cap"), + "lockout-late": (("if (++record->failures >= LOGIN_FAILURES)", "if (++record->failures > LOGIN_FAILURES)"), + "address_login_failed", "lockout"), + "lockout-windowless": (("if (now - record->failures_start >= LOGIN_WINDOW_SECONDS)", "if (0)"), + "address_login_failed", "lockout"), + "lockout-ignored": (("if (locked)\n\t{", "if (0)\n\t{"), "password_role", "lockout"), + "budget-unbounded": (("if (++http.budget_used > LOGIN_BUDGET)", "if (++http.budget_used > 1000)"), "password_role", + "login-budget"), + "empty-password-matches": (("if (password && password[0])\n\t{\n\t\tif (admin[0] && constant_time_equal(password, admin))", + "if (password)\n\t{\n\t\tif (constant_time_equal(password, admin))"), "password_role", + "empty-password"), + "password-change-unseen": (("changed |= 1UL << role;", "changed |= 0;"), "passwords_check", "password-change"), + "password-change-every-role": (("(changed & (1UL << http.sessions[index].role)))", "changed)"), "passwords_check", + "password-change"), + "password-change-console-code": (("ws_close(connection, 1008, \"the password changed\");", + "ws_close(connection, 1000, \"the password changed\");"), "passwords_check", + "password-change"), + "fetch-site-unread": (("HEADER(\"Sec-Fetch-Site\", fetch_site);", "HEADER(\"Sec-Fetch-Mode\", fetch_site);"), + "server_http_parse_head", "fetch-site"), + "fetch-site-any": (('return !strcmp(connection->request.fetch_site, "same-origin");', + 'return connection->request.fetch_site[0] != 0;'), "origin_matches", "fetch-site"), + "upgrades-uncounted": (("if (!address_allows_request(connection->real_address, session != NULL))", "if (0)"), + "handle_upgrade", "ws-allowance"), + "forwarded-leftmost": (("const char *last = strrchr(list, ',');", "const char *last = NULL;"), "forwarded_address", + "forwarded"), + "forwarded-start-kept": (("value_length < sizeof(request->forwarded_for) ? value :", "1 ? value :"), + "server_http_parse_head", "forwarded"), + "output-unbounded": (("if (out_pending(connection) > OUTPUT_PAUSE && out_pending(connection) + size > OUTPUT_MOST)", + "if (0)"), "out_append", "output"), + "output-not-compacted": (("connection->out_length -= connection->out_sent;\n\t\tconnection->out_sent = 0;", + "connection->out_length -= connection->out_sent;"), "out_append", "output"), + "pipelining-unpaused": (("while (!connection->ticket && !connection->close_after_write && " + "out_pending(connection) < OUTPUT_PAUSE)", + "while (!connection->ticket && !connection->close_after_write)"), "connection_take", + "pipelining"), + "frames-unpaused": (("while (connection->in_length >= 2 && !connection->closing && " + "out_pending(connection) < OUTPUT_PAUSE)", + "while (connection->in_length >= 2 && !connection->closing)"), "ws_read_frames", "pipelining"), + "addresses-first-unlocked": (("else if (!unlocked || record->last_seen < unlocked->last_seen)", + "else if (!unlocked)"), "address_record", "address-table"), + "addresses-first-locked": (("if (!locked || record->locked_until < locked->locked_until)", "if (!locked)"), + "address_record", "address-table"), + "addresses-unseen": (("record->last_seen = now;\n\treturn record;", "return record;"), "address_record", + "address-table"), + "evicts-busy": (("if (connection->socket < 0 || connection->websocket || connection->ticket || " + "out_pending(connection) > 0)", "if (connection->socket < 0)"), "connection_to_evict", + "eviction"), + "socket-zero-free": (("if (connection->socket < 0 || connection->websocket", "if (connection->socket <= 0 || " + "connection->websocket"), "connection_to_evict", "eviction"), + "evicts-newest": (("connection->head_started < http.connections[oldest].head_started", + "connection->head_started > http.connections[oldest].head_started"), "connection_to_evict", + "eviction"), + "silent-connections-kept": (("if (!connection->websocket && !connection->ticket && out_pending(connection) == 0 &&", + "if (!connection->websocket && !connection->ticket && out_pending(connection) == 0 && " + "connection->in_length > 0 &&"), "connection_overdue", "deadlines"), + "closing-websockets-kept": (("!(connection->websocket && connection->role != _server_role_none && " + "!connection->closing)", + "!(connection->websocket && connection->role != _server_role_none)"), + "connection_overdue", "deadlines"), + "close-before-queue": (("while (connection->queue_head)", "while (0)"), "ws_close", "close-order"), + "address-parts-unbounded": (("if (!digits || parts[index] > 255 ||", "if (!digits ||"), "parse_address", + "addresses"), + "origin-prefix": (("return text_equal_case(after_scheme + 3, host);", + "return !strncasecmp(after_scheme + 3, host, strlen(host));"), "origin_matches", "origin"), + "forwarded-host-from-anyone": (("if (connection->request.forwarded_host[0] && connection->forwarded)", + "if (connection->request.forwarded_host[0])"), "request_host", "origin"), + "query-inside-names": (("at = strchr(at, '&');\n\t\tif (at)\n\t\t\tat++;", "at++;"), "query_number", "query"), + "compare-prefix": (("unsigned char difference = (unsigned char)(length_a != length_b);", + "unsigned char difference = 0;"), "constant_time_equal", "compare"), + "unmasked-taken": (("if (!(data[1] & 0x80))", "if (0)"), "ws_read_frames", "frame-refusals"), + "control-frames-long": (("if (opcode >= 0x8 && (size > 125 || !fin))", "if (opcode >= 0x8 && !fin)"), + "ws_read_frames", "frame-refusals"), + "frames-unbounded": (("if (size > WS_FRAME)", "if (size > WS_FRAME + 1)"), "ws_read_frames", "frame-refusals"), + "pong-empty": (("ws_frame(connection, 0xA, data + head, (int)size);", "ws_frame(connection, 0xA, data + head, 0);"), + "ws_read_frames", "frames"), + "messages-interleaved": (("if ((opcode == 0x1) == (connection->message_open != 0))", + "if (opcode == 0x0 && !connection->message_open)"), "ws_read_frames", "fragments"), + "messages-unbounded": (("if (connection->message_length + (int)size > WS_MESSAGE)", "if (0)"), "ws_read_frames", + "fragments"), + "owner-from-anywhere": (("if (local)\n\t\t\treturn _server_role_owner;", "if (1)\n\t\t\treturn _server_role_owner;"), + "password_role", "owner-local"), + "owner-through-proxy": (("return (connection->address >> 24) == 127 && !connection->forwarded;", + "return (connection->address >> 24) == 127;"), "connection_is_local", "owner-local"), + "owner-session-anywhere": (("if (session && session->role >= _server_role_owner && !connection_is_local(connection))" + "\n\t\tsession = NULL;", "if (0)\n\t\tsession = NULL;"), "handle_request", "owner-local"), + "owner-elsewhere-uncounted": (("address_login_failed(address, address_shown);\n\tif (owner_elsewhere)", + "if (!owner_elsewhere)\n\t\taddress_login_failed(address, address_shown);\n\t" + "if (owner_elsewhere)"), "password_role", "owner-local"), + "owner-password-change-unseen": (('"server.web_owner_password" };', '"server.web_password" };'), "passwords_check", + "owner-local"), +} + + +def generated(fault=None, faulty_function=None): + source = read("port/linux/src/server_http.c") + state = re.search(r"^static struct\s*\{.*?^\} http;", source, re.M | re.S) + if not state: + raise LookupError("server_http.c's state (static struct { ... } http;) not found") + config = enum_with(source, "MAXIMUM_HEAD = ") + "\n" + config += enum_with(source, "_ticket_api") + "\n" + config += enum_with(read("port/linux/src/server_console.h"), "_server_role_none") + "\n" + config += f"enum {{ P2P_SHA256_SIZE = {constant(read('port/linux/src/p2p_internal.h'), 'P2P_SHA256_SIZE')}, " + config += f"SERVER_EVENT_GROUP_SIZE = {constant(read('port/linux/src/server_events.h'), 'SERVER_EVENT_GROUP_SIZE')} }};\n" + config += "struct push;\n" + for name in STRUCTURES: + config += structure(source, name) + "\n" + config += state.group(0) + "\n" + config += function(read("port/linux/src/p2p.c"), "p2p_hex") + "\n" + message = bytes((index * 7 + 3) & 255 for index in range(SHA1_LENGTHS)) + config += f"#define SHA1_LENGTHS {SHA1_LENGTHS}\nstatic const char *const sha1_digests[] = {{\n" + config += "".join(f'\t"{hashlib.sha1(message[:length]).hexdigest()}",\n' for length in range(SHA1_LENGTHS)) + config += "};\n" + json = mutated(read("port/linux/src/server_json.c"), '#include "../../third_party/jsmn/jsmn.h"', '#include "jsmn.h"') + text = "" + for name in FUNCTIONS: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + # (connection_take's cases hand their requests to the test's handle_request; the sign-in's call the real one) + if name == "handle_request": + code = mutated(code, "static void handle_request(", "static void real_handle_request(") + text += code + "\n" + return (("server_json.h", read("port/linux/src/server_json.h")), ("jsmn.h", read("port/third_party/jsmn/jsmn.h")), + ("config.inc", config), ("server_json.inc", json), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_http", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_http", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"server_http.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_json.py b/tools/harness/tests/test_server_json.py new file mode 100644 index 00000000..c7c43bcd --- /dev/null +++ b/tools/harness/tests/test_server_json.py @@ -0,0 +1,79 @@ +"""The real JSON of a dedicated server (port/linux/src/server_json.c): the writer's escaping, nesting and values, the +reader over the vendored jsmn with its depth and token limits, strings unescaped, and base64.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, mutated, read, run # noqa: E402 + +CASES = [ + "escaping", "nesting", "values", "write-depth", "write-size", "read", "strings", "malformed", "primitives", + "read-depth", "tokens", "base64", + "utf8-invalid-leads", + "utf8-overlong-surrogates", + "numbers-json-only", + "list-tokens", + "nul-refused", +] + +JSMN = '#include "../../third_party/jsmn/jsmn.h"' + +# a fault in server_json.c and the case that must catch it +NEGATIVE_CONTROLS = { + "grammar-unchecked": (("maximum_tokens <= 0 || !json_check(text, length, maximum_depth))", "maximum_tokens <= 0)"), + "malformed"), + "quotes-unescaped": (("text[run] != '\"' && text[run] != '\\\\')", "text[run] != '\\\\')"), "escaping"), + "controls-unescaped": (("(unsigned char)text[run] >= 0x20 && (unsigned char)text[run] < 0x80", + "(unsigned char)text[run] < 0x80"), "escaping"), + "no-commas": (("\t\t\tjson_append(json, \",\", 1);\n", ""), "nesting"), + "nan-written": (("if (value != value || value > 1e300", "if (value > 1e300"), "values"), + "shallower-writer": (("if (json->depth >= SERVER_JSON_MAXIMUM_DEPTH)", + "if (json->depth >= SERVER_JSON_MAXIMUM_DEPTH - 1)"), "write-depth"), + "no-size-limit": (("if (capacity > SERVER_JSON_MAXIMUM_SIZE || ", "if ("), "write-size"), + "keys-by-prefix": (("current->type == _server_json_string_token && current->end - current->start == length &&", + "current->type == _server_json_string_token &&"), "read"), + "any-primitive-a-number": (("\telse\n\t\treturn 0;\n\tif (check->at < check->length && check->text[check->at] == '.')", + "\tif (check->at < check->length && check->text[check->at] == '.')"), "primitives"), + "infinite-numbers": (("return *end == 0 && *value - *value == 0.0;", "return *end == 0;"), "numbers-json-only"), + "surrogates-written": (("!(character == 0xED && second > 0x9F)", "1"), "utf8-overlong-surrogates"), + "no-depth-limit": (("\t\tif (depth > maximum_depth)\n", "\t\tif (0)\n"), "read-depth"), + "pairs-split": (("low >= 0xDC00 && low < 0xE000", "low >= 0xDC00 && low < 0xDC00"), "strings"), + "string-past-room": (("if (out + encoded_length >= size)", "if (out + encoded_length > size)"), "strings"), + "fewer-tokens": (("maximum_depth, SERVER_JSON_MAXIMUM_TOKENS);", "maximum_depth, SERVER_JSON_MAXIMUM_TOKENS - 1);"), + "tokens"), + "list-fewer-tokens": (("tokens, (unsigned int)maximum_tokens);", "tokens, (unsigned int)maximum_tokens - 1);"), + "list-tokens"), + "body-takes-list-tokens": (("maximum_depth, SERVER_JSON_MAXIMUM_TOKENS);", + "maximum_depth, SERVER_JSON_MAXIMUM_LIST_TOKENS);"), "list-tokens"), + "nul-read": (("if (!json_hex(source + index + 1, &code) || !code)", "if (!json_hex(source + index + 1, &code))"), + "nul-refused"), + "padding-anywhere": (("if (!found || padding)", "if (!found)"), "base64"), + "bytes-past-most": (("if (count + 3 - padding > maximum)", "if (count + 2 - padding > maximum)"), "base64"), + "padding-miscounted": (("text[out++] = index + 2 < size ? base64_alphabet[group & 63] : '=';", + "text[out++] = index + 1 < size ? base64_alphabet[group & 63] : '=';"), "base64"), +} + + +def generated(fault=None): + code = read("port/linux/src/server_json.c") + if fault: + code = mutated(code, *fault) + code = mutated(code, JSMN, '#include "jsmn.h"') + return (("server_json.h", read("port/linux/src/server_json.h")), ("jsmn.h", read("port/third_party/jsmn/jsmn.h")), + ("under_test.inc", code)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_json", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_json", generated(fault)), case) + assert status == CHECK_FAILED, f"server_json.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_log.py b/tools/harness/tests/test_server_log.py new file mode 100644 index 00000000..30fb5085 --- /dev/null +++ b/tools/harness/tests/test_server_log.py @@ -0,0 +1,61 @@ +"""The real log of a dedicated server (port/linux/src/server_log.c): its ring of the last 2048 lines, numbered and read +after a cursor (the API's, the remote console's, the web page's), and its lines on standard output as text or JSON, a +secret's there alone.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, function, mutated, read, run # noqa: E402 + +CASES = [ + "numbering", "maximum", "wrap", "behind", "past-latest", "text", "listener", "stdout", + "past-latest-largest", "private", +] + +HEADERS = ("server_log.h", "server_mode.h", "server_json.h", "posix_server.h") + +# a fault in server_log.c and the case that must catch it +NEGATIVE_CONTROLS = { + "kept-a-place-early": (("lines[server_log_globals.latest % SERVER_LOG_RING_SIZE]", + "lines[(server_log_globals.latest - 1) % SERVER_LOG_RING_SIZE]"), "numbering"), + "next-past-the-last": (("*next = number - 1;", "*next = number;"), "maximum"), + "oldest-overwritten": (("server_log_globals.latest - SERVER_LOG_RING_SIZE + 1 : 1", + "server_log_globals.latest - SERVER_LOG_RING_SIZE : 1"), "wrap"), + "missed-untold": (("*missed = after + 1 < oldest ? oldest - (after + 1) : 0;", "*missed = 0;"), "behind"), + "restart-not-followed": (("\tif (after > server_log_globals.latest)\n\t\tafter = server_log_globals.latest;\n", ""), + "past-latest"), + "controls-kept": (("character < 0x20 || character == 0x7F ? ' '", "character == 0x7F ? ' '"), "text"), + "text-unbounded": (("text[index] && index < SERVER_LOG_TEXT_SIZE - 1;", "text[index] && index < SERVER_LOG_TEXT_SIZE;"), + "text"), + "ring-unheard": (("\tlistener = server_log_globals.listener;\n\tpthread_mutex_unlock", + "\tlistener = NULL;\n\tpthread_mutex_unlock"), "listener"), + "never-json": (("if (server_log_globals.json)", "if (0)"), "stdout"), + "private-kept": (("server_log_write(level, category, text, 0);", "server_log_write(level, category, text, 1);"), + "private"), +} + + +def generated(fault=None): + log = read("port/linux/src/server_log.c") + if fault: + log = mutated(log, *fault) + json = mutated(read("port/linux/src/server_json.c"), '#include "../../third_party/jsmn/jsmn.h"', '#include "jsmn.h"') + text = function(read("port/linux/src/posix_server.c"), "posix_server_utc_text") + "\n" + json + "\n" + log + files = tuple((name, read(f"port/linux/src/{name}")) for name in HEADERS) + return files + (("jsmn.h", read("port/third_party/jsmn/jsmn.h")), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_log", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_log", generated(fault)), case) + assert status == CHECK_FAILED, f"server_log.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_rotation.py b/tools/harness/tests/test_server_rotation.py new file mode 100644 index 00000000..151cb951 --- /dev/null +++ b/tools/harness/tests/test_server_rotation.py @@ -0,0 +1,78 @@ +"""A dedicated server's rotation (port/linux/src/server_rotation.c), taken whole: the entries' grammar (map, gametype, ++rule=value), the rules' table and values, the separators and limits, the text written back and read again, and the +maps found on disk from small cache file headers the test writes.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, mutated, read, run # noqa: E402 + +CASES = [ + "names", "custom-maps", "paths", "gametypes", "rules", "whitespace-comments", "quotes", "long", "ui", + "rotation-separators", "rotation-limits", "round-trip", "rule-find", "rule-values", "check-kind", "map-header", + "resolve", "maps-each", + "long-map-level", + "rotation-cut-entry", + "resolve-version", +] + +# a fault in server_rotation.c and the case that must catch it +NEGATIVE_CONTROLS = { + "comments-read": (("if (text[start] == '#')\n\t\treturn 0;", ""), "whitespace-comments"), + "dots-climb": (("if (!length || dots == length)", "if (!length || dots > length)"), "paths"), + "slashes-kept": (("if (map[index] == '/')", "if (0)"), "paths"), + "custom-maps-path": (("if (!plain_name(map + 12))", "if (!level_path(map + 12))"), "custom-maps"), + "long-map-cut": (("if (map_length >= sizeof(entry->map))", "if (map_length > sizeof(entry->map))"), "long"), + "long-gametype-cut": (("gametype_length >= sizeof(entry->gametype)", "gametype_length > sizeof(entry->gametype)"), + "gametypes"), + "file-names-unchecked": (('!plain_name(!strncmp(gametype, "file=", 5) ? gametype + 5 : gametype)', + '!plain_name(gametype)'), "gametypes"), + "ui-played": (('if (!strcmp(entry->map_name, "ui"))', "if (0)"), "ui"), + "rule-twice-counted": (("&& strcmp(entry->rules[existing].key, definition->key);", ";"), "rules"), + "rule-untrimmed": (("while (key_end > rule && is_space(key_end[-1]))", "while (0)"), "rules"), + "aliases-unknown": (("|| (rule->alias && text_equal_case(key, rule->alias))", ""), "rule-find"), + "prefixes-equal": (("return !*a && !*b;", "return 1;"), "rule-find"), + "range-unchecked": (("if (number < rule->minimum || number > rule->maximum)", "if (0)"), "rule-values"), + "index-past-words": (("number >= 0 && number < index", "number >= 0 && number <= index"), "rule-values"), + "plus-signs-taken": (("(isdigit((unsigned char)text[0]) || (text[0] == '-'", "(isdigit((unsigned char)text[0]) || " + "(text[0] == '+'"), "rule-values"), + "semicolons-joined": ((" && text[end] != ';'", ""), "rotation-separators"), + "empty-segments-truncate": (("if (server_rotation_parse_entry(text + start, end - start, &ignored))\n\t\t\t\t" + "rotation->truncated = 1;", "rotation->truncated = 1;"), "rotation-limits"), + "entries-run-together": (('index ? ", " : ""', 'index ? " " : ""'), "round-trip"), + "campaign-as-multiplayer": (("else if (type == 0 && !coop)", "else if (0)"), "check-kind"), + "foot-unchecked": (('|| memcmp(header + 0x7FC, "toof", 4)', ""), "map-header"), + "type-unsigned": (("(long)(short)(header[0x60]", "(long)(unsigned short)(header[0x60]"), "map-header"), + "custom-level-kept": (('entry->map_kind = _server_map_custom;\n\t\t\tsnprintf(entry->level, sizeof(entry->level), ' + '"custom_maps\\\\%s", entry->map_name);', "entry->map_kind = _server_map_custom;"), + "resolve"), + "missing-unflagged": (("entry->map_name, entry->map_name, entry->map_name);\n\t\tentry->map_missing = 1;", + "entry->map_name, entry->map_name, entry->map_name);"), "resolve"), + "campaign-listed": (("type != 1 ||", ""), "maps-each"), +} + + +def generated(fault=None): + code = read("port/linux/src/server_rotation.c") + if fault: + code = mutated(code, *fault) + # (the platform's headers: the test's stubs stand for what the file calls) + stub = "/* (the test's stubs) */\n" + return (("server_rotation.h", read("port/linux/src/server_rotation.h")), ("platform.h", stub), + ("port_config.h", stub), ("posix.h", stub), ("under_test.inc", code)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_rotation", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_rotation", generated(fault)), case) + assert status == CHECK_FAILED, f"server_rotation.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_server_toml.py b/tools/harness/tests/test_server_toml.py new file mode 100644 index 00000000..9299b8d1 --- /dev/null +++ b/tools/harness/tests/test_server_toml.py @@ -0,0 +1,79 @@ +"""A dedicated server's gametype files (port/linux/src/server_toml.c): the TOML, read by the vendored tomlc17, into a +base, a name and the rules of server_rotation.c's table, each in its own table and range, with the reason and line of +what is refused (unknown keys and tables, two engines, values of the wrong kind, malformed TOML, duplicates, a key +outside a table); and the names a gametype file may have.""" + +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, function, mutated, read, run # noqa: E402 + +CASES = ["example", "base-name", "sections", "values", "comments", "duplicates", "malformed", "size", "every-rule", + "name-valid"] + +FUNCTIONS = ("server_gametype_file_name_valid", "gametype_value_text", "gametype_error", "server_gametype_file_parse") + +# a fault in server_toml.c, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "aliases-taken": (("if (!rule || strcmp(rule->key, rule_key))", "if (!rule)"), "server_gametype_file_parse", + "sections"), + "tables-unchecked": (("if (strcmp(rule->table, key))", "if (0)"), "server_gametype_file_parse", "sections"), + "two-engines": (("if (file->engine_table[0] && strcmp(file->engine_table, key))", "if (0)"), + "server_gametype_file_parse", "sections"), + "unknown-tables": (('if (strcmp(key, "universal") && strcmp(key, "options") &&', "if (0 &&"), + "server_gametype_file_parse", "sections"), + "rule-line-of-table": (('rule_value.lineno, "%s goes in %s"', 'value.lineno, "%s goes in %s"'), + "server_gametype_file_parse", "sections"), + "long-name-cut": (("if (strlen(value.u.s) >= sizeof(file->name))", "if (0)"), "server_gametype_file_parse", + "base-name"), + "control-characters-named": (("(unsigned char)value.u.s[index] < 0x20 ||", ""), "server_gametype_file_parse", + "base-name"), + "base-case-kept": (("file->base[index] = (char)tolower((unsigned char)file->base[index]);", ";"), + "server_gametype_file_parse", "base-name"), + "no-base-taken": (("if (ok && !file->base[0])", "if (0)"), "server_gametype_file_parse", "base-name"), + "other-values-zero": (("default:\n\t\treturn 0;", 'default:\n\t\tsnprintf(text, (size_t)size, "0");\n\t\treturn 1;'), + "gametype_value_text", "values"), + "size-unlimited": (("if (length > SERVER_GAMETYPE_FILE_MAXIMUM_SIZE)", "if (0)"), "server_gametype_file_parse", + "size"), + "toml-line-lost": (('gametype_error(file, line, "%s%s", result.errmsg, "");', + 'gametype_error(file, 0, "%s%s", result.errmsg, "");'), "server_gametype_file_parse", + "malformed"), + "paths-named": (("character == '_' || character == '-') ||", + "character == '_' || character == '-' || character == '.' || character == '/') ||"), + "server_gametype_file_name_valid", "name-valid"), + "names-unbounded": (("|| index >= SERVER_GAMETYPE_SIZE - 6", ""), "server_gametype_file_name_valid", "name-valid"), + "empty-name": (("return index > 0;", "return 1;"), "server_gametype_file_name_valid", "name-valid"), +} + + +def generated(fault=None, faulty_function=None): + source = read("port/linux/src/server_toml.c") + text = re.search(r"^static const char \*const engine_tables\[\] = \{[^;]*\};", source, re.M).group(0) + "\n" + for name in FUNCTIONS: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("tomlc17.h", read("port/third_party/tomlc17/tomlc17.h")), + ("toml.inc", read("port/third_party/tomlc17/tomlc17.c")), + ("server_rotation.h", read("port/linux/src/server_rotation.h")), + ("rotation.inc", read("port/linux/src/server_rotation.c")), + ("server_toml.h", read("port/linux/src/server_toml.h")), + ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_toml", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_toml", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"server_toml.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/linux_build.py b/tools/linux_build.py index e987a546..a6e45996 100644 --- a/tools/linux_build.py +++ b/tools/linux_build.py @@ -13,7 +13,7 @@ import sys from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple -from .embed_assets import hud_assets_build, hud_configure_inputs +from .embed_assets import hud_assets_build, hud_configure_inputs, server_ui_build from .ninja_syntax import Writer PORT_DIR = Path("port/linux") @@ -499,6 +499,8 @@ def generate_linux_build(n: Writer, sln: Any) -> None: # the high-res HUD's textures (port/assets/hud; port/linux/src/hud_hires.c) embedded_assets = hud_assets_build(n, "linux", build_dir / "generated" / "hud_hires_assets.c") + # a dedicated server's admin page (port/assets/server_ui; port/linux/src/server_http.c) + embedded_assets += server_ui_build(n, "linux", build_dir / "generated" / "server_ui_assets.c") # the portable build's system root and SDL3, or the build machine's if getattr(sln, "port_portable", False): @@ -597,7 +599,7 @@ def generate_linux_build(n: Writer, sln: Any) -> None: add_object(source, f"{posix_cflags} -I{MINIUPNPC_DIR / 'include'} -DMINIUPNP_STATICLIB", posix=True) elif source.name.startswith("posix_"): add_object(source, posix_cflags, posix=True) - elif source.name == "updater.c": + elif source.name in ("updater.c", "server_mode.c"): add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))}") else: add_object(source, platform_cflags) @@ -712,6 +714,10 @@ def generate_linux_build(n: Writer, sln: Any) -> None: # internet play's MQTT brokers, a file beside the game (network.brokers_file) brokers = build_dir / "brokers.txt" n.build(outputs=brokers, rule="linux_copy", inputs=Path("port/assets/network/brokers.txt")) + # the master servers of dedicated servers, a file beside the game too + # (network.masters_file, server.masters_file) + masters = build_dir / "masters.txt" + n.build(outputs=masters, rule="linux_copy", inputs=Path("port/assets/network/masters.txt")) n.build(outputs="linux", rule="phony", - inputs=[output, brokers, validator, *sdl_outputs]) + inputs=[output, brokers, masters, validator, *sdl_outputs]) n.newline() diff --git a/tools/opence_rcon.py b/tools/opence_rcon.py new file mode 100644 index 00000000..7868e7ba --- /dev/null +++ b/tools/opence_rcon.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""A command-line client of the dedicated server's remote console (RCON, +docs/dedicated_server.md): a WebSocket to /ws with the subprotocol +opence-rcon-1. Standard library only. + + python3 tools/opence_rcon.py --url http://127.0.0.1:8080 --password … sv_status + python3 tools/opence_rcon.py --url http://127.0.0.1:8080 --password … sv_say "back in five" + python3 tools/opence_rcon.py --url http://127.0.0.1:8080 (an interactive session) + python3 tools/opence_rcon.py --events player,chat,game (prints events as they come) + +The password comes from --password, else OPENCE_RCON_PASSWORD, else it is +asked for; --token takes a session's token instead. Exit codes: 0 the +command ran, 1 the server refused or failed it, 2 no connection or the +sign-in was refused. + +A command given on the command line goes as its command and its words +("command" and "args"), each word as the shell gave it: the server quotes +one with a space as it makes the line. An interactive session's lines go as +they are typed ("line"). +""" + +import argparse +import base64 +import getpass +import hashlib +import json +import os +import socket +import ssl +import struct +import sys +import threading +import time +from urllib.parse import urlsplit + +SUBPROTOCOL = "opence-rcon-1" +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +class RconError(Exception): + """no connection, a broken one, or a refused sign-in""" + + +class Closed(RconError): + def __init__(self, code, reason): + super().__init__(f"the server closed the connection ({code}{': ' + reason if reason else ''})") + self.code = code + self.reason = reason + + +class WebSocket: + """a client's WebSocket (RFC 6455): text messages, masked frames, + fragments joined, pings answered""" + + def __init__(self, url, timeout=10, headers=None, protocols=(SUBPROTOCOL,)): + parts = urlsplit(url) + secure = parts.scheme in ("https", "wss") + if parts.scheme not in ("http", "https", "ws", "wss") or not parts.hostname: + raise RconError(f"{url} is not an http(s):// or ws(s):// address") + port = parts.port or (443 if secure else 80) + try: + sock = socket.create_connection((parts.hostname, port), timeout=timeout) + if secure: + sock = ssl.create_default_context().wrap_socket(sock, server_hostname=parts.hostname) + except OSError as failure: + raise RconError(f"cannot reach {parts.hostname}:{port}: {failure}") from None + self.sock = sock + self.buffer = b"" + self.send_lock = threading.Lock() + key = base64.b64encode(os.urandom(16)).decode() + host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}" + lines = [f"GET {parts.path.rstrip('/') + '/ws' if not parts.path.endswith('/ws') else parts.path} HTTP/1.1", + f"Host: {host}", "Upgrade: websocket", "Connection: Upgrade", f"Sec-WebSocket-Key: {key}", + "Sec-WebSocket-Version: 13"] + if protocols: + lines.append("Sec-WebSocket-Protocol: " + ", ".join(protocols)) + for name, value in (headers or {}).items(): + lines.append(f"{name}: {value}") + try: + sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode()) + while b"\r\n\r\n" not in self.buffer: + chunk = sock.recv(65536) + if not chunk: + raise RconError("the server closed the connection during the handshake") + self.buffer += chunk + if len(self.buffer) > 65536: + raise RconError("a handshake answer too long") + except OSError as failure: + raise RconError(f"the handshake failed: {failure}") from None + head, self.buffer = self.buffer.split(b"\r\n\r\n", 1) + status_line, *header_lines = head.decode("latin-1").split("\r\n") + self.status = int(status_line.split(" ")[1]) if len(status_line.split(" ")) > 1 else 0 + self.headers = {} + for line in header_lines: + name, _, value = line.partition(":") + self.headers[name.strip().lower()] = value.strip() + if self.status != 101: + body = self.buffer + length = int(self.headers.get("content-length", "0") or 0) + try: + while len(body) < length: + chunk = sock.recv(65536) + if not chunk: + break + body += chunk + message = json.loads(body.decode("utf-8")).get("error", {}).get("message", "") + except (OSError, ValueError, AttributeError): + message = "" + sock.close() + raise RconError(f"the server refused the WebSocket ({self.status}{': ' + message if message else ''})") + expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() + if self.headers.get("sec-websocket-accept") != expected: + raise RconError("the server's Sec-WebSocket-Accept is wrong") + if protocols and self.headers.get("sec-websocket-protocol") not in protocols: + raise RconError("the server did not take the subprotocol " + SUBPROTOCOL) + sock.settimeout(None) + + def send_frame(self, opcode, payload, fin=True, mask=True): + first = (0x80 if fin else 0) | opcode + size = len(payload) + bit = 0x80 if mask else 0 + if size < 126: + head = struct.pack("!BB", first, bit | size) + elif size < 65536: + head = struct.pack("!BBH", first, bit | 126, size) + else: + head = struct.pack("!BBQ", first, bit | 127, size) + if mask: + key = os.urandom(4) + payload = bytes(byte ^ key[index % 4] for index, byte in enumerate(payload)) + head += key + with self.send_lock: + self.sock.sendall(head + payload) + + def send(self, message): + self.send_frame(0x1, json.dumps(message, separators=(",", ":")).encode("utf-8")) + + def read(self, count): + while len(self.buffer) < count: + try: + chunk = self.sock.recv(1 << 20) + except socket.timeout: + raise + except OSError as failure: + raise RconError(f"the connection broke: {failure}") from None + if not chunk: + raise RconError("the connection was lost") + self.buffer += chunk + data, self.buffer = self.buffer[:count], self.buffer[count:] + return data + + def frame(self): + first, second = self.read(2) + size = second & 0x7F + if size == 126: + size = struct.unpack("!H", self.read(2))[0] + elif size == 127: + size = struct.unpack("!Q", self.read(8))[0] + if second & 0x80: + raise RconError("the server masked a frame") + if size > 64 << 20: + raise RconError("a frame of more than 64 MiB") + return bool(first & 0x80), first & 0x0F, self.read(size) + + def receive(self, timeout=None): + """the next message (a dict); Closed when the server closes""" + self.sock.settimeout(timeout) + pieces = [] + try: + while True: + fin, opcode, payload = self.frame() + if opcode == 0x9: + self.send_frame(0xA, payload) + continue + if opcode == 0xA: + continue + if opcode == 0x8: + code = struct.unpack("!H", payload[:2])[0] if len(payload) >= 2 else 1005 + try: + self.send_frame(0x8, payload[:2]) + except OSError: + pass + raise Closed(code, payload[2:].decode("utf-8", "replace")) + pieces.append(payload) + if fin: + return json.loads(b"".join(pieces).decode("utf-8")) + except socket.timeout: + raise TimeoutError("no message in time") from None + finally: + self.sock.settimeout(None) + + def close(self, code=1000): + try: + self.send_frame(0x8, struct.pack("!H", code)) + self.sock.close() + except OSError: + pass + + +class Rcon: + """a signed-in RCON connection: commands and their replies, events""" + + def __init__(self, url, password=None, token=None, by=None, timeout=10): + self.timeout = timeout + self.socket = WebSocket(url, timeout=timeout) + self.next_id = 1 + self.pending = [] + hello = self.socket.receive(timeout) + if hello.get("type") != "hello": + raise RconError(f"the server did not say hello: {hello}") + self.hello = hello + if token: + self.socket.send({"type": "auth", "token": token}) + else: + message = {"type": "auth", "password": password or ""} + if by: + message["by"] = by + self.socket.send(message) + while True: + try: + answer = self.socket.receive(timeout) + except Closed as closed: + raise RconError(f"signed out: {closed}") from None + if answer.get("type") == "auth": + break + self.pending.append(answer) + if not answer.get("ok"): + refusal = answer.get("error") or {} + raise RconError(f"sign-in refused: {refusal.get('code', '')} {refusal.get('message', '')}".strip()) + self.role = answer.get("role") + + def command(self, line, timeout=None, args=None): + """the reply to one command: a whole line, or (with args) a command and its words, which the server + quotes as it joins them (a word with a space, or an empty one)""" + identifier = self.next_id + self.next_id += 1 + if args is None: + self.socket.send({"type": "command", "id": identifier, "line": line}) + else: + self.socket.send({"type": "command", "id": identifier, "command": line, "args": list(args)}) + deadline = time.monotonic() + (timeout or max(self.timeout, 30)) + while True: + message = self.socket.receive(max(0.1, deadline - time.monotonic())) + if message.get("type") == "reply" and message.get("id") == identifier: + return message + if message.get("type") == "error" and message.get("id") == identifier: + return {"type": "reply", "id": identifier, "ok": False, "lines": [], "error": message.get("error")} + if message.get("type") == "overflow": + raise RconError("the server dropped the connection: it was read too slowly") + self.pending.append(message) + + def subscribe(self, groups, after=None): + message = {"type": "subscribe", "events": list(groups)} + if after is not None: + message["after"] = after + self.socket.send(message) + + def receive(self, timeout=None): + if self.pending: + return self.pending.pop(0) + return self.socket.receive(timeout) + + def close(self): + self.socket.close() + + +def print_reply(reply, out=sys.stdout): + """a reply's lines on standard output; a failed command's (which say why) on standard error""" + failed = not reply.get("ok") + for line in reply.get("lines") or []: + print(line, file=sys.stderr if failed else out) + if failed: + refusal = reply.get("error") + # (a command that ran and failed: its lines, no error; one refused before it ran: an error) + if refusal or not reply.get("lines"): + refusal = refusal or {} + print(f"error: {refusal.get('message') or refusal.get('code') or 'the command failed'}", file=sys.stderr) + return 1 + return 0 + + +def interactive(rcon): + print(f"{rcon.hello.get('server', '')} ({rcon.hello.get('build', '')}, network version " + f"{rcon.hello.get('network_version', '')}), signed in as {rcon.role}. quit or Ctrl-D leaves.") + status = 0 + while True: + try: + line = input("> ").strip() + except EOFError: + print() + return status + if line in ("exit", "logout") or (line == "quit" and rcon.role != "owner"): + return status + if not line: + continue + try: + status = print_reply(rcon.command(line)) + except (RconError, TimeoutError) as failure: + print(f"opence_rcon: {failure}", file=sys.stderr) + return 2 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], + epilog="Exit codes: 0 ok, 1 the command failed, 2 no connection or sign-in.") + parser.add_argument("--url", default=os.environ.get("OPENCE_RCON_URL", "http://127.0.0.1:8080"), + help="the server's web address (OPENCE_RCON_URL)") + parser.add_argument("--password", default=None, help="the admin or moderator password (OPENCE_RCON_PASSWORD)") + parser.add_argument("--token", default=os.environ.get("OPENCE_RCON_TOKEN"), + help="a session token from POST /api/login instead (OPENCE_RCON_TOKEN)") + parser.add_argument("--by", default=os.environ.get("USER", ""), help="who you are, for the moderation log") + parser.add_argument("--timeout", type=float, default=10, help="seconds to wait for the server") + parser.add_argument("--json", action="store_true", help="print the reply as JSON") + parser.add_argument("--events", default="", help="groups to print events of (player,chat,game,moderation," + "status,log,master), until Ctrl-C") + parser.add_argument("--after", type=int, default=None, help="with --events: replay the events after this seq") + parser.add_argument("command", nargs=argparse.REMAINDER, help="a command line (none: an interactive session)") + options = parser.parse_args(argv) + password = options.password or os.environ.get("OPENCE_RCON_PASSWORD") + if not password and not options.token: + if not sys.stdin.isatty(): + print("opence_rcon: no password (--password or OPENCE_RCON_PASSWORD)", file=sys.stderr) + return 2 + password = getpass.getpass("Password: ") + try: + rcon = Rcon(options.url, password=password, token=options.token, by=options.by, timeout=options.timeout) + except (RconError, TimeoutError, ValueError) as failure: + print(f"opence_rcon: {failure}", file=sys.stderr) + return 2 + try: + if options.events: + rcon.subscribe([group.strip() for group in options.events.split(",") if group.strip()], options.after) + while True: + message = rcon.receive() + if message.get("type") == "overflow": + print("opence_rcon: the server dropped the connection (read too slowly)", file=sys.stderr) + return 2 + print(json.dumps(message, ensure_ascii=False), flush=True) + if options.command: + # (the shell's words as they are: the server quotes one with a space) + words = options.command[1:] if options.command[0] == "--" else options.command + if not words: + print("opence_rcon: no command", file=sys.stderr) + return 2 + reply = rcon.command(words[0], timeout=max(options.timeout, 30), args=words[1:]) + if options.json: + print(json.dumps(reply, ensure_ascii=False)) + return 0 if reply.get("ok") else 1 + return print_reply(reply) + return interactive(rcon) + except (KeyboardInterrupt, BrokenPipeError): + return 0 + except (RconError, TimeoutError, ValueError) as failure: + print(f"opence_rcon: {failure}", file=sys.stderr) + return 2 + finally: + rcon.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/p2p_lobby_check.c b/tools/p2p_lobby_check.c index a7e8d0ab..211df689 100644 --- a/tools/p2p_lobby_check.c +++ b/tools/p2p_lobby_check.c @@ -80,6 +80,14 @@ void p2p_signal_lobby_publish(const unsigned char *listing, int size, int closin publish_count++; } void p2p_signal_lobby_quit(void) {} +/* (not a dedicated server, and no master servers: master_list.c) */ +int halo_server_mode(void) { return 0; } +void master_list_browse(int on) { (void)on; } +void master_list_refresh(void) {} +/* (the token handed out with a listing, and the masters' prompt publish: +p2p.c, server_master.c) */ +void p2p_token_listed(const unsigned char *token) { (void)token; } +void server_master_changed(void) {} /* the probes asked for (p2p.c's), and the last one's host and token: each ends at once, its host not reached (as p2p.c's does when it needs no diff --git a/tools/port_settings.py b/tools/port_settings.py index bd4a8efb..ba5f0478 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -303,6 +303,8 @@ _network["categories"] = [ "Players' names above their heads, the\nscoreboard, and the text chat."), ("STREAMER MODE:", "network_setup/streamer_mode", "Hide offensive words in names and text from\nother players, maps and mods, as stars."), + ("SERVER BROWSER:", "network_setup/server_browser", + "What the Server Browser lists and its order: the\nsame settings as its Filters screen."), ] SCREENS["network_setup/map_sharing"] = { "screen": "map_sharing_settings_screen", "header": _network["header"], "spacing": 30, @@ -318,6 +320,37 @@ SCREENS["network_setup/map_sharing"] = { ], } +# Network Setup's server browser (menu_functions.c's lobby_browser_update): where +# its games come from (network.listings, master_list.c), the Filters screen's +# settings and its order; the master servers that answered, shown, not set +# (a row with no values: its value's text, menu_functions.c's +# master_servers_show) +SCREENS["network_setup/server_browser"] = { + "screen": "server_browser_settings_screen", "header": _network["header"], "spacing": 30, + "rows": [ + ("LISTS:", "network.listings", + [("ALL GAMES", "both"), ("DEDICATED", "masters"), ("P2P GAMES", "brokers")], + "The master servers' dedicated servers, the games\nplayers host (through the brokers), or all of them.", None), + ("SHOW EMPTY SERVERS:", "network.browser_show_empty", YES_NO, "List games no one is playing yet.", None), + ("SHOW FULL SERVERS:", "network.browser_show_full", YES_NO, "List games with no room left.", None), + ("SHOW MISSING MAPS:", "network.browser_maps", + [("NO", "no"), ("YES", "yes"), ("IF DOWNLOADABLE", "downloadable")], + "List games on maps you do not have: IF\nDOWNLOADABLE, those Map Sharing can download.", None), + # (a column title chosen again writes the order reversed, "-players": the row shows its order's word, + # menu_functions.c's setting_value_index, and writes it again only if another is chosen) + ("SORT BY:", "network.browser_sort", + [("DEDICATED FIRST", "servers"), ("PLAYERS", "players"), ("NAME", "name"), ("MAP", "map"), + ("GAME TYPE", "type"), ("LOCKED FIRST", "locked")], + "The list's order. Its column titles sort it too;\nchoosing one again reverses it.", None), + ("MASTER SERVERS:", "network.masters_file", None, + "The master servers (masters.txt) that answered\nwhen the Server Browser last asked.", None, + "master_servers"), + ], +} +# (the read-only row's words, its screen's words: menu_functions.c's +# master_servers_show) +SCREENS["network_setup/server_browser"]["words"] = ["OF", "NONE LISTED", "NOT ASKED YET", "OFF"] + # Network Setup's multiplayer HUD: its rows, moved from Network Setup, and the # text chat's (port/linux/game/network_chat.c) SCREENS["network_setup/multiplayer_hud"] = { @@ -354,7 +387,8 @@ SCREENS["network_setup/streamer_mode"] = { # the clipboard, the updates) SCREENS["network_setup/in_game"] = { "screen": "in_game_network_settings_screen", "header": _network["header"], "spacing": 30, - "platform_places": True, "rows": [], "categories": _network["categories"], + "platform_places": True, "rows": [], + "categories": [category for category in _network["categories"] if category[1] != "network_setup/server_browser"], } # Controls Setup: the keyboard and mouse's actions, in groups (the order of @@ -364,7 +398,7 @@ CONTROL_ROWS = 7 # the settings whose rows have the wider spinner (a device's name) WIDE_SETTINGS = {"audio.output_device", "audio.input_device", "streamer.slurs", "streamer.swearing", - "streamer.insults"} + "streamer.insults", "network.listings", "network.browser_maps", "network.browser_sort"} # the host's voice chat and vote kicks, on Teamplay Options # (_teamplay_options_extras): each row's key, its label, its choices' words @@ -510,6 +544,21 @@ def _setting_screen(folder: str, spec: dict) -> list: if setting not in spec.get("same_place", ()) and key not in spec.get("same_place", ()): place += 1 rows.append((row, platform, place)) + # (a row with no values shows its value, set by the game: its text) + if choices is None: + extra += _widget(row, [("width", 512), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF"), ("platform", platform)], + [f'', + f'']) + extra += _widget(f"{base}/{key}_label", + [("type", "text"), ("controller", 1), ("width", 300), ("height", 22), + ("string_list", f"{base}/labels"), ("string_index", index), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) + extra += _widget(f"{base}/{key}_value", + [("type", "text"), ("controller", 1), ("width", 212), ("height", 22), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("align", "center"), ("text_y", 1), + ("text_flags", "no_focus_test")], []) + continue # (a device's name wants the wider spinner Server Setup's co-op rows # have: WIDE_SETTINGS) wide = setting in WIDE_SETTINGS @@ -560,6 +609,8 @@ def _setting_screen(folder: str, spec: dict) -> list: '']) extra += _strings(f"{base}/labels", [label for label, *_ in spec["rows"]] + [label for label, *_ in spec.get("categories", ())]) + if spec.get("words"): + extra += _strings(f"{base}/words", spec["words"]) # (the help of the row whose label is string n is n + 1: the buttons' is 0) extra += _strings(f"{base}/help_strings", [""] + [help_text.replace("\n", "\\n") for _, _, _, help_text, *_ in spec["rows"]] + @@ -710,6 +761,10 @@ OPENCE_ROWS = [ OPENCE_ITEM = f"{PLAYLIST_EDIT}/playlist_edit_opence_item" STRING_INSERTS = { + # (Filters' SHOW MISSING MAPS' third value's help, after the PC version's: + # menu_functions.c's filters_help) + f"{MT}/join_game/filters/cap_filter_options": [(11, [ + "Show servers on maps you do not have if their\\nhost shares them: they download as you join."])], # (the gametype editor's OPENCE OPTIONS' words, after the PC version's: # menu_functions.c's playlist_edit_description) f"{PLAYLIST_EDIT}/playlist_edit_list_extended_dsc_text": [(8, [ @@ -902,6 +957,43 @@ WIDGET_PATCHES = { ]}, # (the small font's line does not fit below their 4 units of offset) f"{MT}/join_game/ticker_player_info": {"set": {"text_y": 1}}, + # (the server browser's top-left tab: which games it lists, not Internet + # or LAN, which are menu items of their own here; wider for its words, in + # the small font of the counts beside it, and moved left to stay centred + # in its tab: ce_menus.py's CHILD_OFFSETS) + # (the tab behind it without CE's rounded box: the screen's border + # picture already outlines the tab, and the two outlines clashed) + f"{MT}/join_game/op_browser_mode": {"set": {"bitmap": None}}, + f"{MT}/join_game/browser_mode_spinner": {"set": { + "string_list": None, "strings": "ALL GAMES|DEDICATED|P2P GAMES", "setting": "network.browser_source", + "values": "all|dedicated|players", "width": 117, "footer_bounds": "7 120 19 126", "font": "ui\\small_ui", + "text_y": 3}}, + # (the server browser's error panel: its words in a box of their own in + # the error box's place, under its title and button; menu_functions.c's + # lobby_browser_error hides the error box) + f"{MT}/join_game/gamespy_error_fullscreen": {"insert_before": {"error/error_modal": [ + f'']}}, + # (wider to the left, for "SERVERS: 8 (5 dedicated)": ce_menus.py's + # CHILD_OFFSETS keeps its right edge) + f"{MT}/join_game/server_count_label": {"set": {"width": 210}}, + # (the page, when there is more than one: centred in the counts' panel, + # between the players and the servers, whatever their numbers' widths; + # menu_functions.c's lobby_browser_update places it) + f"{MT}/join_game/page_count_label": {"set": {"align": "center"}}, + # (Filters' rows set the network.browser_* settings, as Network Setup's + # SERVER BROWSER does: menu_functions.c's filters_show and + # "gamespy update filter settings". SHOW MISSING MAPS has a third value, + # those a host shares: Map Sharing downloads them) + f"{MT}/join_game/filters/empty_servers_spinner": {"set": { + "string_list": None, "strings": "NO|YES", "setting": "network.browser_show_empty", "values": "false|true"}}, + f"{MT}/join_game/filters/full_servers_spinner": {"set": { + "string_list": None, "strings": "NO|YES", "setting": "network.browser_show_full", "values": "false|true"}}, + f"{MT}/join_game/filters/game_type_spinner": {"set": { + "string_list": None, "strings": "ANY|CTF|SLAYER|ODDBALL|KING|RACE", "setting": "network.browser_gametype", + "values": "any|ctf|slayer|oddball|king|race"}}, + f"{MT}/join_game/filters/installed_maps_spinner": {"set": { + "string_list": None, "strings": "NO|YES|IF DOWNLOADABLE", "setting": "network.browser_maps", + "values": "no|yes|downloadable"}}, f"{MT}/join_game/ticker_rules_info": {"set": {"text_y": 1}}, # (Item Options' loadout rows, over its buttons) "main_menu/settings_select/multiplayer_setup/item_options_edit/item_options_menu": {"insert_before": { @@ -1076,11 +1168,50 @@ def _value_row(base: str, key: str, label_index: int, run: str) -> list: return lines +# the server browser's words (menu_functions.c's lobby_browser_update, in +# this order: its _browser_word_*): the line below the rows, the rows' status, +# the counts, and the error panel's +BROWSER_WORDS = [ + # (the chosen game's line: "12 of 16 players", and its state) + "of", "players", "player", "under way", "full or starting", + # (where it was listed, and what it needs) + "Dedicated server by", "listed on", "master server", "master servers", "Dedicated server", + "Hosted by a player (anonymous listing)", "Password required", + "Needs the newer build: Settings > Network Setup > Check for updates", + "Runs an older build: it can be joined once it updates", + # (a join, as it goes: p2p_join_progress) + "Asking the brokers for the host...", "The host answered: connecting (UDP hole punching)...", + "Connected: joining the game...", + "The host did not answer (offline, or its listing is stale)", + "No route to the host: a forwarded port on either side helps", + "The host refused: full", "The host refused: closed", "The host refused: banned", + "The host refused: wrong password", "The host refused: another version", "The host refused: its invite changed", + "Strict NAT: turn on UPnP, or forward a port", + # (the list's state) + "Internet play is off (Settings)", "The server browser is off (network.public_lobby)", + "Looking for public games...", "Games on a newer version (update the game to join them):", + "No public games found", "No games match the filters", + "No master server answered: showing player-hosted games only", + # (the counts: "SERVERS: 8 (3 dedicated)"; the rows' status) + "dedicated", "JOINING", "NEWER", "OLDER", "FAILED", "LIVE", "CLOSED", + # (the error panel: nothing can be listed) + "Internet play is off:\\nSettings > Network Setup", + "Neither the master servers\\nnor the brokers answer:\\ncheck the connection", +] + + def _join_game_extras() -> list: - """the browser's added titles, Direct Link's clipboard button, and the - password screen""" + """the browser's added titles, Direct Link's clipboard button, the + password screen, and the browser's words""" base = f"{MT}/join_game" lines = _password_screen() + lines += _strings(f"{base}/browser_words", BROWSER_WORDS) + # (the error panel's words, in a box as the error box: its own text is + # the game's error messages') + lines += _widget(f"{base}/error_words", [("type", "text"), ("controller", 1), ("width", 280), ("height", 159), + ("bitmap", "bitmaps/alert_bkd"), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 9), + ("text_y", 35), ("text_flags", "no_focus_test")], []) lines += _header(f"{base}/header_server_browser", f"{base}/header_server_browser") lines += _header(f"{base}/header_direct_link", f"{base}/header_direct_link") lines += _widget(f"{base}/button_clipboard", [("type", "text"), ("width", 128), ("height", 24), @@ -1256,8 +1387,8 @@ def _server_settings() -> list: "Each team's vehicles and their respawn time, for\\nthis game.", "The motion tracker and nav points, for this game.", "Friendly fire and team balance, for this game.", - # (LISTING's, by its choice) - "Anyone can see and join your game: it is listed\\nin everyone's Server Browser.", + # (LISTING's, by its choice; PUBLIC's with a publisher key is last) + "Anyone can see and join your game: it is listed\\nfor everyone through the brokers.", "Only players with your invite link can join.", # (co-op's options' rows: COOP_SETUP_SCREENS) *(help_text for _, _, help_text, _ in COOP_SETUP_SCREENS), @@ -1265,6 +1396,9 @@ def _server_settings() -> list: "Players from the Server Browser must type it to\\njoin (an invite link needs none). Enter sets it.", # (MAP SHARING's) MAP_SHARE_SETUP[2], + # (LISTING's PUBLIC, on a machine with a publisher key: menu_functions.c's + # server_settings_update) + "Listed for everyone through the brokers, and on\\nthe master servers with your publisher key.", ]) lines = _screen(base, spec, rows, ["server settings update"], [''], extra) @@ -1489,6 +1623,9 @@ def _lobby() -> list: lines += _widget(f"{base}/lobby_game_data", [("type", "text"), ("controller", 1), ("left", 417), ("top", 250), ("width", 146), ("height", 144), ("font", "ui\\small_ui"), ("color", "#FF2896FF")], []) + # (its words: menu_functions.c's lobby_update. " - dedicated": the + # game's host is a dedicated server) + lines += _strings(f"{base}/lobby_words", ["dedicated"]) lines += _widget(f"{base}/lobby_button_bar", [("type", "column_list"), ("width", 640), ("height", 28), ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")], # (SWITCH TEAM first: hidden in a game without teams, which diff --git a/tools/release_sign.sh b/tools/release_sign.sh new file mode 100755 index 00000000..24d756ca --- /dev/null +++ b/tools/release_sign.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# A release's SHA256SUMS signed, and checked, with the project's Ed25519 +# release key (.github/workflows/build.yml signs each release's): +# +# RELEASE_SIGNING_KEY= tools/release_sign.sh sign SHA256SUMS +# tools/release_sign.sh verify SHA256SUMS +# +# sign writes .sig: the Ed25519 signature of the file's exact bytes, +# as 128 lowercase hex digits and a newline (the form opence-master-server's +# cmd/release-sign writes). verify checks .sig against the public key +# of deploy/release-key.pub (64 hex digits), or RELEASE_PUBLIC_KEY's; it +# exits 0 when the signature is good, 1 when it is not. Needs OpenSSL 3 +# (openssl pkeyutl -rawin) and the POSIX tools; the seed only ever goes to a +# file of a private temporary folder, removed at the end, and is never +# printed. +set -eu + +usage() { + echo "usage: RELEASE_SIGNING_KEY= $0 sign | $0 verify " >&2 + exit 2 +} + +# the hex digits of the standard input as the bytes they spell, on the +# standard output (printf's octal escapes: no xxd needed) +hex_bytes() { + escapes=$(tr -d ' \t\r\n' | sed 's/../& /g' | awk ' + BEGIN { digits = "0123456789abcdef" } + { + for (i = 1; i <= NF; i++) { + pair = tolower($i) + printf "\\0%03o", (index(digits, substr(pair, 1, 1)) - 1) * 16 + index(digits, substr(pair, 2, 1)) - 1 + } + }') + printf '%b' "$escapes" +} + +# whether the text is exactly so many hex digits +is_hex() { + case $1 in + *[!0-9a-fA-F]*) return 1 ;; + esac + [ "${#1}" -eq "$2" ] +} + +[ $# -eq 2 ] || usage +command=$1 +file=$2 +[ -f "$file" ] || { echo "$0: no file $file" >&2; exit 2; } +root=$(cd "$(dirname "$0")/.." && pwd) +umask 077 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT INT TERM + +case $command in +sign) + seed=${RELEASE_SIGNING_KEY:-} + if ! is_hex "$seed" 64; then + echo "$0: RELEASE_SIGNING_KEY is not a seed of 64 hex digits" >&2 + exit 2 + fi + # (the seed as an Ed25519 private key's PKCS#8 DER: RFC 8410's prefix) + printf '%s' "302e020100300506032b657004220420$seed" | hex_bytes > "$work/key.der" + openssl pkeyutl -sign -rawin -inkey "$work/key.der" -keyform DER -in "$file" -out "$work/signature" + signature=$(od -An -v -tx1 "$work/signature" | tr -d ' \n') + if ! is_hex "$signature" 128; then + echo "$0: openssl did not make a signature of 64 bytes" >&2 + exit 1 + fi + printf '%s\n' "$signature" > "$file.sig" + echo "$file.sig written" + ;; +verify) + public=${RELEASE_PUBLIC_KEY:-$(head -n 1 "$root/deploy/release-key.pub" | tr -d ' \t\r')} + if ! is_hex "$public" 64; then + echo "$0: the public key is not 64 hex digits" >&2 + exit 2 + fi + [ -f "$file.sig" ] || { echo "$0: no $file.sig" >&2; exit 1; } + signature=$(head -n 1 "$file.sig" | tr -d '\r') + if ! is_hex "$signature" 128; then + echo "$0: $file.sig is not 128 hex digits" >&2 + exit 1 + fi + # (the public key as a SubjectPublicKeyInfo's DER: RFC 8410's prefix) + printf '%s' "302a300506032b6570032100$public" | hex_bytes > "$work/public.der" + printf '%s' "$signature" | hex_bytes > "$work/signature" + if openssl pkeyutl -verify -pubin -inkey "$work/public.der" -keyform DER -rawin -in "$file" \ + -sigfile "$work/signature" > /dev/null 2>&1; then + echo "$file: the signature is good" + else + echo "$file: the signature is not the release key's" >&2 + exit 1 + fi + ;; +*) + usage + ;; +esac diff --git a/tools/server_sim.py b/tools/server_sim.py new file mode 100644 index 00000000..8264441d --- /dev/null +++ b/tools/server_sim.py @@ -0,0 +1,3130 @@ +#!/usr/bin/env python3 +"""The dedicated server's protocol simulator: the HTTP API, the RCON over +WebSocket and the admin page of docs/dedicated_server.md, with a scripted +server behind them and no game. + + python3 tools/server_sim.py --port 8099 then open http://127.0.0.1:8099/ + +It serves the page's files from port/assets/server_ui (read at each request, +so an edit shows on a reload), plays games one after another with players +joining, chatting, killing and leaving, a speed hack and a kick, and keeps +the doc's rules: the headers, sessions, CSRF and Origin, the roles, +lockout. It is the page's development server and the reference that the +page's tests (tools/server_ui_tests) and third-party tools test against. + +Like the real server: --trusted-proxy
    believes that proxy's +X-Forwarded-For (its last entry), -Host and -Proto (and only then marks the +cookie Secure); --request-limit counts each address's requests (60 a minute +without a session, 600 with one, WebSocket upgrades too: off by default, as +the tests sign in often). --owner-password signs in as the owner from +loopback alone (a connection's own address in 127.0.0.0/8, not the trusted +proxy's), as server.web_owner_password does. A stop (quit, sv_stop, +sv_restart, a drain's end) is said (the shutdown event) and the simulator +goes on, unless --exit-on-stop: then it exits with the stop's code. + +Fault injection: --slow-consumer (each WebSocket is written slowly and a +burst follows a subscription, so its queue overflows), --drop (WebSockets +are cut without a close frame a few seconds after they open, and one API +request in ten loses its connection unanswered), --big-replies (command +replies of more than 1 MiB), --fragment (every message the server sends is +split into continuation frames, with a ping between them). + +The first line on standard output is `listening on http://
    :` +(with --port 0 a free port is taken). Standard library only. +""" + +import argparse +import base64 +import hashlib +import hmac +import http +import ipaddress +import json +import os +import random +import re +import secrets +import socket +import socketserver +import struct +import sys +import threading +import time +from collections import deque +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +UI_DIR = ROOT / "port" / "assets" / "server_ui" + +ROLES = ["viewer", "moderator", "admin", "owner"] +SUBPROTOCOL = "opence-rcon-1" +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +MAXIMUM_HEAD = 8 * 1024 +MAXIMUM_BODY = 64 * 1024 +HEAD_SECONDS = 10 +SESSION_LIFETIME = 12 * 3600 +SESSION_IDLE = 30 * 60 +MAXIMUM_SESSIONS = 16 +LOG_RING = 2048 +EVENT_RING = 512 +WS_QUEUE = 1024 +WS_CONNECTIONS = 8 +WS_FRAME = 64 * 1024 +WS_MESSAGE = 256 * 1024 +BUILD = "build-206" +COMMAND_LINE = 511 +ROTATION_TEXT = 4096 +ENTRY_TEXT = 255 +NOTICE_SIZE = 128 +NETWORK_VERSION = 38 +# the RCON's hello's capabilities, and the status's (server_http.c's server_http_capabilities) +CAPABILITIES = ["events", "status", "rotation", "settings", "bans", "owner_web", "ban_address", "stop", + "event_addresses", "identity"] +PUBLISHER_KEY_ID = "3f9a2c7d1e5b8a04" +DRAINS = ("now", "game", "empty", "cancel") + +CSP = "default-src 'self'; frame-ancestors 'none'" +SECURITY_HEADERS = [ + ("Cache-Control", "no-store"), + ("X-Content-Type-Options", "nosniff"), + # (same-origin, not no-referrer: under no-referrer a browser sends the page's own posts with Origin: null) + ("Referrer-Policy", "same-origin"), + ("Content-Security-Policy", CSP), +] +ASSETS = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/app.css": ("app.css", "text/css; charset=utf-8"), + "/icon.svg": ("icon.svg", "image/svg+xml"), +} + +# ---------- the CSP check on the page's files (the build's embedder uses it too) + +_JS_FORBIDDEN = [ + (r"\binnerHTML\b|\bouterHTML\b|\binsertAdjacentHTML\b", "HTML set from a string"), + (r"\bdocument\.write", "document.write"), + (r"(?]*)>(.*?)", text, re.I | re.S): + if kind == "svg" or not re.search(r"\bsrc\s*=", match.group(1), re.I) or match.group(2).strip(): + problems.append("an inline ") + assert server_sim.csp_problems("index.html", '') + assert not server_sim.csp_problems("index.html", '') + assert server_sim.csp_problems("index.html", "") + assert server_sim.csp_problems("index.html", '

    ') + assert server_sim.csp_problems("index.html", '