Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,45 @@ FROM python:3.11-slim-bookworm
#
# std = standard clock; feranick also ships *-max .debs for ~2x speed at higher
# power/heat. TFLITE_SPEC is any pip requirement: a "name @ URL" or "name==ver".
# The downloads are checksum-verified (supply chain): when you override
# LIBEDGETPU_DEB or a URL TFLITE_SPEC, also override the matching *_SHA256
# (compute with `sha256sum <file>`). A PyPI TFLITE_SPEC (name==ver) is resolved
# and integrity-checked by uv against the index, so TFLITE_SHA256 is unused.
ARG LIBEDGETPU_DEB=https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-std_16.0tf2.17.1-1.bookworm_amd64.deb
ARG LIBEDGETPU_SHA256=a3e1f9ae3e4725032a863ea38f52aa396ee18fdb65135fed56e8efae5c2e467d
ARG TFLITE_SPEC=tflite-runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl
ARG TFLITE_SHA256=5983bc00bc47d86a6a39f258ad07b69bd4d9fd61703535b10347c13f6901eb05

RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL "$LIBEDGETPU_DEB" -o /tmp/libedgetpu.deb \
&& echo "${LIBEDGETPU_SHA256} /tmp/libedgetpu.deb" | sha256sum -c - \
&& apt-get install -y --no-install-recommends /tmp/libedgetpu.deb \
&& rm /tmp/libedgetpu.deb \
&& apt-get purge -y curl && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# uv is pinned (not :latest) so the builder tool is reproducible.
COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /usr/local/bin/uv

WORKDIR /app
COPY pyproject.toml README.md ./
COPY src ./src
# Install the selected tflite_runtime, plus the project with the 'prometheus'
# extra (so /metrics can be enabled via env with no rebuild) but WITHOUT the
# 'tpu' extra (which would pull the incompatible PyPI tflite-runtime).
RUN uv pip install --system --no-cache "${TFLITE_SPEC}" ".[prometheus]"
# 'tpu' extra (which would pull the incompatible PyPI tflite-runtime). A URL
# wheel is downloaded and checksum-verified before install; a PyPI spec goes
# straight to uv. Then curl is removed (ca-certificates stays for runtime
# model downloads over https).
RUN set -eux; \
case "${TFLITE_SPEC}" in \
*://*) url="${TFLITE_SPEC##* }"; \
curl -fsSL "$url" -o /tmp/tflite.whl; \
echo "${TFLITE_SHA256} /tmp/tflite.whl" | sha256sum -c -; \
uv pip install --system --no-cache /tmp/tflite.whl ".[prometheus]"; \
rm /tmp/tflite.whl ;; \
*) uv pip install --system --no-cache "${TFLITE_SPEC}" ".[prometheus]" ;; \
esac; \
apt-get purge -y curl && apt-get autoremove -y

