diff --git a/docs/explanations/2026-07-13-explanation-setup-syncyomi.html b/docs/explanations/2026-07-13-explanation-setup-syncyomi.html new file mode 100644 index 00000000..8bd95cec --- /dev/null +++ b/docs/explanations/2026-07-13-explanation-setup-syncyomi.html @@ -0,0 +1,402 @@ + + + + + +Adding SyncYomi to a one-edge home server + + + +
+ +

Adding SyncYomi to a one-edge home server

+

How setup_syncyomi.sh stands up a manga-sync server that a phone can reach over the public internet — by slotting into infrastructure that already existed, rather than building a new stack.

+ +
+ Contents +
    +
  1. Background — the pieces already on the box
  2. +
  3. Intuition — one door, three rooms
  4. +
  5. Code — a walkthrough by concern
  6. +
  7. Quiz — check your understanding
  8. +
+
+ +

1 · Background

+ +

SyncYomi is a small self-hosted server. The manga reader app TachiyomiSY on a phone talks to it to keep its library and reading progress in sync across devices — the phone uploads a blob of its library, the server stores it, and other devices download it. The phone authenticates with a single API key, and it needs to reach the server from anywhere, including mobile data.

+ +

The interesting part isn't SyncYomi itself — it's that this PC was already a public server for two other things, and the new script had to fit into that without disturbing them. Three existing facts shaped every decision:

+ +
+

a. There is exactly one front door

+

A single Caddy container (named gitea-caddy) owns the public ports 80 and 443. It terminates TLS and forwards each request to a small backend that listens only on 127.0.0.1 (loopback — unreachable from the network). Two tenants already live behind it:

+

+ kuhy.duckdns.org → 127.0.0.1:3000 (Gitea) + kuhy-cloud.duckdns.org → 127.0.0.1:5000 (dufs) +

+

Caddy tells them apart by the hostname in the request. It also fetches and renews a free Let's Encrypt TLS certificate for each hostname automatically.

+
+ +
+

b. DuckDNS gives every service the same public IP

+

The home connection has one changing public IP. A cron job (~/.joplin-server/duckdns-update.sh) tells DuckDNS "these labels all point at my current IP". Adding a service means adding its label to that one updater — not creating a second updater.

+
+ +
+

c. The firewall silently kills bridged containers

+

This host's nftables has a default-drop FORWARD chain. A normal Docker bridge network routes through FORWARD, so a bridged container's traffic gets dropped with no error. The existing services dodge this by using network_mode: host and binding to 127.0.0.1. The new service must do the same.

+
+ +

So the change is not "install a server." It's "add a third tenant to a shared, hostname-routed edge, reusing its TLS, its DNS updater, and its firewall posture."

+ +

2 · Intuition — one door, three rooms

+ +

Picture the whole public surface as a single reception desk. Everything from the outside world arrives at that one desk (port 443 on gitea-caddy). The desk reads the name on the envelope — the hostname — and walks it to the right internal room. The rooms themselves have no external doors at all.

+ +
+
+
📱 Phonehttps://kuhy-sync.duckdns.org
+ → +
gitea-caddy:443 · TLS · host-net
+
+
+ routes by hostname ↓ +
+
+
Gitea127.0.0.1:3000
+
dufs127.0.0.1:5000
+
SyncYomi ⭐ new127.0.0.1:8282
+
+
+ +

Adding SyncYomi is therefore four small edits, not a new stack:

+ + +
+ Why loopback matters. If SyncYomi listened on 0.0.0.0, it would have an external door of its own — bypassing the TLS desk entirely and serving your library in plaintext. Binding 127.0.0.1 forces every request to go through Caddy. SyncYomi actually defaults to 0.0.0.0, so the script rewrites that after first launch.

+
+ +

Try it: how the desk routes a request

+

Pick a hostname and watch Caddy decide where it goes. This mirrors the real site blocks in ~/gitea/Caddyfile.

+ +
+ +
+ + +
+
+
+ +

3 · Code — a walkthrough by concern

+

The script is one file, setup_syncyomi.sh, with setup / status / help subcommands. Here are the parts that carry the design, grouped by what they achieve.

+ +

Reuse, don't rebuild: preflight

