Server
Cronhoster hosts no public web content. The one public surface is the sync API, reached by the server's
bare IP. Three processes run on the box: Caddy, the only public listener, and two small
dependency-free Node services. The API, bound to
127.0.0.1:8788, is what Caddy reverse-proxies to: it mirrors the crons the local sync pushes,
serves the tools' read-only data, and samples the machine's health on a timer. The runner
is a separate service that reads those crons and runs each one when it is due. There is no configuration to
speak of — every path is fixed in the code, and the only per-install state is one secret. Everything on disc
is namespaced under localhoster so several hoster services can run side by side on one box:
this service's own files sit under /opt/localhoster/cronhoster/, beside any sibling service,
while the parts every service shares — the access logs, the machine-health samples, and the bare-IP
certificate — live once under /srv/localhoster/ and /etc/localhoster/.
/opt/localhoster/ every localhoster service on this box, side by side
cronhoster/ this service (a sibling like sitehoster/ sits beside it)
cronhoster-server-<version>/ all the cronhoster server code (one dir per installed version)
cronhoster-api.js the API systemd runs: the sync + tools HTTP API
manifest.js the Merkle tree + path-safety helpers the sync uses
version.js the wire/protocol version (the /api/vN prefix)
VERSION this package's version (matches the dir name)
localhoster-server/ shared server toolkit, identical across every hoster
logs-stats.js the read-only /logs + /stats routes
stats-recorder.js the loop that writes the stat day-files
stats.js one machine-health snapshot
cronhoster-runner/ cronhoster's own scheduling runtime (the second service)
runner.js the scheduler systemd runs: fires each cron when due
schedule.js the settings grammar + per-second matcher
run-recorder.js appends one record per finished run
deploy/
install.sh the installer this box ran
uninstall.sh
start.sh
stop.sh
Caddyfile the Caddy config source
cronhoster-api.service the API systemd unit source
cronhoster-runner.service the runner systemd unit source
cronhoster-server-current → cronhoster-server-<version>/ (both units follow this; an upgrade flips it)
cronhoster-crons/ this service's data — one folder per synced cron
hello-world/
cronhoster-cron-handler.js
cronhoster-cron-settings.json
cronhoster-runs/ the runner's run-record day-files
runs-<YYYY>-<MM>-<DD>.log
cronhoster-token this service's bearer token (mode 600)
/srv/localhoster/ box-wide runtime data, shared by every service
web-logs/ Caddy access logs (one Caddy fronts every service)
access.log the active log, one JSON line per request
access-<timestamp>.log rotated, immutable (keyed by mtime, not name)
server-logs/ machine-health samples (the box, not any one service)
cpu-<YYYY>-<MM>-<DD>.log
memory-<YYYY>-<MM>-<DD>.log
disk-<YYYY>-<MM>-<DD>.log
diskio-<YYYY>-<MM>-<DD>.log
netio-<YYYY>-<MM>-<DD>.log
/etc/localhoster/ box-wide localhoster config, shared
localhoster-api-certificate.crt the self-signed cert for the bare-IP endpoint
localhoster-api-certificate.key its private key (mode 640 root:caddy)
/etc/caddy/
Caddyfile the Caddy config (fronts every service on this box)
/etc/systemd/system/
cronhoster-api.service the sync API service
cronhoster-runner.service the scheduler service
caddy.service the shared public listener (from the Caddy package)
Contents
Every path is fixed in the code; the installer creates all of them and nothing lives outside them.
| Name | Type | Description |
|---|---|---|
/opt/localhoster/cronhoster/cronhoster-crons/ | directory | The crons mirror — one folder per cron — what the sync writes and the runner runs. |
/opt/localhoster/cronhoster/cronhoster-runs/ | directory | The run records the runner appends, one dated day-file per day, the data behind the Cron Logs tool. |
/opt/localhoster/cronhoster/cronhoster-server-<version>/ | directory | The self-contained server package: the API entry, the shared localhoster-server/ toolkit, cronhoster's own cronhoster-runner/, and the deploy scripts. |
/opt/localhoster/cronhoster/cronhoster-token | file | The one secret: the bearer token the whole API is gated on. Absent turns the API off. |
/srv/localhoster/web-logs/ | directory | Caddy's combined JSON access log, active plus size-rotated files. |
/srv/localhoster/server-logs/ | directory | The server-health samples, one dated file per statistic type. |
/etc/localhoster/ | directory | The self-signed certificate for the bare-IP endpoint. |
/etc/caddy/Caddyfile | file | The Caddy config the installer lays down, plus a backup of any earlier one. |
/etc/systemd/system/ | directory | The service units — the cronhoster API and runner, and Caddy's from its package. |
Caddy
Caddy is the only public listener. Its config is fixed in deploy/Caddyfile, with no
environment placeholders. It terminates TLS on the server's IP with a self-signed certificate, then
reverse-proxies requests whose Host is that IP (v4 or bracketed v6) to the localhost API on
127.0.0.1:8788. Plain HTTP is redirected to HTTPS. There are no hosted domains, so there is
no on-demand certificate issuance and no static file server. Any request that is not for the IP, and any
Caddy error, renders the same plain <h1>Not Found</h1> page the API uses, so the
two are indistinguishable from outside.
The self-signed cert lives in its own ch.invalid block, selected by default_sni,
so it answers no-SNI and bare-IP handshakes only. The local scripts reach the API by IP and do not verify
the certificate, so the handshake just completes and the token does the authenticating. Caddy also writes
the access log that the Web Logs tool reads, into the box-wide /srv/localhoster/web-logs/.
/opt/localhoster/cronhoster/cronhoster-crons/
The crons mirror — the tree the sync pushes up, the API writes, and
the runner runs. Its children are the crons: one directory per cron, each holding a
cronhoster-cron-handler.js entry point and a cronhoster-cron-settings.json
(the schedule), plus whatever libraries that cron needs — nothing is shared between crons. Names beginning
_ or . are reserved: the sync never pushes or prunes them, so a draft
_next-version/ of a cron can sit here untouched. It is owned by the cronhoster
service user, the tree both services work in.
/opt/localhoster/cronhoster/cronhoster-crons/
hello-world/ a CRON, named for itself (no timing in the name)
cronhoster-cron-handler.js the entry point the runner spawns
cronhoster-cron-settings.json the runs (named schedules) + the concurrency ceiling
uptime/ another cron
cronhoster-cron-handler.js
cronhoster-cron-settings.json
_next-version/ reserved (_ prefix) — never synced, never pruned, never run
A file arriving on the API is written to its path here, and the runner watches the tree and folds a burst
of writes into a single rescan. A directory with no valid cronhoster-cron-settings.json is
simply left unscheduled. The Runner page covers discovery,
concurrency and timing; the Settings page is the schedule grammar.
/opt/localhoster/cronhoster/cronhoster-runs/
The runner appends one JSON line per finished run to a dated day-file here, cronhoster's own tool data
source. A day-file is frozen the moment the UTC date rolls over — no rename, no lock — and files older than
fourteen days are pruned. The API serves these read-only (GET /api/v2/runs) and the local
Cron Logs browser mirrors them and keeps its pages forever. Each
record carries the cron and run names, the matched time, the duration and exit, any capacity skips, and the
captured stdout/stderr tail. It is owned by the cronhoster user — the runner writes it, the
API reads it.
/opt/localhoster/cronhoster/cronhoster-runs/
runs-<YYYY>-<MM>-<DD>.log today's records, one JSON line per finished run
runs-<YYYY>-<MM>-<DD>.log a past day, frozen until the retention cutoff
/opt/localhoster/cronhoster/cronhoster-server-<version>/
Each version installs into its own directory; the service
units point at cronhoster-server-current, a symlink to the installed
cronhoster-server-<version>/, so an upgrade is atomic (the installer flips the symlink)
and the previous version stays on disc for rollback. The tree below is the whole self-contained package:
the API entry, the shared localhoster-server/ toolkit (the read-only /logs +
/stats API and the health recorder), cronhoster's own cronhoster-runner/ (the
scheduler and its schedule and run-record helpers), and the deploy scripts. Every module is plain Node with
no dependencies — cronhoster speaks only HTTP, so there is no vendored library and no
node_modules, and nothing needs an npm install.
cronhoster-server-<version>/
cronhoster-api.js the API systemd runs (the sync + tools HTTP API)
manifest.js the Merkle tree + path-safety helpers the sync uses
version.js the wire/protocol version (the /api/vN prefix)
VERSION this package's version (names the install dir)
localhoster-server/ shared server toolkit, identical across every hoster
logs-stats.js the read-only /logs + /stats routes
stats-recorder.js the loop that writes the stat day-files
stats.js one machine-health snapshot
cronhoster-runner/ cronhoster's own scheduling runtime (the second service)
runner.js the scheduler: fires each cron's runs when they are due
schedule.js the settings grammar + per-second matcher
run-recorder.js appends one record per finished run
deploy/
install.sh the installer this box ran
uninstall.sh
start.sh
stop.sh
Caddyfile the Caddy config source
cronhoster-api.service the API systemd unit source
cronhoster-runner.service the runner systemd unit source
| Name | Type | Description |
|---|---|---|
cronhoster-api.js | file | The sync API and the process the cronhoster-api unit starts. One localhost HTTP listener carrying the token-gated sync surface (tree, file), the read-only logs/stats/runs routes, the version probe, and Caddy's on-demand-TLS gate (which approves nothing — cronhoster hosts no domains). It reads the token once, refuses to run without it, and starts the stats recorder on listen(). |
manifest.js | file | The Merkle-tree builder and path-safety helpers the sync shares: a file's hash is its metadata, a directory's is its sorted children, so one root hash decides "nothing changed". The local sync carries its own copy. |
version.js | file | The wire/protocol version — the /api/vN path prefix — so a stale local end gets a clear 426 instead of misparsing. Distinct from the code-release VERSION. |
localhoster-server/logs-stats.js | file | The read-only /logs and /stats routes (plus the /stats/now debug snapshot), identical across every hoster and mounted under the same token. |
localhoster-server/stats-recorder.js | file | Started by the API, it snapshots on the interval, appends a JSON line per type to the dated day-file, and prunes files past the retention window. |
localhoster-server/stats.js | file | Takes one snapshot of the machine's health — CPU, memory, disk, and cumulative disk- and network-IO counters. Common to every hoster. |
cronhoster-runner/runner.js | file | The scheduler and the process the cronhoster-runner unit starts. It discovers the cron directories, reads the UTC clock once a second, and fires every run whose five time fields match — spawning the cron's handler as a child, capturing its output, enforcing the per-cron concurrency ceiling, and writing a run record. The Runner page is this in full. |
cronhoster-runner/schedule.js | file | Turns a cronhoster-cron-settings.json into a validated schedule — the run grammar, field ranges, the per-second matcher, and the run descriptor — shared by the runner and the local Cron Logs browser (its own copy). |
cronhoster-runner/run-recorder.js | file | Appends one JSON line per finished run to the dated day-file under cronhoster-runs/, and prunes files past the retention window. Best-effort: a run never fails because its record could not be written. |
deploy/ | dir | The install and service scripts: install.sh, uninstall.sh, start.sh, stop.sh, the Caddyfile, and the two systemd units. |
/opt/localhoster/cronhoster/cronhoster-token
The whole per-install configuration is one secret. The installer generates it once (32 random bytes,
printed once) and the API reads it at startup; the local sync and tools are handed the same value out of
band. Every API call is gated on it, and with no token file the API is off entirely. To rotate it, write a
new value and restart the API. It is mode 600, owned by the cronhoster user. The
runner needs no secret — it only reads the crons and writes their run records.
/srv/localhoster/web-logs/
Caddy writes one combined JSON access log for the proxied sync API, each line tagged with its host. It
rotates by size only (there is no daily rotation), so there is one active file and a trail of size-rotated
ones. The API exposes this directory read-only (GET /api/v2/logs) and the local scripts mirror
it down for the Web Logs tool; the directory is mode
0770 group caddy, which writes it, and the cronhoster user is added
to the caddy group so the API can read it.
/srv/localhoster/web-logs/
access.log the active log, one JSON line per request
access-<timestamp>.log rotated and immutable; the number is its rotation time in ms
access-<timestamp>.log
Rotation is set in the Caddyfile (roll_size 64MiB, roll_keep 50,
roll_keep_for 2160h). Rotated files are keyed to the local end by mtime, never by name, so
filenames never cross the wire.
/srv/localhoster/server-logs/
The API samples the machine's own health every 15 seconds and appends one JSON line per statistic type to
a dated, per-type day-file. A day-file is frozen the moment the UTC date rolls over — no rename, no lock —
and files older than 60 days are pruned. The API serves these read-only (GET /api/v2/stats);
the local scripts mirror each past day once and keep it, which is the data behind the
Server Logs tool. The directory is mode 750, owned by
the cronhoster user, and is one of only two paths the API may write.
/srv/localhoster/server-logs/
cpu-<YYYY>-<MM>-<DD>.log per-core cumulative CPU times
memory-<YYYY>-<MM>-<DD>.log total / used / available / free bytes
disk-<YYYY>-<MM>-<DD>.log usage of the filesystem holding /
diskio-<YYYY>-<MM>-<DD>.log cumulative bytes read / written
netio-<YYYY>-<MM>-<DD>.log cumulative bytes in / out
cpu-<YYYY>-<MM>-<DD>.log yesterday's files, immutable until the retention cutoff
Rates are never stored: the counter fields are cumulative, so the reader diffs two samples and divides by the true elapsed time — a gap after downtime averages out instead of spiking, and a reboot's counter reset is dropped. Metrics a host can't report (e.g. disk/net IO off Linux) are simply absent.
/etc/localhoster/
A self-signed certificate for the bare-IP endpoint, generated once by the installer
(CN=localhoster, ten-year life). There are no hosted domains, so this is the only certificate:
it completes the handshake for a no-SNI / bare-IP connection, selected via the Caddyfile's
default_sni. It is what lets the local tool reach the API by the server's IP — it skips
verification for an IP endpoint, and the token is what
actually authenticates it.
/etc/localhoster/
localhoster-api-certificate.crt self-signed cert for the bare-IP endpoint
localhoster-api-certificate.key its private key (mode 640 root:caddy)
/etc/caddy/Caddyfile
The Caddyfile is fully static — no environment placeholders. It terminates TLS, redirects
HTTP → HTTPS, serves the bare-IP block from the self-signed cert, and reverse-proxies
IP-addressed requests to the API on 127.0.0.1:8788; any other Host, and any Caddy error, gets
the same uniform not-found page. The installer copies it from the source deploy/Caddyfile,
backing up any file already there once.
/etc/caddy/
Caddyfile our config, installed from deploy/Caddyfile
Caddyfile.pre-cronhoster.<ts> a one-time backup of any earlier Caddyfile
/etc/systemd/system/
Three units run at boot. Cronhoster ships its own two; Caddy's comes from its package (the installer
requires Caddy to be installed from a package so this unit exists) and the installer just enables and
restarts it. Both cronhoster units run as the unprivileged cronhoster user with
NoNewPrivileges and no EnvironmentFile — every path is fixed in the code. The
API adds ProtectSystem=strict and ProtectHome, may write only
cronhoster-crons and /srv/localhoster/server-logs, and is in the
caddy supplementary group so it can read the access logs. The runner is kept
deliberately less confined — it spawns arbitrary cron handlers — and may write only
cronhoster-crons and cronhoster-runs.
/etc/systemd/system/
cronhoster-api.service the API — runs cronhoster-server-current/cronhoster-api.js
cronhoster-runner.service the scheduler — runs cronhoster-server-current/cronhoster-runner/runner.js
caddy.service the public listener, from the Caddy package
Security
There is one token. It is read once at startup, and the API refuses to run without it. To rotate it, write
a new value and restart the API. Every authenticated route lives behind the uniform 404 wall, so an
unauthenticated probe learns nothing about what is here. The bare-IP endpoint uses a self-signed
certificate that the local scripts never verify; the token is what authenticates the connection. The code
installs under a versioned directory and both units point at the current symlink; the two
newest versions are kept so an upgrade can roll back, and the token, certificate, crons and run records all
survive an upgrade untouched.