# Run as an unprivileged user. The TPU device node is typically owned by a
# host group (e.g. 'apex' for PCIe, 'plugdev' for USB) — pass that group at
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,12 +317,16 @@ All settings are environment variables prefixed `CORALAPI_`:
| `CORALAPI_MODEL_SOURCE` | Coral test_data mirror | Base URL for model downloads (must be `https`) |
| `CORALAPI_MODEL_DOWNLOAD` | `true` | Disable to serve only local models |
| `CORALAPI_ALLOW_INSECURE_MODEL_SOURCE` | `false` | Permit a non-HTTPS `model_source` (not recommended) |
| `CORALAPI_MAX_DOWNLOADED_MODELS` | `100` | Cap on models cached to disk via download |
| `CORALAPI_MAX_DOWNLOADED_MODELS` | `100` | Cap on models cached to disk via download (count) |
| `CORALAPI_MAX_MODEL_BYTES` | `268435456` (256 MB) | Per-model download size cap; an over-size download is aborted |
| `CORALAPI_MAX_MODEL_CACHE_BYTES` | `4294967296` (4 GB) | Total model-cache size cap; a download over it is refused |
| `CORALAPI_MODEL_CHECKSUMS` | `{}` | Optional JSON `{name: sha256}`; a listed model's download is verified |
| `CORALAPI_QUEUE_DEPTH` | `16` | Requests allowed to wait for the TPU (else `429`) |
| `CORALAPI_JOB_RETENTION_SECONDS` | `3600` | How long finished async job results are pollable |
| `CORALAPI_MAX_UPLOAD_BYTES` | `524288000` (500 MB) | Request-body size limit (`413` beyond; enforced at the ASGI layer) |
| `CORALAPI_UPLOAD_DIR` | `<tmp>/coralapi-uploads` | Directory streamed uploads are written to |
| `CORALAPI_MAX_IMAGE_DIM` | `15360` (16K) | Longest allowed image edge in pixels |
| `CORALAPI_ALLOWED_IMAGE_FORMATS` | `[]` (any) | Optional JSON list of decoded formats to accept (e.g. `["JPEG","PNG"]`); others get `415` |
| `CORALAPI_MAX_VIDEO_DIM` | `15360` (16K) | Reserved for the planned video endpoints |
| `CORALAPI_EDGETPU_LIB` | unset | Override the libedgetpu shared-library path |
| `CORALAPI_METRICS_ENABLED` | `false` | Expose Prometheus metrics at `/metrics` |
Expand All @@ -334,11 +338,13 @@ All settings are environment variables prefixed `CORALAPI_`:
| Profile | Target | Build |
|---|---|---|
| **avx2** (default) | x86-64 with AVX2 (Intel Haswell / 2013+, most modern CPUs) | `docker build -t coralapi .` |
| **compat** | x86-64 **without** AVX2 (e.g. Sandy/Ivy Bridge Xeon) | `--build-arg TFLITE_SPEC=tflite-runtime==2.14.0 --build-arg LIBEDGETPU_DEB=<feranick 2.13.1 std .deb>` |
| **arm** | Raspberry Pi (armhf) | `--platform linux/arm/v7` plus the armhf `LIBEDGETPU_DEB` and armv7l `TFLITE_SPEC` wheel |
| **compat** | x86-64 **without** AVX2 (e.g. Sandy/Ivy Bridge Xeon) | `--build-arg TFLITE_SPEC=tflite-runtime==2.14.0 --build-arg LIBEDGETPU_DEB=<feranick 2.13.1 std .deb> --build-arg LIBEDGETPU_SHA256=4ffb6c2251b61535afb6b368d821211a914d2d4783770ec7e6001c073c2d193b` |
| **arm** | Raspberry Pi (armhf) | `--platform linux/arm/v7` plus the armhf `LIBEDGETPU_DEB` and armv7l `TFLITE_SPEC` wheel, each with its matching `LIBEDGETPU_SHA256` / `TFLITE_SHA256` |

Not sure whether your CPU has AVX2? Run `grep -o avx2 /proc/cpuinfo` — no output means use **compat**.

Whenever you override `LIBEDGETPU_DEB` or point `TFLITE_SPEC` at a wheel URL, also pass the matching `LIBEDGETPU_SHA256` / `TFLITE_SHA256` (get it with `sha256sum`). The build verifies every downloaded artifact against the pinned checksum and fails on a mismatch, so a stale default checksum stops the build rather than silently installing the wrong binary.