+

It refuses to invent infrastructure. It hard-requires the existing edge to be present and bails with a pointer if not — so it can never accidentally spin up a competing proxy.

+
has_cmd docker || die "docker not found — run setup_gitea.sh first."
+docker ps --format '{{.Names}}' | grep -qx "${CADDY_CONTAINER}" ||
+    die "Caddy container '${CADDY_CONTAINER}' not running — run setup_gitea.sh first."
+[[ -f ${CADDYFILE} ]]       || die "Caddyfile ${CADDYFILE} missing — run setup_gitea.sh first."
+[[ -x ${DUCKDNS_UPDATER} ]] || die "DuckDNS updater missing — run install_joplin.sh first."
+ +

The container: host networking, loopback bind

+

network_mode: host sidesteps the FORWARD-chain trap; the loopback bind (enforced just below) keeps it private.

+
services:
+  syncyomi:
+    image: ghcr.io/syncyomi/syncyomi:v1.1.11   # pinned, not :latest
+    restart: unless-stopped                     # ← survives reboot
+    network_mode: host
+    volumes: [ ./config:/config ]
+

restart: unless-stopped plus the enabled Docker service is the entire "auto-start after reboot" story — no bespoke systemd unit needed.

+ +

Readiness + the loopback rewrite

+

SyncYomi v1.1.x has no public health route (/api/healthz is a 404). The script instead treats GET /api/auth/onboard as "up" — it answers 204 when no account exists yet, or 403 once one does. Either means the API is serving.

+
syncyomi_ready() {
+  code="$(curl -sS -o /dev/null -w '%{http_code}' "${base}/api/auth/onboard" ...)"
+  [[ ${code} == "204" || ${code} == "403" ]]
+}
+

Once it's up, the script patches the generated config from the insecure default and restarts:

+
sed -i -E 's|^\s*host\s*=.*|host = "127.0.0.1"|' "${CONFIG_TOML}"   # was 0.0.0.0
+ +

Minting the API key — the deliverable

+

This is the part that lets the script hand you a key instead of making you click through a web UI. It onboards the first account (only if none exists), logs in for a session cookie, then reuses or creates a key. Two gotchas discovered live are baked in:

+ +
key="$(curl -sS -b "${jar}" "${SYNCYOMI_LOCAL}/api/keys" \
+      | jq -r '[.[] | select(.name=="tachiyomisy")][0].key // empty')"
+if [[ -z ${key} ]]; then
+  key="$(curl -sS -b "${jar}" \
+        -d '{"name":"tachiyomisy","scopes":["read","write"]}' \
+        "${SYNCYOMI_LOCAL}/api/keys" | jq -r '.key // empty')"
+fi
+ +

Joining the shared edge: Caddy + DuckDNS

+

One site block appended to the shared Caddyfile, then an in-container reload. Caddy issues the TLS cert on its own once the name resolves.

+
printf '\n%s {\n\treverse_proxy 127.0.0.1:%s\n}\n' \
+    "${SYNCYOMI_SUBDOMAIN}" "${SYNCYOMI_PORT}" >>"${CADDYFILE}"
+docker exec "${CADDY_CONTAINER}" caddy reload --config "${CADDYFILE_IN_CONTAINER}"
+

And one label spliced into the existing updater's domains= list — no second cron job:

+
sed -i -E "s|(domains=)([^&\"']*)|\1\2,${label}|" "${DUCKDNS_UPDATER}"
+ +
+ The one fragility. That Caddy block lives in a file owned by setup_gitea.sh, which rewrites it from scratch when re-run. If that happens, SyncYomi's block vanishes — the fix is simply to re-run setup_syncyomi.sh setup, which idempotently re-appends it. This is a known, documented trade-off inherited from the sibling setup_dufs_cloud.sh. +
+ +

4 · Quiz

+

Five questions on the reasoning behind the change. Click an option to check it.

+
+
Score: 0 / 0
+ +
+ Generated by /explain-diff for commit 59430f9 · + linux_configuration/scripts/single_use/features/setup_syncyomi.sh +
+
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 8142f8f6..30e6f49b 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -3,7 +3,7 @@ -explanations — change explanations +testsAndMisc — change explanations