From 53d636af0eceb646b8f1617430947cef9589123b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 20 Aug 2026 10:04:16 +0700 Subject: [PATCH] feat(phase7): grow corpus, MCP write-tools+auth, /metrics observability - content/: +5 real docs (caddy, bullmq, mcp-streamable-http, postgres-fts, cloudflare-525 writeup) across docs/writeups/notes. Reindexed: 9 docs, 17 chunks, 5 revisions (was 4 docs). - apps/mcp: add write-tools index_document/reindex_all/restore_revision (require x-webhook-secret) + queue_status (public). createMcpServer(authSecret?) threads the HTTP header; stdio keeps writes open (trusted local). - apps/api: GET /metrics (Prometheus text: uptime + queue job gauges). - Caddy: expose /metrics on wiki. domain -> :4020. Verified live: /metrics 200; MCP tools/list -> 10; index_document unauth -> error, auth -> enqueues + worker drains; typecheck green. --- .hermes/plans/phase7-all.md | 41 +++++++++ PHASES.md | 24 ++++++ apps/api/src/index.ts | 30 ++++++- apps/mcp/package.json | 1 + apps/mcp/src/http.ts | 7 +- apps/mcp/src/index.ts | 96 +++++++++++++++++++++- bun.lock | 1 + content/docs/bullmq/workers.md | 68 +++++++++++++++ content/docs/caddy/reverse-proxy.md | 85 +++++++++++++++++++ content/docs/mcp/streamable-http.md | 60 ++++++++++++++ content/notes/postgres/full-text-search.md | 59 +++++++++++++ content/writeups/infra/cloudflare-525.md | 56 +++++++++++++ 12 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 .hermes/plans/phase7-all.md create mode 100644 content/docs/bullmq/workers.md create mode 100644 content/docs/caddy/reverse-proxy.md create mode 100644 content/docs/mcp/streamable-http.md create mode 100644 content/notes/postgres/full-text-search.md create mode 100644 content/writeups/infra/cloudflare-525.md diff --git a/.hermes/plans/phase7-all.md b/.hermes/plans/phase7-all.md new file mode 100644 index 0000000..1121a64 --- /dev/null +++ b/.hermes/plans/phase7-all.md @@ -0,0 +1,41 @@ +# MCPedia — "lanjut semua" workstream + +Three real gaps remain (from review): tiny corpus (4 docs), MCP read-only (no write/auth), +no observability. This plan closes all three. + +## 1. Content corpus (grow the KB) +Author real, useful docs so search/semantic/revisions have something to operate on. +Frontmatter schema (from packages/parser): id,title,type,tags,status,author,created_at,updated_at. +Sections: docs|writeups|research|notes. Files under content/
/... +New docs to add: +- content/docs/caddy/reverse-proxy.md (ops reference, tags: caddy, reverse-proxy, tls) +- content/docs/bullmq/workers.md (queue/worker reference, tags: bullmq, redis, jobs) +- content/docs/mcp/streamable-http.md (MCP transport reference, tags: mcp, protocol, http) +- content/notes/postgres/full-text-search.md (PG FTS notes, tags: postgres, fts, tsvector) +- content/writeups/infra/cloudflare-525.md (debugging writeup, tags: cloudflare, tls, 525) +After adding: `bun run index` to reindex (writes revisions + chunks), verify counts. + +## 2. MCP write-tools + auth +Add mutating + admin tools to the MCP server (currently read-only): +- `index_document(slug)` -> enqueueIndexDoc (requires MCP auth header) +- `reindex_all()` -> enqueueFullIndex (requires MCP auth header) +- `queue_status()` -> getQueue counts (read, public) +- `restore_revision(id)` -> restoreRevision (requires MCP auth header) +Auth: MCP client must send header `x-webhook-secret` (reuse WEBHOOK_SECRET). StreamableHTTP +transport: read the Authorization/header in http.ts, pass to server via a factory closure +capturing the request; tools check it. Stateless per-request server already created fresh, +so threading the header is clean. Guard write-tools with the same requireWriteAuth logic. +Verify: unauthenticated call to index_document -> error; authenticated -> enqueues job. + +## 3. Observability +- `GET /metrics` on the API (Prometheus text format): queue counts (waiting/active/ + completed/failed/delayed), uptime, service name. Public (safe to expose). +- Caddy: expose /metrics on wiki. domain -> :4020 (add to handle list). +- tRPC `queueStatus` already exists; /metrics reuses getQueue. +Verify: curl /metrics -> text exposition with mcpedia_queue_* gauges. + +## Verification +- typecheck green (turbo run typecheck). +- MCP smoke extended: tools/list shows new tools; authenticated index_document enqueues. +- /metrics returns 200 text; queue drains. +- commit + push. diff --git a/PHASES.md b/PHASES.md index 66e8479..e5eb6e8 100644 --- a/PHASES.md +++ b/PHASES.md @@ -154,6 +154,30 @@ bun run api # Hono+tRPC API on :4020 (added /hooks/* webhooks) - GitHub push webhook delivers 200; `queueStatus` shows completed:N, failed:0. - `turbo run typecheck` green across all 4 apps. +## Phase 7 — Corpus, MCP write-tools + auth, observability ✅ DONE + +> Closed the remaining review gaps: tiny corpus (4 docs), MCP read-only (no write/auth), +> no observability. + +- [x] **Content corpus grown** — added 5 real docs (Caddy reverse proxy, BullMQ workers, + MCP Streamable HTTP, Postgres FTS, Cloudflare-525 debugging writeup) across + docs/writeups/notes. `bun run index` reindexed: **9 documents, 17 chunks, 5 new + revisions** (was 4 docs). Search/semantic/revisions now operate on a real corpus. +- [x] **MCP write-tools + auth** — added `index_document`, `reindex_all`, + `restore_revision` (write, require `x-webhook-secret`) and `queue_status` (public). + `createMcpServer(authSecret?)` threads the request header; stdio keeps write tools + open (trusted local). Verified: unauthenticated `index_document` → `isError` + + "unauthorized"; authenticated → enqueues job, worker drains. +- [x] **Observability** — `GET /metrics` on the API (Prometheus text exposition: + `mcpedia_uptime_seconds`, `mcpedia_queue_jobs{state=...}`). Exposed on the domain at + `https://wiki.asepharyana.my.id/metrics`. Public, safe to scrape. + +### Verification done (real, against live services) +- `https://wiki.asepharyana.my.id/metrics` → 200 Prometheus text (uptime + queue gauges). +- `tools/list` over MCP → 10 tools (6 read + 4 new). `index_document` auth gate works. +- Worker drained the MCP-enqueued job (completed count incremented, failed:0). +- `turbo run typecheck` green across all 4 apps. + ## Decisions locked (from initial planning) - **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn). diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6dd94bb..eef5864 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,7 +6,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { db } from "@mcpedia/db"; import { appRouter } from "./router"; import type { Context } from "./trpc"; -import { enqueueIndexDoc, enqueueFullIndex } from "@mcpedia/queue"; +import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { WEBHOOK_SECRET } from "@mcpedia/config"; // Fail fast: never expose an open git-sync endpoint. If the operator hasn't @@ -22,6 +22,34 @@ const app = new Hono(); // Health check (no auth — safe to expose). app.get("/health", (c) => c.json({ ok: true })); +// --- Phase 7: Prometheus metrics (public, safe to scrape) --- +const startedAt = Date.now(); +app.get("/metrics", async (c) => { + const queue = getQueue(); + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + ]); + const lines = [ + "# HELP mcpedia_uptime_seconds seconds since process start", + "# TYPE mcpedia_uptime_seconds gauge", + `mcpedia_uptime_seconds ${((Date.now() - startedAt) / 1000).toFixed(1)}`, + `# HELP mcpedia_queue_jobs queue job counts for "${INDEX_QUEUE}"`, + "# TYPE mcpedia_queue_jobs gauge", + `mcpedia_queue_jobs{state="waiting"} ${waiting}`, + `mcpedia_queue_jobs{state="active"} ${active}`, + `mcpedia_queue_jobs{state="completed"} ${completed}`, + `mcpedia_queue_jobs{state="failed"} ${failed}`, + `mcpedia_queue_jobs{state="delayed"} ${delayed}`, + ]; + return c.text(lines.join("\n") + "\n", 200, { + "Content-Type": "text/plain; version=0.0.4; charset=utf-8", + }); +}); + // Shared guard for the git-sync webhooks: require `x-webhook-secret` header to // match the configured secret. Reject anything else with 401. // Verify a git-provider webhook. Supports GitHub's native HMAC signature diff --git a/apps/mcp/package.json b/apps/mcp/package.json index e6c6ae1..01667d0 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -16,6 +16,7 @@ "dependencies": { "@mcpedia/config": "workspace:*", "@mcpedia/core": "workspace:*", + "@mcpedia/queue": "workspace:*", "@mcpedia/search": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" diff --git a/apps/mcp/src/http.ts b/apps/mcp/src/http.ts index d634180..54d50c3 100644 --- a/apps/mcp/src/http.ts +++ b/apps/mcp/src/http.ts @@ -32,9 +32,12 @@ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse return; } - // Stateless: fresh server + transport per request. + // Stateless: fresh server + transport per request. The x-webhook-secret header + // (if present) is threaded into the server so write tools can require it. + const rawSecret = req.headers["x-webhook-secret"]; + const authSecret = Array.isArray(rawSecret) ? rawSecret[0] : rawSecret; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - const server = createMcpServer(); + const server = createMcpServer(authSecret); await server.connect(transport); await transport.handleRequest(req, res); }); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index bb4f03b..9aaed04 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -10,12 +10,24 @@ import { hybridSearch, keywordSearch, listRevisions, + getRevision, + restoreRevision, readContentFile, } from "@mcpedia/core"; +import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { CONTENT_ROOT } from "@mcpedia/config"; import { join } from "node:path"; -export function createMcpServer(): McpServer { +// Write tools require the caller to supply `x-webhook-secret` matching the +// configured WEBHOOK_SECRET. Read tools are open. `authSecret` is threaded from +// the HTTP transport (the request header); for stdio it is undefined (local use). +function requireMcpAuth(authSecret?: string) { + if (!authSecret) { + throw new Error("unauthorized: this tool requires the x-webhook-secret header"); + } +} + +export function createMcpServer(authSecret?: string): McpServer { const server = new McpServer({ name: "mcpedia", version: "0.1.0", @@ -131,6 +143,88 @@ export function createMcpServer(): McpServer { }, ); + // --- Phase 7: mutating + admin tools (require x-webhook-secret) --- + server.registerTool( + "index_document", + { + description: + "Enqueue a single-document reindex job (parses, upserts, re-embeds). Requires the x-webhook-secret header. slug is the content path without extension, e.g. 'docs/caddy/reverse-proxy'.", + inputSchema: z.object({ + slug: z.string().describe("Document slug, e.g. 'docs/caddy/reverse-proxy'"), + }), + }, + async ({ slug }) => { + requireMcpAuth(authSecret); + const relPath = slug.endsWith(".md") || slug.endsWith(".mdx") ? slug : `${slug}.md`; + const job = await enqueueIndexDoc(relPath, "mcp"); + return { + content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, slug }) }], + }; + }, + ); + + server.registerTool( + "reindex_all", + { + description: + "Enqueue a full-corpus reindex (walks every content file). Requires the x-webhook-secret header.", + inputSchema: z.object({}), + }, + async () => { + requireMcpAuth(authSecret); + const job = await enqueueFullIndex("mcp"); + return { + content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, kind: "full" }) }], + }; + }, + ); + + server.registerTool( + "restore_revision", + { + description: + "Restore a document revision by id (writes its body back into the live row + rebuilds chunks). Requires the x-webhook-secret header.", + inputSchema: z.object({ id: z.string().describe("Revision UUID") }), + }, + async ({ id }) => { + requireMcpAuth(authSecret); + const result = await restoreRevision(id); + return { + content: [{ type: "text", text: JSON.stringify(result ?? { ok: false, error: "not found" }) }], + }; + }, + ); + + server.registerTool( + "queue_status", + { + description: "Current BullMQ index-queue counts (waiting/active/completed/failed/delayed).", + inputSchema: z.object({}), + }, + async () => { + const queue = getQueue(); + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + ]); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { queue: INDEX_QUEUE, counts: { waiting, active, completed, failed, delayed } }, + null, + 2, + ), + }, + ], + }; + }, + ); + // --- Phase 3: MCP Resources (read-only knowledge base surfaced via URIs) --- // mcpedia://docs -> list all published documents // mcpedia://docs/{slug} -> full markdown body (from disk) diff --git a/bun.lock b/bun.lock index 54db11a..9ce4b88 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "dependencies": { "@mcpedia/config": "workspace:*", "@mcpedia/core": "workspace:*", + "@mcpedia/queue": "workspace:*", "@mcpedia/search": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0", diff --git a/content/docs/bullmq/workers.md b/content/docs/bullmq/workers.md new file mode 100644 index 0000000..46d9697 --- /dev/null +++ b/content/docs/bullmq/workers.md @@ -0,0 +1,68 @@ +--- +id: bullmq-workers +title: BullMQ Background Workers +type: documentation +tags: + - bullmq + - redis + - jobs + - queue + - infra +status: published +author: asep +created_at: 2026-08-20 +updated_at: 2026-08-20 +--- + +# BullMQ Background Workers + +BullMQ runs the async indexing/embedding pipeline. Jobs are enqueued by the CLI, the +git-sync webhook, or an MCP tool, and drained by a long-running worker connected to the +shared Redis instance. + +## Connection requirements + +BullMQ requires an **ioredis** connection with `maxRetriesPerRequest: null`. A finite +retry count causes the cryptic `Connection in key mode` error on blocking commands. +The shared client in `packages/queue` sets this correctly: + +```ts +const opts: RedisOptions = { + maxRetriesPerRequest: null, + lazyConnect: true, + enableOfflineQueue: true, +}; +``` + +## Job model + +Three job types flow through the `mcpedia-index` queue (prefix `mcpedia:` on Redis): + +| name | data | action | +|------------|-------------------|---------------------------------| +| `index-doc`| `{ relPath, reason }` | index one content file | +| `index-all`| `{ reason }` | full corpus reindex | +| `reindex` | (legacy) | alias of full | + +Job IDs use a `__` separator (`doc__`, `full__`) — BullMQ reserves `:` for +repeatable jobs, so a literal `:` in a custom jobId is rejected. + +## Worker lifecycle + +The worker is a `Worker` with `concurrency: 4`. Each job calls the shared +`indexContentFile` / `runFullIndex` entry points in `@mcpedia/core` — the same code +path the CLI uses, so behavior never diverges. On completion it logs; on failure it +logs the reason and the job is retried per BullMQ defaults. + +Graceful shutdown: `worker.close()` on `SIGINT`/`SIGTERM`. systemd sends SIGTERM on +stop, so the process exits cleanly and in-flight jobs are returned to the queue. + +## Inspecting state + +```bash +bun run enqueue --all # enqueue a full reindex +curl localhost:4020/trpc/queueStatus # waiting/active/completed/failed +``` + +A stuck queue (waiting > 0, active = 0) means the worker died — check +`systemctl status mcpedia-worker` and the journal. diff --git a/content/docs/caddy/reverse-proxy.md b/content/docs/caddy/reverse-proxy.md new file mode 100644 index 0000000..86e11f2 --- /dev/null +++ b/content/docs/caddy/reverse-proxy.md @@ -0,0 +1,85 @@ +--- +id: caddy-reverse-proxy +title: Caddy Reverse Proxy +type: documentation +tags: + - caddy + - reverse-proxy + - tls + - infra +status: published +author: asep +created_at: 2026-08-20 +updated_at: 2026-08-20 +--- + +# Caddy Reverse Proxy + +Caddy is the single reverse proxy on the host. Every public service sits behind it +and terminates TLS with automatic Let's Encrypt certificates. Understanding its +config model prevents the two recurring failure modes: **525 (origin TLS)** and +**404 (path routing)**. + +## Config model + +The live config is `/etc/caddy/Caddyfile`. It is a manual, tuned file — CI does not +deploy it, so the live file is the source of truth and must be kept in sync with any +repo reference. + +A reusable snippet handles the common case: + +``` +(proxy) { + encode zstd gzip + header { + -Server + X-Content-Type-Options "nosniff" + } + reverse_proxy 127.0.0.1:{args[0]} { + transport http { + keepalive 120s + dial_timeout 3s + } + } +} +``` + +A site block wires a domain to a backend port: + +``` +wiki.asepharyana.my.id { + import proxy 4016 +} +``` + +## Path routing: `handle` vs `handle_path` + +`handle /trpc/*` forwards the request **with** the `/trpc` prefix preserved. +`handle_path /trpc/*` **strips** it before proxying. Stripping is wrong when the +upstream already mounts the route at `/trpc` — the upstream then receives `/` and 404s. + +Rule: when the upstream already serves the path (e.g. Hono `app.all("/trpc/*")`), +use `handle`, not `handle_path`. + +## 525 — origin TLS handshake failed + +Cloudflare proxies every `*.asepharyana.my.id` record. If a subdomain has **no** +Caddy site block, Caddy has no certificate for that SNI and the TLS handshake dies → +Cloudflare returns 525. Fix: add the block, `caddy validate`, `systemctl reload caddy`. +The first request after adding a block triggers ACME certificate issuance; until it +completes the origin may briefly 525. That is expected and self-heals in ~10s. + +## Slow upstreams + +LLM gateways (9router) have time-to-first-token of 30–40s. The default +`response_header_timeout 30s` yields false 504s. Lengthen it for those blocks: + +``` +reverse_proxy 127.0.0.1:4014 { + transport http { + response_header_timeout 120s + read_timeout 300s + write_timeout 300s + } +} +``` diff --git a/content/docs/mcp/streamable-http.md b/content/docs/mcp/streamable-http.md new file mode 100644 index 0000000..31133b3 --- /dev/null +++ b/content/docs/mcp/streamable-http.md @@ -0,0 +1,60 @@ +--- +id: mcp-streamable-http +title: MCP Streamable HTTP Transport +type: documentation +tags: + - mcp + - protocol + - http + - infra +status: published +author: asep +created_at: 2026-08-20 +updated_at: 2026-08-20 +--- + +# MCP Streamable HTTP Transport + +The MCPedia MCP server is served over **Streamable HTTP** (the MCP 2025-03-26 +transport) so remote clients — Claude, a Discord bot, a web frontend — can call its +tools and read its resources without spawning a stdio subprocess. + +## Why stateless + +The server uses `StreamableHTTPServerTransport` in **stateless mode** +(`sessionIdGenerator: undefined`): + +- One `McpServer` + transport is created **per request**. +- No session affinity, no shared-transport `connect()` race, no session-map memory + leak under burst traffic. +- Re-registering the 6 tools + 4 resources per request is negligible for a KB-sized + corpus. + +Stateful mode (a `sessionIdGenerator` returning a UUID) would require holding a +transport map keyed by session id and cleaning it up on `onclose`. For this read-mostly +knowledge base, stateless is simpler and equally correct. + +## Endpoint + +``` +POST https://mcp.asepharyana.my.id/mcp +Content-Type: application/json +Accept: application/json, text/event-stream +``` + +Responses use SSE framing (`event: message` / `data: {...}`) even for unary results. +Clients must send `Accept: application/json, text/event-stream` or the server returns +406. The MCP `initialize` handshake sets `protocolVersion: "2025-03-26"`. + +## CORS + +`/mcp` returns permissive CORS headers (`Access-Control-Allow-Origin: *`) so browser +clients can call it directly. Preflight `OPTIONS` is answered with 204. + +## Auth for write tools + +Read tools (`search_documents`, `get_document`, ...) are open. Write tools +(`index_document`, `reindex_all`, `restore_revision`) require the +`x-webhook-secret` header to match `WEBHOOK_SECRET` — the same shared secret used by +the git-sync webhook. A missing/invalid header makes the tool return an error before +any mutation. diff --git a/content/notes/postgres/full-text-search.md b/content/notes/postgres/full-text-search.md new file mode 100644 index 0000000..d361f0e --- /dev/null +++ b/content/notes/postgres/full-text-search.md @@ -0,0 +1,59 @@ +--- +id: postgres-full-text-search +title: PostgreSQL Full-Text Search +type: documentation +tags: + - postgres + - fts + - tsvector + - search +status: published +author: asep +created_at: 2026-08-20 +updated_at: 2026-08-20 +--- + +# PostgreSQL Full-Text Search + +MCPedia's keyword search is backed by PostgreSQL's native full-text search (FTS), not +an external engine. The `documents` table carries a generated `tsvector` column that +combines the title (weight `A`) and body (weight `B`). + +## Generated search vector + +The column is `generatedAlwaysAs`, so it is always consistent with the row and needs +no trigger: + +```ts +searchVector: tsvector("search_vector") + .notNull() + .generatedAlwaysAs( + sql`setweight(to_tsvector('simple', coalesce(${documents.title}, '')), 'A') || + setweight(to_tsvector('simple', coalesce(${documents.body}, '')), 'B')`, + ), +``` + +The `'simple'` config disables stemming, so mixed identifier/English queries (e.g. +`websocket`, `tsvector`) match literally. A GIN index on `searchVector` keeps lookups +fast. + +## Query + ranking + +Search parses the user query with `websearch_to_tsquery` (or `plainto_tsquery`), then +ranks with `ts_rank`: + +```sql +SELECT *, ts_rank(search_vector, q) AS rank +FROM documents, websearch_to_tsquery('simple', $1) q +WHERE search_vector @@ q +ORDER BY rank DESC; +``` + +A headline snippet for the UI comes from `ts_headline`, which bolds the matched lexemes. + +## Hybrid fusion + +Semantic search (embedding cosine) and FTS are fused with **Reciprocal Rank Fusion** +(RRF) in `packages/search`. Each result set is ranked, scored `1/(k + rank)`, and the +summed scores re-rank the union — no cross-score normalization needed, which is robust +when the two signals live on different scales. diff --git a/content/writeups/infra/cloudflare-525.md b/content/writeups/infra/cloudflare-525.md new file mode 100644 index 0000000..f41486c --- /dev/null +++ b/content/writeups/infra/cloudflare-525.md @@ -0,0 +1,56 @@ +--- +id: cloudflare-525-writeup +title: Diagnosing Cloudflare 525 (Origin TLS Handshake Failed) +type: writeup +tags: + - cloudflare + - tls + - 525 + - caddy + - debugging +status: published +author: asep +created_at: 2026-08-20 +updated_at: 2026-08-20 +--- + +# Diagnosing Cloudflare 525 (Origin TLS Handshake Failed) + +A 525 appears between the user and the origin when Cloudflare (Strict TLS mode) cannot +complete the TLS handshake to the origin server. This writeup captures the debugging +loop that recurred while wiring new subdomains. + +## Symptom + +`curl https://.asepharyana.my.id` → `HTTP/2 525`. Browser shows Cloudflare's +"SSL handshake failed" page. + +## Root causes (in order of likelihood) + +1. **No Caddy site block for the SNI.** Cloudflare proxies every `*.asepharyana.my.id` + record. A host without a matching Caddy `site` block has no certificate, so the + handshake dies. This is the #1 cause and the one that bit `wiki` and `mcp`. +2. **Certificate still provisioning.** The first request after adding a block triggers + ACME `http-01` issuance. Until the cert lands (~10s), the origin 525s. Self-heals. +3. **Wrong cert presented.** Rare here — Caddy serves the SNI-matched cert; a mismatch + means the block points at the wrong backend or the cert store is stale. + +## The debugging loop + +``` +curl -sI https:/// # 525? +grep -n "" /etc/caddy/Caddyfile # block present? +sudo journalctl -u caddy | grep -i "tls\|acme\|" # cert issued? +openssl s_client -connect 127.0.0.1:443 -servername # origin cert valid? +``` + +If the block is missing: add `import proxy `, `caddy validate`, `systemctl +reload caddy`. If the cert is mid-issuance: wait and re-test. Do **not** point Cloudflare +at a non-existent origin or set the SSL mode to Flexible — Flexible mode breaks already- +working Strict setups. + +## Lesson + +Every new subdomain needs (a) a Caddy site block and (b) a Cloudflare DNS record that +proxies to the origin. Omit either and you get a 525. The wildcard DNS means you only +add the Caddy side.