Skip to content

Repository files navigation

csl-on-demand

A Rust HTTP service that serves X-Plane CSL (Custom Scenery Library) aircraft models on demand to XPMP2-based plugins (e.g. LiveTraffic), so a flight sim client doesn't need every CSL package installed locally — it asks this service for the best-matching model and downloads just that one.

Originally started as a port of a C# ASP.NET Core service of the same purpose (see git history on the main branch's earlier commits if curious); this is a from-scratch Rust design now, not a line-for-line translation.

Attribution

The match-quality algorithm, and the related.txt/relOp.txt/Doc8643.txt file formats it depends on, are not this project's design — they're Birger Hoppe's, from XPMP2 (CSLModels.cpp/ RelatedDoc8643.cpp). csl-service/src/matching.rs is a Rust re-implementation of that C++ logic, checked bit-for-bit against XPMP2's current source, not an independent design.

How it works

  1. A client calls GET /match?icao=A320&airline=DLH&livery=D-AIZQ.
  2. The service picks the best-scoring CSL model for those criteria (see "Matching" below) and responds 303 See Other to /manifest/{root}/{id}.
  3. GET /manifest/{root}/{id} returns a small JSON manifest — not a bundle of file bytes — describing every file the aircraft needs:
    • every texture the aircraft's OBJ8 model(s) reference, each as a {"kind":"blob","path":...,"url":...,"hash":...} entry pointing at /objects/{hash}.{ext}
    • one raw .obj template per unique base model (placeholders where TEXTURE/TEXTURE_LIT lines belong), also a blob entry — shared across every livery of that model
    • one {"kind":"template",...} entry per OBJ8 instance, naming its template plus the small list of per-slot texture overrides to splice in locally (no full .obj body ever crosses the wire — see "Client-side template reconstruction" below)
    • the synthesized xsb_aircraft.txt text, inlined directly (it's small and per-aircraft, so there's no benefit to a separate fetch)
  4. The client (see "csl-client" below) fetches every blob in parallel, each independently a plain GET /objects/{hash}.{ext} — ordinary, immutable, individually CDN-cacheable HTTP responses, not sub-parts of a bundle — then splices each templated .obj locally.

