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. 1. Copy repo to your server.
2. Build client and publish `client/dist/` to your web root/subdirectory. 2. Build client and publish `client/dist/` to your web root/subdirectory.
3. Configure server `config.toml` and run it via `systemd`. 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 ## Key Paths

View File

@@ -1,6 +1,6 @@
// Maintainer-controlled web client version metadata. // Maintainer-controlled web client version metadata.
window.CHGRID_RELEASE_VERSION = "0.1.0"; 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}`; 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

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

View File

@@ -79,21 +79,19 @@ cd "$REPO_ROOT"
Expected proxy endpoints: Expected proxy endpoints:
```apache ```apache
ProxyPass /ws ws://127.0.0.1:8765 ProxyPass /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
ProxyPassReverse /ws ws://127.0.0.1:8765 ProxyPassReverse /chgrid/ws ws://127.0.0.1:8765/chgrid/ws
ProxyPass /auth/session/ http://127.0.0.1:8765/auth/session/ ProxyPass /chgrid/auth/session/ http://127.0.0.1:8765/chgrid/auth/session/
ProxyPassReverse /auth/session/ http://127.0.0.1:8765/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 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: What each route does:
- `/ws`: websocket signaling (presence, movement, item actions, chat, voice signaling). - `<base_path>ws`: websocket signaling (presence, movement, item actions, chat, voice signaling).
- `/auth/session/set`: called by client after successful login to set `HttpOnly` session cookie. - `<base_path>auth/session/set`: called by client after successful login to set the instance-scoped `HttpOnly` session cookie.
- `/auth/session/clear`: called by client on logout/session-reset to clear `HttpOnly` session cookie. - `<base_path>auth/session/clear`: called by client on logout/session-reset to clear the instance-scoped `HttpOnly` session cookie.
Important:
- Keep `/auth/session/*` at domain root even when the app is served from a subpath like `/chgrid`.
After Apache changes, reload Apache using your host's command. After Apache changes, reload Apache using your host's command.

View File

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

View File

@@ -57,6 +57,7 @@ except ModuleNotFoundError: # pragma: no cover - compatibility fallback
config_path = Path(sys.argv[1]) config_path = Path(sys.argv[1])
host = "127.0.0.1" host = "127.0.0.1"
port = 8765 port = 8765
base_path = "/"
if config_path.exists(): if config_path.exists():
with config_path.open("rb") as fp: with config_path.open("rb") as fp:
data = tomllib.load(fp) data = tomllib.load(fp)
@@ -72,7 +73,9 @@ if config_path.exists():
port = int(server.get("port", port)) port = int(server.get("port", port))
except (TypeError, ValueError): except (TypeError, ValueError):
port = 8765 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 PY
)" )"
escaped_host_origin=${CHGRID_HOST_ORIGIN//\\/\\\\} escaped_host_origin=${CHGRID_HOST_ORIGIN//\\/\\\\}

View File

@@ -21,12 +21,13 @@ Open: `http://localhost:5173`
Defaults: Defaults:
- Server reads `config.toml` automatically when present. - Server reads `config.toml` automatically when present.
- Server default bind/port is `127.0.0.1:8765`. - 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. - 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`. - Client dev default is `localhost:5173`.
- Auth requires `CHGRID_AUTH_SECRET` in environment. - Auth requires `CHGRID_AUTH_SECRET` in environment.
- Browser-origin enforcement requires `CHGRID_HOST_ORIGIN` in environment. - Browser-origin enforcement requires `CHGRID_HOST_ORIGIN` in environment.
- A starter env file is available at `server/.env.sample`. - 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 ## Quick Restarts

View File

@@ -96,7 +96,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `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.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.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.movementTickMs`: server movement-rate window used for client movement pacing.
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window. - `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 persists account state (last nickname + last position) and restores spawn from that state on auth login/resume.
- Server also supports websocket handshake cookie resume: - Server also supports websocket handshake cookie resume:
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN` - 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 - reads `chgrid_session_token` from websocket `Cookie` header
- attempts resume before sending `auth_required` - 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: - Server applies auth hardening before accepting login/register/resume:
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency - 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 - repeated auth failures are rate-limited by IP and IP+identity windows

View File

@@ -5,14 +5,14 @@
1. User clicks connect. 1. User clicks connect.
2. Client validates auth form and sets up local media. 2. Client validates auth form and sets up local media.
3. Client connects signaling websocket from the configured app origin. 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`. 5. If resume does not authenticate, server sends `auth_required`.
- includes `authPolicy` limits for username/password. - includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
7. Server sends `auth_result`. 7. Server sends `auth_result`.
- includes role + permissions for authenticated session. - 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. 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 /auth/session/check` before relaying media - 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. 9. Server sends `welcome` with users/items snapshot.
10. Client: 10. Client:
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - 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" bind_ip: str = "127.0.0.1"
port: int = 8765 port: int = 8765
base_path: str = "/"
class NetworkConfigSection(BaseModel): class NetworkConfigSection(BaseModel):

View File

@@ -133,9 +133,10 @@ RADIO_METADATA_TIMEOUT_S = 6.0
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0 CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
AUTH_SESSION_COOKIE_NAME = "chgrid_session_token" AUTH_SESSION_COOKIE_NAME = "chgrid_session_token"
AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60 AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60
AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set" AUTH_SESSION_COOKIE_SET_PATH = "auth/session/set"
AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear" AUTH_SESSION_COOKIE_CLEAR_PATH = "auth/session/clear"
AUTH_SESSION_COOKIE_CHECK_PATH = "/auth/session/check" AUTH_SESSION_COOKIE_CHECK_PATH = "auth/session/check"
WEBSOCKET_PATH = "ws"
AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client" 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_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." 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_debounce_ms: int = 200,
state_save_max_delay_ms: int = 1000, state_save_max_delay_ms: int = 1000,
host_origin: str | None = None, host_origin: str | None = None,
base_path: str = "/",
): ):
"""Initialize runtime state, TLS context, and item service.""" """Initialize runtime state, TLS context, and item service."""
@@ -190,6 +192,11 @@ class SignalingServer:
self.instance_id = str(uuid.uuid4()) self.instance_id = str(uuid.uuid4())
self.server_version = self._resolve_server_version() self.server_version = self._resolve_server_version()
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None 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_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.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 self._pending_state_save_handle: asyncio.TimerHandle | None = None
@@ -263,6 +270,23 @@ class SignalingServer:
"passwordMaxLength": self.auth_service.password_max_length, "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: def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
"""Return True when session cookies should be marked Secure.""" """Return True when session cookies should be marked Secure."""
@@ -278,7 +302,7 @@ class SignalingServer:
secure = "; Secure" if self._session_cookie_secure(request) else "" secure = "; Secure" if self._session_cookie_secure(request) else ""
return ( 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}" 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.""" """Build Set-Cookie header value that expires the session cookie."""
secure = "; Secure" if self._session_cookie_secure(request) else "" 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: def _origin_allowed(self, request: HttpRequest) -> bool:
"""Return whether one auth helper HTTP request comes from the configured app origin.""" """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.""" """Handle lightweight same-origin auth cookie set/clear HTTP endpoints."""
path = request.path.split("?", 1)[0] 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 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 = Headers()
headers["Content-Type"] = "text/plain; charset=utf-8" headers["Content-Type"] = "text/plain; charset=utf-8"
@@ -328,7 +362,7 @@ class SignalingServer:
if not self._origin_allowed(request): if not self._origin_allowed(request):
return HttpResponse(403, "Forbidden", headers, b"origin not allowed") 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() cookie_header = str(request.headers.get("Cookie", "")).strip()
token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME) token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
if not token: if not token:
@@ -339,7 +373,7 @@ class SignalingServer:
return HttpResponse(401, "Unauthorized", headers, b"invalid session") return HttpResponse(401, "Unauthorized", headers, b"invalid session")
return HttpResponse(204, "No Content", headers, b"") 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) headers["Set-Cookie"] = self._clear_session_cookie_header(request=request)
return HttpResponse(200, "OK", headers, b"cleared") return HttpResponse(200, "OK", headers, b"cleared")
@@ -1415,6 +1449,11 @@ class SignalingServer:
LOGGER.info("websocket opened id=%s", client.id) LOGGER.info("websocket opened id=%s", client.id)
try: 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) cookie_token = self._session_token_from_websocket_cookie(websocket)
if cookie_token: if cookie_token:
await self._handle_auth_packet( await self._handle_auth_packet(
@@ -3245,5 +3284,6 @@ def run() -> None:
state_save_debounce_ms=config.storage.state_save_debounce_ms, state_save_debounce_ms=config.storage.state_save_debounce_ms,
state_save_max_delay_ms=config.storage.state_save_max_delay_ms, state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
host_origin=host_origin, host_origin=host_origin,
base_path=config.server.base_path,
) )
asyncio.run(server.start()) asyncio.run(server.start())

View File

@@ -3,6 +3,8 @@
bind_ip = "127.0.0.1" bind_ip = "127.0.0.1"
# Listen port for signaling websocket server. # Listen port for signaling websocket server.
port = 8765 port = 8765
# Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/".
base_path = "/"
[network] [network]
# Maximum inbound websocket message size in bytes. # 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: def test_load_config_defaults_when_path_none() -> None:
cfg = load_config(None) cfg = load_config(None)
assert cfg.server.bind_ip == "127.0.0.1" assert cfg.server.bind_ip == "127.0.0.1"
assert cfg.server.base_path == "/"
assert cfg.network.allow_insecure_ws is False assert cfg.network.allow_insecure_ws is False
assert cfg.storage.state_file == "runtime/items.json" assert cfg.storage.state_file == "runtime/items.json"
assert cfg.storage.state_save_debounce_ms == 200 assert cfg.storage.state_save_debounce_ms == 200
@@ -43,3 +44,18 @@ state_save_max_delay_ms = 900
cfg = load_config(config_path) cfg = load_config(config_path)
assert cfg.storage.state_save_debounce_ms == 150 assert cfg.storage.state_save_debounce_ms == 150
assert cfg.storage.state_save_max_delay_ms == 900 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) 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 @pytest.mark.asyncio
async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None: 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]}" username = f"user_{uuid.uuid4().hex[:8]}"
session = server.auth_service.register(username, "password99") session = server.auth_service.register(username, "password99")
request = _request( request = _request(
AUTH_SESSION_COOKIE_SET_PATH, server.auth_session_cookie_set_path,
headers={ headers={
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
"Authorization": f"Bearer {session.token}", "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 assert response.status_code == 200
set_cookie = response.headers.get("Set-Cookie", "") set_cookie = response.headers.get("Set-Cookie", "")
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
assert "Path=/chgrid/" in set_cookie
assert "HttpOnly" in set_cookie assert "HttpOnly" in set_cookie
assert "SameSite=Lax" in set_cookie assert "SameSite=Lax" in set_cookie
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_cookie_clear_endpoint_expires_cookie() -> None: 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( request = _request(
AUTH_SESSION_COOKIE_CLEAR_PATH, server.auth_session_cookie_clear_path,
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"}, 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 @pytest.mark.asyncio
async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None: 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]}" username = f"user_{uuid.uuid4().hex[:8]}"
session = server.auth_service.register(username, "password99") session = server.auth_service.register(username, "password99")
request = _request( request = _request(
AUTH_SESSION_COOKIE_CHECK_PATH, server.auth_session_cookie_check_path,
headers={ headers={
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}", "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 @pytest.mark.asyncio
async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None: 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( request = _request(
AUTH_SESSION_COOKIE_CHECK_PATH, server.auth_session_cookie_check_path,
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"}, 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 @pytest.mark.asyncio
async def test_session_cookie_helpers_reject_wrong_origin() -> None: 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( 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"}, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://evil.example.com"},
) )