docs: full operator manual in README (setup, spec, API, troubleshooting)

Replaces the 3-line upstream stub with a manual that documents the platform
as it actually runs. Every claim is derived from the live code and registry
rather than from memory.

Challenge spec:
- 28-challenge tables (6 XVIII / 10 XVI / 12 XVII, 16 active) generated from
  teams/challenge_registry.json, with per-challenge org_port, chall/ssh
  offsets, and the real team-1 runtime ports read from state.json.
- Port formula corrected to the real one:
  port = 30000 + idx*1000 + chall_offset. org_port is the native graveyard
  port and is NOT used for runtime allocation, so two challenges sharing an
  org_port (carbeat offset 1 vs anti-alchemy offset 30) never collide.
- Per-challenge ssh_user documented: only the 6 native XVIII images provision
  ctfuser; all imported XVI/XVII images chpasswd root, so hardcoding ctfuser
  breaks 10 of the 16 active challenges.
- Scoring: 100 per flag awarded to the ATTACKER (first solve only), +50 SLA
  bonus at most once per 5-minute window, runtime threshold documented as
  len(enabled_challenges()) rather than the hardcoded constant 6.

Setup and operations:
- Setup from clone: required /opt path, Docker, venv, panel credentials, both
  systemd units verbatim, team creation, verification step.
- Full HTTP API split into public / admin / team, including why challenge
  toggle and bulk team delete are async jobs.
- Troubleshooting and operational traps as declarative rules: the bare domain
  is the receiver and not the panel, EOL base images, UFW default-deny
  silently blackholing ports, the mandatory compose -p teamN project name,
  and why docker image prune -af destroys services-* images that are in use.
- Image sizes measured from the host (189MB-903MB, ~8GB for 16 active)
  instead of the incorrect "~3GB per challenge" figure.
- Topology section: PixiJS v8, on-demand rendering, and the parent-to-child
  drag hierarchy derived from the edge list.

The flag example is redacted to a placeholder. No live credential, token, or
flag is committed. README.md is the only file touched.
This commit is contained in:
Cyrene
2026-09-28 19:13:51 +08:00
parent cf47770798
commit 2da6305626
+636 -14
View File
@@ -1,18 +1,640 @@
# Gemastik XVIII Cybersecurity Final Round - Attack Defense Repository
# Attack Defense Platform
## challenges
Platform attack-defense (serang–serang) untuk GEMASTIK Final Round, menyatukan
**28 challenge** dari tiga set (**GEMASTIK XVIII**, **XVI**, **XVII**) di bawah
satu panel admin, satu flag store, satu SLA checker, dan satu skorboard.
| challenges | author | category |
| ---------- | ----------- | -------- |
| blogpost | keii | web |
| cdn | keii | foren |
| phew | itoid | crypto |
| sheesh | itoid | crypto |
| carbeat | rui | pwn |
| warmup | hanz0 | warmup |
Multi-team: N tim, masing-masing memiliki copy semua challenge + flag sendiri,
dengan receiver terisolasi per tim, checker SLA otomatis, dan visualisasi
topologi serangan real-time.
## how-to-run
```
Browser (admin/team)
| HTTP + WebSocket (proxy server-side)
v
Panel :18081 (FastAPI) ---- systemd: gemastik-panel
|
+--> Receiver global :18080 ---- systemd: gemastik-receiver
+--> Receiver tim N :31080+1000*(N-1) ---- systemd: gemastik-receiver-teamN
| (menjalankan checker SLA untuk tim N)
+--> N x <challenge>_container_teamN ---- docker compose
|
+--> flags + leaderboard + points (teams/leaderboard.json, teams/points.json)
```
````
sudo python3 starter.py
````
---
## Daftar Isi
- [Arsitektur](#arsitektur)
- [Spesifikasi Challenge](#spesifikasi-challenge)
- [Spesifikasi Port](#spesifikasi-port)
- [Skor dan Penilaian](#skor-dan-penilaian)
- [Kebutuhan Sistem](#kebutuhan-sistem)
- [Setup](#setup)
- [Operasional Harian](#operasional-harian)
- [Topologi](#topologi)
- [HTTP API](#http-api)
- [Troubleshooting](#troubleshooting)
- [Jebakan Operasional](#jebakan-operasional)
- [Struktur Direktori](#struktur-direktori)
---
## Arsitektur
Tiga proses inti, semuanya dikelola systemd:
| Proses | Port | Unit systemd | Peran |
|---|---|---|---|
| Panel admin | **18081** | `gemastik-panel` | Web UI admin + seluruh API |
| Receiver global | **18080** | `gemastik-receiver` | Flag store untuk mode single-node |
| Receiver tim N | **31080 + 1000×(N−1)** | `gemastik-receiver-teamN` | Checker SLA tim N |
**Mengapa receiver per tim harus unit terpisah.** Kalau receiver dijalankan
sebagai child process dari panel, `systemctl restart gemastik-panel` akan
membunuh seluruh cgroup — termasuk semua receiver — dan SLA semua tim ikut
turun ke 0. Unit terpisah membuat restart panel tidak menyentuh receiver.
Generatornya: `panel/gen_receiver_services.py`.
**Kredensial tidak pernah masuk browser.** Panel melakukan proxy ke receiver
secara server-side, sehingga password admin hanya ada di `panel/.env` pada host.
**Sumber data tunggal.** `teams/challenge_registry.json` dibaca oleh panel,
generator compose, dan generator receiver. Menambah challenge = menambah satu
entri di registry, bukan menyunting tiga tempat.
### Alur satu flag
```
tim penyerang submit flag
-> panel POST /api/flag/submit
-> baca flag tim target dari receiver
-> cocok?
ya -> catat di leaderboard + skor untuk PENYERANG
tidak -> tolak
```
Flag di-mint per (tim, challenge), bukan satu flag global.
---
## Spesifikasi Challenge
**28 challenge terdaftar, 16 aktif secara default.**
Kolom `Port team 1` / `SSH team 1` diisi `-` untuk challenge nonaktif karena
port-nya baru dialokasikan saat challenge diaktifkan.
### GEMASTIK XVIII — 6 challenge, 6 aktif
User SSH: `ctfuser`.
| # | Challenge | Kategori | org_port | Offset chall/SSH | Port team 1 | SSH team 1 | Status |
|---|---|---|---|---|---|---|---|
| 1 | `blogpost` | web | 10000 | 0/22 | 31000 | 31022 | aktif |
| 2 | `carbeat` | pwn | 11000 | 1/23 | 31001 | 31023 | aktif |
| 3 | `cdn` | web | 12000 | 2/24 | 31002 | 31024 | aktif |
| 4 | `phew` | crypto | 13000 | 3/25 | 31003 | 31025 | aktif |
| 5 | `sheesh` | crypto | 14000 | 4/26 | 31004 | 31026 | aktif |
| 6 | `warmup` | warmup | 15000 | 5/27 | 31005 | 31027 | aktif |
### GEMASTIK XVI — 10 challenge, 3 aktif
User SSH: `root`.
| # | Challenge | Kategori | org_port | Offset chall/SSH | Port team 1 | SSH team 1 | Status |
|---|---|---|---|---|---|---|---|
| 7 | `art` | web | 10000 | 10/110 | 31010 | 31110 | aktif |
| 8 | `xl` | web | 11000 | 11/111 | 31011 | 31111 | aktif |
| 9 | `gemas-notes` | web | 12000 | 12/112 | - | - | nonaktif |
| 10 | `pasta` | web | 13000 | 13/113 | - | - | nonaktif |
| 11 | `burvesigner` | crypto | 14000 | 14/114 | - | - | nonaktif |
| 12 | `hirnfick` | pwn | 15000 | 15/115 | - | - | nonaktif |
| 13 | `gemas-fetcher` | web | 16000 | 16/116 | - | - | nonaktif |
| 14 | `s3` | web | 20000 | 20/120 | 31020 | 31120 | aktif |
| 15 | `crawlback` | web | 21000 | 21/121 | - | - | nonaktif |
| 16 | `back-to-basic` | warmup | 22000 | 22/122 | - | - | nonaktif |
### GEMASTIK XVII — 12 challenge, 7 aktif
User SSH: `root`.
| # | Challenge | Kategori | org_port | Offset chall/SSH | Port team 1 | SSH team 1 | Status |
|---|---|---|---|---|---|---|---|
| 17 | `anti-alchemy` | web | 11000 | 30/130 | 31030 | 31130 | aktif |
| 18 | `asmr` | pwn | 15000 | 31/131 | - | - | nonaktif |
| 19 | `bit-canvas` | pwn | 20000 | 32/132 | 31032 | 31132 | aktif |
| 20 | `fjb` | web-pwn | 17000 | 33/133 | - | - | nonaktif |
| 21 | `gift-card` | crypto | 21000 | 34/134 | 31034 | 31134 | aktif |
| 22 | `gift-voucher` | crypto | 16000 | 35/135 | 31035 | 31135 | aktif |
| 23 | `gleam-drive` | web-crypto | 12000 | 36/136 | 31036 | 31136 | aktif |
| 24 | `go-green` | rev | 20000 | 37/137 | - | - | nonaktif |
| 25 | `kode-viewer` | web | 10000 | 38/138 | - | - | nonaktif |
| 26 | `more-less` | web | 22000 | 39/139 | 31039 | 31139 | aktif |
| 27 | `tempest-poc` | web | 14080 | 40/140 | - | - | nonaktif |
| 28 | `ticketer` | crypto | 14000 | 41/141 | 31041 | 31141 | aktif |
Kategori: 13 web, 6 crypto, 4 pwn, 2 warmup, dan masing-masing satu
web-pwn, web-crypto, rev. `gleam-drive` dilayani lewat HTTPS (field `scheme`
di registry), 27 challenge lainnya HTTP.
### Format flag
```
GEMASTIK18{TEAM<idx>_<CHALLENGE>_<12 hex>}
contoh: GEMASTIK18{TEAM1_BLOGPOST_<12 hex acak>}
```
### User SSH per challenge
Hanya 6 challenge native GEMASTIK XVIII yang membuat user `ctfuser`. Semua
challenge impor XVI/XVII menjalankan `echo root:${PASSWORD} | chpasswd` di
Dockerfile, sehingga login sebagai `ctfuser` ditolak walaupun password benar.
Field `ssh_user` di `teams/challenge_registry.json` yang menentukan ini, dibaca
`panel/teams.py` saat `set_ssh_passwords()`. Men-hardcode `ctfuser` membuat 10
dari 16 challenge gagal login padahal `state.json` terlihat benar.
---
## Spesifikasi Port
Setiap tim mendapat blok port sendiri, dengan basis 30000 dan langkah 1000:
```
port_challenge(tim i, challenge c) = 30000 + 1000 x i + chall_offset(c)
port_ssh(tim i, challenge c) = 30000 + 1000 x i + ssh_offset(c)
port_receiver(tim i) = 30000 + 1000 x i + 80
```
`chall_offset` dan `ssh_offset` dibaca dari `teams/challenge_registry.json`
(`panel/teams.py`, `create_team()`). Field `org_port` di registry adalah port
native challenge di graveyard asalnya dan **tidak dipakai** untuk menghitung
port runtime.
Enam challenge native GEMASTIK XVIII memakai offset challenge 0–5 dan SSH
22–27, sehingga untuk team 1 berada di 31000–31005 dan 31022–31027:
| Tim | Port challenge | Port SSH | Receiver |
|---|---|---|---|
| 1 | 31000–31005 | 31022–31027 | 31080 |
| 2 | 32000–32005 | 32022–32027 | 32080 |
| 3 | 33000–33005 | 33022–33027 | 33080 |
| 4 | 34000–34005 | 34022–34027 | 34080 |
Challenge impor memakai offset sendiri, jadi portnya tidak selalu berakhiran
`0000`–`0005`. Angka nyata team 1: `art` 31010/31110, `xl` 31011/31111,
`s3` 31020/31120, `anti-alchemy` 31030/31130, `bit-canvas` 31032/31132,
`gift-card` 31034/31134, `gift-voucher` 31035/31135,
`gleam-drive` 31036/31136, `more-less` 31039/31139, `ticketer` 31041/31141.
Dua challenge dengan `org_port` sama tidak bentrok, karena yang dipakai adalah
`chall_offset`. `anti-alchemy` (XVII, offset 30) dan `carbeat` (XVIII,
offset 1) sama-sama punya `org_port` 11000, tetapi memakai port 31030 dan
31001.
---
## Skor dan Penilaian
```python
POINTS_PER_FLAG = 100 # ke tim PENYERANG, hanya solve pertama
SLA_BONUS_POINTS = 50 # bonus bila semua challenge aktif UP
SLA_BONUS_MIN_ALIVE = 6 # konstanta; threshold runtime = len(enabled_challenges())
```
**Attack points.** Submit flag benar milik tim lain memberi +100 ke tim
penyerang. Duplikat (flag + penyerang + target sama) tidak dihitung dua kali.
**SLA bonus.** Diberi bila semua challenge yang aktif UP, maksimal sekali per
jendela 5 menit. Threshold runtime bukan angka tetap 6 melainkan
`len(enabled_challenges())` — mengaktifkan challenge ke-17 membuat syaratnya
"semua 17 UP".
**Badge.** Juara, runner-up, dan tempat ketiga dihitung dari total poin.
---
## Kebutuhan Sistem
Host reference: Ubuntu 24.04 (noble), x86_64, Docker + Compose v2, systemd.
| Sumber daya | Minimum | Recommended |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU |
| RAM | 8 GB | 16 GB |
| Disk | 60 GB | 100 GB+ |
| Docker | Compose v2 (`docker compose`) | — |
Compose v1 (`docker-compose`) tidak didukung — seluruh generator memakai
`docker compose`.
Disk adalah pembatas utama. Tiap challenge yang aktif menjadi satu image
`services-<name>`; ukurannya bervariasi dari ~190 MB (`gift-card`) sampai ~900 MB
(`warmup`), dan pada host ini 16 image aktif menempati sekitar 8 GB. Membangun
banyak challenge sekaligus akan mengisi disk sebelum selesai — implementasi
terbaik adalah membangun challenge secara berurutan dan menjalankan
`docker builder prune -af` di antaranya.
`phew` menjalankan generator kunci Paillier saat start (±12 detik) sehingga
butuh RAM ekstra dan checker-nya memakai `_CRYPTO_TIMEOUT`, bukan timeout prompt
bawaan 5 detik.
Prasyarat jaringan: setiap compose template sudah memuat
`extra_hosts: host.docker.internal:host-gateway`, dan UFW host harus
mengizinkan port challenge (lihat [Jebakan Operasional](#jebakan-operasional)).
Dependency checker ada di `receiver/requirements.txt`: fastapi, uvicorn,
pwntools, pyelftools, pycryptodome, fastecdsa, ecdsa, Pillow, pandas, openpyxl,
PyPDF2.
---
## Setup
### 1. Clone
```bash
git clone <repo-url> attack-defense-platform
cd attack-defense-platform
```
Semua path di dalam kode memakai `/opt/gemastik18-final` sebagai `BASE`, jadi
letakkan repo di sana:
```bash
sudo mkdir -p /opt
sudo mv attack-defense-platform /opt/gemastik18-final
cd /opt/gemastik18-final
```
### 2. Docker
```bash
sudo bash node.sh
```
`node.sh` memasang Docker CE dari repo resmi lalu menjalankan `starter.py`.
Instalasi manual:
```bash
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
```
### 3. Kredensial panel
```bash
cat > panel/.env <<'EOF'
PANEL_ADMIN_USER=admin
PANEL_ADMIN_PASS=ganti-dengan-password-kuat
EOF
chmod 600 panel/.env
```
`panel/.env` sudah masuk `.gitignore` dan tidak pernah ter-commit.
### 4. Python environment
```bash
cd /opt/gemastik18-final/receiver
sudo python3 -m venv .venv
sudo .venv/bin/pip install -r requirements.txt
```
### 5. Unit systemd
Panel (:18081):
```ini
# /etc/systemd/system/gemastik-panel.service
[Unit]
Description=Gemastik A/D Panel (web UI for receiver)
After=gemastik-receiver.service network-online.target
Wants=gemastik-receiver.service
[Service]
Type=simple
WorkingDirectory=/opt/gemastik18-final/panel
EnvironmentFile=-/opt/gemastik18-final/panel/.env
ExecStart=/opt/gemastik18-final/receiver/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 18081
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
Receiver global (:18080):
```ini
# /etc/systemd/system/gemastik-receiver.service
[Unit]
Description=Gemastik18 Receiver Service (CTF flag/control API)
After=docker.service network-online.target
Wants=docker.service
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/opt/gemastik18-final/receiver
ExecStart=/opt/gemastik18-final/receiver/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 18080
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now gemastik-receiver gemastik-panel
systemctl is-active gemastik-panel gemastik-receiver
```
### 6. Buat tim
Lewat UI (**Teams** tab, admin login) atau API:
```bash
curl -X POST http://127.0.0.1:18081/api/teams/set \
-H 'Content-Type: application/json' \
-b cookies.txt -c cookies.txt \
-d '{"count":2,"labels":{"1":"Tim Satu","2":"Tim Dua"}}'
```
Endpoint ini idempoten (membuat yang hilang, mempertahankan yang ada) dan
otomatis menjalankan `sync_team_ufw()` untuk setiap tim baru — tanpa itu port
tim akan di-blackhole UFW.
### 7. Verifikasi
```bash
bash panel/verify_platform_health.py
```
---
## Operasional Harian
| Aksi | Perintah |
|---|---|
| Lihat status semua service | `systemctl is-active gemastik-panel gemastik-receiver gemastik-receiver-team*` |
| Restart panel | `systemctl restart gemastik-panel` |
| Sinkronkan container tim dengan registry | `bash panel/apply_registry.sh` |
| Health check | `python3 panel/verify_platform_health.py` |
| SSH round-trip ke semua challenge | `python3 panel/verify_ssh_e2e.py` |
| Cek user SSH per challenge | `bash panel/audit_ssh_users.sh` |
| Reset penuh (tim, flag, kredensial) | `bash panel/reset_runtime.sh` |
| Health suite topologi | `bash panel/verify_topo_full.sh` |
| Beban host | `bash panel/watch_load.sh` |
**Reverse proxy.** Domain challenge dan panel dilayani Traefik lewat file
dynamic di `/data/coolify/proxy/dynamic/attackdefense.yaml`. Pola service:
```yaml
services:
gemastik-panel-service:
loadBalancer:
servers:
- url: "http://host.docker.internal:18081"
```
Cert TLS terbit otomatis lewat `certResolver: letsencrypt` selama DNS
terresolve dan port 80 terbuka.
Domain yang dipakai di host ini: `panel.attackdefense.imrnes.team` (panel, :18081)
dan `attackdefense.imrnes.team` (receiver global, :18080), plus subdomain
per challenge aktif. Perhatikan domain bare menunjuk ke receiver, bukan panel —
`/login` di sana akan 404 dan terlihat seperti panel mati.
---
## Topologi
Tab **Topology** merender graf serangan antar tim dengan PixiJS v8
(`panel/static/topo_pixi.js`, engine di `panel/static/vendor/pixi.mjs`).
- Pan, zoom, dan drag berjalan lewat satu funnel `applyView()`.
- Drag node tim menyeret seluruh challenge-nya. Indeks parent→child dibangun
dari edge list — sumber yang sama untuk menggambar garis — sehingga hierarki
drag tidak mungkin berbeda dari gambar.
- Ticker Pixi didaftarkan tapi tidak dinyalakan: render berlangsung on demand
(hanya saat ada pulse serangan atau sedang drag), lalu berhenti saat sunyi.
Pada host tanpa GPU, repaint 60fps atas scene statis membuat halaman tidak
merespons (rAF turun ke 2 FPS, lag `setTimeout(0)` 1353 ms).
- Posisi drag kembali ke layout otomatis saat data di-refresh tiap 10 detik.
Untuk merender ulang objek, `.text` hanya di-set bila string benar-benar
berubah — setiap `Text` baru meng-upload texture GPU (~1,6 detik per siklus
bila di-rebuild terus-menerus).
Suite tes: `bash panel/run_topo_tests.sh`
(`test_topo_pixels`, `test_topo_browser`, `test_topo_viewports`,
`test_topo_race`, `test_topo_drag`).
---
## HTTP API
Semua endpoint di `/api` kecuali yang ditandai publik.
### Publik
| Method | Path | Keterangan |
|---|---|---|
| GET | `/submit` | UI submit flag publik |
| POST | `/api/flag/submit` | Submit flag |
| GET | `/api/public/scoreboard` | Skorboard tanpa login |
| GET | `/api/public/teams` | Daftar tim tanpa login |
### Admin (butuh login)
| Method | Path | Keterangan |
|---|---|---|
| POST | `/api/login` | Login admin |
| POST | `/api/logout` | Logout |
| GET | `/api/challenges` | Daftar challenge + status |
| PATCH | `/api/challenges/{challenge}` | Toggle enable/disable (body `{"enabled":bool}`) |
| GET | `/api/challenges/jobs/{job_id}` | Progress job toggle |
| GET | `/api/status` | Status runtime |
| GET | `/api/topology` | Data graf topologi |
| GET | `/api/teams` | Daftar tim |
| POST | `/api/teams/set` | Buat N tim (idempoten) |
| PUT | `/api/teams/{idx}` | Ubah label/domain tim |
| DELETE | `/api/teams/{idx}` | Hapus satu tim (body `{"purge_scores":true}`) |
| POST | `/api/teams/bulk-delete` | Hapus beberapa tim (job) |
| GET | `/api/teams/bulk-delete/{job_id}` | Progress job hapus massal |
| POST | `/api/teams/{idx}/ufw` | Sinkronkan aturan UFW tim |
| POST | `/api/teams/start` | Start semua tim |
| POST | `/api/teams/stop` | Stop semua tim |
| POST | `/api/teams/{idx}/randomize` | Acak flag tim |
| GET | `/api/teams/{idx}/logs` | Log tim |
| GET | `/api/teams/{idx}/creds` | Kredensial tim |
| GET | `/api/credential/{challenge}` | Kredensial satu challenge |
| GET | `/api/targets` | Target serangan |
| GET | `/api/attacks` | Log serangan |
| GET | `/api/leaderboard` | Leaderboard |
| GET | `/api/scoreboard` | Skorboard internal |
| GET | `/api/history` | Riwayat |
| POST | `/api/restart/{challenge}` | Restart challenge |
| POST | `/api/rollback/{challenge}` | Rollback challenge |
| POST | `/api/activate/{challenge}` | Aktifkan challenge |
| POST | `/api/deactivate/{challenge}` | Nonaktifkan challenge |
| POST | `/api/reset/scores` | Reset skor |
| POST | `/api/reset/environment` | Reset environment (hapus tim + flag) |
Toggle challenge jalan asinkron: build per tim bisa memakan waktu menit, jadi
kerjaan dijalankan di background thread dan klien melakukan polling ke
`/api/challenges/jobs/{job_id}`. Hapus tim massal juga berupa job karena satu
tim butuh sekitar 100 detik (`compose down` 16 service) — empat tim inline akan
menahan request sekitar 7 menit dan memicu timeout di semua proxy.
### Tim (login tim)
| Method | Path | Keterangan |
|---|---|---|
| GET | `/team/{idx}` | Portal tim |
| GET | `/team/{idx}/guide` | Panduan tim |
| POST | `/api/team/{idx}/login` | Login tim |
| POST | `/api/team/logout` | Logout tim |
| GET | `/api/team/{idx}/session` | Status sesi |
| GET | `/api/team/{idx}/own-challenges` | Challenge milik tim |
| GET | `/api/team/{idx}/targets` | Target untuk diserang |
| GET | `/api/team/{idx}/info` | Info tim |
| GET | `/api/team/{idx}/status` | Status challenge tim |
| GET | `/api/team/{idx}/activity` | Aktivitas tim |
| WS | `/api/team/{idx}/ssh/ws` | Terminal web ke container tim |
---
## Troubleshooting
**`/login` di domain attackdefense.imrnes.team mengembalikan 404.**
Domain bare diarahkan ke receiver global (:18080), bukan panel. Panel ada di
`panel.attackdefense.imrnes.team` (:18081). Dua router berbeda melayani kedua
subdomain di `attackdefense.yaml`.
**SLA turun ke 0 padahal container hidup.**
Bisa jadi receiver-nya mati, atau sering: restart panel mematikan receiver
karena keduanya satu cgroup. Cek `systemctl is-active gemastik-receiver-team*`.
**SSH ditolak padahal password di `state.json` benar.**
Cek `ssh_user` untuk challenge tersebut di registry. 10 dari 16 challenge
impor login sebagai `root`, bukan `ctfuser`.
**Flag expired / tidak cocok.**
`reset_environment()` menghapus flag lama. Cek
`teams/team<N>/receiver/flags/<challenge>.txt`.
**Waktu toggle sangat lama.**
Normal — satu toggle membangun image per tim. Pantau lewat
`/api/challenges/jobs/{job_id}`, bukan dengan kill prosesnya.
**`pull access denied for services-<name>`.**
Image belum ada sehingga compose mencoba build dengan context yang salah.
`compose_gen` hanya menukar `build` menjadi `image` bila image-nya benar-benar
ada di `docker images`.
**Disk penuh (`/` 0 byte).**
Lihat [Jebakan Operasional](#jebakan-operasional). Yang benar:
`docker builder prune -af` dan `journalctl --vacuum-size=50M`.
`docker image prune -af` menghapus image `services-*` yang sedang dipakai.
---
## Jebakan Operasional
Yang sudah ketahuan dan sudah diperbaiki. Semua masih berlaku sebagai alasan
mengapa kode sekarang berbentuk seperti sekarang.
**Base image EOL.** `debian:buster`, `ubuntu:20.04`, dan `node:14` gagal
`apt-get update` karena GPG kedaluwarsa atau mirror 404. Pakai bookworm/noble,
`node:20`.
**UFW default deny.** Host ini punya UFW aktif default deny. Port baru harus
dibuka (`ufw allow <port>/tcp`) atau traffic di-blackhole diam-diam —
termasuk dari container lewat docker bridge. `POST /api/teams/set` sudah
menjalankan `sync_team_ufw()` karena alasan ini.
**Project name compose wajib.** Per-team compose harus dijalankan dengan
`-p teamN`. Tanpa itu `docker compose` memakai nama direktori induk (`services`)
untuk semua tim, sehingga container team2 tertimpa team1.
**`docker image prune -af` menghapus image yang sedang dipakai.** Image
`services-*` menjadi dangling dan terhapus meski container masih jalan.
Container tetap hidup tetapi image hilang dan tidak bisa di-recreate. Untuk
membersihkan ruang: `docker builder prune -af` +
`journalctl --vacuum-size=100M` + hapus `/root/.cache`.
**Container orphan.** Sweep dengan:
```bash
docker ps --format '{{.Names}}' | grep -E '_container$' | grep -vE '_team[0-9]+$'
```
**Beban checker.** Host 2-CPU/8GB tidak boleh meng-probe 4 receiver
sekaligus (Flask sinkron + CPU bersama = SLA timeout palsu), dan tidak boleh
menjalankan banyak generator kunci Paillier/RSA bersamaan. Cek `uptime`,
`free -m`, `vmstat 1 3` sebelum menyalahkan checker.
**`subprocess.run(['docker','exec',...])` tanpa timeout.** Container yang
jenuh memblokir selamanya dan menahan seluruh loop SLA.
---
## Struktur Direktori
```
/opt/gemastik18-final/
├── README.md
├── node.sh # installer Docker
├── starter.py # bootstrap single-node (upstream)
├── teams/
│ ├── challenge_registry.json # sumber data tunggal 28 challenge
│ ├── points.json # skor + event
│ ├── leaderboard.json # solve
│ ├── attacks.json # log serangan (visualisasi topologi)
│ └── teamN/
│ ├── state.json # port, flag, kredensial, label, domain
│ ├── services/docker-compose.yml # hasil generate per tim
│ └── receiver/ # receiver terisolasi tim N
├── services/<challenge>/ # Dockerfile + compose template (28 challenge)
├── panel/
│ ├── main.py # FastAPI: seluruh route
│ ├── teams.py # orkestrasi tim, port, flag, skor, SLA
│ ├── compose_gen.py # render compose per tim dari registry
│ ├── gen_receiver_services.py # generate unit systemd per tim
│ ├── gen_receiver_main.py # generate main.py receiver per tim
│ ├── apply_registry.sh # sinkronkan container dengan registry
│ ├── reset_runtime.sh # reset penuh
│ ├── verify_platform_health.py
│ ├── verify_ssh_e2e.py
│ ├── audit_ssh_users.sh
│ ├── run_topo_tests.sh
│ ├── verify_topo_full.sh
│ └── static/
│ ├── index.html # dashboard admin
│ ├── team.html # portal tim
│ ├── topo_pixi.js # renderer topologi PixiJS v8
│ └── vendor/pixi.mjs
└── receiver/
├── main.py # API receiver (flag store + checker)
├── config.py
├── requirements.txt
├── challenges/ # checker: Blogpost, Phew, xvi/, xvii/
├── flags/ # flag default
└── .venv/
```
---
## Credit
Challenge berasal dari tiga repositori:
[gemastik18-final](https://github.com/rayhanhanaputra/gemastik18-final),
[gemastik-xvi-final](https://github.com/vidner/gemastik-xvi-final),
[gemastik-xvii-final](https://github.com/vidner/gemastik-xvii-final).