A self-hosted service that mirrors Earth Orientation Parameter (EOP) files, star catalogs (FK5, Hipparcos, Tycho-2), and planetary textures into a public Cloudflare R2 bucket served at https://yourgreatdomain.com, for use with Brahe.
A long-running Docker daemon (src/) fetches each product in src/products.rs
on its own interval, compares the download against a locally persisted
status.json (the change-detection source of truth), and uploads changed bytes
to a public R2 bucket under /<category>/<source>/<name>/latest/<filename>. A
public R2 custom domain serves the bucket directly via Cloudflare's CDN; the
daemon is only ever in the write path.
Each product has its own cadence (daily EOP, weekly realizations, monthly
availability checks for the fixed star catalogs). The daemon sleeps until the
soonest product is due, syncs the due set sequentially, and persists
status.json after each product (locally and to R2). Per-host rate limiting and
a small stagger keep us polite to upstreams.
Only a successful fetch consumes a product's interval. Failed downloads retry
briefly in-run, then on a short cadence (RETRY_MS in src/schedule.rs) rather
than waiting out the full interval — otherwise one transient failure on a monthly
product would leave it stale for a month while the daemon sat idle. A product's
content hash is likewise only recorded once its bytes are actually stored, so a
failed upload is retried instead of being mistaken for "unchanged" forever.
Every product declares an Availability (src/products.rs):
| State | Fetched | Listed on the landing page | Object in the bucket |
|---|---|---|---|
Active |
yes | yes | kept current |
Frozen |
no | yes, greyed out | frozen at its last value |
Disabled |
no | no | kept, but unadvertised |
Frozen is for a superseded realization whose versioned path must keep
resolving for existing consumers. Disabled is for an upstream that is broken:
the product stays in the registry so it can be restored by flipping one field.
CelesTrak is currently Disabled — as of 2026-07-15, celestrak.org
connection-times-out for both the GP groups and the space-weather file. All 11
products remain in src/products.rs; set the CELESTRAK constant back to
Availability::Active to resume the whole provider in one edit. To test it
before committing to that, force a fetch by name — this works regardless of
availability:
cargo run -- sync --product starlinkcargo test # all unit tests
cargo run -- sync --all # one-shot full sync (needs R2 env vars set)
cargo run -- sync --product starlink # force a single product
cargo run -- daemon # run the scheduler loopConfiguration is via environment variables (see .env.example). Copy it to
.env and fill in the R2 credentials; the binary loads .env automatically.
For a local cargo run, also set DATA_DIR to a writable local path (e.g.
DATA_DIR=./data) — the default /data is the Docker volume mount.
- Create the bucket:
npx wrangler r2 bucket create ssdm-data - Public custom domain: R2 →
ssdm-data→ Settings → Public access → Connect a custom domain →yourgreatdomain.com. - Serve the landing page at
/: Rules → Transform Rules → Rewrite URL → if URI path equals/, rewrite path to/index.html. - Create an R2 API token (Object Read & Write) and put its values in
.envasBUCKET_ACCESS_KEY_ID/BUCKET_SECRET_ACCESS_KEY. SetBUCKET_ENDPOINTtohttps://<account-id>.r2.cloudflarestorage.comandSITE_DOMAINto your public domain. (Storage is S3-generic — pointBUCKET_ENDPOINT/BUCKET_REGIONat AWS S3, MinIO, etc. to use a different provider.)
cp .env.example .env # fill in R2 credentials
docker compose up -d # build + run the daemon
docker compose logs -f # watch sync activity
docker compose run --rm ssdm sync --all # force a full sync on demanddocker compose stop # pause the daemon (keeps the container + volume)
docker compose down # stop and remove the container (keeps the /data volume)
docker compose down -v # also delete the local /data volume (file mirror + status.json)Stopping the daemon only halts syncing — whatever is already in R2 keeps serving at https://yourgreatdomain.com.
status.json
served at https://yourgreatdomain.com. Only do this to retire the service.
- Stop the daemon (above) so nothing re-uploads mid-teardown.
- Disconnect public access: R2 →
ssdm-data→ Settings → remove theyourgreatdomain.comcustom domain, and delete the/→/index.htmlrule under Rules → Transform Rules. - Empty the bucket — R2 will not delete a non-empty bucket:
- Dashboard: R2 →
ssdm-data→ ⋯ → Empty bucket, or - AWS CLI against the R2 S3 endpoint (configured with the same R2 key/secret):
aws s3 rm s3://ssdm-data --recursive \ --endpoint-url "https://<account-id>.r2.cloudflarestorage.com"
- Dashboard: R2 →
- Delete the bucket:
npx wrangler r2 bucket delete ssdm-data
- Revoke the R2 API token (Cloudflare → R2 → Manage API Tokens) and delete
your local
.env.
Edit src/products.rs:
- Add a product: add a
Product { … }entry toproducts(). A dataset that spans several files (e.g. a catalog plus its ReadMe) shares onename; the object key includes the filename, so they do not collide. - Add a CelesTrak group: add its slug to
CELESTRAK_GROUPS. - New C04 realization (e.g.
21u25): addc04_21u25withavailability: Availability::Active, alias_name: Some("c04"), and set the oldc04_20u24toavailability: Availability::Frozen, alias_name: None. The old versioned path freezes (stays served and listed); thec04alias follows the new realization. - Pause a broken upstream: set its products to
Availability::Disabled.
Run cargo test (the registry validation test enforces one fetched product per
alias) and redeploy.
Note that upstream fetches are bounded by an inactivity timeout rather than a
total deadline (src/fetch.rs), so a large-but-slow product is fine while a
dead host still fails fast. The largest product today
(Hipparcos_Catalog.txt, ~53 MB) takes ~80s to download.
The star catalogs are served as plain text under a uniform naming scheme, so both datasets are consumed identically:
| Mirror path | Size | Upstream |
|---|---|---|
star_catalog/cds/fk5/latest/FK5_Catalog.txt |
293 KB | ftp/I/149A/catalog.gz (gunzipped) |
star_catalog/cds/fk5/latest/FK5_Readme.txt |
14 KB | ftp/I/149A/ReadMe |
star_catalog/cds/hipparcos/latest/Hipparcos_Catalog.txt |
53 MB | ftp/cats/I/239/hip_main.dat |
star_catalog/cds/hipparcos/latest/Hipparcos_Readme.txt |
69 KB | ftp/cats/I/239/ReadMe |
star_catalog/cds/tycho2/latest/Tycho2_Catalog.txt |
501 MB | ftp/cats/I/259/tyc2.dat.00…19.gz (20 parts, gunzipped and joined) |
star_catalog/cds/tycho2/latest/Tycho2_Readme.txt |
17 KB | ftp/cats/I/259/ReadMe |
That uniformity costs two transforms:
- Decompression. CDS archives FK5 and Tycho-2 only gzipped and Hipparcos
only uncompressed, so products carrying
gunzip: trueare decompressed before being hashed and served (decode_bodyinsrc/sync.rs). The served bytes are the archive's contents, which is what eachReadMe's fixed-width byte columns describe. - Joining. CDS splits the Tycho-2 main catalog across 20 files.
Product.urlslists them in index order;fetch_productfetches each, decodes it, and concatenates, so consumers get one file. Row order across parts is meaningful, so the join order matters, and any part failing fails the whole product — a silently short catalog would still parse.
Tycho-2's supplements (suppl_1, suppl_2) are deliberately excluded: they use
a different column layout, so appending them would put two incompatible record
formats in one file.
The 2k body textures from Solar System Scope
are mirrored under texture/solarsystemscope/<body>/latest/2k_<body>.<ext> —
20 files, ~11.5 MB in total. The body maps are 2048×1024 equirectangular; the
ring texture is not (2k_saturn_ring_alpha.png is 2048×125), so do not assume a
uniform aspect ratio across the set.
Unlike the star catalogs these need no renaming: 2k_<body>.<ext> is already
uniform and self-describing, so the served filename is the upstream one and the
<body> path segment is derived from it rather than listed separately. All are
JPEG except 2k_saturn_ring_alpha.png, which carries an alpha channel a JPEG
could not represent.
Two of the 22 published textures are deliberately excluded: Earth Normal Map
and Earth Specular Map. They are the only two published as .tif rather than
web-ready .jpg/.png, and they encode surface relief and reflectivity rather
than appearance.
The dwarf-planet textures keep upstream's _fictional marker in their path
(ceres_fictional, haumea_fictional, makemake_fictional, eris_fictional).
They are artistic impressions, not observed imagery, and dropping the marker
would present them as real surface maps.
These poll yearly — the set changes only when Solar System Scope re-renders
it. A failed fetch retries sooner than that, but not hourly: the retry cadence
is derived from the interval (MAX_RETRIES_PER_INTERVAL in src/schedule.rs),
so a texture retries about every 12 hours rather than issuing ~8,760 requests a
year at a free provider for a URL that is permanently broken. Products at or
below the 30-day star-catalog cadence are unaffected — 30 days divides to exactly
RETRY_MS.
Because these are binary, they also carry a format check: a fetched body must
start with the JPEG or PNG signature its content type declares
(check_declared_format in src/sync.rs). Without it, a download endpoint
answering 200 with an HTML interstitial would be hashed, uploaded as
image/jpeg, and then compare "unchanged" forever.
The textures are licensed CC BY 4.0, which permits redistribution with attribution. The landing page footer credits Solar System Scope and links the license; keep that notice in place if you re-render the page.
Note the memory cost. Tycho2_Catalog.txt is held in memory to be hashed and
uploaded, so a refresh transiently needs ~500 MB+. docker-compose.yml sets no
memory limit; if you add one, size it accordingly.
Two upstream traps, both verified and both worth not rediscovering:
cdsarc.u-strasbg.frpresents a self-signed certificate. It serves identical bytes, but only overhttp://. Usecdsarc.cds.unistra.fr.- VizieR's
nph-Cat/txt.gzandnph-Cat/txtendpoints are not shortcuts.txt.gzappends an uncompressed copy of the table after the gzip member (67.6 MB to deliver 15.3 MB, andgunzipreports trailing garbage).txtre-renders the table with a column-ruler header and does not reproduce the archive bytes. Fetch from theftp/tree instead.