4 Commits
Author SHA1 Message Date
Nomads_ReachandGitHub 7995f71b4b Merge pull request #1 from G-A-R-D-E-N/ci/sync-to-gitea
Open a Gitea PR automatically when main is merged on GitHub
2026-08-15 20:53:57 -04:00
NomadsReach 34fb54990c Open a Gitea PR automatically when main is merged on GitHub 2026-08-15 20:53:03 -04:00
andrew dab76146cf Handle config path args in server startup
Linux Compatibility / Ubuntu dedicated server (push) Canceled after 0s
Linux Compatibility / Arch Linux container (push) Canceled after 0s
Updated server launch behavior to reliably treat JSON file arguments as config paths, including file-manager “Open with” cases. The CLI `serve` command now accepts an optional positional config path (equivalent to `--config`) and errors on conflicting values. `start.sh` and `start.bat` now parse config-related arguments more explicitly, validate required values, and always pass `--config` to avoid accidental positional forwarding. Added tests covering both positional and `--config` forms, plus README documentation for the new startup behavior.
2026-08-01 21:49:48 +12:00
andrew 881aa33eef Harden Linux server runtime and packaging
Linux Compatibility / Ubuntu dedicated server (push) Has been cancelled
Linux Compatibility / Arch Linux container (push) Has been cancelled
Adds end-to-end Linux compatibility work for the dedicated server: new CI workflow (Ubuntu + Arch), line-ending/executable safeguards, and a local venv-first startup flow with split dependency files for server vs optional host GUI tooling. Improves runtime resilience with better bind error messages, stricter config validation, writable-state checks, cleaner socket/thread shutdown behavior, and SIGTERM-aware graceful stop handling for headless/systemd use. Updates deployment/startup docs and adds portability/runtime integration tests to lock in these behaviors.
2026-08-01 21:39:23 +12:00
30 changed files with 1713 additions and 270 deletions
+45
View File
@@ -0,0 +1,45 @@
# Normalize text files by default; platform-specific EOL below.
* text=auto
# Shell / service / config (LF)
*.sh text eol=lf
*.py text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.json text eol=lf
*.toml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.cmake text eol=lf
*.qrc text eol=lf
*.h text eol=lf
*.cpp text eol=lf
*.c text eol=lf
*.hpp text eol=lf
CMakeLists.txt text eol=lf
# Windows scripts (CRLF)
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.exe binary
*.dll binary
*.zip binary
*.tar binary
*.gz binary
*.lib binary
*.pdb binary
*.obj binary
*.o binary
*.a binary
*.so binary
*.dylib binary
+108
View File
@@ -0,0 +1,108 @@
name: Linux Compatibility
on:
push:
paths:
- "server/**"
- ".gitattributes"
- ".gitignore"
- ".github/workflows/linux-compatibility.yml"
pull_request:
paths:
- "server/**"
- ".gitattributes"
- ".gitignore"
- ".github/workflows/linux-compatibility.yml"
jobs:
ubuntu:
name: Ubuntu dedicated server
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
- name: Verify start.sh LF and executable bit
run: |
python3 - <<'PY'
from pathlib import Path
data = Path("start.sh").read_bytes()
assert b"\r\n" not in data, "start.sh must use LF line endings"
PY
test -x start.sh
- name: Syntax and shellcheck
run: |
bash -n start.sh
shellcheck start.sh
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Create clean venv and install server deps
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-server.txt pytest
# Ensure GUI-only deps are not required for dedicated server
! .venv/bin/python -c "import PySide6" 2>/dev/null
- name: Compile and test
run: |
.venv/bin/python -m compileall -q .
.venv/bin/python -m pytest -q
.venv/bin/python test_npc_protocol.py
.venv/bin/python test_combat_protocol.py
arch:
name: Arch Linux container
runs-on: ubuntu-latest
container:
image: archlinux:latest
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install packages
run: |
pacman -Syu --noconfirm python python-pip shellcheck
- name: Verify start.sh LF and executable bit
run: |
python - <<'PY'
from pathlib import Path
data = Path("start.sh").read_bytes()
assert b"\r\n" not in data, "start.sh must use LF line endings"
PY
test -x start.sh
- name: Syntax and shellcheck
run: |
bash -n start.sh
shellcheck start.sh
- name: Create clean venv and install server deps
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-server.txt pytest
- name: Compile and test
run: |
.venv/bin/python -m compileall -q .
.venv/bin/python -m pytest -q
.venv/bin/python test_npc_protocol.py
.venv/bin/python test_combat_protocol.py
+66
View File
@@ -0,0 +1,66 @@
name: Open Gitea PR on merge to main
# When main changes on GitHub (i.e. after a PR is merged here), push those
# commits to a branch on Gitea and open a pull request there, so the same
# change can be reviewed and landed on the Gitea side. One-way: GitHub -> Gitea.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
open-gitea-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Push main to Gitea and open a pull request
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_HOST: git.zambazosmedia.group
GITEA_USER: nomad
GITEA_REPO: Commonwealth-Online/Commonwealth-Online-Server
SYNC_BRANCH: sync/from-github
run: |
set -euo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::Missing GITEA_TOKEN secret. Add a Gitea access token as a"
echo "::error::repository secret named GITEA_TOKEN (Settings -> Secrets and"
echo "::error::variables -> Actions -> New repository secret)."
exit 1
fi
git config user.name "github-sync"
git config user.email "github-sync@users.noreply.github.com"
# Mirror the current main onto a dedicated Gitea branch. Force is safe:
# this branch is owned by the automation and only ever tracks GitHub main.
git remote add gitea "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_REPO}.git"
git push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
# Open a PR on Gitea: sync/from-github -> main. If one is already open,
# the push above has already updated it, so a 409 is success too.
http_code=$(curl -sS -o /tmp/gitea_pr.json -w "%{http_code}" -X POST \
"https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"Automated: GitHub main was updated. Review and merge to land it on Gitea.\"}")
echo "Gitea pulls API returned HTTP ${http_code}"
cat /tmp/gitea_pr.json || true
echo
if [ "${http_code}" = "201" ]; then
echo "Opened a new Gitea pull request."
elif [ "${http_code}" = "409" ] || grep -qiE "already exist|issue_exist" /tmp/gitea_pr.json; then
echo "A Gitea PR from ${SYNC_BRANCH} is already open; it now has the latest commits."
else
echo "::warning::Unexpected Gitea response (${http_code}). The branch was pushed;"
echo "::warning::open the PR manually on Gitea if it did not appear."
fi
+2
View File
@@ -42,6 +42,7 @@ __pycache__/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.venv/ .venv/
.venv-ci/
venv/ venv/
env/ env/
@@ -49,5 +50,6 @@ env/
*.log *.log
*.tmp *.tmp
*.bak *.bak
logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+32 -2
View File
@@ -162,11 +162,12 @@ host-gui/
## Creating a Portable Distribution ## Creating a Portable Distribution
### Windows Host GUI (unchanged)
To create a self-contained package anyone can run: To create a self-contained package anyone can run:
```bash ```bash
# Build the application # Build the application from the repository root
cd host-gui
build.bat build.bat
# Deploy DLLs # Deploy DLLs
@@ -183,6 +184,35 @@ copy README.md Commonwealth-Online-Host\README.txt
``` ```
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed! Users extract and run `CommonwealthOnlineHost.exe` - no setup needed!
Target machines still need Python 3.9+ on PATH for the bundled relay server.
### Linux dedicated server packaging
Prefer a `.tar.gz` archive of the `server/` directory so the executable bit on `start.sh` is retained.
Do **not** include:
- `server/.venv/`
- `server/__pycache__/`
- `server/logs/`
- local runtime files such as operator-specific `bans.json` unless intentional
Example:
```bash
tar --exclude='.venv' --exclude='__pycache__' --exclude='logs' \
--exclude='*.pyc' -czf commonwealth-online-server-linux.tar.gz -C server .
```
If you also ship a ZIP archive, document that some extraction tools may drop Unix executable permissions. Recipients can recover with:
```bash
sed -i 's/\r$//' start.sh
chmod +x start.sh
./start.sh
```
Windows Host GUI packaging remains ZIP-based and is unchanged by the Linux packaging guidance above.
--- ---
+22
View File
@@ -6,6 +6,28 @@ Production-ready Qt6 GUI application for hosting Commonwealth Online servers on
This repository is for building the **Host GUI**. The GUI wraps the same Python server from `server/` in a native Windows UI. This repository is for building the **Host GUI**. The GUI wraps the same Python server from `server/` in a native Windows UI.
## Linux dedicated server
The dedicated server lives in [`server/`](server/) and is the supported way to host on Ubuntu, Debian, Arch Linux, CachyOS, Fedora, and similar distributions.
```bash
cd server
# Ubuntu/Debian: sudo apt install python3 python3-venv
# Arch/CachyOS: sudo pacman -S --needed python
# Fedora: sudo dnf install python3
chmod +x start.sh
./start.sh
```
Important notes:
- `./start.sh` creates a local `.venv` and never uses `--break-system-packages`
- Do not run `sudo ./start.sh`
- Fish shell users do not need to activate the virtual environment
- Allow TCP `7777` (and optionally UDP `7778` for LAN discovery) through the firewall
- `0.0.0.0` is a bind address, not the address players should join
- See [server/README.md](server/README.md) for systemd, journalctl, CRLF recovery, and admin-CLI usage
## Quick Start (GUI) ## Quick Start (GUI)
**First time? Follow the [Setup Instructions](SETUP.md)** **First time? Follow the [Setup Instructions](SETUP.md)**
+7 -1
View File
@@ -19,7 +19,11 @@ file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
# Prefer a filtered copy so Python caches do not ship with the build. # Prefer a filtered copy so Python caches do not ship with the build.
file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*") file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*")
foreach(_entry IN LISTS _server_entries) foreach(_entry IN LISTS _server_entries)
if(_entry STREQUAL "__pycache__" OR _entry STREQUAL ".pytest_cache") if(_entry STREQUAL "__pycache__"
OR _entry STREQUAL ".pytest_cache"
OR _entry STREQUAL ".venv"
OR _entry STREQUAL "logs"
OR _entry STREQUAL "tests")
continue() continue()
endif() endif()
file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}" file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}"
@@ -27,6 +31,8 @@ foreach(_entry IN LISTS _server_entries)
PATTERN "__pycache__" EXCLUDE PATTERN "__pycache__" EXCLUDE
PATTERN "*.pyc" EXCLUDE PATTERN "*.pyc" EXCLUDE
PATTERN ".pytest_cache" EXCLUDE PATTERN ".pytest_cache" EXCLUDE
PATTERN ".venv" EXCLUDE
PATTERN "logs" EXCLUDE
) )
endforeach() endforeach()
+7 -1
View File
@@ -31,7 +31,13 @@ Option 3: Use the CLI with port override
----------------------------------------- -----------------------------------------
Open a terminal in the server folder and run: Open a terminal in the server folder and run:
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000 # Windows
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
# Linux / macOS
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
If .venv does not exist yet, run start.bat / ./start.sh once first.
Option 4: Find and kill the blocking process manually Option 4: Find and kill the blocking process manually
------------------------------------------------------ ------------------------------------------------------
+157 -47
View File
@@ -1,26 +1,32 @@
# Commonwealth Online Server - Quick Start # Commonwealth Online Server - Quick Start
Standalone dedicated relay server. No Qt/Host GUI build is required.
## Quick Launch ## Quick Launch
**Windows:** double-click `start.bat`, or run it from a terminal: ### Windows
``` Double-click `start.bat`, or run it from a terminal:
```bat
start.bat start.bat
``` ```
**Linux / macOS:** ### Linux / macOS
```bash ```bash
chmod +x start.sh fix-port.sh # once chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost
./start.sh ./start.sh
``` ```
The start script will: The start script will:
1. Check that Python 3.9+ is installed 1. Check that Python 3.9+ is installed
2. Install required dependencies (typer, rich) 2. Create a local `.venv` virtual environment (never installs into the OS Python)
3. Generate a default `commonwealth-server.json` config file (if needed) 3. Install dedicated-server dependencies from `requirements-server.txt` when needed
4. Start the server listening on `0.0.0.0:7777` 4. Generate a default `commonwealth-server.json` config file (if needed)
5. Open an interactive `commonwealth>` prompt in the same window 5. Start `consumer_server_cli.py` listening on `0.0.0.0:7777` by default
6. Open an interactive `commonwealth>` prompt when stdin/stdout are a real terminal
At the prompt you can type commands directly, for example: At the prompt you can type commands directly, for example:
@@ -35,6 +41,109 @@ quit
`users` live-updates the player table every second (press Enter to stop). `users` live-updates the player table every second (press Enter to stop).
Update dependencies explicitly when needed:
```bash
./start.sh --update-dependencies
```
`--update-dependencies` is consumed by the start script and is **not** forwarded to the server.
If a file manager “Open with” passes `commonwealth-server.json` as an argument, `start.sh` treats that path as the config file and still launches with `--config` (it is not forwarded as a bare positional argument).
---
## Linux Installation
Do **not** run `sudo ./start.sh`. Do **not** use `pip install --break-system-packages`.
The script creates `.venv` automatically and invokes `.venv/bin/python` directly.
Fish users do not need to activate anything.
### Ubuntu and Debian
```bash
sudo apt install python3 python3-venv
chmod +x start.sh
./start.sh
```
### Arch Linux and CachyOS
```bash
sudo pacman -S --needed python
chmod +x start.sh
./start.sh
```
### Fedora
```bash
sudo dnf install python3
chmod +x start.sh
./start.sh
```
### Firewall and networking
- Allow **TCP 7777** (or your configured game port) through the host firewall.
- LAN discovery uses **UDP 7778**. If discovery is blocked, clients can still connect directly by IP/port.
- The admin channel binds to **127.0.0.1:7779** and should stay localhost-only.
- Router port forwarding is only needed when hosting behind a home router for outside connections.
- VPS users normally only need the provider firewall and OS firewall configured.
- `0.0.0.0` is a **bind address**, not the address clients should enter.
- Direct connections still work if LAN discovery is unavailable.
Example firewall openings:
```bash
# firewalld
sudo firewall-cmd --add-port=7777/tcp --permanent
sudo firewall-cmd --add-port=7778/udp --permanent
sudo firewall-cmd --reload
# ufw
sudo ufw allow 7777/tcp
sudo ufw allow 7778/udp
```
### systemd (optional)
1. Install the server files under `/opt/commonwealth-online` (or another path).
2. Create an unprivileged `commonwealth` user/group.
3. Run `./start.sh --update-dependencies` once as that user to create `.venv`.
4. Copy and edit [`commonwealth-online.service.example`](commonwealth-online.service.example):
```bash
sudo cp commonwealth-online.service.example /etc/systemd/system/commonwealth-online.service
sudo systemctl daemon-reload
sudo systemctl enable --now commonwealth-online
journalctl -u commonwealth-online -f
```
Manage a headless/systemd server from another shell with the admin CLI:
```bash
.venv/bin/python -u consumer_server_cli.py status
.venv/bin/python -u consumer_server_cli.py users
.venv/bin/python -u consumer_server_cli.py help
```
`systemctl stop commonwealth-online` sends SIGTERM and the server shuts down cleanly.
### CRLF / executable-bit recovery
Git clones should keep LF endings for `start.sh` because of `.gitattributes`.
If you extracted a ZIP on Windows or otherwise lost Unix permissions/line endings:
```bash
sed -i 's/\r$//' start.sh
chmod +x start.sh
```
Prefer `.tar.gz` Linux releases so the executable bit is retained.
---
### First Run ### First Run
On first run, a default `commonwealth-server.json` file is created in the server directory with these settings: On first run, a default `commonwealth-server.json` file is created in the server directory with these settings:
@@ -44,91 +153,92 @@ On first run, a default `commonwealth-server.json` file is created in the server
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 7777, "port": 7777,
"server_name": "Commonwealth Online Server", "server_name": "Commonwealth Online Server",
"server_description": "",
"max_players": 16, "max_players": 16,
"log_verbosity": "info" "log_verbosity": "info",
"admin_port": 7779
} }
``` ```
### Port Already in Use? ### Port Already in Use?
If you get an error like "Only one usage of each socket address ... is normally permitted", port 7777 is already in use by another process. If you get an address-already-in-use error, the game port is already taken.
**Option 1: Kill the blocking process** **Option 1: Kill the blocking process**
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS) to: Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS).
- Detect which process is using port 7777
- Kill the process
- Restart the server
**Option 2: Use a different port** **Option 2: Use a different port**
Run `fix-port.bat` / `./fix-port.sh` and select "Use a different port" to change the port in your config file. Use the same helper and choose “Use a different port”, or edit `commonwealth-server.json`.
Alternatively, manually edit `commonwealth-server.json` and change the `"port"` value to something like `7778`, `8000`, or `9999`.
### Customizing the Server ### Customizing the Server
Edit `commonwealth-server.json` to customize: Edit `commonwealth-server.json` to customize:
- **host**: Bind address (default `0.0.0.0` for all interfaces) - **host**: Bind address (default `0.0.0.0` for all interfaces)
- **port**: Server port (default `7777`) - **port**: Game TCP port (default `7777`)
- **server_name**: Display name - **server_name**: Display name
- **server_description**: Optional short description
- **max_players**: Metadata for client displays - **max_players**: Metadata for client displays
- **log_verbosity**: `debug`, `info`, `warning`, or `error` (`info` hides per-packet transform spam; use `debug` for protocol troubleshooting) - **log_verbosity**: `debug`, `info`, `warning`, or `error`
- **admin_port**: Localhost admin TCP port (default `7779`)
### Connecting Clients ### Connecting Clients
Once the server is running: Once the server is running:
- **Local PC**: Connect to `127.0.0.1:7777` (or your custom port) - **Local PC**: Connect to `127.0.0.1:7777` (or your custom port)
- **LAN**: Connect to your PC's local IP (displayed on startup, e.g., `192.168.1.64:7777`) - **LAN**: Connect to your PC's local IP (displayed on startup, e.g. `192.168.1.64:7777`)
- **Remote**: Forward port 7777/TCP on your router and use your public IP - **Remote**: Forward TCP 7777 on your router and use your public IP
### Stopping the Server ### Stopping the Server
Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`. Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`.
Under systemd use `systemctl stop commonwealth-online`.
---
## Dependencies
| File | Purpose |
|------|---------|
| `requirements-server.txt` | Dedicated CLI/headless server (typer, rich) |
| `requirements-host-gui.txt` | Optional Python PySide6 GUI (`dev_server_app.py`) |
| `requirements.txt` | Compatibility aggregate (includes PySide6) |
Dedicated servers should install only `requirements-server.txt`. The Windows Qt Host GUI wraps this CLI and does not need PySide6.
--- ---
## For Advanced Users (Command Line) ## For Advanced Users (Command Line)
If you prefer command-line usage, use the CLI directly:
```bash ```bash
cd server cd server
pip install -r requirements.txt python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-server.txt
# Generate config # Generate config
python consumer_server_cli.py config init my-config.json .venv/bin/python -u consumer_server_cli.py config init my-config.json
# Start server with interactive prompt (same as start.bat / start.sh) # Start server with interactive prompt (same as start.sh on a TTY)
python consumer_server_cli.py serve --config my-config.json --interactive .venv/bin/python -u consumer_server_cli.py serve --config my-config.json --interactive
# Start server without a prompt (Host GUI / headless) # Start server without a prompt (Host GUI / systemd / headless)
python consumer_server_cli.py serve --config my-config.json .venv/bin/python -u consumer_server_cli.py serve --config my-config.json
# Management commands from another terminal while a server is running # Management commands from another terminal while a server is running
python consumer_server_cli.py help .venv/bin/python -u consumer_server_cli.py help
python consumer_server_cli.py status .venv/bin/python -u consumer_server_cli.py status
python consumer_server_cli.py users .venv/bin/python -u consumer_server_cli.py users
python consumer_server_cli.py world time 1430 .venv/bin/python -u consumer_server_cli.py world time 1430
python consumer_server_cli.py world weather 0002b52a .venv/bin/python -u consumer_server_cli.py world weather 0002b52a
``` ```
Run `python consumer_server_cli.py --help` to see all available commands. Run `.venv/bin/python -u consumer_server_cli.py --help` to see all available commands.
### Troubleshooting: Port in Use ### Troubleshooting: Port in Use
If using the CLI, manually change the port:
```bash ```bash
# Generate config with custom port .venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
python consumer_server_cli.py config init my-config.json
# Edit my-config.json and change "port" to a different number
python consumer_server_cli.py serve --config my-config.json --port 8000
```
Or use the CLI port override:
```bash
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000
``` ```
+33 -15
View File
@@ -34,6 +34,7 @@ class AdminServer:
self._lock = threading.RLock() self._lock = threading.RLock()
self._server_socket: socket.socket | None = None self._server_socket: socket.socket | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._client_sockets: set[socket.socket] = set()
self._running = False self._running = False
def start(self) -> None: def start(self) -> None:
@@ -50,9 +51,12 @@ class AdminServer:
server_socket.bind((ADMIN_HOST, self.port)) server_socket.bind((ADMIN_HOST, self.port))
server_socket.listen() server_socket.listen()
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS) server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
except OSError: except OSError as error:
server_socket.close() server_socket.close()
raise raise OSError(
f"Could not bind the admin server to {ADMIN_HOST}:{self.port}. "
f"The port may already be in use or unavailable. ({error})"
) from error
self._server_socket = server_socket self._server_socket = server_socket
self._running = True self._running = True
@@ -65,6 +69,10 @@ class AdminServer:
self._running = False self._running = False
server_socket = self._server_socket server_socket = self._server_socket
self._server_socket = None self._server_socket = None
clients = list(self._client_sockets)
self._client_sockets.clear()
thread = self._thread
self._thread = None
if server_socket is not None: if server_socket is not None:
try: try:
@@ -72,10 +80,14 @@ class AdminServer:
except OSError: except OSError:
pass pass
thread = self._thread for client in clients:
try:
client.close()
except OSError:
pass
if thread is not None and thread is not threading.current_thread(): if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0) thread.join(timeout=1.0)
self._thread = None
def is_running(self) -> bool: def is_running(self) -> bool:
with self._lock: with self._lock:
@@ -99,6 +111,9 @@ class AdminServer:
except OSError: except OSError:
break break
with self._lock:
self._client_sockets.add(connection)
thread = threading.Thread( thread = threading.Thread(
target=self._handle_connection, target=self._handle_connection,
args=(connection, address), args=(connection, address),
@@ -111,17 +126,17 @@ class AdminServer:
def _handle_connection(self, connection: socket.socket, address: tuple[str, int]) -> None: def _handle_connection(self, connection: socket.socket, address: tuple[str, int]) -> None:
peer = f"{address[0]}:{address[1]}" peer = f"{address[0]}:{address[1]}"
with connection:
buffer = ""
try: try:
with connection:
buffer = b""
while True: while True:
chunk = connection.recv(4096) chunk = connection.recv(4096)
if not chunk: if not chunk:
break break
buffer += chunk.decode("utf-8", errors="replace") buffer += chunk
while "\n" in buffer: while b"\n" in buffer:
line, buffer = buffer.split("\n", 1) line_bytes, buffer = buffer.split(b"\n", 1)
line = line.strip() line = line_bytes.decode("utf-8", errors="replace").strip()
if not line: if not line:
continue continue
response = self._dispatch_line(line) response = self._dispatch_line(line)
@@ -129,6 +144,9 @@ class AdminServer:
connection.sendall(encoded) connection.sendall(encoded)
except OSError as error: except OSError as error:
self._log(f"Admin connection error from {peer}: {error}") self._log(f"Admin connection error from {peer}: {error}")
finally:
with self._lock:
self._client_sockets.discard(connection)
def _dispatch_line(self, line: str) -> dict[str, Any]: def _dispatch_line(self, line: str) -> dict[str, Any]:
try: try:
@@ -160,14 +178,14 @@ def send_admin_command(
encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n" encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n"
with socket.create_connection((host, port), timeout=timeout_seconds) as connection: with socket.create_connection((host, port), timeout=timeout_seconds) as connection:
connection.sendall(encoded) connection.sendall(encoded)
buffer = "" buffer = b""
while "\n" not in buffer: while b"\n" not in buffer:
chunk = connection.recv(4096) chunk = connection.recv(4096)
if not chunk: if not chunk:
raise ConnectionError("Admin server closed the connection without a response.") raise ConnectionError("Admin server closed the connection without a response.")
buffer += chunk.decode("utf-8", errors="replace") buffer += chunk
line, _rest = buffer.split("\n", 1) line_bytes, _rest = buffer.split(b"\n", 1)
response = json.loads(line) response = json.loads(line_bytes.decode("utf-8"))
if not isinstance(response, dict): if not isinstance(response, dict):
raise ValueError("Admin response must be a JSON object.") raise ValueError("Admin response must be a JSON object.")
return response return response
+2 -2
View File
@@ -91,8 +91,8 @@ class BanStore:
payload = { payload = {
"banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)] "banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)]
} }
with open(self._path, "w", encoding="utf-8") as handle: with open(self._path, "w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, indent=2) json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n") handle.write("\n")
def is_banned(self, ip: str) -> bool: def is_banned(self, ip: str) -> bool:
@@ -0,0 +1,23 @@
[Unit]
Description=Commonwealth Online Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=commonwealth
Group=commonwealth
WorkingDirectory=/opt/commonwealth-online
# Complete setup before enabling this unit:
# cd /opt/commonwealth-online
# ./start.sh --update-dependencies
# Do not install dependencies from ExecStart.
ExecStart=/opt/commonwealth-online/.venv/bin/python -u /opt/commonwealth-online/consumer_server_cli.py serve --config /opt/commonwealth-online/commonwealth-server.json
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Do not run as root. Create the commonwealth user/group first.
[Install]
WantedBy=multi-user.target
+62 -13
View File
@@ -7,7 +7,7 @@ Supports JSON-based configuration files for hosted deployment.
from __future__ import annotations from __future__ import annotations
import json import json
import os import socket
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,7 +15,7 @@ from typing import Any
SERVER_NAME_MAX_LENGTH = 64 SERVER_NAME_MAX_LENGTH = 64
SERVER_DESCRIPTION_MAX_LENGTH = 256 SERVER_DESCRIPTION_MAX_LENGTH = 256
MAX_PLAYERS_HARD_LIMIT = 256
DEFAULT_ADMIN_PORT = 7779 DEFAULT_ADMIN_PORT = 7779
@@ -46,14 +46,21 @@ class Config:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> Config: def from_dict(cls, data: dict[str, Any]) -> Config:
"""Create config from dictionary.""" """Create config from dictionary."""
try:
port = int(data.get("port", 7777))
max_players = int(data.get("max_players", 16))
admin_port = int(data.get("admin_port", DEFAULT_ADMIN_PORT))
except (TypeError, ValueError) as error:
raise ValueError(f"Invalid numeric config field: {error}") from error
return cls( return cls(
host=data.get("host", "0.0.0.0"), host=str(data.get("host", "0.0.0.0")),
port=int(data.get("port", 7777)), port=port,
server_name=data.get("server_name", "Commonwealth Online Server"), server_name=str(data.get("server_name", "Commonwealth Online Server")),
server_description=str(data.get("server_description", "")), server_description=str(data.get("server_description", "")),
max_players=int(data.get("max_players", 16)), max_players=max_players,
log_verbosity=data.get("log_verbosity", "info"), log_verbosity=str(data.get("log_verbosity", "info")),
admin_port=int(data.get("admin_port", DEFAULT_ADMIN_PORT)), admin_port=admin_port,
) )
@@ -70,6 +77,7 @@ def load_config(config_path: str | None = None) -> Config:
Raises: Raises:
FileNotFoundError: If config_path is provided but file does not exist. FileNotFoundError: If config_path is provided but file does not exist.
json.JSONDecodeError: If config file is invalid JSON. json.JSONDecodeError: If config file is invalid JSON.
ValueError: If config root is not an object or values are invalid.
""" """
if config_path is None: if config_path is None:
return Config() return Config()
@@ -78,7 +86,7 @@ def load_config(config_path: str | None = None) -> Config:
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}") raise FileNotFoundError(f"Config file not found: {config_path}")
with open(path, "r") as f: with open(path, "r", encoding="utf-8", newline=None) as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -98,8 +106,9 @@ def save_config(config: Config, config_path: str) -> None:
path = Path(config_path) path = Path(config_path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f: with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(config.to_dict(), f, indent=2) json.dump(config.to_dict(), f, indent=2, ensure_ascii=False)
f.write("\n")
def generate_default_config(config_path: str) -> Config: def generate_default_config(config_path: str) -> Config:
@@ -117,6 +126,25 @@ def generate_default_config(config_path: str) -> Config:
return config return config
def _is_valid_bind_host(host: str) -> bool:
"""Return True when host can be used as an AF_INET bind address."""
text = host.strip()
if not text:
return False
if text in ("0.0.0.0", "127.0.0.1", "localhost"):
return True
try:
socket.inet_aton(text)
return True
except OSError:
pass
try:
socket.getaddrinfo(text, None, family=socket.AF_INET, type=socket.SOCK_STREAM)
return True
except OSError:
return False
def validate_config(config: Config) -> tuple[bool, list[str]]: def validate_config(config: Config) -> tuple[bool, list[str]]:
""" """
Validate configuration values. Validate configuration values.
@@ -127,10 +155,14 @@ def validate_config(config: Config) -> tuple[bool, list[str]]:
Returns: Returns:
(is_valid, list_of_errors). Empty list if valid. (is_valid, list_of_errors). Empty list if valid.
""" """
errors = [] errors: list[str] = []
if not config.host: if not config.host or not str(config.host).strip():
errors.append("host cannot be empty") errors.append("host cannot be empty")
elif not _is_valid_bind_host(str(config.host)):
errors.append(
f"host '{config.host}' is not a valid IPv4 address or resolvable hostname"
)
if config.port < 1 or config.port > 65535: if config.port < 1 or config.port > 65535:
errors.append(f"port must be 1-65535, got {config.port}") errors.append(f"port must be 1-65535, got {config.port}")
@@ -156,8 +188,25 @@ def validate_config(config: Config) -> tuple[bool, list[str]]:
if config.max_players < 1: if config.max_players < 1:
errors.append(f"max_players must be >= 1, got {config.max_players}") errors.append(f"max_players must be >= 1, got {config.max_players}")
elif config.max_players > MAX_PLAYERS_HARD_LIMIT:
errors.append(
f"max_players must be <= {MAX_PLAYERS_HARD_LIMIT}, got {config.max_players}"
)
if config.log_verbosity not in ("debug", "info", "warning", "error"): if config.log_verbosity not in ("debug", "info", "warning", "error"):
errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}") errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}")
return len(errors) == 0, errors return len(errors) == 0, errors
def ensure_writable_directory(path: Path) -> None:
"""Raise PermissionError when the directory cannot be created or written."""
path.mkdir(parents=True, exist_ok=True)
probe = path / ".commonwealth-write-probe"
try:
probe.write_text("ok\n", encoding="utf-8", newline="\n")
finally:
try:
probe.unlink(missing_ok=True)
except OSError:
pass
+143 -12
View File
@@ -23,7 +23,11 @@ Usage:
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os
import shlex import shlex
import signal
import sys
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
@@ -40,10 +44,19 @@ from rich import box
from admin_server import send_admin_command from admin_server import send_admin_command
from server_service import ServerService, ServerConfig, looks_like_ipv4 from server_service import ServerService, ServerConfig, looks_like_ipv4
from config import Config, load_config, generate_default_config, validate_config, DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT from config import (
Config,
load_config,
generate_default_config,
validate_config,
ensure_writable_directory,
DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT,
)
# Rich console for beautiful output # Rich console for beautiful output when attached to a TTY.
console = Console() _FORCE_COLOR = os.environ.get("FORCE_COLOR", "").strip() not in ("", "0", "false", "False")
_USE_COLOR = _FORCE_COLOR or (sys.stdout.isatty() and os.environ.get("NO_COLOR") is None)
console = Console(force_terminal=_USE_COLOR, color_system="auto" if _USE_COLOR else None)
app = typer.Typer( app = typer.Typer(
name="commonwealth", name="commonwealth",
help="Commonwealth Online Server CLI", help="Commonwealth Online Server CLI",
@@ -52,6 +65,8 @@ app = typer.Typer(
# Global service instance (used by the serve process only) # Global service instance (used by the serve process only)
_service: Optional[ServerService] = None _service: Optional[ServerService] = None
_shutdown_requested = threading.Event()
_logger = logging.getLogger("commonwealth.server")
def get_service() -> ServerService: def get_service() -> ServerService:
@@ -62,10 +77,61 @@ def get_service() -> ServerService:
return _service return _service
def log_callback(message: str) -> None: def _configure_logging() -> None:
if _logger.handlers:
return
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
handler.flush = sys.stdout.flush # type: ignore[method-assign]
_logger.setLevel(logging.INFO)
_logger.addHandler(handler)
_logger.propagate = False
def log_callback(message: str, *, level: str = "info") -> None:
"""Callback for server logs from the service.""" """Callback for server logs from the service."""
severity = str(level or "info").strip().lower()
if _USE_COLOR:
timestamp = datetime.now().strftime("%H:%M:%S") timestamp = datetime.now().strftime("%H:%M:%S")
console.print(f"[dim]{timestamp}[/dim] {message}") console.print(f"[dim]{timestamp}[/dim] [{severity}] {message}")
return
_configure_logging()
log_level = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
}.get(severity, logging.INFO)
_logger.log(log_level, message)
for handler in _logger.handlers:
handler.flush()
def _require_python_version() -> None:
if sys.version_info < (3, 9):
console.print(
f"[red]ERROR: Python 3.9+ is required (found {sys.version.split()[0]}).[/red]"
)
raise typer.Exit(code=1)
def _request_shutdown(_signum: int, _frame: Any) -> None:
"""Handle SIGINT/SIGTERM by requesting a clean shutdown."""
_shutdown_requested.set()
service = _service
if service is not None:
try:
service.stop()
except Exception:
pass
# Interrupt blocking main-thread waits (accept/input) so shutdown completes.
raise KeyboardInterrupt
def admin_request( def admin_request(
@@ -206,6 +272,10 @@ def _print_help_table(*, interactive: bool = False) -> None:
@app.command() @app.command()
def serve( def serve(
config_path: Optional[str] = typer.Argument(
None,
help="Optional path to config.json (same as --config)",
),
config: Optional[str] = typer.Option( config: Optional[str] = typer.Option(
None, None,
"--config", "--config",
@@ -232,8 +302,25 @@ def serve(
), ),
) -> None: ) -> None:
"""Start the Commonwealth Online relay server.""" """Start the Commonwealth Online relay server."""
_require_python_version()
service: Optional[ServerService] = None service: Optional[ServerService] = None
_shutdown_requested.clear()
# Accept either `serve --config FILE` or `serve FILE` (file managers may pass FILE).
if config and config_path and Path(config).resolve() != Path(config_path).resolve():
console.print(
"[red]Error: Conflicting config paths from positional argument and --config.[/red]"
)
raise typer.Exit(code=1)
config = config or config_path
previous_sigint = signal.getsignal(signal.SIGINT)
previous_sigterm = signal.getsignal(signal.SIGTERM) if hasattr(signal, "SIGTERM") else None
try: try:
signal.signal(signal.SIGINT, _request_shutdown)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, _request_shutdown)
# Load or create config # Load or create config
if config: if config:
try: try:
@@ -244,10 +331,13 @@ def serve(
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]") console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]")
raise typer.Exit(code=1) raise typer.Exit(code=1)
bans_path = str(Path(config).resolve().parent / "bans.json") except ValueError as e:
console.print(f"[red]Error: Invalid config values: {e}[/red]")
raise typer.Exit(code=1)
bans_path = Path(config).resolve().parent / "bans.json"
else: else:
cfg = Config() cfg = Config()
bans_path = str(Path(__file__).resolve().parent / "bans.json") bans_path = Path(__file__).resolve().parent / "bans.json"
# Apply CLI overrides # Apply CLI overrides
if host: if host:
@@ -263,6 +353,14 @@ def serve(
console.print(f"{error}") console.print(f"{error}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
try:
ensure_writable_directory(bans_path.parent)
except OSError as error:
console.print(
f"[red]ERROR: Cannot write server state in {bans_path.parent}: {error}[/red]"
)
raise typer.Exit(code=1)
# Create and configure service # Create and configure service
server_config = ServerConfig( server_config = ServerConfig(
host=cfg.host, host=cfg.host,
@@ -272,7 +370,7 @@ def serve(
max_players=cfg.max_players, max_players=cfg.max_players,
log_verbosity=cfg.log_verbosity, log_verbosity=cfg.log_verbosity,
admin_port=cfg.admin_port, admin_port=cfg.admin_port,
bans_path=bans_path, bans_path=str(bans_path),
) )
service = get_service() service = get_service()
@@ -282,8 +380,15 @@ def serve(
# Print startup banner # Print startup banner
print_startup_banner(server_config) print_startup_banner(server_config)
use_interactive = bool(interactive and sys.stdin.isatty() and sys.stdout.isatty())
if interactive and not use_interactive:
console.print(
"[yellow]stdin/stdout are not a terminal; starting non-interactive mode. "
"Use the admin CLI against 127.0.0.1 to manage the server.[/yellow]"
)
console.print("[yellow]Starting server...[/yellow]") console.print("[yellow]Starting server...[/yellow]")
if interactive: if use_interactive:
service.start() service.start()
if not _wait_for_server_ready(service, timeout_seconds=5.0): if not _wait_for_server_ready(service, timeout_seconds=5.0):
console.print("[red]Server failed to become ready.[/red]") console.print("[red]Server failed to become ready.[/red]")
@@ -297,8 +402,10 @@ def serve(
service.stop() service.stop()
console.print("[green]Server stopped gracefully.[/green]") console.print("[green]Server stopped gracefully.[/green]")
else: else:
# Non-interactive mode for Host GUI / headless hosting. # Non-interactive mode for Host GUI / systemd / headless hosting.
service.serve_forever() service.serve_forever()
if _shutdown_requested.is_set():
console.print("[green]Server stopped gracefully.[/green]")
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]") console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]")
@@ -306,6 +413,14 @@ def serve(
service = get_service() service = get_service()
service.stop() service.stop()
console.print("[green]Server stopped gracefully.[/green]") console.print("[green]Server stopped gracefully.[/green]")
except OSError as e:
console.print(f"[red]ERROR: {e}[/red]")
if service is not None:
try:
service.stop()
except Exception:
pass
raise typer.Exit(code=1)
except Exception as e: except Exception as e:
console.print(f"[red]Fatal error: {e}[/red]") console.print(f"[red]Fatal error: {e}[/red]")
if service is not None: if service is not None:
@@ -314,6 +429,13 @@ def serve(
except Exception: except Exception:
pass pass
raise typer.Exit(code=1) raise typer.Exit(code=1)
finally:
try:
signal.signal(signal.SIGINT, previous_sigint)
if previous_sigterm is not None and hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, previous_sigterm)
except Exception:
pass
@app.command() @app.command()
@@ -987,7 +1109,16 @@ def _dispatch_interactive_command(args: list[str], *, admin_port: int) -> bool:
def _run_interactive_shell(*, admin_port: int) -> None: def _run_interactive_shell(*, admin_port: int) -> None:
"""Read management commands from stdin until quit/exit/Ctrl+C.""" """Read management commands from stdin until quit/exit/Ctrl+C."""
while True: if not sys.stdin.isatty():
console.print(
"[yellow]Interactive prompt requires a terminal. "
"Server remains available via the admin port.[/yellow]"
)
while not _shutdown_requested.is_set():
time.sleep(0.5)
return
while not _shutdown_requested.is_set():
try: try:
line = input("commonwealth> ") line = input("commonwealth> ")
except EOFError: except EOFError:
@@ -1002,7 +1133,7 @@ def _run_interactive_shell(*, admin_port: int) -> None:
continue continue
try: try:
args = shlex.split(line) args = shlex.split(line, posix=(os.name != "nt"))
except ValueError as error: except ValueError as error:
console.print(f"[red]Could not parse command: {error}[/red]") console.print(f"[red]Could not parse command: {error}[/red]")
continue continue
+6 -4
View File
@@ -40,8 +40,10 @@ find_pids_on_port() {
echo "$pids" echo "$pids"
} }
# Prefer python3, fall back to python # Prefer local venv, then python3, then python.
if command -v python3 >/dev/null 2>&1; then if [[ -x ".venv/bin/python" ]]; then
PYTHON=".venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON=python3 PYTHON=python3
elif command -v python >/dev/null 2>&1; then elif command -v python >/dev/null 2>&1; then
PYTHON=python PYTHON=python
@@ -129,9 +131,9 @@ with path.open("w", encoding="utf-8") as f:
f.write("\n") f.write("\n")
PY PY
echo "Config updated. Starting server on port ${NEW_PORT}..." echo "Config updated. Restarting server on port ${NEW_PORT}..."
echo echo
exec "$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json exec bash ./start.sh
;; ;;
3) 3)
echo echo
+18 -5
View File
@@ -21,8 +21,10 @@ class LanDiscoveryResponder:
self._socket: socket.socket | None = None self._socket: socket.socket | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._running = False self._running = False
self._lock = threading.RLock()
def start(self) -> None: def start(self) -> None:
with self._lock:
if self._running: if self._running:
return return
@@ -32,11 +34,14 @@ class LanDiscoveryResponder:
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
else: else:
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
discovery_socket.bind(("0.0.0.0", self._discovery_port)) discovery_socket.bind(("0.0.0.0", self._discovery_port))
discovery_socket.settimeout(0.5) discovery_socket.settimeout(0.5)
except OSError: except OSError as error:
discovery_socket.close() discovery_socket.close()
raise raise OSError(
f"Could not bind LAN discovery to 0.0.0.0:{self._discovery_port}. {error}"
) from error
self._socket = discovery_socket self._socket = discovery_socket
self._running = True self._running = True
@@ -44,9 +49,12 @@ class LanDiscoveryResponder:
self._thread.start() self._thread.start()
def stop(self) -> None: def stop(self) -> None:
with self._lock:
self._running = False self._running = False
discovery_socket = self._socket discovery_socket = self._socket
self._socket = None self._socket = None
thread = self._thread
self._thread = None
if discovery_socket is not None: if discovery_socket is not None:
try: try:
@@ -54,13 +62,16 @@ class LanDiscoveryResponder:
except OSError: except OSError:
pass pass
thread = self._thread
if thread is not None and thread is not threading.current_thread(): if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0) thread.join(timeout=1.0)
def _listen_loop(self) -> None: def _listen_loop(self) -> None:
while self._running: while True:
with self._lock:
if not self._running:
break
discovery_socket = self._socket discovery_socket = self._socket
if discovery_socket is None: if discovery_socket is None:
break break
@@ -69,6 +80,7 @@ class LanDiscoveryResponder:
except socket.timeout: except socket.timeout:
continue continue
except OSError: except OSError:
with self._lock:
if self._running: if self._running:
break break
continue continue
@@ -78,7 +90,8 @@ class LanDiscoveryResponder:
continue continue
self._server._log( self._server._log(
f"LAN discovery probe from {address[0]}:{address[1]}replying with game port {response['port']}" f"LAN discovery probe from {address[0]}:{address[1]}"
f"replying with game port {response['port']}"
) )
try: try:
+5
View File
@@ -0,0 +1,5 @@
# Optional Python Host GUI / development tooling (PySide6).
# The Windows Qt Host GUI does not require this file; it wraps the CLI server.
# Install only when running server/dev_server_app.py.
-r requirements-server.txt
PySide6
+4
View File
@@ -0,0 +1,4 @@
# Dedicated CLI / headless server dependencies (Linux, macOS, Windows).
# Do not add GUI frameworks or Windows-only packages here.
typer==0.12.3
rich==13.7.0
+3 -2
View File
@@ -1,3 +1,4 @@
# Compatibility aggregate. Prefer requirements-server.txt for dedicated servers.
# Use requirements-host-gui.txt when you need the optional PySide6 Python GUI.
-r requirements-server.txt
PySide6 PySide6
typer==0.12.3
rich==13.7.0
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "${ROOT}"
echo "==> Checking start.sh line endings (LF)"
python3 - <<'PY'
from pathlib import Path
data = Path("start.sh").read_bytes()
if b"\r\n" in data or data.count(b"\r"):
raise SystemExit("ERROR: start.sh contains CR/CRLF line endings")
PY
echo "==> Checking start.sh is executable"
test -x start.sh
echo "==> bash -n start.sh"
bash -n start.sh
if command -v shellcheck >/dev/null 2>&1; then
echo "==> shellcheck start.sh"
shellcheck start.sh
else
echo "WARNING: shellcheck not installed; skipping"
fi
echo "==> Creating clean virtual environment"
rm -rf .venv-ci
python3 -m venv .venv-ci
.venv-ci/bin/python -m pip install --upgrade pip
.venv-ci/bin/python -m pip install -r requirements-server.txt pytest
echo "==> compileall"
.venv-ci/bin/python -m compileall -q .
echo "==> pytest"
.venv-ci/bin/python -m pytest -q
echo "All Linux compatibility checks passed."
+95 -17
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import math import math
import socket import socket
import sys
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -35,13 +36,28 @@ _LOG_LEVELS = {
def get_lan_addresses() -> list[str]: def get_lan_addresses() -> list[str]:
"""Return likely LAN IPv4 addresses for this machine.""" """Return likely non-loopback IPv4 addresses for this machine."""
addresses: list[str] = []
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.connect(("8.8.8.8", 80)) probe.connect(("8.8.8.8", 80))
return [probe.getsockname()[0]] primary = probe.getsockname()[0]
if primary and not primary.startswith("127."):
addresses.append(primary)
except OSError: except OSError:
return [] pass
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
candidate = info[4][0]
if candidate and not candidate.startswith("127.") and candidate not in addresses:
addresses.append(candidate)
except OSError:
pass
return addresses
class FalloutTogetherServer: class FalloutTogetherServer:
@@ -67,6 +83,7 @@ class FalloutTogetherServer:
self._lock = threading.RLock() self._lock = threading.RLock()
self._log_lock = threading.RLock() self._log_lock = threading.RLock()
self._clients: dict[socket.socket, ClientSession] = {} self._clients: dict[socket.socket, ClientSession] = {}
self._client_threads: set[threading.Thread] = set()
self._log_listeners: list[Callable[[str], None]] = [] self._log_listeners: list[Callable[[str], None]] = []
self._server_socket: socket.socket | None = None self._server_socket: socket.socket | None = None
self._accept_thread: threading.Thread | None = None self._accept_thread: threading.Thread | None = None
@@ -105,8 +122,7 @@ class FalloutTogetherServer:
return return
self._prepare_server_socket() self._prepare_server_socket()
self._discovery = LanDiscoveryResponder(self) self._start_discovery()
self._discovery.start()
self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True)
self._accept_thread.start() self._accept_thread.start()
@@ -116,20 +132,25 @@ class FalloutTogetherServer:
raise RuntimeError("Server is already running.") raise RuntimeError("Server is already running.")
self._prepare_server_socket() self._prepare_server_socket()
self._discovery = LanDiscoveryResponder(self) self._start_discovery()
self._discovery.start()
self._accept_loop() self._accept_loop()
def stop(self) -> None: def stop(self) -> None:
clients: list[ClientSession] clients: list[ClientSession]
server_socket: socket.socket | None server_socket: socket.socket | None
client_threads: list[threading.Thread]
with self._lock: with self._lock:
if not self._running and self._server_socket is None and self._discovery is None:
return
self._running = False self._running = False
server_socket = self._server_socket server_socket = self._server_socket
self._server_socket = None self._server_socket = None
clients = list(self._clients.values()) clients = list(self._clients.values())
client_threads = [
thread for thread in self._client_threads if thread is not threading.current_thread()
]
discovery = self._discovery discovery = self._discovery
self._discovery = None self._discovery = None
@@ -148,6 +169,13 @@ class FalloutTogetherServer:
accept_thread = self._accept_thread accept_thread = self._accept_thread
if accept_thread is not None and accept_thread is not threading.current_thread(): if accept_thread is not None and accept_thread is not threading.current_thread():
accept_thread.join(timeout=1.0) accept_thread.join(timeout=1.0)
self._accept_thread = None
for thread in client_threads:
thread.join(timeout=1.0)
with self._lock:
self._client_threads.clear()
def is_running(self) -> bool: def is_running(self) -> bool:
with self._lock: with self._lock:
@@ -293,12 +321,17 @@ class FalloutTogetherServer:
try: try:
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
else:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((self.host, self.port)) server_socket.bind((self.host, self.port))
server_socket.listen() server_socket.listen()
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS) server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
except OSError: except OSError as error:
server_socket.close() server_socket.close()
raise raise OSError(
f"Could not bind the server to {self.host}:{self.port}. "
f"The port may already be in use or unavailable. ({error})"
) from error
self._server_socket = server_socket self._server_socket = server_socket
self._running = True self._running = True
@@ -311,20 +344,47 @@ class FalloutTogetherServer:
self._last_npc_state = None self._last_npc_state = None
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}") self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
if self.host == "0.0.0.0": if self.host == "0.0.0.0":
self._log(
f"Bind address 0.0.0.0 means all interfaces; clients should not join 0.0.0.0."
)
self._log(f"Local clients can connect at 127.0.0.1:{self.port}") self._log(f"Local clients can connect at 127.0.0.1:{self.port}")
lan_addresses = get_lan_addresses() lan_addresses = get_lan_addresses()
for address in lan_addresses: for address in lan_addresses:
self._log(f"LAN clients can connect at {address}:{self.port}") self._log(f"LAN clients can connect at {address}:{self.port}")
if not lan_addresses: if not lan_addresses:
self._log("Could not detect a LAN IPv4 address for this machine.") self._log("Could not detect a LAN IPv4 address for this machine.")
if sys.platform == "win32":
self._log( self._log(
"Forward this port on your router and allow it through Windows Firewall " "Forward this port on your router and allow it through Windows Firewall "
"for connections from outside your network." "for connections from outside your network."
) )
else:
self._log(
"Allow TCP "
f"{self.port} through your host firewall. Router port forwarding is only "
"needed for connections from outside your LAN."
)
else:
self._log(f"Clients can connect at {self.host}:{self.port}")
self._log("Waiting for newline-separated JSON transform packets...") self._log("Waiting for newline-separated JSON transform packets...")
def _start_discovery(self) -> None:
discovery = LanDiscoveryResponder(self)
try:
discovery.start()
except OSError as error:
self._log(
f"LAN discovery unavailable on UDP {DISCOVERY_PORT}: {error}. "
"Direct TCP connections still work.",
level="warning",
)
self._discovery = None
return
self._discovery = discovery
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
def _accept_loop(self) -> None: def _accept_loop(self) -> None:
try: try:
while self.is_running(): while self.is_running():
@@ -344,7 +404,13 @@ class FalloutTogetherServer:
self._log("Server socket closed unexpectedly.") self._log("Server socket closed unexpectedly.")
break break
thread = threading.Thread(target=self._handle_client, args=(connection, address), daemon=True) thread = threading.Thread(
target=self._handle_client,
args=(connection, address),
daemon=True,
)
with self._lock:
self._client_threads.add(thread)
thread.start() thread.start()
finally: finally:
with self._lock: with self._lock:
@@ -407,8 +473,9 @@ class FalloutTogetherServer:
level="debug", level="debug",
) )
try:
with connection: with connection:
buffer = "" buffer = b""
try: try:
with self._lock: with self._lock:
@@ -434,10 +501,11 @@ class FalloutTogetherServer:
if not chunk: if not chunk:
break break
buffer += chunk.decode("utf-8", errors="replace") buffer += chunk
while "\n" in buffer: while b"\n" in buffer:
line, buffer = buffer.split("\n", 1) line_bytes, buffer = buffer.split(b"\n", 1)
self._handle_line(client, line.strip()) line = line_bytes.decode("utf-8", errors="replace").strip()
self._handle_line(client, line)
except ConnectionResetError: except ConnectionResetError:
if client.packets_received > 0: if client.packets_received > 0:
self._log( self._log(
@@ -452,9 +520,15 @@ class FalloutTogetherServer:
) )
except OSError as error: except OSError as error:
if self.is_running(): if self.is_running():
self._log(f"Client connection error: {client.label} (player {client.player_id}): {error}") self._log(
f"Client connection error: {client.label} "
f"(player {client.player_id}): {error}"
)
finally: finally:
self._disconnect_client(client) self._disconnect_client(client)
finally:
with self._lock:
self._client_threads.discard(threading.current_thread())
def _handle_line(self, client: ClientSession, line: str) -> None: def _handle_line(self, client: ClientSession, line: str) -> None:
if not line: if not line:
@@ -1067,6 +1141,10 @@ class FalloutTogetherServer:
for listener in listeners: for listener in listeners:
try: try:
# Prefer level-aware callbacks; fall back for older listeners.
try:
listener(message, level=level) # type: ignore[call-arg]
except TypeError:
listener(message) listener(message)
except Exception: except Exception:
# Future UI listeners must not be able to break server networking. # Future UI listeners must not be able to break server networking.
+24 -7
View File
@@ -76,29 +76,34 @@ class ServerService:
self.config = config or ServerConfig() self.config = config or ServerConfig()
self._server: FalloutTogetherServer | None = None self._server: FalloutTogetherServer | None = None
self._admin: AdminServer | None = None self._admin: AdminServer | None = None
self._log_listeners: list[Callable[[str], None]] = [] self._log_listeners: list[Callable[..., None]] = []
self._log_lock = threading.RLock() self._log_lock = threading.RLock()
self._serve_thread: threading.Thread | None = None
self._running = False self._running = False
self._stop_requested = False
def add_log_listener(self, callback: Callable[[str], None]) -> None: def add_log_listener(self, callback: Callable[..., None]) -> None:
"""Register a callback for server log messages.""" """Register a callback for server log messages."""
with self._log_lock: with self._log_lock:
if callback not in self._log_listeners: if callback not in self._log_listeners:
self._log_listeners.append(callback) self._log_listeners.append(callback)
def remove_log_listener(self, callback: Callable[[str], None]) -> None: def remove_log_listener(self, callback: Callable[..., None]) -> None:
"""Unregister a log callback.""" """Unregister a log callback."""
with self._log_lock: with self._log_lock:
if callback in self._log_listeners: if callback in self._log_listeners:
self._log_listeners.remove(callback) self._log_listeners.remove(callback)
def _dispatch_log(self, message: str) -> None: def _dispatch_log(self, message: str, *, level: str = "info") -> None:
"""Dispatch a log message to all registered listeners.""" """Dispatch a log message to all registered listeners."""
with self._log_lock: with self._log_lock:
listeners = list(self._log_listeners) listeners = list(self._log_listeners)
for listener in listeners: for listener in listeners:
try: try:
try:
listener(message, level=level)
except TypeError:
listener(message) listener(message)
except Exception: except Exception:
pass pass
@@ -138,13 +143,14 @@ class ServerService:
self._dispatch_log("Server is already running.") self._dispatch_log("Server is already running.")
return return
self._stop_requested = False
self._server = self._create_server() self._server = self._create_server()
self._server.add_log_listener(self._dispatch_log) self._server.add_log_listener(self._dispatch_log)
self._running = True self._running = True
self._start_admin() self._start_admin()
thread = threading.Thread(target=self._serve_forever, daemon=True) self._serve_thread = threading.Thread(target=self._serve_forever, daemon=True)
thread.start() self._serve_thread.start()
self._dispatch_log("Server started in background thread.") self._dispatch_log("Server started in background thread.")
def serve_forever(self) -> None: def serve_forever(self) -> None:
@@ -152,6 +158,7 @@ class ServerService:
if self._running: if self._running:
raise RuntimeError("Server is already running.") raise RuntimeError("Server is already running.")
self._stop_requested = False
self._server = self._create_server() self._server = self._create_server()
self._server.add_log_listener(self._dispatch_log) self._server.add_log_listener(self._dispatch_log)
self._running = True self._running = True
@@ -174,12 +181,22 @@ class ServerService:
def stop(self) -> None: def stop(self) -> None:
"""Stop the server.""" """Stop the server."""
if not self._running or not self._server: if self._stop_requested and not self._running:
return
self._stop_requested = True
if not self._running and self._server is None:
self._dispatch_log("Server is not running.") self._dispatch_log("Server is not running.")
return return
self._stop_admin() self._stop_admin()
if self._server is not None:
self._server.stop() self._server.stop()
serve_thread = self._serve_thread
if serve_thread is not None and serve_thread is not threading.current_thread():
serve_thread.join(timeout=2.0)
self._serve_thread = None
self._running = False self._running = False
self._dispatch_log("Server stopped.") self._dispatch_log("Server stopped.")
+91 -13
View File
@@ -1,13 +1,14 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
cd /d "%~dp0"
echo. echo.
echo ================================================================================ echo ================================================================================
echo Commonwealth Online Server - Start Script echo Commonwealth Online Server - Start Script
echo ================================================================================ echo ================================================================================
echo. echo.
REM Check if Python is installed
python --version >nul 2>&1 python --version >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Python is not installed or not in PATH. echo ERROR: Python is not installed or not in PATH.
@@ -16,24 +17,100 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
echo Installing dependencies... if not exist "requirements-server.txt" (
pip install -q typer rich 2>nul echo ERROR: Missing requirements-server.txt
if errorlevel 1 ( pause
echo WARNING: Failed to install dependencies quietly. Retrying with output... exit /b 1
pip install typer rich )
set "UPDATE_DEPENDENCIES=0"
set "CONFIG_FILE=%cd%\commonwealth-server.json"
set "SERVER_ARGS="
:parse_args
if "%~1"=="" goto args_done
if /I "%~1"=="--update-dependencies" (
set "UPDATE_DEPENDENCIES=1"
shift
goto parse_args
)
if /I "%~1"=="--config" (
if "%~2"=="" (
echo ERROR: --config requires a config file path.
pause
exit /b 1
)
set "CONFIG_FILE=%~f2"
shift
shift
goto parse_args
)
if /I "%~1"=="-c" (
if "%~2"=="" (
echo ERROR: -c requires a config file path.
pause
exit /b 1
)
set "CONFIG_FILE=%~f2"
shift
shift
goto parse_args
)
echo %~1| findstr /I /R "\.json$" >nul
if not errorlevel 1 (
set "CONFIG_FILE=%~f1"
shift
goto parse_args
)
set "SERVER_ARGS=!SERVER_ARGS! %1"
shift
goto parse_args
:args_done
if not exist ".venv\Scripts\python.exe" (
echo Creating virtual environment at .venv...
python -m venv .venv
if errorlevel 1 (
echo ERROR: Failed to create virtual environment.
pause
exit /b 1
)
)
set "VENV_PYTHON=.venv\Scripts\python.exe"
if not exist "%VENV_PYTHON%" (
echo ERROR: Virtual environment interpreter missing.
pause
exit /b 1
)
set "NEED_INSTALL=0"
if not exist ".venv\.requirements-server.sha256" set "NEED_INSTALL=1"
if "%UPDATE_DEPENDENCIES%"=="1" set "NEED_INSTALL=1"
if "%NEED_INSTALL%"=="0" (
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; expected=Path('.venv/.requirements-server.sha256').read_text(encoding='utf-8').strip(); actual=sha256(Path('requirements-server.txt').read_bytes()).hexdigest(); raise SystemExit(0 if expected==actual else 1)"
if errorlevel 1 set "NEED_INSTALL=1"
)
if "%NEED_INSTALL%"=="1" (
echo Installing dedicated-server dependencies into .venv...
"%VENV_PYTHON%" -m pip install --upgrade pip >nul 2>&1
"%VENV_PYTHON%" -m pip install -r requirements-server.txt
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Failed to install required packages. echo ERROR: Failed to install required packages.
pause pause
exit /b 1 exit /b 1
) )
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; Path('.venv/.requirements-server.sha256').write_text(sha256(Path('requirements-server.txt').read_bytes()).hexdigest() + chr(10), encoding='utf-8')"
echo Dependencies installed.
) else (
echo Dependencies are up to date.
) )
echo Dependencies installed.
echo. echo.
REM Check if config file exists if not exist "!CONFIG_FILE!" (
if not exist "commonwealth-server.json" (
echo Generating default configuration file... echo Generating default configuration file...
python consumer_server_cli.py config init commonwealth-server.json "%VENV_PYTHON%" -u consumer_server_cli.py config init "!CONFIG_FILE!"
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Failed to generate config file. echo ERROR: Failed to generate config file.
pause pause
@@ -46,10 +123,10 @@ echo Starting Commonwealth Online Server...
echo Type help for commands. Type quit or press Ctrl+C to stop. echo Type help for commands. Type quit or press Ctrl+C to stop.
echo. echo.
python consumer_server_cli.py serve --config commonwealth-server.json --interactive "%VENV_PYTHON%" -u consumer_server_cli.py serve --config "!CONFIG_FILE!" --interactive !SERVER_ARGS!
set "EXIT_CODE=!ERRORLEVEL!"
REM Check if the error was port in use if not "!EXIT_CODE!"=="0" (
if errorlevel 1 (
echo. echo.
echo Server failed to start. If you see "Only one usage of each socket address" echo Server failed to start. If you see "Only one usage of each socket address"
echo error, the port may already be in use. echo error, the port may already be in use.
@@ -62,3 +139,4 @@ if errorlevel 1 (
) )
pause pause
exit /b !EXIT_CODE!
+183 -51
View File
@@ -1,7 +1,76 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -Eeuo pipefail
cd "$(dirname "$0")" SCRIPT_PATH="$(readlink -f -- "${BASH_SOURCE[0]}" 2>/dev/null || true)"
if [[ -z "${SCRIPT_PATH}" ]]; then
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
else
SCRIPT_DIR="$(cd -- "$(dirname -- "${SCRIPT_PATH}")" && pwd)"
fi
cd -- "${SCRIPT_DIR}"
SERVER_DIR="${SCRIPT_DIR}"
VENV_DIR="${SERVER_DIR}/.venv"
REQUIREMENTS_FILE="${SERVER_DIR}/requirements-server.txt"
REQUIREMENTS_HASH_FILE="${VENV_DIR}/.requirements-server.sha256"
CONFIG_FILE="${SERVER_DIR}/commonwealth-server.json"
ENTRY_POINT="${SERVER_DIR}/consumer_server_cli.py"
UPDATE_DEPENDENCIES=0
SERVER_ARGS=()
ARGS=("$@")
ARG_INDEX=0
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do
arg="${ARGS[${ARG_INDEX}]}"
case "${arg}" in
--update-dependencies)
UPDATE_DEPENDENCIES=1
;;
--config|-c)
ARG_INDEX=$((ARG_INDEX + 1))
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
echo "ERROR: ${arg} requires a config file path." >&2
exit 1
fi
CONFIG_FILE="${ARGS[${ARG_INDEX}]}"
;;
--config=*)
CONFIG_FILE="${arg#--config=}"
;;
--host|--port|-H|-p|--interactive|-i)
SERVER_ARGS+=("${arg}")
if [[ "${arg}" == "--host" || "${arg}" == "-H" || "${arg}" == "--port" || "${arg}" == "-p" ]]; then
ARG_INDEX=$((ARG_INDEX + 1))
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
echo "ERROR: ${arg} requires a value." >&2
exit 1
fi
SERVER_ARGS+=("${ARGS[${ARG_INDEX}]}")
fi
;;
--host=*|--port=*)
SERVER_ARGS+=("${arg}")
;;
*.json)
# File managers / "Open with" often pass the config path as $1.
CONFIG_FILE="${arg}"
;;
-*)
echo "ERROR: Unknown option: ${arg}" >&2
echo "Supported: --update-dependencies, --config PATH, --host HOST, --port PORT, --interactive" >&2
exit 1
;;
*)
if [[ -f "${arg}" ]]; then
CONFIG_FILE="${arg}"
else
echo "ERROR: Unexpected argument: ${arg}" >&2
exit 1
fi
;;
esac
ARG_INDEX=$((ARG_INDEX + 1))
done
echo echo
echo "================================================================================" echo "================================================================================"
@@ -9,72 +78,135 @@ echo " Commonwealth Online Server - Start Script"
echo "================================================================================" echo "================================================================================"
echo echo
# Prefer python3, fall back to python die() {
if command -v python3 >/dev/null 2>&1; then echo "ERROR: $*" >&2
PYTHON=python3
elif command -v python >/dev/null 2>&1; then
PYTHON=python
else
echo "ERROR: Python is not installed or not in PATH."
echo "Please install Python 3.9+ from https://www.python.org"
exit 1 exit 1
}
detect_python() {
local candidate
for candidate in python3 python; do
if command -v "${candidate}" >/dev/null 2>&1; then
if "${candidate}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
echo "${candidate}"
return 0
fi
fi
done
return 1
}
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then
die "Missing requirements file: ${REQUIREMENTS_FILE}"
fi fi
if ! "$PYTHON" --version >/dev/null 2>&1; then if [[ ! -f "${ENTRY_POINT}" ]]; then
echo "ERROR: Python is not installed or not in PATH." die "Missing server entry point: ${ENTRY_POINT}"
echo "Please install Python 3.9+ from https://www.python.org"
exit 1
fi fi
if command -v pip3 >/dev/null 2>&1; then if ! BASE_PYTHON="$(detect_python)"; then
PIP=(pip3) die "Python 3.9+ is required. Install python3 (and python3-venv on Debian/Ubuntu)."
elif command -v pip >/dev/null 2>&1; then
PIP=(pip)
else
PIP=("$PYTHON" -m pip)
fi fi
echo "Installing dependencies..." echo "Using system interpreter: ${BASE_PYTHON} ($("${BASE_PYTHON}" --version 2>&1))"
if ! "${PIP[@]}" install -q typer rich 2>/dev/null; then
echo "WARNING: Failed to install dependencies quietly. Retrying with output..." if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
if ! "${PIP[@]}" install typer rich; then echo "Creating virtual environment at ${VENV_DIR}..."
echo "ERROR: Failed to install required packages." if ! "${BASE_PYTHON}" -m venv "${VENV_DIR}"; then
exit 1 die "Failed to create virtual environment. On Debian/Ubuntu install python3-venv."
fi fi
fi fi
echo "Dependencies installed."
VENV_PYTHON="${VENV_DIR}/bin/python"
if [[ ! -x "${VENV_PYTHON}" ]]; then
die "Virtual environment interpreter missing: ${VENV_PYTHON}"
fi
if ! "${VENV_PYTHON}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
die "Virtual environment Python is older than 3.9."
fi
hash_requirements() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -- "${REQUIREMENTS_FILE}" | awk '{print $1}'
else
"${VENV_PYTHON}" - <<'PY'
from hashlib import sha256
from pathlib import Path
print(sha256(Path("requirements-server.txt").read_bytes()).hexdigest())
PY
fi
}
CURRENT_HASH="$(hash_requirements)"
STORED_HASH=""
if [[ -f "${REQUIREMENTS_HASH_FILE}" ]]; then
STORED_HASH="$(tr -d '[:space:]' < "${REQUIREMENTS_HASH_FILE}")"
fi
NEED_INSTALL=0
if [[ ! -f "${REQUIREMENTS_HASH_FILE}" ]]; then
NEED_INSTALL=1
elif [[ "${CURRENT_HASH}" != "${STORED_HASH}" ]]; then
NEED_INSTALL=1
elif [[ "${UPDATE_DEPENDENCIES}" -eq 1 ]]; then
NEED_INSTALL=1
fi
if [[ "${NEED_INSTALL}" -eq 1 ]]; then
echo "Installing dedicated-server dependencies into .venv..."
if ! "${VENV_PYTHON}" -m pip install --upgrade pip >/dev/null 2>&1; then
echo "WARNING: Could not upgrade pip quietly; continuing with existing pip."
fi
if ! "${VENV_PYTHON}" -m pip install -r "${REQUIREMENTS_FILE}"; then
die "Failed to install dependencies from ${REQUIREMENTS_FILE}."
fi
printf '%s\n' "${CURRENT_HASH}" > "${REQUIREMENTS_HASH_FILE}"
echo "Dependencies installed."
else
echo "Dependencies are up to date."
fi
echo echo
if [[ ! -f commonwealth-server.json ]]; then if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "Generating default configuration file..." echo "Generating default configuration file..."
if ! "$PYTHON" consumer_server_cli.py config init commonwealth-server.json; then if ! "${VENV_PYTHON}" -u "${ENTRY_POINT}" config init "${CONFIG_FILE}"; then
echo "ERROR: Failed to generate config file." die "Failed to generate config file."
exit 1
fi fi
echo echo
fi fi
echo "Starting Commonwealth Online Server..." INTERACTIVE_ARGS=()
echo "Type help for commands. Type quit or press Ctrl+C to stop." if [[ -t 0 && -t 1 ]]; then
INTERACTIVE_ARGS+=(--interactive)
echo "Starting Commonwealth Online Server (interactive)..."
echo "Type help for commands. Type quit or press Ctrl+C to stop."
else
echo "Starting Commonwealth Online Server (non-interactive)..."
echo "Manage the server with: ${VENV_PYTHON} -u consumer_server_cli.py status"
fi
echo echo
set +e # Resolve config to an absolute path after cd'ing into the server directory.
"$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json --interactive if [[ "${CONFIG_FILE}" != /* ]]; then
exit_code=$? CONFIG_FILE="${SERVER_DIR}/${CONFIG_FILE}"
set -e
if [[ $exit_code -ne 0 ]]; then
echo
echo "Server failed to start. If you see an address already in use"
echo "error, the port may already be in use."
echo
read -r -p "Would you like to fix the port issue? (y/N): " answer
case "$answer" in
[yY]|[yY][eE][sS])
echo
exec bash ./fix-port.sh
;;
esac
fi fi
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
exit "$exit_code" # Do not source .venv/bin/activate — invoke the venv interpreter directly.
# Always pass --config explicitly so a bare path is never a positional serve arg.
CMD=(
"${VENV_PYTHON}"
-u
"${ENTRY_POINT}"
serve
--config
"${CONFIG_FILE}"
)
if [[ ${#INTERACTIVE_ARGS[@]} -gt 0 ]]; then
CMD+=("${INTERACTIVE_ARGS[@]}")
fi
if [[ ${#SERVER_ARGS[@]} -gt 0 ]]; then
CMD+=("${SERVER_ARGS[@]}")
fi
exec "${CMD[@]}"
View File
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
import sys
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from pathlib import Path
import pytest
from config import (
Config,
ensure_writable_directory,
load_config,
save_config,
validate_config,
)
def test_save_and_load_utf8_lf(tmp_path: Path) -> None:
path = tmp_path / "commonwealth-server.json"
cfg = Config(
host="0.0.0.0",
port=7777,
server_name="Café Server",
server_description="résumé",
max_players=8,
log_verbosity="info",
admin_port=7779,
)
save_config(cfg, str(path))
raw = path.read_bytes()
assert b"\r\n" not in raw
assert "Café Server".encode("utf-8") in raw
loaded = load_config(str(path))
assert loaded.server_name == "Café Server"
assert loaded.server_description == "résumé"
assert loaded.admin_port == 7779
def test_validate_rejects_bad_ports_and_max_players() -> None:
cfg = Config(port=70000, admin_port=7777, max_players=0)
ok, errors = validate_config(cfg)
assert not ok
assert any("port must be 1-65535" in error for error in errors)
assert any("max_players must be >= 1" in error for error in errors)
collide = Config(port=7777, admin_port=7777)
ok, errors = validate_config(collide)
assert not ok
assert any("admin_port must differ" in error for error in errors)
def test_validate_rejects_unresolvable_host() -> None:
cfg = Config(host="this-host-should-not-resolve.invalid")
ok, errors = validate_config(cfg)
assert not ok
assert any("not a valid IPv4" in error for error in errors)
def test_ensure_writable_directory(tmp_path: Path) -> None:
target = tmp_path / "state"
ensure_writable_directory(target)
assert target.is_dir()
assert not (target / ".commonwealth-write-probe").exists()
def test_load_invalid_json(tmp_path: Path) -> None:
path = tmp_path / "bad.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(Exception):
load_config(str(path))
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
import pytest
from admin_server import send_admin_command
from lan_discovery import LanDiscoveryResponder
from server_core import FalloutTogetherServer, get_lan_addresses
from server_service import ServerConfig, ServerService
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def test_get_lan_addresses_never_returns_wildcard() -> None:
addresses = get_lan_addresses()
assert "0.0.0.0" not in addresses
for address in addresses:
assert not address.startswith("127.")
def test_discovery_failure_does_not_stop_game_server(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
game_port = _free_port()
admin_port = _free_port()
def _fail_start(self: LanDiscoveryResponder) -> None:
raise OSError("Could not bind LAN discovery to 0.0.0.0:7778. simulated failure")
monkeypatch.setattr(LanDiscoveryResponder, "start", _fail_start)
service = ServerService(
ServerConfig(
host="127.0.0.1",
port=game_port,
admin_port=admin_port,
bans_path=str(tmp_path / "bans.json"),
)
)
thread = threading.Thread(target=service.serve_forever, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline and not service.is_running():
time.sleep(0.05)
assert service.is_running()
with socket.create_connection(("127.0.0.1", game_port), timeout=2.0) as conn:
data = conn.recv(4096)
assert b"welcome" in data
service.stop()
thread.join(timeout=3.0)
assert not service.is_running()
def test_stop_is_idempotent(tmp_path: Path) -> None:
game_port = _free_port()
admin_port = _free_port()
service = ServerService(
ServerConfig(
host="127.0.0.1",
port=game_port,
admin_port=admin_port,
bans_path=str(tmp_path / "bans.json"),
)
)
service.start()
deadline = time.time() + 5.0
while time.time() < deadline and not service.is_running():
time.sleep(0.05)
assert service.is_running()
service.stop()
service.stop()
assert not service.is_running()
def test_admin_and_game_bind_errors_include_address() -> None:
occupied = _free_port()
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
holder.bind(("127.0.0.1", occupied))
holder.listen()
server = FalloutTogetherServer(host="127.0.0.1", port=occupied)
with pytest.raises(OSError, match=rf"127\.0\.0\.1:{occupied}"):
server._prepare_server_socket()
finally:
holder.close()
def test_headless_admin_roundtrip(tmp_path: Path) -> None:
game_port = _free_port()
admin_port = _free_port()
service = ServerService(
ServerConfig(
host="127.0.0.1",
port=game_port,
admin_port=admin_port,
bans_path=str(tmp_path / "bans.json"),
)
)
thread = threading.Thread(target=service.serve_forever, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline and not service.is_running():
time.sleep(0.05)
assert service.is_running()
response = send_admin_command({"cmd": "ping"}, port=admin_port)
assert response.get("ok") is True
assert response.get("data", {}).get("pong") is True
service.stop()
thread.join(timeout=3.0)
def test_lan_discovery_sets_broadcast_option() -> None:
class DummyServer:
def get_stats(self):
return {
"connectedClients": 0,
"port": 7777,
"serverName": "Test",
"serverDescription": "",
"maxPlayers": 16,
}
def _log(self, message: str, *, level: str = "info") -> None:
return None
# Bind an ephemeral discovery port to avoid colliding with a real server.
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
port = int(sock.getsockname()[1])
sock.close()
responder = LanDiscoveryResponder(DummyServer(), discovery_port=port)
responder.start()
try:
assert responder._socket is not None
# SO_BROADCAST should be enabled; querying may return 0/1 depending on OS.
value = responder._socket.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
assert value in (0, 1)
probe = {
"type": "discover",
"protocol": "commonwealth-online",
}
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.settimeout(2.0)
client.sendto(json.dumps(probe).encode("utf-8"), ("127.0.0.1", port))
data, _addr = client.recvfrom(2048)
packet = json.loads(data.decode("utf-8"))
assert packet["type"] == "discoverResponse"
assert packet["port"] == 7777
finally:
responder.stop()
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import json
from pathlib import Path
from typer.testing import CliRunner
import consumer_server_cli
runner = CliRunner()
def test_serve_accepts_positional_config_path(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "commonwealth-server.json"
config_path.write_text(
json.dumps(
{
"host": "127.0.0.1",
"port": 1,
"server_name": "Arg Test",
"max_players": 2,
"log_verbosity": "info",
"admin_port": 2,
}
)
+ "\n",
encoding="utf-8",
newline="\n",
)
# Avoid binding real sockets; validate CLI parsing only.
called: dict[str, object] = {}
class FakeService:
def __init__(self) -> None:
self.config = None
def add_log_listener(self, _callback) -> None:
return None
def serve_forever(self) -> None:
called["served"] = True
def stop(self) -> None:
return None
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
monkeypatch.setattr(
consumer_server_cli,
"validate_config",
lambda _cfg: (True, []),
)
monkeypatch.setattr(
consumer_server_cli.signal,
"signal",
lambda *_args, **_kwargs: None,
)
result = runner.invoke(
consumer_server_cli.app,
["serve", str(config_path)],
)
assert result.exit_code == 0, result.output
assert called.get("served") is True
def test_serve_accepts_config_option(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "commonwealth-server.json"
config_path.write_text(
json.dumps(
{
"host": "127.0.0.1",
"port": 1,
"server_name": "Arg Test",
"max_players": 2,
"log_verbosity": "info",
"admin_port": 2,
}
)
+ "\n",
encoding="utf-8",
newline="\n",
)
called: dict[str, object] = {}
class FakeService:
def __init__(self) -> None:
self.config = None
def add_log_listener(self, _callback) -> None:
return None
def serve_forever(self) -> None:
called["served"] = True
def stop(self) -> None:
return None
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
monkeypatch.setattr(consumer_server_cli, "validate_config", lambda _cfg: (True, []))
monkeypatch.setattr(consumer_server_cli.signal, "signal", lambda *_args, **_kwargs: None)
result = runner.invoke(
consumer_server_cli.app,
["serve", "--config", str(config_path)],
)
assert result.exit_code == 0, result.output
assert called.get("served") is True
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(os.name == "nt", reason="SIGTERM integration is POSIX-only")
SERVER_DIR = Path(__file__).resolve().parents[1]
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def test_sigterm_exits_cleanly(tmp_path: Path) -> None:
game_port = _free_port()
admin_port = _free_port()
config_path = tmp_path / "commonwealth-server.json"
config_path.write_text(
json.dumps(
{
"host": "127.0.0.1",
"port": game_port,
"server_name": "CI Test Server",
"server_description": "",
"max_players": 4,
"log_verbosity": "info",
"admin_port": admin_port,
},
indent=2,
)
+ "\n",
encoding="utf-8",
newline="\n",
)
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["NO_COLOR"] = "1"
process = subprocess.Popen(
[
sys.executable,
"-u",
str(SERVER_DIR / "consumer_server_cli.py"),
"serve",
"--config",
str(config_path),
],
cwd=str(SERVER_DIR),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
try:
deadline = time.time() + 10.0
connected = False
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", game_port), timeout=0.5) as conn:
welcome = conn.recv(4096)
if b"welcome" in welcome:
connected = True
break
except OSError:
time.sleep(0.1)
assert connected, "server did not accept a test client in time"
# Admin probe over localhost only.
with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as admin:
admin.sendall(b'{"cmd":"ping"}\n')
response = admin.recv(4096)
assert b'"ok":true' in response.replace(b" ", b"")
process.send_signal(signal.SIGTERM)
try:
exit_code = process.wait(timeout=10.0)
except subprocess.TimeoutExpired:
process.kill()
pytest.fail("server did not exit after SIGTERM")
assert exit_code == 0
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5.0)