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.
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.
- A client calls
GET /match?icao=A320&airline=DLH&livery=D-AIZQ. - The service picks the best-scoring CSL model for those criteria (see
"Matching" below) and responds
303 See Otherto/manifest/{root}/{id}. 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
.objtemplate per unique base model (placeholders whereTEXTURE/TEXTURE_LITlines belong), also ablobentry — 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.objbody ever crosses the wire — see "Client-side template reconstruction" below) - the synthesized
xsb_aircraft.txttext, inlined directly (it's small and per-aircraft, so there's no benefit to a separate fetch)
- every texture the aircraft's OBJ8 model(s) reference, each as a
- The client (see "
csl-client" below) fetches every blob in parallel, each independently a plainGET /objects/{hash}.{ext}— ordinary, immutable, individually CDN-cacheable HTTP responses, not sub-parts of a bundle — then splices each templated.objlocally.
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)
cargo run -p csl-service| 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
End-to-end walkthrough, from a raw CSL library on disk to a server
answering /match requests.
-
Get the repo and its config files onto the host:
git clone <this-repo-url> csl-on-demand cd csl-on-demand
compose.ymlandDockerfileare already checked in — nothing to author yourself. -
Put your CSL library at
./models, one level up from a plainResourcesfolder 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-ingestis pointed atmodels/Resources(the parent ofCSL/, notCSL/itself) — it findsCSL/on its own and ignores the unrelated siblings; see step 4.Also drop
Doc8643.txt,related.txt, andrelOp.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/
-
Build the image once (both
csl-serviceandcsl-ingestship in the same image — see "Layout" incompose.ymlbelow):docker compose build
-
Ingest —
CSL_SOURCE_DIRdefaults to./models/Resourcesnext tocompose.yml, matching the layout above; override it (in a.envfile, 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-resourceswith the blob store + manifests. It's one-shot, not part ofdocker 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-resourcesafterward —models/is never touched by anything butcsl-ingest. -
Start the server:
docker compose up -d csl-service
By default this binds container port
8080to host port8080(seecompose.yml'sports:) and mounts./csl-resourcesread-only — the server never writes to it, onlycsl-ingestdoes. -
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
-
Check logs / tear down:
docker compose logs -f csl-service docker compose down # stops csl-service; ./csl-resources is untouched -
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-serviceProduction 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.
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>...(seecsl-service/src/bin/csl-ingest.rs, orscripts/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 aResourcesfolder containing aCSL/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>.jsonper package records itsxsb_aircraft.txttext and arelative-path -> hashmap for every non-.objfile — everythingcache.rs/pack.rsneed, without ever touching the original input again. Once ingestion finishes, the input directory is no longer needed. .objfiles get one further step (see ".objtemplate 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 |
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.
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
ETagis just the hash andCache-Controlcan bepublic, max-age=31536000, immutableunconditionally — 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-clientbelow, 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.rsalso 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.
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
TEXTUREline declaring.pngwhen the file shipped is.dds(or vice versa) — X-Plane itself tolerates this, trying both - case differences (
Wings_lit.pngdeclared,Wings_LIT.pngon 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.
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%%formcsl-ingestproduces), once per unique template — an ordinaryblobentry 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
templateentry per.objinstance, naming that template plus the small per-slot override list:(one override per{ "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" } ] }%%OBJREF:i%%placeholder in the template). Its own content-addressed identity (the hash inpath) 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 ofbuild_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 atpath— the same content-addressed path any other tool readingout_dirwould expect.
cargo run -p csl-client --bin csl-fetch -- \
http://localhost:8080/csl ./out --icao A320 --airline DLH --livery D-AIZQA 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.
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).
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.
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.
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'szstdfeature, so it always advertises and transparently decompresses zstd — guaranteed best-case bandwidth for that path.gzipstays enabled too, purely as a fallback if some intermediary between it and the origin strips zstd out ofAccept-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.
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-AIZQConstruct 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 underout_dir, and two aircraft referencing the same texture or.objtemplate get told the exact same path. So the fast path for "have I already got this?" is a singleO(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'sCONCURRENCYconstant), rather than one request at a time. A.objtemplate'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 atokio::sync::OnceCellkeyed by that path and does the work; every other concurrent caller for that exact path awaits the same cell and reuses its result — seeModelClient::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
.parttemp 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.partfile a crashed run left behind gets swept on the nextModelClient's firstrequest()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.
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).
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.
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_requestonly on/match(seenginx/csl.conf.sample'slocation /csl/matchblock) — it's alreadyno-store/always-hits-origin, so paying an auth subrequest there costs nothing extra. - Leave
/manifest//objectsunauthenticated. 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.
- 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
MIT — see Cargo.toml. XPMP2 itself is separately MIT-licensed by Birger
Hoppe; see https://github.com/TwinFan/XPMP2 for its license text.