feat(docker): enforce sidecar and AI deployment policy (Part 5) (#2817)

* feat(docker): enforce sidecar and AI deployment policy (part 5)

* style(docker): apply policy Python formatting
This commit is contained in:
Daniel Hofbauer authored and GitHub committed 2026-10-03 07:42:00 -04:00
1 parent 9b25c5be50
commit 9dc4f0bdfb
9 files changed
+386 -36

No files matched your search

+4 -1
View File
@@ -171,12 +171,15 @@ RUN mkdir -p /data
COPY docker/nginx.conf /etc/nginx/nginx.conf.template
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY docker/deployment_policy.py /usr/local/lib/geolibre/deployment_policy.py
COPY docker/sidecar_policy.py /usr/local/lib/geolibre/sidecar_policy.py
RUN chmod +x /usr/local/bin/entrypoint.sh \
# Default auth snippet (disabled). entrypoint.sh rewrites it at start based
# on GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD; baking a valid default keeps
# `nginx -t` and non-entrypoint invocations working.
&& printf '# Basic Auth disabled (GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD not set).\n' > /etc/nginx/geolibre-auth.conf \
&& printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > /etc/nginx/geolibre-ai-proxy.conf
&& printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > /etc/nginx/geolibre-ai-proxy.conf \
&& printf '# Sidecar guards generated at boot.\n' > /etc/nginx/geolibre-sidecar-guards.conf \
&& printf '# Sidecar off switch generated at boot.\n' > /etc/nginx/geolibre-sidecar-off.conf
COPY --from=build /app/apps/geolibre-desktop/dist /usr/share/nginx/html
EXPOSE 80
@@ -0,0 +1,36 @@
"""Every backend route prefix must have an explicit container policy owner."""
import importlib.util
from pathlib import Path
from geolibre_server.app.main import app
ROOT = Path(__file__).resolve().parents[3]
_spec = importlib.util.spec_from_file_location(
"sidecar_policy", ROOT / "docker" / "sidecar_policy.py"
)
sp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sp)
def route_paths(routes, prefix=""):
for route in routes:
if hasattr(route, "path"):
yield prefix + route.path
else:
# Recent FastAPI versions retain lazy include_router wrappers.
# Inspect all routes, including those excluded from OpenAPI.
yield from route_paths(
route.original_router.routes, prefix + route.include_context.prefix
)
def test_every_sidecar_route_prefix_is_classified():
prefixes = {path.lstrip("/").split("/", 1)[0] for path in route_paths(app.routes)}
guarded = set(sp.ROUTE_CAPABILITIES)
unguarded = set(sp.UNGUARDED_PREFIXES)
assert not guarded & unguarded, "A prefix cannot be both guarded and exempt"
assert prefixes == guarded | unguarded, (
f"Unclassified backend prefixes: {prefixes - guarded - unguarded}; "
f"policy prefixes without routes: {(guarded | unguarded) - prefixes}"
)
+24 -4
View File
@@ -30,12 +30,21 @@ esac
# policy. See docs/deployment-policy.md.
python /usr/local/lib/geolibre/deployment_policy.py /usr/share/nginx/html/deployment.json
# Generate capability guards from the final validated policy, not raw env.
SIDECAR_DECISION=$(python /usr/local/lib/geolibre/sidecar_policy.py /usr/share/nginx/html/deployment.json)
case "$SIDECAR_DECISION" in
SIDECAR_START=0) SIDECAR_START=0 ;;
SIDECAR_START=1) SIDECAR_START=1 ;;
*) echo "ERROR: Invalid sidecar policy decision." >&2; exit 1 ;;
esac
AI_ENABLED=$(python -c 'import json; print(int(json.load(open("/usr/share/nginx/html/deployment.json")).get("ai", {}).get("enabled") is True))')
# Optional AI proxy. All three values are required together so merely setting a
# model never embeds or enables ai.geolibre.app. GEOLIBRE_AI_URL is deliberately
# restricted to the same-origin /ai route; the remote Worker URL and instance
# token remain server-side in nginx.
AI_PROXY_CONF=/etc/nginx/geolibre-ai-proxy.conf
if [ -n "${GEOLIBRE_AI_URL:-}" ] || [ -n "${GEOLIBRE_AI_PROXY_URL:-}" ] || [ -n "${GEOLIBRE_AI_PROXY_TOKEN:-}" ]; then
if [ "$AI_ENABLED" = "1" ]; then
if [ -z "${GEOLIBRE_AI_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_TOKEN:-}" ]; then
echo "ERROR: GEOLIBRE_AI_URL, GEOLIBRE_AI_PROXY_URL, and GEOLIBRE_AI_PROXY_TOKEN must be set together." >&2
exit 1
@@ -105,7 +114,7 @@ if real_ip:
token = os.environ["GEOLIBRE_AI_PROXY_TOKEN"]
config = f"""
location /ai/ {{
location ^~ /ai/ {{
{real_ip}
proxy_pass {upstream}/;
proxy_http_version 1.1;
@@ -127,7 +136,13 @@ open("/etc/nginx/geolibre-ai-proxy.conf", "w").write(config)
chmod 640 "$AI_PROXY_CONF"
echo "Authenticated AI proxy enabled at /ai."
else
printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > "$AI_PROXY_CONF"
cat > "$AI_PROXY_CONF" <<'EOF'
location ^~ /ai {
types {}
default_type application/json;
return 403 '{"detail":"AI disabled by deployment policy"}';
}
EOF
fi
# Runtime config the app reads at load (index.html pulls it in before the
@@ -578,9 +593,14 @@ else
rm -f "$HTPASSWD"
fi
if [ "${GEOLIBRE_DISABLE_SIDECAR:-0}" != "1" ]; then
if [ "${GEOLIBRE_DISABLE_SIDECAR:-0}" = "1" ]; then
echo "Sidecar disabled: GEOLIBRE_DISABLE_SIDECAR=1."
elif [ "$SIDECAR_START" = "1" ]; then
echo "Sidecar starting: deployment capabilities permit sidecar routes."
python -m uvicorn geolibre_server.app.main:app \
--host 127.0.0.1 --port 8765 &
else
echo "Sidecar disabled: neither processing:run nor data:add is granted."
fi
exec nginx -g 'daemon off;'
+9 -5
View File
@@ -42,10 +42,8 @@ server {
# TLS (a reverse proxy in front) on anything but a trusted network.
include /etc/nginx/geolibre-auth.conf;
# Optional authenticated AI reverse proxy. entrypoint.sh writes a location
# block here only when GEOLIBRE_AI_URL, GEOLIBRE_AI_PROXY_URL, and
# GEOLIBRE_AI_PROXY_TOKEN are all configured. The browser calls the
# same-origin /ai route; nginx injects the server-only instance token.
# Optional authenticated AI reverse proxy, enabled only by the final
# deployment policy. URL/token remain private container configuration.
include /etc/nginx/geolibre-ai-proxy.conf;
# Unauthenticated health endpoint for the container HEALTHCHECK (and any
@@ -64,10 +62,16 @@ server {
gzip_types text/css application/javascript application/json application/wasm image/svg+xml;
gzip_min_length 1024;
# More-specific normalized prefixes win over the proxy below; ^~ prevents
# static-asset regexes from bypassing either the guards or the proxy.
merge_slashes on;
include /etc/nginx/geolibre-sidecar-guards.conf;
# Reverse-proxy the optional Python sidecar bundled in the same image. The
# trailing slash strips the /sidecar/ prefix, so /sidecar/conversion/status
# reaches the sidecar at /conversion/status. Being same-origin avoids CORS.
location /sidecar/ {
location ^~ /sidecar/ {
include /etc/nginx/geolibre-sidecar-off.conf;
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
# The sidecar's TrustedHostMiddleware only accepts loopback hosts, so
+79
View File
@@ -0,0 +1,79 @@
"""Container-only capability enforcement for the normalized sidecar URI."""
import json
import os
import sys
from pathlib import Path
# Any one owning capability grants the entire route prefix. Conversion is also
# polled by raster jobs and used when adding data, so both owners must work.
ROUTE_CAPABILITIES = {
"whitebox": frozenset({"processing:run"}),
"raster": frozenset({"processing:run"}),
"vector": frozenset({"processing:run"}),
"pointcloud": frozenset({"processing:run"}),
"ml": frozenset({"processing:run"}),
"sql": frozenset({"processing:run"}),
"postgis": frozenset({"data:add"}),
"conversion": frozenset({"processing:run", "data:add"}),
}
# Explicit exceptions checked against FastAPI's routes by the backend suite.
# These retain their existing behavior whenever the sidecar is running.
UNGUARDED_PREFIXES = frozenset(
{
"health",
"algorithms",
"shutdown",
"run",
"docs",
"redoc",
"openapi.json",
}
)
_DENY = (
" default_type application/json;\n"
' return 403 \'{"detail":"Sidecar disabled by deployment policy"}\';\n'
" types {}\n"
)
def generate_snippets(policy, disabled=False):
"""Return (prefix guards, whole-sidecar off directives, should start)."""
capabilities = policy.get("capabilities")
# Omitted means unrestricted; [] is an explicit empty grant.
granted = (
frozenset().union(*ROUTE_CAPABILITIES.values())
if capabilities is None
else frozenset(capabilities)
)
start = not disabled and any(granted & required for required in ROUTE_CAPABILITIES.values())
guards = "# Generated sidecar capability guards.\n"
for prefix, required in ROUTE_CAPABILITIES.items():
if not granted & required:
# nginx matches its normalized URI (percent decoding and slash
# merging), not $request_uri. ^~ also defeats static-asset regexes.
guards += f"location ^~ /sidecar/{prefix} {{\n{_DENY}}}\n"
off = "# Generated whole-sidecar switch.\n" + ("" if start else _DENY)
return guards, off, start
def main(argv):
if len(argv) != 2:
print("usage: sidecar_policy.py <deployment-policy-path>", file=sys.stderr)
return 2
with open(argv[1], encoding="utf-8") as source:
policy = json.load(source)
guards, off, start = generate_snippets(
policy, disabled=os.environ.get("GEOLIBRE_DISABLE_SIDECAR") == "1"
)
directory = Path("/etc/nginx")
(directory / "geolibre-sidecar-guards.conf").write_text(guards, encoding="utf-8")
(directory / "geolibre-sidecar-off.conf").write_text(off, encoding="utf-8")
print(f"SIDECAR_START={int(start)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+63
View File
@@ -0,0 +1,63 @@
"""Unit coverage for container sidecar capability enforcement."""
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
_spec = importlib.util.spec_from_file_location(
"sidecar_policy", ROOT / "docker" / "sidecar_policy.py"
)
sp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sp)
PROCESSING = {"whitebox", "raster", "vector", "pointcloud", "ml", "sql"}
def test_route_ownership_contract():
assert sp.ROUTE_CAPABILITIES == {
**{prefix: frozenset({"processing:run"}) for prefix in PROCESSING},
"postgis": frozenset({"data:add"}),
"conversion": frozenset({"processing:run", "data:add"}),
}
@pytest.mark.parametrize(
"capabilities,denied,start",
[
(None, set(), True),
([], PROCESSING | {"postgis", "conversion"}, False),
(["export:data"], PROCESSING | {"postgis", "conversion"}, False),
(["data:add"], PROCESSING, True),
(["processing:run"], {"postgis"}, True),
(["processing:run", "data:add"], set(), True),
(["project:edit", "sharing:embed"], PROCESSING | {"postgis", "conversion"}, False),
],
)
def test_capability_matrix(capabilities, denied, start):
policy = {"version": 1}
if capabilities is not None:
policy["capabilities"] = capabilities
guards, off, should_start = sp.generate_snippets(policy)
assert should_start is start
for prefix in sp.ROUTE_CAPABILITIES:
assert (f"location ^~ /sidecar/{prefix} {{" in guards) == (prefix in denied)
assert guards.count("return 403") == len(denied)
assert ("return 403" in off) == (not start)
for snippet in (guards, off):
if "return 403" in snippet:
assert "default_type application/json;" in snippet
assert '"detail":' in snippet
for prefix in sp.UNGUARDED_PREFIXES:
assert f"location ^~ /sidecar/{prefix} {{" not in guards
@pytest.mark.parametrize(
"policy", [{"version": 1}, {"version": 1, "capabilities": ["processing:run", "data:add"]}]
)
def test_disable_override_turns_unrestricted_sidecar_off(policy):
_, off, start = sp.generate_snippets(policy, disabled=True)
assert start is False
assert "return 403" in off
assert "default_type application/json;" in off
+14 -9
View File
@@ -7,18 +7,23 @@ Capabilities are coarse on purpose. Each one names a whole class of action
("may add data at all"), not an individual menu item, so a locked-down
deployment cannot be defeated by one item somebody forgot to list.
!!! warning "This is a client-side gate, not an authorization boundary"
!!! warning "Client gates are not a general authorization boundary"
Withholding a capability removes the affordance: the menu is not rendered,
the command palette does not list or run the action, the keyboard shortcut
does nothing, and the embed API refuses the command. It does **not** stop
someone with browser devtools, and it does not restrict the server.
does nothing, and the embed API refuses the command. These client gates do
**not** stop someone with browser devtools.
The sidecar (`/sidecar`) and AI proxy (`/ai`) endpoints answer the same
requests whatever capabilities are configured. For a deployment that must
hold up against its own users, keep the server-side protections in
[Self-Hosting](self-hosting.md) — Basic Auth or a real auth proxy,
`GEOLIBRE_CONVERSION_ROOTS`, `GEOLIBRE_DISABLE_SIDECAR` — and treat
capabilities as the interface half of the story.
The Docker container also enforces its final deployment policy at nginx:
selected `/sidecar/` route families require `processing:run` or `data:add`,
and `/ai` is disabled unless the final policy enables AI and the approved
proxy environment is configured. See the exact route table and startup
rules in [Self-Hosting](self-hosting.md#container-policy-enforcement).
This enforcement is **container-only**: it does not restrict browser WASM
engines, desktop processing, or a separately hosted sidecar.
Keep Basic Auth or a real auth proxy and `GEOLIBRE_CONVERSION_ROOTS` in
place. Container policy enforcement is not user authentication, and
capabilities remain the interface half of the story outside those routes.
## Not the same as UI Profiles
+49 -3
View File
@@ -149,14 +149,15 @@ Settings that matter for a private deployment:
| `GEOLIBRE_AUTH0_DOMAIN` / `GEOLIBRE_AUTH0_CLIENT_ID` | unset, or both, if you use Auth0 instead of Clerk | The same sign-in gate backed by Auth0 Universal Login. Both are required together, and the container refuses to start if Clerk is configured as well — pick one provider. |
| `GEOLIBRE_CONVERSION_ROOTS` | `/data` (the image default) | Confines every sidecar read and write to the mounted directory. |
| `GEOLIBRE_POSTGIS_HOSTS` | unset unless needed | The sidecar's PostGIS endpoints refuse every destination until this names the allowed databases, so a caller cannot aim them at hosts only the container can reach. |
| `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Runs nginx only. |
| `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Skips uvicorn regardless of capabilities; every `/sidecar/` request returns HTTP 403 with an `application/json` detail response. |
| `GEOLIBRE_EMBED_ORIGINS` | unset, or the exact host page origin | Off by default, so a framed deployment cannot be driven by whoever frames it. |
| `GEOLIBRE_NO_EXTERNAL_CDN` (build arg) | `1` for restricted deployments | Strips GeoLibre's own references to external CDNs (`unpkg.com`, `cdn.jsdelivr.net`) from the build output. Features whose assets are only available from a CDN are disabled or degraded: storymap HTML export, built-in object detection models, ONNX WASM, 3D Tiles Draco/KTX2 decoders, and gdal3.js export. Pyodide is not hard-disabled — the flag drops only its default index URL, so setting `VITE_PYODIDE_INDEX_URL` to an approved mirror keeps it working. Also forces `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0` — so PGlite/PostGIS, CereusDB, and DuckDB-WASM stay **available**, vendored into the build under `/assets/` (at a larger build size) rather than fetched. Note that some third-party packages (DuckDB-WASM, loaders.gl, maplibre-gl-3d-tiles) carry their own internal CDN URLs that this flag cannot remove; see [architecture.md](architecture.md) for the details. Intended for deployments that cannot reference untrusted external CDNs (e.g. enterprise environments with strict CSP requirements). |
| `GEOLIBRE_APP_NAME` | optional, e.g. `Acme Maps` | Replaces "GeoLibre" at the start of the toolbar and in the browser tab title. Whitespace runs collapse to one space and the name is capped at 60 characters. `VITE_GEOLIBRE_APP_NAME` is the equivalent build arg. |
| `VITE_WELCOME_DISABLED=1` (build arg) | optional | Skips the first-launch wizard for every visitor. |
| `VITE_GEOLIBRE_CAPABILITIES` (build arg) | unset, or the capabilities to grant | Unset grants everything (today's behavior). Naming a subset — or `none` — pins what the interface offers: adding data, processing, export, plugins, settings, project authoring. Removes affordances only; it is not a server-side restriction. See [Deployment Capabilities](deployment-capabilities.md). |
| `VITE_GEOLIBRE_CAPABILITIES` (build arg) | unset, or the capabilities to grant | Unset grants everything (today's behavior). Naming a subset — or `none` — pins what the interface offers: adding data, processing, export, plugins, settings, project authoring. Build-time client grants do not configure nginx guards; set `GEOLIBRE_CAPABILITIES` or mount a policy for container enforcement. See [Deployment Capabilities](deployment-capabilities.md). |
| `GEOLIBRE_DEPLOYMENT_FILE` | unset, or a mounted policy file | Path to a [`deployment.json`](deployment-policy.md#docker) inside the container. The entrypoint validates it, applies the `GEOLIBRE_*` overrides per field, and writes `/deployment.json` on every boot. An invalid file stops the container. |
| `GEOLIBRE_CAPABILITIES` | unset, or the capabilities to grant | Same as `VITE_GEOLIBRE_CAPABILITIES`, but set at run time with `-e` on a prebuilt image. Unset leaves the file or build value in force; `none` grants nothing. An unknown name stops the container and lists the accepted values. See [Deployment Capabilities](deployment-capabilities.md). |
| `GEOLIBRE_CAPABILITIES` | unset, or the capabilities to grant | Same as `VITE_GEOLIBRE_CAPABILITIES`, but set at run time with `-e` on a prebuilt image. Unset leaves the file or build value in force; `none` grants nothing. An unknown name stops the container and lists the accepted values. The final policy controls nginx sidecar guards and whether uvicorn starts. See [Deployment Capabilities](deployment-capabilities.md). |
| `GEOLIBRE_AI_URL` / `GEOLIBRE_AI_PROXY_URL` / `GEOLIBRE_AI_PROXY_TOKEN` | unset, or the approved proxy trio | `GEOLIBRE_AI_URL=/ai` overrides the policy to enable AI. All three values must be configured together under the existing proxy validation rules. The upstream URL and token stay private to the server, not in `deployment.json`. |
See [Getting Started](getting-started.md#run-with-docker) for the full list.
@@ -167,6 +168,51 @@ See [Getting Started](getting-started.md#run-with-docker) for the full list.
visitor's loopback interface. Drop those allowances from the CSP for a
public deployment.
### Container policy enforcement
The container uses the final deployment policy, after environment overrides,
to guard these route families at nginx. Each row is an **any-of** check: at
least one listed capability must be granted.
| Sidecar route prefix | Required capability (any of) |
| --- | --- |
| `/sidecar/whitebox` | `processing:run` |
| `/sidecar/raster` | `processing:run` |
| `/sidecar/vector` | `processing:run` |
| `/sidecar/pointcloud` | `processing:run` |
| `/sidecar/ml` | `processing:run` |
| `/sidecar/sql` | `processing:run` |
| `/sidecar/postgis` | `data:add` |
| `/sidecar/conversion` | `processing:run` **or** `data:add` |
A denied route returns HTTP 403 with an `application/json` body containing
`detail`, rather than reaching the sidecar. Omitting `capabilities` grants all
capabilities. If the final grant contains neither `processing:run` nor
`data:add`, uvicorn is not started and all `/sidecar/` requests return that JSON
403 response. `GEOLIBRE_DISABLE_SIDECAR=1` does the same regardless of the grant.
When uvicorn runs, existing utility routes such as `/health`, `/algorithms`,
`/run`, and `/shutdown` remain unguarded by capabilities; this is a route-family
policy, not per-endpoint authorization. Guards use nginx's normalized URI,
including duplicate-slash merging and percent-decoded prefixes.
The final policy also controls `/ai`: `ai.enabled: false`, or an absent
`ai.enabled`, disables the proxy. `ai.enabled: true` requires the approved
proxy environment trio (`GEOLIBRE_AI_URL=/ai`, `GEOLIBRE_AI_PROXY_URL`, and
`GEOLIBRE_AI_PROXY_TOKEN`); missing or invalid configuration stops boot.
The existing environment override is preserved: setting
`GEOLIBRE_AI_URL=/ai` enables AI even if a mounted policy says false. Merely
setting the upstream URL or token does not enable AI. Proxy infrastructure,
including its upstream URL and token, remains server-private.
!!! warning "Container guards are not general authorization"
These restrictions apply only to the bundled container's nginx routes.
They do not enforce capabilities on browser WASM engines, desktop
processing, or separately exposed services. Keep the sidecar and AI
upstream private behind nginx, retain filesystem confinement, and use
Basic Auth or a real auth proxy for user authentication. Client-side
hiding alone is not an authorization boundary.
### Putting both behind one auth layer
Basic Auth is fine for a single shared credential. For accounts or SSO, put
+108 -14
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# End-to-end check of docker/entrypoint.sh and the deployment.json it writes
# (issue #2783). Builds a small image holding the REAL entrypoint.sh, nginx.conf
# and deployment_policy.py with a stub index.html and the Python sidecar disabled,
# and deployment/sidecar policy with a stub index.html and the real Python sidecar,
# so it needs no app build. The COPY/printf lines below mirror the runtime stage
# of the Dockerfile (COPY docker/..., default auth and AI snippets) and must be
# kept in sync with it.
@@ -15,6 +15,9 @@ IMAGE=geolibre-entrypoint:test
FIXTURES=tests/fixtures/deployment-policy
TMP="$(mktemp -d)"
CONTAINERS=()
RUN_ID="geolibre-entrypoint-$(basename "$TMP")"
exec_container() { local name="$1"; shift; docker exec "$RUN_ID-$name" "$@"; }
container_logs() { docker logs "$RUN_ID-$1"; }
cleanup() {
for name in "${CONTAINERS[@]:-}"; do
@@ -34,25 +37,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends nginx openssl c
COPY docker/nginx.conf /etc/nginx/nginx.conf.template
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY docker/deployment_policy.py /usr/local/lib/geolibre/deployment_policy.py
COPY docker/sidecar_policy.py /usr/local/lib/geolibre/sidecar_policy.py
COPY backend/geolibre_server /opt/geolibre-server
RUN pip install --no-cache-dir /opt/geolibre-server pytest
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& printf '# Basic Auth disabled.\n' > /etc/nginx/geolibre-auth.conf \
&& printf '# AI proxy disabled.\n' > /etc/nginx/geolibre-ai-proxy.conf \
&& printf '# Sidecar guards disabled.\n' > /etc/nginx/geolibre-sidecar-guards.conf \
&& printf '# Sidecar enabled.\n' > /etc/nginx/geolibre-sidecar-off.conf \
&& mkdir -p /usr/share/nginx/html \
&& printf '<!doctype html><title>stub</title>\n' > /usr/share/nginx/html/index.html
ENV GEOLIBRE_DISABLE_SIDECAR=1
CMD ["/usr/local/bin/entrypoint.sh"]
DOCKERFILE
# start NAME DOCKER_ARGS... : run detached and wait for /healthz.
start() {
local name="$1"; shift
CONTAINERS+=("$name")
docker run -d --name "$name" "$@" "$IMAGE" >/dev/null
docker run -d --name "$RUN_ID-$name" "$@" "$IMAGE" >/dev/null
CONTAINERS+=("$RUN_ID-$name")
for _ in $(seq 1 60); do
if docker exec "$name" curl -fsS http://127.0.0.1/healthz >/dev/null 2>&1; then return 0; fi
if exec_container "$name" curl -fsS http://127.0.0.1/healthz >/dev/null 2>&1; then
exec_container "$name" nginx -t || fail "$name: nginx configuration"
return 0
fi
sleep 0.5
done
docker logs "$name" >&2 || true
container_logs "$name" >&2 || true
fail "$name did not become healthy"
}
@@ -67,28 +77,63 @@ expect_fail() {
}
mount_policy() { echo "-v $1:/etc/geolibre/deployment.json:ro -e GEOLIBRE_DEPLOYMENT_FILE=/etc/geolibre/deployment.json"; }
fetch() { docker exec "$1" curl -fsS "http://127.0.0.1$2"; }
fetch() { exec_container "$1" curl -fsS "http://127.0.0.1$2"; }
json_eq() { python3 -c 'import json,sys; a=json.loads(sys.argv[1]); b=json.load(open(sys.argv[2])); b.pop("$schema",None); sys.exit(0 if a==b else 1)' "$1" "$2"; }
# A proxy response must come from the real backend, not an nginx fallback.
sidecar_ready() {
local name="$1"
for _ in $(seq 1 60); do
if fetch "$name" /sidecar/health 2>/dev/null | python3 -c 'import json,sys; assert json.load(sys.stdin)["status"] == "ok"' 2>/dev/null; then
return 0
fi
sleep 0.5
done
fail "$name: sidecar did not start"
}
no_sidecar() {
exec_container "$1" python -c 'import os; from pathlib import Path; assert not any(b"uvicorn" in p.read_bytes() for p in Path("/proc").glob("[0-9]*/cmdline") if p.parent.name != str(os.getpid())), "uvicorn is running"' \
|| fail "$1: uvicorn should not run"
}
status_eq() {
local name="$1" path="$2" expected="$3" status
status="$(exec_container "$name" curl --path-as-is -sS -o /tmp/response -w '%{http_code}' "http://127.0.0.1$path")"
[ "$status" = "$expected" ] || fail "$name $path: expected $expected, got $status"
}
forbidden() {
status_eq "$1" "$2" 403
exec_container "$1" curl --path-as-is -sSI "http://127.0.0.1$2" \
| grep -qi '^content-type: application/json' || fail "$1 $2: not JSON"
exec_container "$1" cat /tmp/response \
| python3 -c 'import json,sys; assert isinstance(json.load(sys.stdin)["detail"], str)' \
|| fail "$1 $2: missing JSON detail"
}
# 1. defaults ------------------------------------------------------------------
start defaults
headers="$(docker exec defaults curl -si http://127.0.0.1/deployment.json)"
headers="$(exec_container defaults curl -si http://127.0.0.1/deployment.json)"
grep -q '^HTTP/1.1 200' <<<"$headers" || fail "defaults: status"
grep -qi '^content-type: application/json' <<<"$headers" || fail "defaults: content-type"
grep -qi '^cache-control: no-store' <<<"$headers" || fail "defaults: cache-control"
grep -qi '^x-content-type-options: nosniff' <<<"$headers" || fail "defaults: nosniff"
[ "$(fetch defaults /deployment.json | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)))')" = '{"version": 1}' ] \
|| fail "defaults: body"
[ "$(docker exec defaults cat /usr/share/nginx/html/geolibre-runtime-config.js)" = 'window.__GEOLIBRE_DEPLOYMENT_ENV__ = {};' ] \
[ "$(exec_container defaults cat /usr/share/nginx/html/geolibre-runtime-config.js)" = 'window.__GEOLIBRE_DEPLOYMENT_ENV__ = {};' ] \
|| fail "defaults: runtime config changed"
! docker logs defaults 2>&1 | grep -q ' from GEOLIBRE_' || fail "defaults: unexpected override log"
! container_logs defaults 2>&1 | grep -q ' from GEOLIBRE_' || fail "defaults: unexpected override log"
ok "defaults: empty policy, headers, runtime config unchanged"
sidecar_ready defaults
status_eq defaults /sidecar/postgis/status 200
status_eq defaults /sidecar/conversion/status 200
# 2. capabilities env ----------------------------------------------------------
start caps -e GEOLIBRE_CAPABILITIES=data:add,export:data
json_eq "$(fetch caps /deployment.json)" <(echo '{"version":1,"capabilities":["data:add","export:data"]}') \
|| fail "caps: body"
docker exec caps cat /usr/share/nginx/html/geolibre-runtime-config.js \
exec_container caps cat /usr/share/nginx/html/geolibre-runtime-config.js \
| grep -qF '"VITE_GEOLIBRE_CAPABILITIES":"data:add,export:data"' || fail "caps: runtime config"
ok "GEOLIBRE_CAPABILITIES serves exactly those capabilities"
@@ -99,9 +144,9 @@ start mixed $(mount_policy "$TMP/mixed.json") -e "GEOLIBRE_APP_NAME=From Env"
body="$(fetch mixed /deployment.json)"
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); assert d["branding"]["appName"]=="From Env" and d["capabilities"]==["data:add"], d' "$body" \
|| fail "mixed: body"
lines="$(docker logs mixed 2>&1 | grep -c '^Deployment policy: .* from GEOLIBRE_' || true)"
lines="$(container_logs mixed 2>&1 | grep -c '^Deployment policy: .* from GEOLIBRE_' || true)"
[ "$lines" = 1 ] || fail "mixed: expected exactly one override log line, got $lines"
docker logs mixed 2>&1 | grep '^Deployment policy: .* from GEOLIBRE_' | grep -q '(overrides GEOLIBRE_DEPLOYMENT_FILE)$' \
container_logs mixed 2>&1 | grep '^Deployment policy: .* from GEOLIBRE_' | grep -q '(overrides GEOLIBRE_DEPLOYMENT_FILE)$' \
|| fail "mixed: override log suffix"
ok "env overrides one file field with one log line"
@@ -113,7 +158,7 @@ json_eq "$(fetch minimal /deployment.json)" "$FIXTURES/good/minimal.json" || fai
start full $(mount_policy "$PWD/$FIXTURES/good/full.json") \
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
json_eq "$(fetch full /deployment.json)" "$FIXTURES/good/full.json" || fail "full: round trip"
docker exec full curl -sI http://127.0.0.1/ | grep -i '^content-security-policy' | grep -qF ' wss://relay.example.com' \
exec_container full curl -sI http://127.0.0.1/ | grep -i '^content-security-policy' | grep -qF ' wss://relay.example.com' \
|| fail "full: CSP lacks the collab origin from the file"
ok "good fixtures round-trip"
@@ -140,3 +185,52 @@ echo '{"version":1,"ai":{"enabled":true}}' >"$TMP/ai.json"
# shellcheck disable=SC2046
expect_fail "ai.enabled is true, but the AI proxy is not configured" $(mount_policy "$TMP/ai.json")
ok "bad env fails the boot"
# 7. sidecar capability enforcement --------------------------------------------
sidecar_ready caps
status_eq caps /sidecar/postgis/status 200
status_eq caps /sidecar/conversion/status 200
for path in /sidecar/whitebox/status /sidecar//whitebox/status /sidecar/%77hitebox/status /sidecar%2fwhitebox/status /sidecar/whitebox%2fstatus /sidecar/whitebox /sidecar/whitebox/status.js; do
forbidden caps "$path"
done
start processing -e GEOLIBRE_CAPABILITIES=processing:run
sidecar_ready processing
status_eq processing /sidecar/conversion/status 200
status_eq processing /sidecar/conversion/jobs/x 404
forbidden processing /sidecar/postgis/status
start export-only -e GEOLIBRE_CAPABILITIES=export:data
no_sidecar export-only
for path in /sidecar/health /sidecar/postgis/status /sidecar/unknown /sidecar/unknown.js /sidecar//whitebox/status /sidecar/%77hitebox/status /sidecar/whitebox; do
forbidden export-only "$path"
done
start sidecar-disabled -e GEOLIBRE_DISABLE_SIDECAR=1
no_sidecar sidecar-disabled
forbidden sidecar-disabled /sidecar/health
forbidden sidecar-disabled /sidecar/conversion/status
ok "sidecar capabilities guard normalized paths and control the real process"
# 8. final AI policy controls the actual proxy ----------------------------------
forbidden defaults /ai/chat/completions
echo '{"version":1,"ai":{"enabled":false}}' >"$TMP/ai-false.json"
# Proxy credentials alone must not turn an explicitly disabled policy on.
# shellcheck disable=SC2046
start ai-false $(mount_policy "$TMP/ai-false.json") \
-e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
forbidden ai-false /ai/chat/completions
start ai-absent -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
forbidden ai-absent /ai/chat/completions
status_eq full /ai/chat/completions 502
# Part 3's URL override enables AI even when the file disables it.
# shellcheck disable=SC2046
start ai-override $(mount_policy "$TMP/ai-false.json") \
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
status_eq ai-override /ai/chat/completions 502
# shellcheck disable=SC2046
expect_fail "must be set together" $(mount_policy "$TMP/ai.json") -e GEOLIBRE_AI_URL=/ai
expect_fail "must be the same-origin path /ai" \
-e GEOLIBRE_AI_URL=/wrong -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
expect_fail "must contain only [A-Za-z0-9._-]" \
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e 'GEOLIBRE_AI_PROXY_TOKEN=bad token'
expect_fail "must be an HTTPS origin without credentials, path, query, or fragment" \
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=http://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
ok "final AI policy gates the proxy and preserves boot validation"