Why not a bundled response (multipart, as an earlier version of this service used): bundling every file for one aircraft into a single HTTP response means a CDN can only cache that bundle by its outer URL (/pack/{root}/{id}). Two different aircraft that happen to share the exact same texture or .obj geometry get that content cached twice at the edge — once per bundle — because the shared content never has a URL of its own. Describing files instead of bundling them means every texture and template gets its own content-addressed URL, so a CDN (or this service's own disk) naturally deduplicates across every aircraft that references it, without any bundle-aware logic on either side.

Why X-Plane needs any of this materialized to disk at all: X-Plane's OBJ8 loader only reads from local disk — the client must write every file this service describes into a local folder before X-Plane can render the model. This is a file delivery service, not a live asset server, which is why so much of the design below is about caching aggressively and re-fetching as little as possible.

sequenceDiagram
    participant Client as csl-client (ModelClient)
    participant Match as GET /match
    participant Manifest as GET /manifest/{root}/{id}
    participant Cache as Cache (in-memory manifests)
    participant Objects as GET /objects/{hash}.{ext}
    participant Blobs as BlobStore (blobs/*.zst)

    Client->>Match: icao, airline, livery, seed?
    Match->>Cache: match_core() scores candidates
    Cache-->>Match: best (root, id)
    Match-->>Client: 303 -> /manifest/{root}/{id}<br/>Cache-Control: no-store (or short-lived if seeded)

    Client->>Manifest: GET /manifest/{root}/{id}
    Manifest->>Cache: look up aircraft, build Package
    Manifest->>Cache: resolve each texture/.obj-template reference by hash
    Manifest-->>Client: JSON: xsb_aircraft.txt text<br/>+ blob entries (textures, .obj templates)<br/>+ template entries (per-.obj override lists)

    par every blob entry, concurrently, single-flighted per hash
        Client->>Objects: GET /objects/{hash}.{ext} (skipped if already on disk)
        Objects->>Blobs: get_compressed(hash)
        Blobs-->>Client: bytes<br/>Cache-Control: public, immutable
    end
    Client->>Client: splice each template entry locally<br/>(read template + overrides, write final .obj)
Loading

Running it

cargo run -p csl-service

Configuration (env vars)

Var Default Meaning
CSL_RESOURCES . Directory holding Doc8643.txt, related.txt, relOp.txt, and the ingested blob store + manifests (see "Storage" below)
CSL_PATH_PREFIX /csl Path this service's router is mounted under
CSL_BIND 0.0.0.0:8080 Listen address
CSL_ASSET_BASE_URL (unset) Fixed CDN/origin base for every manifest's /objects/ URLs (see "CDN & caching" below) instead of mirroring the request's own Host
CSL_BLOBS_PACKAGE _blobs EXPORT_NAME/directory the shared blob pool is emitted under (see "Storage" below) — XPMP2's mapCSLPkgs is a global namespace shared with every other CSL package X-Plane has loaded, so change this if _blobs ever collides with an unrelated third-party package of the same name. The client (flybywireless-xpmp2, and whatever X-Plane plugin embeds it) must register the identical name, and csl-fetch's --blobs-pkg must match it too
RUST_LOG (unset) tracing_subscriber filter, e.g. info or csl_service=debug

CSL_RESOURCES is not a raw, expanded package tree anymore — see "Storage" for why and how to populate it:

Doc8643.txt      # ICAO type classification — read directly, never blob-stored
related.txt      # groups of similar-looking aircraft types, one group per line
relOp.txt        # groups of similar-livery operators/airlines, one group per line
blobs/           # content-addressed store — every unique OBJ8/texture, once
manifests/       # one <root>.json per package: its xsb_aircraft.txt + a path -> blob-hash map

Docker: from a folder of CSL packages to a running server

End-to-end walkthrough, from a raw CSL library on disk to a server answering /match requests.

  1. Get the repo and its config files onto the host:

    git clone <this-repo-url> csl-on-demand
    cd csl-on-demand

    compose.yml and Dockerfile are already checked in — nothing to author yourself.

  2. Put your CSL library at ./models, one level up from a plain Resources folder so it can be a separate repo (e.g. added as a git submodule) without colliding with anything this repo tracks — models/ is gitignored here for exactly that reason:

    models/
      Resources/
        CSL/
          Bluebell-A320/          # a package directory, own xsb_aircraft.txt
          FlyJSim-737/
          ...
        (whatever else X-Plane keeps in Resources/ — ignored by csl-ingest)
    

    Either clone/copy an existing library there directly, or add it as a submodule:

    git submodule add <your-x-plane-models-repo-url> models

    csl-ingest is pointed at models/Resources (the parent of CSL/, not CSL/ itself) — it finds CSL/ on its own and ignores the unrelated siblings; see step 4.

    Also drop Doc8643.txt, related.txt, and relOp.txt (from XPMP2 — these three ship with any CSL library/XPMP2 install, not authored by this project) directly under ./csl-resources (created in the next step; see the directory layout further down for exactly where):

    mkdir -p csl-resources
    cp /path/to/Doc8643.txt /path/to/related.txt /path/to/relOp.txt csl-resources/
  3. Build the image once (both csl-service and csl-ingest ship in the same image — see "Layout" in compose.yml below):

    docker compose build
  4. IngestCSL_SOURCE_DIR defaults to ./models/Resources next to compose.yml, matching the layout above; override it (in a .env file, or inline) if your library lives somewhere else instead:

    docker compose run --rm csl-ingest
    # or, pointing elsewhere:
    # CSL_SOURCE_DIR="/path/to/X-Plane 12/Resources" docker compose run --rm csl-ingest

    This reads that directory (mounted read-only) and populates ./csl-resources with the blob store + manifests. It's one-shot, not part of docker compose up — re-run it whenever the source library changes (adding, removing, or updating packages, or pulling a submodule update). Re-ingesting is safe and idempotent: unchanged files hash to the same blob and are skipped, so a second run over a mostly-unchanged library is fast. The server only ever reads ./csl-resources afterward — models/ is never touched by anything but csl-ingest.

  5. Start the server:

    docker compose up -d csl-service

    By default this binds container port 8080 to host port 8080 (see compose.yml's ports:) and mounts ./csl-resources read-only — the server never writes to it, only csl-ingest does.

  6. Smoke-test it — either with csl-client (see below) or plain curl, from the host:

    curl -i "http://localhost:8080/csl/match?icao=A320&airline=DLH&livery=D-AIZQ"
    # expect: 303 See Other, Location: /csl/manifest/<root>/<id>
    curl -s "http://localhost:8080/csl$(curl -s -o /dev/null -w '%{redirect_url}' "http://localhost:8080/csl/match?icao=A320&airline=DLH&livery=D-AIZQ" | sed 's#.*/csl##')" | python3 -m json.tool
    # expect: JSON — xsb_aircraft text + a list of texture/template blob entries

    Or with the Rust client in this repo (see "csl-client" below), which fetches every file the manifest describes (in parallel) and writes real files to disk — more useful for actually confirming the bytes are sane:

    cargo run -p csl-client --bin csl-fetch -- \
      http://localhost:8080/csl ./out --icao A320 --airline DLH --livery D-AIZQ
  7. Check logs / tear down:

    docker compose logs -f csl-service
    docker compose down          # stops csl-service; ./csl-resources is untouched
  8. Put a reverse proxy in front before exposing this beyond your own host — see "Behind nginx / a CDN" below. This container serves plain HTTP with no TLS/auth of its own by design.

compose.yml has two services sharing one image: csl-ingest (one-shot — reads ./csl-source read-only, writes ./csl-resources) and csl-service (long-running — mounts ./csl-resources read-only). The Dockerfile builds on Alpine 3.20 (musl, LTS) for both stages and ships both binaries.

Updating the library later: drop the new/changed packages into ./csl-source, re-run step 4 (docker compose run --rm csl-ingest), then restart the server so it picks up the new manifests (it loads everything into memory once at startup — see cache.rs):

docker compose restart csl-service

Behind nginx / a CDN

Production deployments are expected to sit nginx (or a CDN edge) in front of this plain-HTTP origin for TLS/HTTP2/HTTP3 — see nginx/csl.conf.sample. The app sets Cache-Control/ETag itself; nginx's proxy_cache is configured to obey those rather than hardcode its own lifetimes, so there's one source of truth.

Storage

The naive approach — keep every CSL package expanded on disk, one full copy per texture per package — doesn't scale: a real CSL library is thousands of liveries, most of them repainted textures over a handful of shared base models, and it's easily tens of GB of mostly duplicate data if kept that way. This service instead keeps a content-addressed blob store, entirely inspired by git's object store: every unique file, no matter what logical name(s) it goes by, is stored exactly once, named by its own hash.

  • csl-ingest <resources-dir> <input>... (see csl-service/src/bin/csl-ingest.rs, or scripts/ingest.sh/ingest.ps1) reads each <input> — a directory tree, the standard way an X-Plane CSL library is laid out on disk — file by file, hashes each one (SHA-256), and writes it to <resources>/blobs/<hash[..2]>/<hash>.zst (zstd-compressed, so it's smaller at rest than the original and smaller than a naive gzip of it would usually be). A second package containing a byte-identical texture hits the same hash and is a no-op — dedup is just how the store works, not a separate pass. If <input> is a Resources folder containing a CSL/ subdirectory (alongside whatever else X-Plane keeps there), CSL/ is what actually gets walked — point it at the folder you already have, no need to extract or relocate anything first.
  • Alongside that, one <resources>/manifests/<root>.json per package records its xsb_aircraft.txt text and a relative-path -> hash map for every non-.obj file — everything cache.rs/pack.rs need, without ever touching the original input again. Once ingestion finishes, the input directory is no longer needed.
  • .obj files get one further step (see ".obj template dedup" below) instead of a plain hash.
  • Reading any file back is an O(1) path computation (hash -> fan-out directory -> file), not a directory scan — "quick random access" falls out of the design for free, it's not something extra to implement on top.

Ingested against a real ~126-package, 2,000+ aircraft CSL library:

Size
Source 7.8 GB
Blob store, textures only (SHA-256 + zstd, no .obj template dedup) 2.1 GB
Blob store, with .obj template dedup too 1.9 GB

.obj template dedup

Even with per-file hashing, .obj files barely deduped on their own: liveries of the same base model are near-byte-identical except their TEXTURE/TEXTURE_LIT/etc lines, so hashing one verbatim gives every livery its own distinct hash despite ~99% shared geometry. csl-ingest fixes this by stripping every TEXTURE* line out into a %%OBJREF:i%% placeholder before hashing (see manifest::ObjTemplate, bin/csl-ingest.rs's extract_template) — now liveries of the same model collapse onto one shared geometry blob, and the stripped lines (tiny) are kept alongside in the manifest so pack.rs can splice the real, possibly per-aircraft-overridden ones back in at serve time.

Serving everything by hash — including .obj templates

pack.rs doesn't just describe textures by hash — every raw .obj template (placeholders intact, shared across every livery of that base model — see ".obj template dedup" above) is listed the same way, at _blobs/{hash[..2]}/{hash}.tmpl. csl-client reconstructs the final, per-aircraft .obj locally (see "Client-side template reconstruction" below); its TEXTURE* lines reference other blobs via a relative path that's always exactly one directory level away (../{other_hash[..2]}/{other_hash}.{ext}, regardless of where either file originally lived in the source package — see pack::blob_ref), and the generated xsb_aircraft.txt (which stays at the conventional {root}/xsb_aircraft.txt — X-Plane needs to find it there) points at the reconstructed .obj's (synthetic, template-derived) blob path the same way.

Every blob URL is /objects/{hash}.{ext} (textures and .tmpl templates alike — one route serves every blob-addressed file this service has). Knock-on effects, all free:

  • Caching is as strong as caching gets: the URL is the content's identity, so ETag is just the hash and Cache-Control can be public, max-age=31536000, immutable unconditionally — no mtime/size heuristics needed (see "CDN & caching" below).
  • Cross-aircraft dedup a client (or CDN) gets with zero bookkeeping of its own: every aircraft that references the same texture or template gets told the exact same URL/path — see csl-client below, which needs no hard links, symlinks, or hashing of its own to benefit from this, and a CDN in front gets it for free too, since (unlike a bundled response) each shared file has its own cacheable URL regardless of which aircraft's manifest mentions it.
  • pack.rs also dedupes entries within a single manifest by hash, not by original path — two differently-named-but-byte-identical textures from different packages are only ever listed once.

Lenient resolution (found by testing against a real library)

Real CSL packages don't always match their own xsb_aircraft.txt/.obj declarations exactly — found by ingesting the library above, not by guessing:

  • a TEXTURE line declaring .png when the file shipped is .dds (or vice versa) — X-Plane itself tolerates this, trying both
  • case differences (Wings_lit.png declared, Wings_LIT.png on disk) — invisible on the Windows/macOS filesystems these packages are usually authored on

texture_ref::resolve_leniently mirrors both, tried in that order, for every file lookup. If a reference is still unresolvable after that (a genuine authoring mistake — found one: a FlyJSim model's lit-texture reference to a file that doesn't exist under any name), the individual TEXTURE* line is dropped and a warning logged, not a hard failure of the whole /pack response — X-Plane can render an aircraft missing one texture layer, just not one that doesn't exist at all.

Client-side template reconstruction

There's no fully-reconstructed-.obj-over-the-wire mode at all — every .obj is described as a template reference, always. Sending the full, sometimes multi-MB, reconstructed geometry for every livery of a model would waste bandwidth for no reason: liveries of the same base model are ~99% identical geometry, differing only in which texture lines they declare.

GET /manifest/{root}/{id} (pack.rs::build_manifest) describes each .obj as two entries instead:

  • the raw template (placeholders intact, same %%OBJREF:i%% form csl-ingest produces), once per unique template — an ordinary blob entry at _blobs/{hash[..2]}/{hash}.tmpl, deduped exactly like a texture, so N liveries of one model only cost one template download between them
  • a template entry per .obj instance, naming that template plus the small per-slot override list:
    {
      "kind": "template",
      "path": "_blobs/1a/1a1f43....obj",
      "template_path": "_blobs/5f/5f4a0d....tmpl",
      "overrides": [
        { "index": 0, "line": "TEXTURE ../54/544ce9....dds" },
        { "index": 1, "line": "TEXTURE_LIT ../83/83b0e6....dds" }
      ]
    }
    (one override per %%OBJREF:i%% placeholder in the template). Its own content-addressed identity (the hash in path) is derived from (template hash, resolved overrides), not the reconstructed bytes — the server never needs to splice-and-hash the full file just to name it.
  • csl-client (lib.rs::splice_template) does the inverse of build_manifest's splice: reads the template (already fetched as an ordinary blob — either just now, or on disk already from a sibling livery fetched earlier), substitutes each %%OBJREF:i%% with its override line, and writes the final bytes at path — the same content-addressed path any other tool reading out_dir would expect.
cargo run -p csl-client --bin csl-fetch -- \
  http://localhost:8080/csl ./out --icao A320 --airline DLH --livery D-AIZQ

A plain XPMP2 client (one that only understands "fetch a URL, write the bytes") can't consume a template entry directly — it would need this same splicing logic built in. That's the one piece of this protocol that isn't "just an HTTP GET"; everything else (the manifest fetch, every blob fetch) is.

Matching

GET /match scores every candidate aircraft with a 12-bit quality mask (0 = perfect match) — see the doc comment at the top of csl-service/src/matching.rs for the exact bit layout, kept in parity with XPMP2's current CSLFindMatch. Ties are broken randomly by default, matching XPMP2's own "don't put identical liveries on every plane of a type" behavior — which also means the /match redirect is not cacheable by default (see below).

The seed parameter

Pass seed=<0-255> to make that tie-break deterministic instead of random: the same icao/airline/livery/seed combination always resolves to the same aircraft, which makes the redirect cacheable (public, max-age=300 instead of no-store).

seed is deliberately folded down to 16 buckets server-side regardless of the raw value sent (matching::SEED_BUCKETS). A wide seed range would let every client mint its own unique value — cacheable in principle, but never actually shared, since no two clients would collide on the same key. 16 buckets keeps some of XPMP2's livery variety while making collisions near-certain once more than a handful of clients are asking for the same aircraft concurrently.

CDN & caching

Each route sends a deliberately different Cache-Control:

Route Cache-Control Why
/match no-store (or public, max-age=300 with seed) Random tie-break by default — caching it would pin every future request to the first winner
/manifest/{root}/{id} public, max-age=3600 if cacheable, else private, no-cache Only cacheable when the body doesn't vary by request Host — see below
/assets public, max-age=300 if cacheable, else private, no-cache — always ETag-bearing regardless Same Host-dependence as /manifest for Cache-Control, but the ETag itself is Host-independent (see "Static assets" under csl-client), so conditional GETs stay cheap either way
/objects/{hash}.{ext} public, max-age=31536000, immutable + ETag: "{hash}" The URL is the content's hash — nothing about that response can ever legitimately change. Every aircraft sharing a texture/template gets the exact same URL, so a CDN dedupes it across aircraft automatically — see "Why not a bundled response" above

/manifest//assets and Host-dependence: with no CSL_ASSET_BASE_URL set, every /objects/ URL embedded in either response is built from that request's Host header. A CDN keys its cache on URL, not Host — caching that response could serve one client's hostname to a completely different client. Set CSL_ASSET_BASE_URL to a fixed CDN/origin base to make both safely cacheable too.

Compression

Bandwidth is the real dollar cost at scale, so the goal is: whichever codec is smallest for a given client, use it — zstd whenever the client can take it, not a fixed default everyone gets flattened down to.

tower_http::compression::CompressionLayer (compression-full in csl-service/Cargo.toml) negotiates against the request's Accept-Encoding, and — this is the load-bearing detail — already prefers zstd over brotli/gzip/deflate whenever a client's Accept-Encoding offers more than one at equal weight. Its tie-break isn't arbitrary: the underlying Encoding enum is ordered least-to-most-preferred Identity < Deflate < Gzip < Brotli < Zstd, and preferred_encoding picks max_by_key((qvalue, encoding)) — so among codecs a client didn't explicitly de-prioritize with its own q= values, zstd always wins. There's nothing to configure to get this; it's what compression-full already does. (Source: tower-http's content_encoding.rs, preferred_encoding.)

That means the only thing actually worth controlling is what each client advertises:

  • csl-client (the one client in this repo, csl-client/Cargo.toml) enables reqwest's zstd feature, so it always advertises and transparently decompresses zstd — guaranteed best-case bandwidth for that path. gzip stays enabled too, purely as a fallback if some intermediary between it and the origin strips zstd out of Accept-Encoding.
  • Any other client talking to this service (a different plugin's HTTP fetcher, XPMP2 itself if it grows this capability, curl, a browser) automatically gets the best codec it advertises, with no origin-side work needed — that's the point of negotiating instead of hardcoding.

Textures (.dds/.png) are excluded from compression entirely via a CompressionLayer predicate — they're already-compressed binary, so recompressing them burns CPU for no size win regardless of codec. Only the generated OBJ8/xsb_aircraft.txt text parts and the /match redirect body ever get compressed.

nginx and Cloudflare should both pass the already-compressed body through untouched rather than re-compressing — nginx/csl.conf.sample leaves nginx's own gzip/brotli modules off and forwards the client's original Accept-Encoding unmodified, so the app negotiates against the real client, not against nginx's own (typically gzip-only) capabilities. See the caveat in that file about CDNs that rewrite Accept-Encoding on the way to origin — if that's happening, check what your origin actually receives.

csl-client

A small Rust client (csl-client/) that does what a real XPMP2-embedding client needs to: ask for a model (ModelClient::request), fetch the manifest, fetch every file it describes in parallel, splice each templated .obj locally, and return the path X-Plane should be pointed at. Useful for testing this service or building other tooling around it without X-Plane in the loop.

cargo run -p csl-client --bin csl-fetch -- \
  http://localhost:8080/csl ./out --icao A320 --airline DLH --livery D-AIZQ

ModelClient — the optimization design in full

Construct one ModelClient and reuse it across many request() calls (one per live aircraft an X-Plane session is tracking, say) — the coordination below only works within one ModelClient, so reusing it is what makes concurrent requests for related aircraft actually share work instead of each paying full price independently.

  • No hard links, symlinks, or client-side hashing needed at all: the server already names every content-addressed file (_blobs/{hash[..2]}/{hash}.{ext}) with the exact path it belongs at under out_dir, and two aircraft referencing the same texture or .obj template get told the exact same path. So the fast path for "have I already got this?" is a single O(1) existence check — no re-hashing, no directory scan.
  • Parallel, not sequential, fetching: every blob a manifest describes is fetched concurrently (bounded to 8 in flight at a time — ModelClient::request's CONCURRENCY constant), rather than one request at a time. A .obj template's own splice only needs local CPU work once its dependencies have landed, so it happens in a cheap second stage after the network stage completes.
  • Single-flighted per hash, not per call: two concurrent request() calls that both need the same file (two liveries of one model sharing a texture or template — or even two concurrent requests for the same aircraft) don't each issue their own fetch/splice. The first to ask for a given path creates a tokio::sync::OnceCell keyed by that path and does the work; every other concurrent caller for that exact path awaits the same cell and reuses its result — see ModelClient::ensure_written (shared by both the network-fetch and the local-splice paths, since both are just "produce these bytes, write them once"). Nothing is ever fetched, spliced, or written twice for the same destination, no matter how many concurrent callers want it.
  • Corruption-proof by construction, not by re-verification: every write goes to a uniquely-named .part temp file (write_atomic) and only becomes visible at its real path via an atomic rename — so a process killed mid-download can never leave a half-written file for a later "is it already there?" check to mistake for good data. This is why that check can stay a cheap existence test instead of re-hashing the file's content on every single lookup (which would turn an O(1) check into an O(file size) one, on the hot path, for every request). Any .part file a crashed run left behind gets swept on the next ModelClient's first request() call (cleanup_stale_temp_files) — harmless to skip (it'd just be a little wasted disk space, never mistaken for a real file since its name never matches one), so it's a best-effort sweep, not a correctness requirement.

Together: requesting many liveries of the same model concurrently costs one network fetch per unique texture/template (however many liveries ask for it, concurrently or not), plus one cheap local splice per aircraft — not one full download chain per aircraft.

Local caching and eviction

out_dir/.csl-cache-index.json tracks which files each root/id used and when it was last fetched, so:

cargo run -p csl-client --bin csl-fetch -- \
  http://localhost:8080/csl ./out --icao A320 --airline DLH --livery D-AIZQ --keep 200

...evicts every aircraft beyond the 200 most-recently-used from out_dir after this fetch — freeing disk space for aircraft you're done with, while never deleting a file or blob some other, still-retained aircraft still references (see csl_client::cache_index::CacheIndex::evict).

Static assets (GET /assets)

XPMP2 needs a handful of files that aren't per-aircraft at all — category sound sets (Jet.wav, TurboProp.wav, Helo.wav, ...) and map icon/light textures (MapIcons.png, lights.png) — that live at the root of a real Resources folder alongside CSL/, not inside any package. csl-ingest picks these up automatically (see "Storage" above) and the server lists them at GET /assets:

{"assets":[{"name":"Jet.wav","url":".../objects/{hash}.wav","hash":"..."}, ...]}

csl_client::assets::sync (which csl-fetch calls automatically on every run) fetches this and reconciles out_dir against it — download anything missing or changed, remove anything the server no longer lists. Since these files have to land at a fixed conventional name (out_dir/Jet.wav, not a hash-addressed _blobs/... path — that's where XPMP2 expects them), they can't rely on "same path implies same bytes" the way everything else in this client does; a small .csl-assets-index.json tracks the hash each one was last synced with instead.

Cheap to call on every startup: the /assets listing itself carries an ETag (independent of individual asset hashes — see routes::assets_etag), and sync sends it back as If-None-Match next time, so a no-op check is a single 304 response — no per-file comparisons, no JSON body, and (with CSL_ASSET_BASE_URL configured) answerable by a CDN edge without even reaching the origin. Each individual asset is also served by the same immutable, ETag-bearing /objects/{hash}.{ext} route as every texture/.obj template, so a changed asset is a normal conditional-GET away, not a special case.

These files are deliberately never touched by CacheIndex::evict (above): they're not recorded against any root/id entry and don't live under _blobs/, so eviction's GC walk can't reach them even by accident — removal only ever happens through sync's explicit reconciliation against the server's current listing, never from disk-space pressure.

Auth

Not baked into the Rust binary — put it in nginx (or your CDN/edge) instead, via auth_request: nginx sends a subrequest to a small auth-check endpoint (yours — validate a token, a session cookie, whatever) before proxying to csl-service, and only proxies through on 2xx. That keeps auth logic (and its dependencies — a JWT library, a session store client, whatever it needs) entirely out of this codebase, in a place already built for exactly this job, and it composes cleanly with everything already documented here:

  • Put auth_request only on /match (see nginx/csl.conf.sample's location /csl/match block) — it's already no-store/always-hits-origin, so paying an auth subrequest there costs nothing extra.
  • Leave /manifest//objects unauthenticated. They're already content-addressed and effectively unguessable (SHA-256 URLs) — the practical security boundary is "you had to have a valid session recently enough to get redirected here," not "every single byte fetch re-checks auth." Trying to enforce the latter while keeping Cloudflare caching working is the harder, leakier design — see below.
  • If you do need auth on cached routes too (e.g. token-gated licensing, not just anti-hotlinking), see "CDN & caching"'s note on Cloudflare Cache Rules with a custom cache key that excludes the auth token — same idea, same caveat (a cache hit skips re-validation until the entry's TTL expires, so token revocation isn't instant on cached responses).

Baking it into the Rust app directly (an axum middleware) would only make sense if the auth check itself needs something only this process has (the in-memory Cache, for instance) — it doesn't here, so there's no reason to grow this codebase's dependency surface (and attack surface) for something a reverse proxy already does well.

Known gaps

  • archive (zip/rar) package sources — ingest expects an already-expanded directory tree, the standard on-disk layout for a CSL library
  • config file/CLI parsing beyond the env vars above
  • auth is deliberately not implemented in-process — see "Auth" above

License

MIT — see Cargo.toml. XPMP2 itself is separately MIT-licensed by Birger Hoppe; see https://github.com/TwinFan/XPMP2 for its license text.

About

X-Plane Common Shape Library (CSL) On-Demand Service

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages