Scope server routes by base path
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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$">
|
||||||
|
|||||||
@@ -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//\\/\\\\}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/"
|
||||||
|
|||||||
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user