Scope server routes by base path

This commit is contained in:
Jage9
2026-03-08 22:24:32 -04:00
parent bd0ec1b01e
commit 54a7a3085b
14 changed files with 113 additions and 47 deletions

View File

@@ -47,7 +47,7 @@ Summary:
1. Copy repo to your server.
2. Build client and publish `client/dist/` to your web root/subdirectory.
3. Configure server `config.toml` and run it via `systemd`.
4. Add Apache `/ws` websocket proxy from `deploy/apache/chgrid-vhost-snippet.conf`.
4. Add base-path-scoped websocket/auth proxy routes from `deploy/apache/chgrid-vhost-snippet.conf`.
## Key Paths

View File

@@ -1,6 +1,6 @@
// Maintainer-controlled web client version metadata.
window.CHGRID_RELEASE_VERSION = "0.1.0";
window.CHGRID_BUILD_REVISION = "R341";
window.CHGRID_BUILD_REVISION = "R342";
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -236,8 +236,8 @@ const SYSTEM_SOUND_URLS = {
logout: withBase('sounds/logout.ogg'),
notify: withBase('sounds/notify.ogg'),
} as const;
const AUTH_SESSION_COOKIE_SET_URL = '/auth/session/set';
const AUTH_SESSION_COOKIE_CLEAR_URL = '/auth/session/clear';
const AUTH_SESSION_COOKIE_SET_URL = withBase('auth/session/set');
const AUTH_SESSION_COOKIE_CLEAR_URL = withBase('auth/session/clear');
const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client';
const ACTION_SOUND_URL = withBase('sounds/action.ogg');
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
@@ -313,7 +313,7 @@ let activeTeleport:
| null = null;
const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`;
const signalingUrl = `${signalingProtocol}://${window.location.host}${withBase('ws')}`;
const signaling = new SignalingClient(signalingUrl, handleSignalingStatus);
const peerManager = new PeerManager(

View File

@@ -79,21 +79,19 @@ cd "$REPO_ROOT"
Expected proxy endpoints:
```apache
ProxyPass /ws ws://127.0.0.1:8765
ProxyPassReverse /ws ws://127.0.0.1:8765
ProxyPass /auth/session/ http://127.0.0.1:8765/auth/session/
ProxyPassReverse /auth/session/ http://127.0.0.1:8765/auth/session/
ProxyPass /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
ProxyPassReverse /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
ProxyPass /chgrid/auth/session/ http://127.0.0.1:8765/chgrid/auth/session/
ProxyPassReverse /chgrid/auth/session/ http://127.0.0.1:8765/chgrid/auth/session/
```
The websocket server enforces browser origin matching against `CHGRID_HOST_ORIGIN`, so the public site origin must match that env var exactly.
The `server.base_path` value in `config.toml` must match the published client path and the proxy paths above.
What each route does:
- `/ws`: websocket signaling (presence, movement, item actions, chat, voice signaling).
- `/auth/session/set`: called by client after successful login to set `HttpOnly` session cookie.
- `/auth/session/clear`: called by client on logout/session-reset to clear `HttpOnly` session cookie.
Important:
- Keep `/auth/session/*` at domain root even when the app is served from a subpath like `/chgrid`.
- `<base_path>ws`: websocket signaling (presence, movement, item actions, chat, voice signaling).
- `<base_path>auth/session/set`: called by client after successful login to set the instance-scoped `HttpOnly` session cookie.
- `<base_path>auth/session/clear`: called by client on logout/session-reset to clear the instance-scoped `HttpOnly` session cookie.
After Apache changes, reload Apache using your host's command.

View File

@@ -7,14 +7,13 @@
# SetEnv CHGRID_HOST_ORIGIN https://example.com
# Proxy websocket signaling endpoint to local Python service.
# `/ws` is used by the browser signaling client for realtime packets.
ProxyPass /ws ws://127.0.0.1:8765
ProxyPassReverse /ws ws://127.0.0.1:8765
# Replace `/chgrid/` with the same value configured in `server.base_path`.
ProxyPass /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
ProxyPassReverse /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
# Proxy auth cookie helper endpoints to local Python service.
# These root-scoped paths are required even when the app is hosted under `/chgrid`.
# The client calls `/auth/session/set` after login and `/auth/session/clear` on logout/session-reset.
ProxyPass /auth/session/ http://127.0.0.1:8765/auth/session/
ProxyPassReverse /auth/session/ http://127.0.0.1:8765/auth/session/
# These paths should live under the same instance base path.
ProxyPass /chgrid/auth/session/ http://127.0.0.1:8765/chgrid/auth/session/
ProxyPassReverse /chgrid/auth/session/ http://127.0.0.1:8765/chgrid/auth/session/
# Ensure HTML entrypoint is never cached so version updates are picked up quickly.
<LocationMatch "^/chgrid/?$|^/chgrid/index\\.html$">

View File

@@ -57,6 +57,7 @@ except ModuleNotFoundError: # pragma: no cover - compatibility fallback
config_path = Path(sys.argv[1])
host = "127.0.0.1"
port = 8765
base_path = "/"
if config_path.exists():
with config_path.open("rb") as fp:
data = tomllib.load(fp)
@@ -72,7 +73,9 @@ if config_path.exists():
port = int(server.get("port", port))
except (TypeError, ValueError):
port = 8765
print(f"http://{host}:{port}/auth/session/check")
raw_base_path = str(server.get("base_path", base_path)).strip() or "/"
base_path = "/" if raw_base_path == "/" else f"/{raw_base_path.strip('/')}/"
print(f"http://{host}:{port}{base_path}auth/session/check")
PY
)"
escaped_host_origin=${CHGRID_HOST_ORIGIN//\\/\\\\}

View File

@@ -21,12 +21,13 @@ Open: `http://localhost:5173`
Defaults:
- Server reads `config.toml` automatically when present.
- Server default bind/port is `127.0.0.1:8765`.
- Server default base path is `/` for local/dev; production subpath deploys should set `server.base_path` to match the published client path such as `/chgrid/`.
- Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev.
- Client dev default is `localhost:5173`.
- Auth requires `CHGRID_AUTH_SECRET` in environment.
- Browser-origin enforcement requires `CHGRID_HOST_ORIGIN` in environment.
- A starter env file is available at `server/.env.sample`.
- Saved login uses server-managed `HttpOnly` cookie (`chgrid_session_token`) via `GET /auth/session/set` and `GET /auth/session/clear` (both require `X-Chgrid-Auth-Client: 1`).
- Saved login uses instance-scoped server-managed `HttpOnly` cookie helpers under the configured base path (for example `/chgrid/auth/session/set` and `/chgrid/auth/session/clear`) and both require `X-Chgrid-Auth-Client: 1`.
## Quick Restarts

View File

@@ -96,7 +96,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
- `auth_result.sessionToken` is used by the client to call server HTTP endpoint `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`.
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`.
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing.
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window.
@@ -126,9 +126,10 @@ This is a behavior guide for packet semantics beyond raw schemas.
- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume.
- Server also supports websocket handshake cookie resume:
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
- websocket and auth helper routes are scoped under the configured `server.base_path`
- reads `chgrid_session_token` from websocket `Cookie` header
- attempts resume before sending `auth_required`
- exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
- exposes `GET <base_path>auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
- Server applies auth hardening before accepting login/register/resume:
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency
- repeated auth failures are rate-limited by IP and IP+identity windows

View File

@@ -5,14 +5,14 @@
1. User clicks connect.
2. Client validates auth form and sets up local media.
3. Client connects signaling websocket from the configured app origin.
4. Server accepts the socket only when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
5. If resume does not authenticate, server sends `auth_required`.
- includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
7. Server sends `auth_result`.
- includes role + permissions for authenticated session.
8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors.
- the optional PHP media proxy validates that same cookie through `GET /auth/session/check` before relaying media
8. Client persists authenticated session into instance-scoped server-managed `HttpOnly` cookie helpers under the active app base path via `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`), and clears it via `GET <base_path>auth/session/clear` on logout/session errors.
- the optional PHP media proxy validates that same cookie through `GET <base_path>auth/session/check` before relaying media
9. Server sends `welcome` with users/items snapshot.
10. Client:
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering

View File

@@ -13,6 +13,7 @@ class ServerConfigSection(BaseModel):
bind_ip: str = "127.0.0.1"
port: int = 8765
base_path: str = "/"
class NetworkConfigSection(BaseModel):

View File

@@ -133,9 +133,10 @@ RADIO_METADATA_TIMEOUT_S = 6.0
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
AUTH_SESSION_COOKIE_NAME = "chgrid_session_token"
AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60
AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set"
AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear"
AUTH_SESSION_COOKIE_CHECK_PATH = "/auth/session/check"
AUTH_SESSION_COOKIE_SET_PATH = "auth/session/set"
AUTH_SESSION_COOKIE_CLEAR_PATH = "auth/session/clear"
AUTH_SESSION_COOKIE_CHECK_PATH = "auth/session/check"
WEBSOCKET_PATH = "ws"
AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client"
AUTH_LOGIN_FAILURE_MESSAGE = "We couldn't log you in. Check your details and try again."
AUTH_RESUME_FAILURE_MESSAGE = "We couldn't restore your session. Please log in again."
@@ -162,6 +163,7 @@ class SignalingServer:
state_save_debounce_ms: int = 200,
state_save_max_delay_ms: int = 1000,
host_origin: str | None = None,
base_path: str = "/",
):
"""Initialize runtime state, TLS context, and item service."""
@@ -190,6 +192,11 @@ class SignalingServer:
self.instance_id = str(uuid.uuid4())
self.server_version = self._resolve_server_version()
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
self.base_path = self._normalize_base_path(base_path)
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
self.auth_session_cookie_clear_path = self._base_path_join(AUTH_SESSION_COOKIE_CLEAR_PATH)
self.auth_session_cookie_check_path = self._base_path_join(AUTH_SESSION_COOKIE_CHECK_PATH)
self.state_save_debounce_ms = max(1, int(state_save_debounce_ms))
self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms))
self._pending_state_save_handle: asyncio.TimerHandle | None = None
@@ -263,6 +270,23 @@ class SignalingServer:
"passwordMaxLength": self.auth_service.password_max_length,
}
@staticmethod
def _normalize_base_path(value: str) -> str:
"""Normalize one instance base path to leading/trailing slash form."""
text = str(value).strip()
if not text or text == "/":
return "/"
return f"/{text.strip('/')}/"
def _base_path_join(self, suffix: str) -> str:
"""Join one instance-relative route suffix to the configured base path."""
token = suffix.lstrip("/")
if self.base_path == "/":
return f"/{token}"
return f"{self.base_path}{token}"
def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
"""Return True when session cookies should be marked Secure."""
@@ -278,7 +302,7 @@ class SignalingServer:
secure = "; Secure" if self._session_cookie_secure(request) else ""
return (
f"{AUTH_SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; "
f"{AUTH_SESSION_COOKIE_NAME}={token}; Path={self.base_path}; HttpOnly; SameSite=Lax; "
f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}"
)
@@ -286,7 +310,7 @@ class SignalingServer:
"""Build Set-Cookie header value that expires the session cookie."""
secure = "; Secure" if self._session_cookie_secure(request) else ""
return f"{AUTH_SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
return f"{AUTH_SESSION_COOKIE_NAME}=; Path={self.base_path}; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
def _origin_allowed(self, request: HttpRequest) -> bool:
"""Return whether one auth helper HTTP request comes from the configured app origin."""
@@ -316,8 +340,18 @@ class SignalingServer:
"""Handle lightweight same-origin auth cookie set/clear HTTP endpoints."""
path = request.path.split("?", 1)[0]
if path not in {AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH, AUTH_SESSION_COOKIE_CHECK_PATH}:
auth_paths = {
self.auth_session_cookie_set_path,
self.auth_session_cookie_clear_path,
self.auth_session_cookie_check_path,
}
if path == self.websocket_path:
return None
if path not in auth_paths:
headers = Headers()
headers["Content-Type"] = "text/plain; charset=utf-8"
headers["Cache-Control"] = "no-store"
return HttpResponse(404, "Not Found", headers, b"not found")
headers = Headers()
headers["Content-Type"] = "text/plain; charset=utf-8"
@@ -328,7 +362,7 @@ class SignalingServer:
if not self._origin_allowed(request):
return HttpResponse(403, "Forbidden", headers, b"origin not allowed")
if path == AUTH_SESSION_COOKIE_CHECK_PATH:
if path == self.auth_session_cookie_check_path:
cookie_header = str(request.headers.get("Cookie", "")).strip()
token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
if not token:
@@ -339,7 +373,7 @@ class SignalingServer:
return HttpResponse(401, "Unauthorized", headers, b"invalid session")
return HttpResponse(204, "No Content", headers, b"")
if path == AUTH_SESSION_COOKIE_CLEAR_PATH:
if path == self.auth_session_cookie_clear_path:
headers["Set-Cookie"] = self._clear_session_cookie_header(request=request)
return HttpResponse(200, "OK", headers, b"cleared")
@@ -1415,6 +1449,11 @@ class SignalingServer:
LOGGER.info("websocket opened id=%s", client.id)
try:
request = getattr(websocket, "request", None)
request_path = str(getattr(request, "path", "")).split("?", 1)[0]
if request_path != self.websocket_path:
await websocket.close()
return
cookie_token = self._session_token_from_websocket_cookie(websocket)
if cookie_token:
await self._handle_auth_packet(
@@ -3245,5 +3284,6 @@ def run() -> None:
state_save_debounce_ms=config.storage.state_save_debounce_ms,
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
host_origin=host_origin,
base_path=config.server.base_path,
)
asyncio.run(server.start())

