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)
Everything on disc: this service under /opt/localhoster/cronhoster/, and the box-wide logs, stats, cert and Caddy shared under localhoster.

Contents

Every path is fixed in the code; the installer creates all of them and nothing lives outside them.

NameTypeDescription
/opt/localhoster/cronhoster/cronhoster-crons/directoryThe crons mirror — one folder per cron — what the sync writes and the runner runs.
/opt/localhoster/cronhoster/cronhoster-runs/directoryThe run records the runner appends, one dated day-file per day, the data behind the Cron Logs tool.
/opt/localhoster/cronhoster/cronhoster-server-<version>/directoryThe 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-tokenfileThe one secret: the bearer token the whole API is gated on. Absent turns the API off.
/srv/localhoster/web-logs/directoryCaddy's combined JSON access log, active plus size-rotated files.
/srv/localhoster/server-logs/directoryThe server-health samples, one dated file per statistic type.
/etc/localhoster/directoryThe self-signed certificate for the bare-IP endpoint.
/etc/caddy/CaddyfilefileThe Caddy config the installer lays down, plus a backup of any earlier one.
/etc/systemd/system/directoryThe 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
The crons mirror: one folder per cron, each with a handler and its settings.

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
The runner's dated run-record day-files.

/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
The self-contained server package, unrolled file by file.
NameTypeDescription
cronhoster-api.jsfileThe 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.jsfileThe 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.jsfileThe 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.jsfileThe 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.jsfileStarted 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.jsfileTakes one snapshot of the machine's health — CPU, memory, disk, and cumulative disk- and network-IO counters. Common to every hoster.
cronhoster-runner/runner.jsfileThe 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.jsfileTurns 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.jsfileAppends 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/dirThe 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
Caddy's active and rotated access logs.

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
One dated file per statistic, per day.

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)
The bare-IP certificate.

/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
The Caddy config, and a backup of any earlier one.

/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
The three service units.

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.