The default (avx2) uses the [feranick](https://github.com/feranick/libedgetpu) matched pair (libedgetpu + tflite_runtime at the same TF version — the reliable pairing rule, since no formal compatibility matrix is published). The **compat** profile pairs Google's PyPI `tflite-runtime` 2.14.0 (built without AVX2) with feranick's nearest libedgetpu (2.13.1) — verified working end-to-end on a no-AVX2 Xeon. See [Troubleshooting](#troubleshooting) if inference fails.

## Monitoring
Expand All @@ -360,8 +366,12 @@ Set `CORALAPI_METRICS_ENABLED=true` to expose Prometheus metrics at `GET /metric
The service ships **without authentication or rate limiting** — deploy it behind an authenticated gateway/ingress and add per-client rate limiting there. Given that posture, it still bounds unauthenticated resource use and avoids leaking internals:

- **Upload flooding** is capped at the ASGI layer: an over-limit `Content-Length` is rejected before any body is read, and a chunked body is aborted once it passes the limit — a body never fills the disk regardless of what the client declares.
- **Model downloads** run server-side against `CORALAPI_MODEL_SOURCE`: HTTPS-only, redirects not followed (no SSRF pivot), model names allowlist-validated against path traversal, on-disk cache bounded by `CORALAPI_MAX_DOWNLOADED_MODELS`.
- **Error responses are generic**; download URLs, filesystem paths, and library errors are logged server-side, never returned to clients (this includes async `job.error`).
- **Model downloads** run server-side against `CORALAPI_MODEL_SOURCE`: HTTPS-only, redirects not followed (no SSRF pivot), and model names allowlist-validated against path traversal. Disk use is bounded three ways — a per-model byte cap (`CORALAPI_MAX_MODEL_BYTES`, enforced against the declared `Content-Length` and again as a running total so a lying server can't overshoot), a total-cache byte cap (`CORALAPI_MAX_MODEL_CACHE_BYTES`), and a model count cap (`CORALAPI_MAX_DOWNLOADED_MODELS`). Auto-fetched label sidecars carry their own tight cap.
- **Model integrity** is pinnable: list a model's sha256 in `CORALAPI_MODEL_CHECKSUMS` and a download whose hash doesn't match is deleted and rejected rather than served.
- **Decoded-image formats** can be restricted with `CORALAPI_ALLOWED_IMAGE_FORMATS` to shrink the Pillow attack surface; uploads still get full PIL content validation regardless.
- **Internal paths are not disclosed**: `/v1/models` returns model filenames (not absolute server paths), and `/readyz`, `/v1/status`, and the reset response report device counts rather than sysfs paths.
- **Error responses are generic**; download URLs, filesystem paths, and library errors are logged server-side, never returned to clients (this includes async `job.error` and the `/v1/reset` failure path).
- **Supply chain**: the container build pins the `uv` image by version and verifies every downloaded `libedgetpu`/`tflite` artifact against a sha256 checksum, failing the build on mismatch. The K8s manifests cap both the uploads and models volumes with `sizeLimit` to back the application-layer byte caps.
- **Async job IDs are unguessable** (UUID4) but have no per-caller ownership — anyone with a job id can read its result. Treat job ids as bearer secrets; the gateway is the trust boundary.
- **The container runs as a non-root user**; the Kubernetes manifest sets `runAsNonRoot`, a read-only root filesystem, drops all capabilities, and prefers a device plugin over a privileged pod.

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ CoralAPI ships **without authentication or rate limiting by design** — it is m

- **No auth / no rate limiting**: the trust boundary is a fronting gateway. Do not expose the service directly to untrusted networks.
- **Async job IDs are bearer secrets**: job results are readable by anyone holding the (unguessable, UUID4) job ID; there is no per-caller ownership.
- **Model downloads** run server-side against a configured HTTPS source with redirects disabled and the on-disk cache bounded. Point `CORALAPI_MODEL_SOURCE` only at a source you trust; models are loaded onto the TPU.
- **Model downloads** run server-side against a configured HTTPS source with redirects disabled, per-model and total-cache byte caps, and optional sha256 pinning (`CORALAPI_MODEL_CHECKSUMS`). Point `CORALAPI_MODEL_SOURCE` only at a source you trust; models are loaded onto the TPU.
- **Uploads** are streamed to disk with a hard size limit enforced at the ASGI layer and a maximum image resolution; tune `CORALAPI_MAX_UPLOAD_BYTES` and `CORALAPI_MAX_IMAGE_DIM` for your environment.
5 changes: 4 additions & 1 deletion deploy/k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ spec:
# mountPath: /dev/apex_0
volumes:
- name: models
emptyDir: {}
emptyDir:
# Bound the model cache volume. Keep this >= CORALAPI_MAX_MODEL_CACHE_BYTES
# (default 4Gi) so the app-level cap trips before the kubelet evicts.
sizeLimit: 5Gi
- name: uploads
emptyDir:
sizeLimit: 2Gi
Expand Down
20 changes: 12 additions & 8 deletions src/coralapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def _prepare(
settings, registry, executor, jobs = _state(request)
path = await save_upload(file, settings.max_upload_bytes, settings.resolved_upload_dir)
try:
image = open_image_checked(path, settings.max_image_dim)
image = open_image_checked(path, settings.max_image_dim, settings.allowed_image_formats)
except BaseException:
path.unlink(missing_ok=True)
raise
Expand Down Expand Up @@ -156,18 +156,19 @@ def healthz() -> dict:
def readyz(request: Request) -> dict:
"""Ready only if the Coral device bound at startup is still present and
unchanged. Goes 503 when the device is unplugged, or re-enumerated on a
replug (different sysfs path) — in which case the loaded delegate is stale
and `POST /v1/reset` is needed to rebind."""
replug (different sysfs path); then the loaded delegate is stale and
`POST /v1/reset` is needed to rebind."""
_, registry, *_ = _state(request)
current = {d.path for d in device.discover_all()}
bound = getattr(request.app.state, "bound_devices", set())
if not current:
raise HTTPException(503, "No Coral Edge TPU detected")
if current != bound:
raise HTTPException(
503, "Edge TPU changed since startup (replug?) POST /v1/reset to rebind"
503, "Edge TPU changed since startup (replug?); POST /v1/reset to rebind"
)
return {"status": "ok", "devices": sorted(current), "models_available": len(registry.list())}
# Report a count, not sysfs paths (no host topology disclosure).
return {"status": "ok", "devices": len(current), "models_available": len(registry.list())}


@router.post(
Expand Down Expand Up @@ -195,12 +196,14 @@ def do_reset() -> int:
try:
cleared = executor.run_sync(do_reset)
except (tflite.InterpreterUnavailable, tflite.DelegateUnavailable) as exc:
raise HTTPException(503, f"Edge TPU unavailable after reset: {exc}") from exc
# Route through the sanitizer: log detail server-side, return a generic
# message (don't leak str(exc) to the client).
raise _map_inference_error(exc) from exc
request.app.state.bound_devices = {d.path for d in device.discover_all()}
return {
"reset": True,
"models_cleared": cleared,
"devices": sorted(request.app.state.bound_devices),
"devices": len(request.app.state.bound_devices),
}


Expand All @@ -216,7 +219,8 @@ def status(request: Request) -> RuntimeStatus:
return RuntimeStatus(
interpreter=tflite.interpreter_source(),
device_preference=settings.device,
devices=device.discover_all(),
# A count, not sysfs paths (no host device-topology disclosure).
devices=len(device.discover_all()),
)


Expand Down
18 changes: 18 additions & 0 deletions src/coralapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def _validate_device(cls, value: str) -> str:
# model_dir, bounding disk use on the attacker-influenced download path.
max_downloaded_models: int = 100

# Disk caps for the model cache (the download path is attacker-influenced,
# so a single huge "model" or many models could otherwise fill the volume).
# A download over max_model_bytes is aborted mid-stream; a download that
# would push the cache total over max_model_cache_bytes is refused.
max_model_bytes: int = 256 * 1024 * 1024 # 256 MB per model
max_model_cache_bytes: int = 4 * 1024 * 1024 * 1024 # 4 GB total

# Optional content-integrity pinning: model name -> expected sha256 hex.
# When a downloaded model's name is listed, its bytes are verified and the
# download is rejected on mismatch. Empty by default (transit is already
# https-only; this adds content integrity for security-conscious deploys).
model_checksums: dict[str, str] = {}

# Number of inference requests allowed to wait for the TPU (which executes
# one inference at a time) before new requests are rejected with 429.
queue_depth: int = 16
Expand Down Expand Up @@ -83,5 +96,10 @@ def resolved_upload_dir(self) -> Path:
max_image_dim: int = 15360
max_video_dim: int = 15360

# Optional image-format allowlist (PIL format names, e.g. ["JPEG", "PNG"]).
# Empty accepts any decodable image; restricting shrinks the Pillow decoder
# attack surface for deployments that only need a few formats.
allowed_image_formats: list[str] = []


settings = Settings()
3 changes: 3 additions & 0 deletions src/coralapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ async def lifespan(app: FastAPI):
model_download=cfg.model_download,
allow_insecure_source=cfg.allow_insecure_model_source,
max_downloaded_models=cfg.max_downloaded_models,
max_model_bytes=cfg.max_model_bytes,
max_model_cache_bytes=cfg.max_model_cache_bytes,
checksums=cfg.model_checksums,
device=cfg.device,
edgetpu_lib=cfg.edgetpu_lib,
)
Expand Down
Loading
Loading