View File

@@ -3,6 +3,8 @@
bind_ip = "127.0.0.1"
# Listen port for signaling websocket server.
port = 8765
# Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/".
base_path = "/"
[network]
# Maximum inbound websocket message size in bytes.

View File

@@ -8,6 +8,7 @@ from app.config import load_config
def test_load_config_defaults_when_path_none() -> None:
cfg = load_config(None)
assert cfg.server.bind_ip == "127.0.0.1"
assert cfg.server.base_path == "/"
assert cfg.network.allow_insecure_ws is False
assert cfg.storage.state_file == "runtime/items.json"
assert cfg.storage.state_save_debounce_ms == 200
@@ -43,3 +44,18 @@ state_save_max_delay_ms = 900
cfg = load_config(config_path)
assert cfg.storage.state_save_debounce_ms == 150
assert cfg.storage.state_save_max_delay_ms == 900
def test_load_config_reads_server_base_path(tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[network]
allow_insecure_ws = true
[server]
base_path = "/ttgrid/"
""".strip()
)
cfg = load_config(config_path)
assert cfg.server.base_path == "/ttgrid/"

View File

@@ -24,13 +24,17 @@ def _request(path: str, headers: dict[str, str] | None = None) -> Request:
return Request(path=path, headers=values)
def _server() -> SignalingServer:
return SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com", base_path="/chgrid/")
@pytest.mark.asyncio
async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
server = _server()
username = f"user_{uuid.uuid4().hex[:8]}"
session = server.auth_service.register(username, "password99")
request = _request(
AUTH_SESSION_COOKIE_SET_PATH,
server.auth_session_cookie_set_path,
headers={
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
"Authorization": f"Bearer {session.token}",
@@ -44,15 +48,16 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
assert response.status_code == 200
set_cookie = response.headers.get("Set-Cookie", "")
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
assert "Path=/chgrid/" in set_cookie
assert "HttpOnly" in set_cookie
assert "SameSite=Lax" in set_cookie
@pytest.mark.asyncio
async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
server = _server()
request = _request(
AUTH_SESSION_COOKIE_CLEAR_PATH,
server.auth_session_cookie_clear_path,
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"},
)
@@ -68,11 +73,11 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
@pytest.mark.asyncio
async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
server = _server()
username = f"user_{uuid.uuid4().hex[:8]}"
session = server.auth_service.register(username, "password99")
request = _request(
AUTH_SESSION_COOKIE_CHECK_PATH,
server.auth_session_cookie_check_path,
headers={
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}",
@@ -88,9 +93,9 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
@pytest.mark.asyncio
async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
server = _server()
request = _request(
AUTH_SESSION_COOKIE_CHECK_PATH,
server.auth_session_cookie_check_path,
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"},
)
@@ -102,9 +107,9 @@ async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None:
@pytest.mark.asyncio
async def test_session_cookie_helpers_reject_wrong_origin() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
server = _server()
request = _request(
AUTH_SESSION_COOKIE_CLEAR_PATH,
server.auth_session_cookie_clear_path,
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://evil.example.com"},
)