Compare commits

...
8 Commits
Author SHA1 Message Date
Cyrene 2da6305626 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.
2026-09-28 19:13:51 +08:00
root cf47770798 Fix the topology freezing and make team drags carry their challenges
User report: the graph "disappeared". Reproduced, and the cause was not the
graph at all — the panel's main thread was blocked hard enough that the browser
stopped responding to clicks.

Root cause, found by measuring rather than guessing:
  rAF 2 FPS, setTimeout(0) lag 1353ms, with the graph rendering correctly the
  whole time. The scene was repainting continuously at 60fps even though it is
  static between the 10s data refreshes. Each repaint cost ~305ms on this
  host's software GL, so the main thread never got a free slot.

Fixes:
- Render on demand. The ticker is registered but not started; it runs only while
  an attack pulse is in flight or the user is dragging, and stops once the scene
  is quiet. applyView() (the single funnel for pan/zoom/drag/refit) repaints
  synchronously. Result: 2 FPS -> 73 FPS, 1353ms -> 2ms lag. That is faster than
  a blank page in the same harness (29 FPS), which confirms the ticker was the
  cost, not the scene complexity.
- Stop rebuilding Pixi objects every refresh. _redrawEdges destroyed and
  recreated ~70 Graphics + ~36 Text each cycle; every new Text allocates a
  canvas, rasterises glyphs and uploads a texture. Edges are now kept per
  from->to key and only their geometry is redrawn; a label's text is re-set only
  when the string actually changes. Same for node titles/subtitles.
- Bind tooltip handlers once, at node creation. Re-binding inside the refresh
  loop added a pointerover listener every 10s, so a single hover after an hour
  fired thousands of handlers.

Two correctness bugs fixed while in there:
- init() race. The Topology tab button calls showView('topo') AND loadTopo(), and
  showView() itself calls loadTopo(), so two ran concurrently. The re-entry guard
  tested `topoGraph && topoGraph.app`, but topoGraph was assigned BEFORE the
  awaited init(), so app was still null and a second renderer was built. Both
  cleared host.innerHTML and appended their own canvas, so the last init to
  finish won the DOM while the bridge still pointed at the other — a live canvas
  that was no longer on the page. Now guarded by a single-flight promise, and
  the instance is published only after init() resolves.
- Dropped the dead topoLoaded flag left over from the SVG renderer.

Hierarchical drag: dragging a team now carries its 16 challenge nodes with it.
The parent->children index is built from the edge list, the same source the
lines are drawn from, so the drag hierarchy cannot disagree with the picture;
challenges missing from the edge list still attach by id prefix. Children
translate rigidly (verified: 0.000px deviation across all 16).

Verified from a cold panel restart: all 5 topology tests pass, 73 FPS, 2ms
lag, 37 nodes drawn, graph framed at 4 viewport widths, no page errors, and
platform SLA unregressed at 32/32.
2026-09-27 01:17:56 +08:00
root 30f8052e79 Add viewport regression test for the topology layout fix
The off-screen-layout bug (nodes centred on the scrollable width instead of the
viewport) only reproduced at the default window size, so a single-width test
would have shipped it. Verifies the graph stays framed at 1920/1400/1100/820,
and records which assumption in the pixel census is safe: the measurement uses
the canvas buffer size, not the screen size, because the CSS min-width makes
narrow viewports scroll.
2026-09-27 00:39:07 +08:00
root 1461c874c3 Render the topology graph with PixiJS v8
Replaces the hand-rolled inline-SVG topology with a PixiJS 8 scene graph.

Why: the old renderer rebuilt all 37 nodes / 36 edges as one innerHTML string
every 10s, which tore down and recreated every DOM node. That restarted CSS
animations mid-flight and made dragging fight the browser's own hit-testing.
The scene graph gives per-node transforms, so pan/zoom is a single container
transform instead of getScreenCTM() matrix math.

Changes:
- static/topo_pixi.js: new self-contained renderer. Owns its Application and
  tears it down on tab exit so a second WebGL context cannot leak.
- static/index.html: the <svg id=topoSvg> host becomes a <div id=topoHost>;
  the 188-line SVG renderer is replaced by a bridge to the module.
- static/vendor/pixi.mjs: PixiJS 8.21.0 self-hosted (MIT). The .mjs build is
  required; the .js build exports no global. See vendor/README.md.
- main.py: mount /static. Pages were served as inline HTMLResponse, so the
  directory was never mounted and the module had no URL to load from.

Two real bugs found by measuring pixels rather than trusting init():
- preserveDrawingBuffer: without it WebGL clears the back buffer after
  compositing, so any readback or screenshot of the canvas is a coin flip
  depending on which frame it lands on. The graph rendered intermittently
  blank. Now enabled: cheap for a 2D scene, and it makes the view capturable.
- Layout was centred on the SCROLLABLE width (nodes.length * 130), not the
  viewport, so with 37 nodes every team and challenge node landed at
  x=2230-2650 on a 1310px canvas: entirely off-screen. Layout now centres on
  the visible width and reset() frames the whole graph to fit.

test_topo_pixels.js documents three wrong test designs it replaces, all of
which reported false failures against a working graph: counting scene-graph
children (passes on a blank canvas), diffing against the background colour
(the theme is dark by design, so a perfect render measures ~0%), and diffing
two Playwright screenshots (both can be captured after the scene was mutated).
The check now reads the GL back buffer via readPixels in one evaluate.

Verified: 37 nodes / 36 edges drawn (7.94% of frame, max channel delta 225),
graph bbox [437,46,881,476] inside the 1310x520 canvas, glGetError=0, no page
errors, zoom and frame-to-fit reset working. Platform unregressed: SLA 32/32.
2026-09-27 00:31:47 +08:00
root 50cb782ded fix(portal): per-challenge SSH user in web terminal + credential API
The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".

Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
  defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
  6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
  the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table

Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
  holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
  receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
  challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them

Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.

Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
2026-09-26 16:37:40 +08:00
Cyrene 0a56906b18 test(team-delete): verify DELETE /api/teams/{idx} tears a team down completely
Created a scratch team 5, booted all 16 of its containers, snapshotted its
footprint, deleted it via the API, and re-snapshotted:

  before: 16 containers, 1 sidecar, team5_default network, teams/team5 dir
  after : 0 containers, 0 sidecars, network gone, dir GONE, 34 UFW ports
          closed, Traefik domain removed, 0 score rows
  took 102s; the other 4 teams (64 containers, receivers active) were
  untouched and still at 64/64 SLA.

Adds panel/team_footprint.sh (before/after proof of a delete),
team_health.sh, watch_load.sh.
2026-09-26 15:48:07 +08:00
Cyrene aa0bb45633 fix(sla): 100% fleet SLA (64/64) - per-challenge SSH login, phew checker, sidecar detection
Three independent root causes, all found by measuring instead of assuming:

1. SSH failed on 10/16 challenges while state.json looked perfect.
   Only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
   XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
   set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password nobody
   used -> 'Permission denied' on every team. Registry gains a per-challenge
   'ssh_user'; chpasswd targets the real login and reports failures loudly.

2. phew SLA timed out on a healthy service, four bugs stacked:
   - chall.py block-buffers stdout through the exec pipe (PYTHONUNBUFFERED now
     set) and does a fresh Pailier keygen (~12 s) before printing its menu;
   - _read_until read a TEXT pipe, so read(1) pulled 8 KB into Python's
     TextIOWrapper buffer and select() then blocked on data already in memory;
   - its buffer was per-call, so the read satisfying 'pt (hex)' also swallowed
     the '> ' the next call waited for -> a race that failed intermittently;
   - reaping killed chall.py it did not own: a blanket pkill -f, a
     snapshot-diff (concurrent sessions diff against the same pre-spawn set),
     and a class-level _children shared across uvicorn's thread pool. The child
     now prints its own pid so exactly one session is reaped.
   Also: ONE interactive session per check instead of five spawns (Paillier is
   randomized per ciphertext, not per process) - 5 keygens were the CPU load
   that starved the checks. And the 6 orphan single-node containers from the
   original deploy were removed; one held 58 leaked chall.py and drove load
   average 76 on 2 CPUs.

3. missing_sidecars() matched compose-generated names (teamN-<svc>-1) while
   every service sets an explicit container_name, so it reported all 16 running
   challenges as missing and hid the one real gap (anti-alchemy-db, which has
   no container_name). Now reads container_name when present and falls back to
   the compose default otherwise.

Verified: 64/64 SLA across 4 teams; 64/64 real SSH logins succeed with
correct <chall>_teamN hostnames; phew 3/3 sequential with no process leak.

Adds panel/verify_ssh_creds.py, audit_ssh_users.sh, reset_runtime.sh,
sla_sweep.sh, fix_sidecars.sh, phew_concurrency_test.sh, exec_probe_i.py.
2026-09-26 15:37:31 +08:00
Cyrene ae50acfe40 fix(ssh): per-challenge SSH login + phew buffering/leak/timeout
Passwords failed on 10/16 challenges while state.json looked correct:
- only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
  XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
  set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an
  account nobody uses -> 'Permission denied' everywhere.
  Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real
  login (and ctfuser/ctf when present) and reports failures loudly.
- phew checker: chall.py block-buffers stdout through the docker exec pipe
  (PYTHONUNBUFFERED now set) and leaks chall.py inside the container on
  timeout (26 orphans, container saturated) -> reaps the whole exec process
  group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need
  _CRYPTO_TIMEOUT, not the 5 s prompt default.

Adds panel/verify_ssh_creds.py (proves the state->container binding from
inside via a real login), audit_ssh_users.sh, reset_runtime.sh.
2026-09-26 14:40:10 +08:00
65 changed files with 7179 additions and 517 deletions
+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).
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Bring every team's containers in line with the registry's enabled set.
#
# For each team: re-render the compose from the registry, then `up -d`. Because
# the shared `services-<name>` images already exist, this is a start, not a
# rebuild, so it is fast. Containers of challenges that are no longer enabled are
# removed by `up --remove-orphans`.
set -uo pipefail
cd /opt/gemastik18-final/panel
python3 - <<'PY'
import json, sys
sys.path.insert(0, '.')
import teams as orch, compose_gen
orch.reconcile_team_state()
for d in sorted(orch.TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
(d / "services" / "docker-compose.yml").write_text(
compose_gen.render_team_compose(st["index"], st))
print(f"team{st['index']} compose rendered")
PY
for i in 1 2 3 4; do
cd "/opt/gemastik18-final/teams/team$i/services"
echo "--- team$i ---"
docker compose -p "team$i" up -d --remove-orphans 2>&1 | tail -2
done
echo "=== result ==="
docker ps --format '{{.Names}}' | grep -c '_container_team'
df -h / | tail -1
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Which SSH user does each challenge's image actually provision?
# The imported XVI/XVII challenges do NOT all use `ctfuser`: art uses `root`,
# anti-alchemy uses `ctf`. set_ssh_passwords() only does
# `echo 'ctfuser:<pw>' | chpasswd`, so for those images it either fails or sets
# a password on an account nobody logs in as -> "Permission denied" for every
# team, while state.json looks perfectly correct.
set -uo pipefail
cd /opt/gemastik18-final
printf "%-18s %s\n" CHALLENGE "Dockerfile user provisioning"
for d in services/*/; do
name=$(basename "$d")
[ -f "$d/Dockerfile" ] || continue
line=$(grep -hE 'chpasswd|useradd|adduser' "$d/Dockerfile" 2>/dev/null | head -2 | tr '\n' ';' | cut -c1-110)
printf "%-18s %s\n" "$name" "${line:-<none>}"
done
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Bulk-delete regression test: create two scratch teams, then delete BOTH in a
# single API call and poll the job until it settles.
#
# Proves the endpoint exists, validates input, runs teams sequentially inside
# one background job, and leaves the real teams untouched.
set -uo pipefail
BASE=/opt/gemastik18-final
cd "$BASE"
CJ=/tmp/bulk_cj
U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' panel/.env)
P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' panel/.env)
curl -sS -c "$CJ" -X POST -H 'Content-Type: application/json' \
-d "{\"user\":\"$U\",\"pass\":\"$P\"}" http://127.0.0.1:18081/api/login -o /dev/null
echo "=== validation ==="
printf " empty list -> "
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[]}' \
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
printf " missing tms -> "
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[99,98]}' \
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
echo "=== BEFORE ==="
for i in 5 6; do
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
done
echo "=== BULK DELETE [5,6] ==="
S=$(date +%s)
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' \
-d '{"indices":[5,6],"purge_scores":true}' \
http://127.0.0.1:18081/api/teams/bulk-delete -o /tmp/bulk.json -w ' HTTP %{http_code}\n'
cat /tmp/bulk.json; echo
JOB=$(python3 -c "import json;print(json.load(open('/tmp/bulk.json'))['job'])")
echo " job: $JOB"
while :; do
curl -sS -b "$CJ" "http://127.0.0.1:18081/api/teams/bulk-delete/$JOB" -o /tmp/bulkjob.json
read -r ST DET <<<"$(python3 -c "
import json;d=json.load(open('/tmp/bulkjob.json'));print(d['state'],d.get('detail',''))")"
echo " [$(( $(date +%s) - S ))s] $ST — $DET"
[ "$ST" != "running" ] && break
sleep 15
done
echo " elapsed: $(( $(date +%s) - S ))s"
python3 -c "
import json;d=json.load(open('/tmp/bulkjob.json'))
print(' state:',d['state'],' errors:',d.get('errors'))
for x in d.get('done',[]): print(' deleted team',x['team'],x['label'],'->',x['steps'])"
echo "=== AFTER ==="
for i in 5 6; do
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
done
echo " real teams: $(curl -sS -b "$CJ" http://127.0.0.1:18081/api/teams \
| python3 -c "import json,sys;print([t['index'] for t in json.load(sys.stdin)['teams']])")"
+25
View File
@@ -0,0 +1,25 @@
// Syntax-check every inline <script> block across all panel static pages.
const fs = require('fs');
const { execFileSync } = require('child_process');
const files = ['static/index.html', 'static/team.html', 'static/guide.html'];
const base = '/opt/gemastik18-final/panel/';
let bad = 0, total = 0;
for (const f of files) {
const html = fs.readFileSync(base + f, 'utf8');
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let m, i = 0;
while ((m = re.exec(html)) !== null) {
i++; total++;
const p = `/tmp/chk_${f.replace(/\W/g, '_')}_${i}.js`;
fs.writeFileSync(p, m[1]);
try {
execFileSync(process.execPath, ['--check', p], { stdio: 'pipe' });
console.log(` OK ${f} block#${i}`);
} catch (e) {
bad++;
console.log(` FAIL ${f} block#${i}\n${e.stderr.toString().split('\n').slice(0, 5).join('\n')}`);
}
}
}
console.log(bad === 0 ? `\nAll ${total} script block(s) parse cleanly.` : `\n${bad}/${total} FAILED.`);
process.exit(bad ? 1 : 0);
+20
View File
@@ -0,0 +1,20 @@
// Extract every <script> block from index.html and syntax-check each with node.
const fs = require('fs');
const { execFileSync } = require('child_process');
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let m, i = 0, bad = 0;
while ((m = re.exec(html)) !== null) {
i++;
const code = m[1];
const f = `/tmp/blk_${i}.js`;
fs.writeFileSync(f, code);
try {
execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' });
console.log(` script #${i}: OK (${code.split('\n').length} lines)`);
} catch (e) {
bad++;
console.log(` script #${i}: SYNTAX ERROR -> ${e.stderr.toString().split('\n').slice(0, 6).join('\n')}`);
}
}
console.log(bad === 0 ? `\nAll ${i} inline script block(s) parse cleanly.` : `\n${bad} block(s) FAILED.`);
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Delete the teams named as args, one at a time, via the panel API.
# Each per-team delete runs `docker compose down` for every challenge, which
# takes minutes for a 16-challenge team — so never do this in a foreground
# call that has to finish inside one tool timeout.
# Usage: delete_teams.sh <idx> [idx...]
set -uo pipefail
BASE=http://127.0.0.1:18081
JAR=/tmp/ejc
U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' /opt/gemastik18-final/panel/.env)
P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' /opt/gemastik18-final/panel/.env)
curl -sS -c "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"user\":\"$U\",\"pass\":\"$P\"}" "${BASE}/api/login" >/dev/null
for i in "$@"; do
echo "=== $(date -Is) delete team${i} ==="
curl -sS -b "$JAR" -X DELETE -H 'Content-Type: application/json' \
-d '{"purge_scores":true}' "${BASE}/api/teams/${i}" -w '\nHTTP %{http_code}\n'
done
echo "=== done ==="
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# How many concurrent `docker exec` sessions does this host tolerate?
# The Phew SLA checker's 5-way concurrency test failed with
# "No such exec instance" + "failed to open stdin fifo", which points at an
# exec-session ceiling rather than at the checker.
set -uo pipefail
C=${1:-phew_container_team1}
N=${2:-8}
echo "container: $C"
echo "--- $N concurrent trivial execs ---"
fail=0
for i in $(seq 1 "$N"); do
( out=$(docker exec "$C" true 2>&1); rc=$?
if [ $rc -ne 0 ]; then echo " exec$i FAILED: $out"; fi ) &
done
wait
echo "--- done ---"
echo "dockerd max concurrent execs:"
docker info 2>/dev/null | grep -iE 'exec|containerd' | head -3
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""How many concurrent INTERACTIVE (`docker exec -i`) sessions does this host take?
Trivial non-interactive execs succeed 8-way, but the Phew SLA checker's 5-way
interactive test failed with
failed to open stdin fifo: error creating fifo ... -stdin: no such file
No such exec instance: <id>
which is a containerd exec-session limit, not a checker bug. Interactive execs
allocate a fifo + a tracked exec instance, so they hit a ceiling that plain
`docker exec <c> true` never approaches.
python3 panel/exec_probe_i.py [container] [N]
"""
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
CONT = sys.argv[1] if len(sys.argv) > 1 else "phew_container_team1"
N = int(sys.argv[2]) if len(sys.argv) > 2 else 8
def interactive(i: int) -> tuple[int, str]:
"""Mimic the checker: `exec -i`, write a line, read the reply, exit."""
p = subprocess.Popen(
["docker", "exec", "-i", CONT, "sh", "-c",
"echo $$; read line; echo \"got:$line\""],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=0)
try:
out, _ = p.communicate("hello\n", timeout=45)
except subprocess.TimeoutExpired:
p.kill()
out = "TIMEOUT"
return p.returncode, (out or "").strip().replace("\n", " | ")[:150]
def main() -> None:
print(f"container={CONT} N={N} interactive execs")
ok = 0
with ThreadPoolExecutor(max_workers=N) as ex:
for rc, out in ex.map(interactive, range(N)):
if rc == 0 and "got:hello" in out:
ok += 1
else:
print(f" FAIL rc={rc}: {out}")
print(f" {ok}/{N} interactive execs OK")
print("RESULT:", "PASS" if ok == N else f"limit reached at {ok}/{N}")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Replace hardcoded `localhost` URLs in the imported XVI checkers with self.url().
Why: the imported checkers connect to `http://localhost:{self.port}`. The
receiver does run on the same host as the published team ports, so this happens
to work, but it is fragile (breaks the moment a receiver runs in a container or
the port is bound to a specific interface). Challenge.url() builds the URL from
self.host (default 127.0.0.1, overridable with RECEIVER_HOST) plus self.port.
Idempotent; rewrites only the f-string form, leaving class-level constants that
pin a *fixed* port (GemasNotes/Pasta/S3) alone — those are handled separately.
"""
import re
import sys
from pathlib import Path
BASE = Path("/opt/gemastik18-final/receiver/challenges/xvi")
# f"http://localhost:{self.port}/path/{expr}" -> self.url(f"/path/{expr}")
# The leading f is part of the literal being matched and must be consumed.
PAT = re.compile(
r"""f?(?P<q>["'])http://localhost:\{self\.port\}(?P<path>/[^"']*)?(?P=q)"""
)
def repl(m: "re.Match[str]") -> str:
path = m.group("path") or ""
if not path:
return "self.url()"
# the path may itself contain {expr} placeholders — keep them as an f-string
return f"self.url(f{path!r})"
def main() -> int:
changed = []
for p in sorted(BASE.glob("*.py")):
if p.name in ("Challenge.py", "config.py", "__init__.py"):
continue
src = p.read_text()
if "localhost:{self.port}" not in src:
continue
new = PAT.sub(repl, src)
if new != src:
p.write_text(new)
changed.append(p.name)
print("rewritten:", ", ".join(changed) if changed else "(none)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Bring up only the MISSING sidecars of each team (compose default `up -d`
# would also rebuild the 16 main services, which is slow and unnecessary).
set -uo pipefail
BASE=/opt/gemastik18-final
cd "$BASE/panel"
for t in 1 2 3 4; do
missing=$(python3 -c "
import sys; sys.path.insert(0,'.')
import teams
print(' '.join(teams.missing_sidecars($t)))
" 2>/dev/null)
if [ -z "$missing" ]; then
echo "team$t: nothing missing"
continue
fi
echo "team$t missing: $missing"
# map container name -> compose service name
for cont in $missing; do
svc=${cont#team${t}-}
svc=${svc%-1}
echo " starting $svc"
(cd "$BASE/teams/team$t/services" && \
docker compose -p "team$t" -f docker-compose.yml up -d --no-deps "$svc" 2>&1 | tail -2)
done
done
echo "=== verify ==="
python3 -c "
import sys; sys.path.insert(0,'.')
import teams
for i in (1,2,3,4):
print('team%d still missing:' % i, teams.missing_sidecars(i))
"
+20
View File
@@ -14,6 +14,14 @@ from pathlib import Path
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
UNIT_DIR = Path("/etc/systemd/system")
REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json"
def load_registry() -> dict:
try:
return json.loads(REGISTRY_PATH.read_text())
except Exception:
return {"sets": {}, "challenges": []}
def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"]
@@ -23,12 +31,24 @@ def write_unit(idx: int, st: dict):
# NOTE: systemd Environment= keys must be [A-Za-z0-9_]+ — a hyphen in the
# challenge name (gift-card) would make systemd silently drop the line, so
# normalize the name to underscores here. main.py looks up the same key.
# Same normalization applies to CHALLENGE_SCHEME_<NAME>.
schemes = {c["name"]: c.get("scheme") for c in load_registry().get("challenges", [])}
# SSH login user per challenge. The panel already chpasswds the right
# account from this field (teams.challenge_ssh_users); the receiver must
# report the SAME user via /credential/<name> or participants get a login
# that cannot work. Registry is the single source of truth for both sides.
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser")
for c in load_registry().get("challenges", [])}
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
key = ch.upper().replace("-", "_")
env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
if schemes.get(ch):
env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch]
if ssh_users.get(ch):
env[f"SSH_USER_{st['ports'][ch]['chall']}"] = ssh_users[ch]
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
# where self.port is the team challenge port.
pwd = st.get("chall_passwords", {}).get(ch)
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Inject SSH_USER_<port> env into the GLOBAL receiver unit (gemastik-receiver).
Why: the panel's /api/credential proxy targets the global receiver on :18080,
not the per-team receivers. That unit had no Environment= lines at all, so
Challenge.credentials() fell back to the hardcoded 'ctfuser' literal and every
imported XVI/XVII challenge reported a login that could never work.
The registry's `ssh_user` per challenge is the single source of truth (the
panel already chpasswds that same account). Read the port each challenge runs
on from the global receiver's own config so the keys line up with self.port.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
UNIT = Path("/etc/systemd/system/gemastik-receiver.service")
REGISTRY = BASE / "teams/challenge_registry.json"
RECV_CONFIG = BASE / "receiver/config.py"
reg = json.loads(REGISTRY.read_text())
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
# Challenge -> port used by the GLOBAL receiver. main.py builds the challenge
# objects from CHALLENGE_PORT_* / PASSWORD_* env; with none set it falls back to
# the pydantic settings PASSWORD_<port> in config.py, so mirror those ports.
text = RECV_CONFIG.read_text()
ports = {}
for m in re.finditer(r"PASSWORD_(\d+)\s*[:=]", text):
ports.setdefault(m.group(1), int(m.group(1)))
# name -> port needs the CHALLENGE_PORT mapping; take it from registry order +
# the receiver main.py template, else fall back to PASSWORD_<port> keys.
name_to_port = {}
for m in re.finditer(r'"PASSWORD_(\d+)"', text):
name_to_port.setdefault(m.group(1), m.group(1))
print(f"registry challenges: {len(ssh_users)}")
# Build Environment lines for every challenge whose port we can resolve.
env_lines = []
resolved = 0
for name, user in sorted(ssh_users.items()):
# The global receiver uses the node-range ports from config.py; find the
# port by matching the challenge name against the receiver's own mapping.
port = None
m = re.search(rf"{re.escape(name)}.*?(\d{{4,5}})", text)
if m:
port = m.group(1)
if not port:
continue
env_lines.append(f'Environment="SSH_USER_{port}={user}"')
resolved += 1
if not env_lines:
print("ERROR: could not resolve any challenge port from config.py", file=sys.stderr)
sys.exit(1)
body = UNIT.read_text()
# Drop any SSH_USER_ lines we previously injected (idempotent re-runs).
kept = [ln for ln in body.splitlines() if "SSH_USER_" not in ln]
# Insert right after the existing Environment=PYTHONUNBUFFERED line.
out = []
for ln in kept:
out.append(ln)
if ln.startswith("Environment=PYTHONUNBUFFERED"):
out.extend(env_lines)
if not any(ln.startswith("Environment=PYTHONUNBUFFERED") for ln in out):
out.extend(env_lines)
UNIT.write_text("\n".join(out) + "\n")
print(f"injected {resolved} SSH_USER_* lines into {UNIT}")
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "restart", "gemastik-receiver"], check=True)
print("reloaded + restarted gemastik-receiver")
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Inspect per-team port footprint: UFW rules in the 3xxxx/4xxxx range and
# docker volumes/networks named after a team. Read-only.
set -uo pipefail
echo "=== UFW rules matching 3xxxx/4xxxx ==="
ufw status numbered 2>/dev/null | grep -E '\b(3[0-9]{4}|4[0-9]{4})/tcp' || echo "(none)"
echo
echo "=== docker networks team* ==="
docker network ls --format '{{.Name}}' | grep -i team || echo "(none)"
echo
echo "=== docker volumes team* ==="
docker volume ls --format '{{.Name}}' | grep -i team || echo "(none)"
echo
echo "=== all volumes ==="
docker volume ls --format '{{.Name}}' | head -40
+215 -3
View File
@@ -9,6 +9,8 @@ import json
import time
import asyncio
import threading
import subprocess
import sys
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -26,6 +28,13 @@ BASE_DIR = Path(__file__).parent
app = FastAPI(title="Gemastik A/D Panel")
# The panel used to serve every page as an inline HTMLResponse, so static/ was
# never mounted. The PixiJS topology needs real asset URLs: topo_pixi.js is an ES
# module and vendor/pixi.mjs is an 810 KB bundle — neither can be inlined.
# Mounted at the root so the module's own `import './vendor/pixi.mjs'` and
# `import('./topo_pixi.js')` resolve without rewriting paths.
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@app.on_event("startup")
async def _start_background():
@@ -37,6 +46,7 @@ async def _start_background():
# Toggle jobs: Docker builds take minutes, so PATCH /api/challenges runs the
# work on a background thread and the client polls /api/challenges/jobs/<id>.
_DELETE_JOBS = {}
_TOGGLE_JOBS: dict[str, dict] = {}
CHALLENGES = [
@@ -214,6 +224,32 @@ async def api_team_session(idx: int, req: Request):
"""True when this browser has a valid team session for idx."""
return {"authed": _team_authorized(req, idx)}
@app.get("/api/team/{idx}/own-challenges")
async def api_team_own_challenges(idx: int, req: Request):
"""The challenges THIS team runs, each with the SSH login it provisions.
The terminal's challenge picker used to be a hardcoded list of only the 6
native GEMASTIK XVIII entries, so the 10 imported XVI/XVII challenges could
not be selected at all. Names + per-challenge ssh_user only -- no passwords.
"""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
users = orch.challenge_ssh_users()
out = []
for name, _coff, _soff in orch.CHALLENGES:
p = st.get("ports", {}).get(name)
if not p:
continue
out.append({"name": name, "ssh_user": users.get(name, "ctfuser"),
"port": p.get("chall"), "ssh_port": p.get("ssh")})
return {"challenges": out}
@app.get("/api/team/{idx}/targets")
async def api_team_targets(idx: int, req: Request):
"""Team targets — requires team login + own host. Only domain + port."""
@@ -226,6 +262,7 @@ async def api_team_targets(idx: int, req: Request):
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
out = []
ssh_users = orch.challenge_ssh_users()
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
@@ -239,8 +276,12 @@ async def api_team_targets(idx: int, req: Request):
out.append({
"team_idx": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"challenge": name,
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team"),
"port": p["chall"],
# attackers need the same login the victim container provisions;
# it is per-challenge, so surface it instead of assuming ctfuser
"ssh_user": ssh_users.get(name, "ctfuser"),
})
return {"targets": out}
@@ -494,8 +535,26 @@ async def api_deactivate(challenge: str, req: Request):
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.get("/api/credential/{challenge}")
async def api_credential(challenge: str, req: Request):
async def api_credential(challenge: str, req: Request, team: int = None):
require_login(req)
# The global receiver on :18080 only knows the 6 native GEMASTIK XVIII
# challenges, so proxying everything there returns "Invalid challenge" for the
# 10 imported XVI/XVII ones -- participants saw no SSH creds at all. A team's
# state.json is the authority for BOTH the password and the SSH login user
# (the same `ssh_user` the panel chpasswds), plus the team-specific port.
# `?team=N` selects the team; team portal hosts imply their own index.
idx = team
if idx is None:
host = (req.headers.get("host") or "").split(":")[0]
for t in orch.all_teams():
dom = (t.get("domain") or "").split(":")[0]
if dom and dom == host:
idx = t.get("index")
break
if idx is not None:
cred = orch.challenge_credential(idx, challenge)
if cred:
return cred
resp = await _proxy("GET", f"/credential/{challenge}")
if resp.status_code == 200:
return resp.json()
@@ -530,6 +589,7 @@ async def api_teams_set(req: Request):
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
domains = data.get("domains") or {} # { "1": "mycustom", ... }
created = []
ufw = []
for i in range(1, n + 1):
td = orch.TEAMS_DIR / f"team{i}"
if not td.exists():
@@ -537,6 +597,9 @@ async def api_teams_set(req: Request):
dom = domains.get(str(i)) or domains.get(i) or None
st = orch.create_team(i, label, domain=dom)
created.append(st["index"])
# UFW defaults to deny(incoming) on this host: without these rules
# the team's challenge/SSH/receiver ports are silently blackholed.
ufw.append(orch.sync_team_ufw(i))
else:
# team exists: apply any label/domain overrides
label = labels.get(str(i)) or labels.get(i)
@@ -544,7 +607,7 @@ async def api_teams_set(req: Request):
if label or dom:
orch.update_team(i, label=label, domain=dom)
orch.ensure_team_domains()
return {"created": created, "total": len(orch.list_teams())}
return {"created": created, "total": len(orch.list_teams()), "ufw": ufw}
@app.put("/api/teams/{idx}")
async def api_team_update(idx: int, req: Request):
@@ -559,6 +622,148 @@ async def api_team_update(idx: int, req: Request):
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@app.delete("/api/teams/{idx}")
async def api_team_delete(idx: int, req: Request):
"""Permanently delete ONE team: containers, network, receiver unit, ports,
directory, score records and its Traefik domain.
Body: {"purge_scores": true} (default) — set false to keep the team's
leaderboard/points history. Destructive and irreversible, so the UI gates
it behind a confirm dialog.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass # DELETE with no body is fine
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
raise HTTPException(404, f"Team {idx} not found")
try:
# compose down for 16 services takes a while — keep the event loop free
result = await asyncio.to_thread(orch.delete_team, idx,
bool(raw.get("purge_scores", True)))
# refresh the remaining teams' generated artifacts (receiver main.py,
# systemd units) so nothing points at the deleted team
subprocess.run([sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
return result
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/teams/bulk-delete")
async def api_teams_bulk_delete(req: Request):
"""Delete SEVERAL teams in one background job.
Body: {"indices": [5, 6], "purge_scores": true}
Why a job and not a loop of DELETE /api/teams/{idx}: a single team takes
~100 s (compose down of 16 services), so deleting four teams inline would
hold the request open for ~7 minutes and trip every proxy/browser timeout
in front of the panel. Each team is therefore deleted sequentially inside
ONE background thread, and the client polls a single job id.
Teams are processed one at a time on purpose. delete_team() runs
`docker compose -p teamN down` and regenerates shared artifacts
(receiver main.py, systemd units) afterwards, so running several in
parallel would race on those shared files.
A team that fails does NOT abort the rest: the job records the error and
moves on, because the point of a bulk delete is to clear stale teams and
one broken compose shouldn't strand the others.
"""
require_login(req)
data = await req.json()
raw = data.get("indices") or data.get("indices[]") or []
try:
indices = sorted({int(i) for i in raw})
except (TypeError, ValueError):
raise HTTPException(400, "indices must be a list of team numbers")
if not indices:
raise HTTPException(400, "No team selected")
if len(indices) > 20:
raise HTTPException(400, "Refusing to delete more than 20 teams at once")
purge = bool(data.get("purge_scores", True))
existing = [i for i in indices
if (orch.TEAMS_DIR / f"team{i}" / "state.json").exists()]
skipped = [i for i in indices if i not in existing]
if not existing:
raise HTTPException(404, "None of the selected teams exist")
job_id = f"bulkdel-{int(time.time())}-{len(existing)}"
_DELETE_JOBS[job_id] = {
"job": job_id, "indices": existing, "skipped": skipped,
"state": "running", "done": [], "errors": {},
"detail": "queued", "started": int(time.time()),
}
def _worker():
job = _DELETE_JOBS[job_id]
try:
for n, i in enumerate(existing, 1):
job["detail"] = f"menghapus team {i} ({n}/{len(existing)})"
try:
# delete_team is blocking (subprocess + shutil), and this
# runs in a plain thread, so call it directly.
res = orch.delete_team(i, purge)
job["done"].append({
"team": i,
"label": res.get("label", f"Team {i}"),
"steps": res.get("steps", []),
"purged": res.get("purged", {}),
})
except Exception as e:
job["errors"][str(i)] = str(e)
# regenerate shared artifacts once at the end, so the remaining
# teams' receiver main.py / systemd units stop referencing deleted ones
subprocess.run(
[sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
job["state"] = "done" if not job["errors"] else "partial"
job["detail"] = "complete" if not job["errors"] else "completed with errors"
except Exception as e:
job["state"] = "error"
job["detail"] = str(e)
finally:
job["finished"] = int(time.time())
threading.Thread(target=_worker, name=f"bulkdel-{job_id}", daemon=True).start()
return {"ok": True, "job": job_id, "state": "running",
"indices": existing, "skipped": skipped}
@app.get("/api/teams/bulk-delete/{job_id}")
async def api_teams_bulk_delete_job(job_id: str, req: Request):
"""Poll a bulk team delete started by POST /api/teams/bulk-delete."""
require_login(req)
job = _DELETE_JOBS.get(job_id)
if not job:
raise HTTPException(404, "Unknown job")
return job
@app.post("/api/teams/{idx}/ufw")
async def api_team_ufw(idx: int, req: Request):
"""Reconcile UFW rules for a team's port block.
UFW defaults to deny(incoming) on this host, so a team whose ports were
never opened is blackholed. Use this after creating a team out-of-band, or
to close the ports of a team you just deleted by hand.
Body: {"remove": true} deletes the rules instead.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass
return await asyncio.to_thread(orch.sync_team_ufw, idx, bool(raw.get("remove", False)))
@app.post("/api/teams/start")
async def api_teams_start(req: Request):
require_login(req)
@@ -802,7 +1007,14 @@ async def team_ssh_ws(ws: WebSocket, idx: int):
await ws.close(code=4002, reason="unknown challenge")
return
recv_port = st["ports"][chall]["ssh"]
user = st.get("ssh_user", "ctfuser")
# The SSH login is PER-CHALLENGE, not per-team. Only the 6 native GEMASTIK
# XVIII images provision `ctfuser`; every imported XVI/XVII image does
# `RUN echo root:${PASSWORD} | chpasswd`. Reading st["ssh_user"] (a single
# team-wide value, default ctfuser) made the web terminal log in as ctfuser
# for all 16 challenges, so 10 of them always failed with "Permission denied".
# challenge_ssh_users() reads the registry -- the same field set_ssh_passwords()
# chpasswds -- so the login and the password can never drift apart.
user = orch.challenge_ssh_users().get(chall) or st.get("ssh_user", "ctfuser")
# each challenge container has its own password (chall_passwords);
# ssh_pass is the portal login password (may differ).
pw = st.get("chall_passwords", {}).get(chall) or st.get("ssh_pass", "")
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import os
from Pailier import *
from Crypto.Util.number import *
with open("/flag.txt", "rb") as f:
flag_bytes = f.read()
key = os.urandom(66)
key_int = bytes_to_long(key)
cipher = pailier()
while True:
print("1. encrypt")
print("2. bingo")
print("3. decrypt")
print("4. key?")
try:
inp = int(input("> "))
except (ValueError, EOFError):
print("Invalid input")
continue
if inp == 1:
print("pt (hex)")
try:
inp = input("> ")
ct = cipher.encrypt(int(inp, 16))
print('ct : ', '{0:x}'.format(ct))
except (ValueError, EOFError):
print("Invalid hex input")
elif inp == 2:
print("key (hex)")
try:
user_hex = input("> ").strip()
user_key = bytes.fromhex(user_hex)
if len(user_key) == 66 and user_key == key:
try:
print(flag_bytes.decode())
except Exception:
print(flag_bytes.hex())
else:
print("nope")
except (ValueError, EOFError):
print("nope")
elif inp == 3:
print("ct (hex)")
try:
inp = input("> ")
pt = cipher.decrypt(int(inp, 16))
print('pt : ', '{0:x}'.format(pt))
except (ValueError, EOFError):
print("Invalid hex input")
elif inp == 4:
try:
ct = cipher.encrypt(key_int)
print('ct : ', '{0:x}'.format(ct))
except Exception:
print("Encryption error")
else:
exit()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Concurrency regression test for the Phew checker.
#
# The bug this guards against: the checker reaped chall.py processes it did not
# own, so two overlapping checks killed each other's session and the victim
# reported "Process ended while waiting for '> '" on a healthy service. Firing
# several checks at once is the only way to reproduce it — sequential runs pass
# even with the bug present.
set -uo pipefail
BASE=/opt/gemastik18-final
TEAM=${1:-1}
N=${2:-5}
case "$TEAM" in 1) PORT=31080;; 2) PORT=32080;; 3) PORT=33080;; 4) PORT=34080;; esac
U=$(grep -oP '^ADMIN_USERNAME=\K.*' "$BASE/teams/team$TEAM/receiver/.env")
P=$(grep -oP '^ADMIN_PASSWORD=\K.*' "$BASE/teams/team$TEAM/receiver/.env")
count() { docker exec "phew_container_team$TEAM" sh -c 'ps ax | grep -c "[c]hall.py"'; }
echo "before: $(count) chall.py (1 = socat service only)"
pids=()
for i in $(seq 1 "$N"); do
( out=$(timeout 300 curl -sS -u "$U:$P" "http://127.0.0.1:$PORT/check/phew")
echo " req$i: $out" ) &
pids+=($!)
done
for p in "${pids[@]}"; do wait "$p"; done
sleep 5
after=$(count)
echo "after: $after chall.py"
if [ "$after" -le 1 ]; then
echo "RESULT: PASS (no leak)"
else
echo "RESULT: FAIL (leaked $((after - 1)))"
fi
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Replay the Phew checker's exact interaction, timing every step.
Purpose: find WHICH read exceeds its budget. The receiver log only says
"Timeout waiting for '> '. Got so far: <empty>", which cannot distinguish a
slow keygen from a hang. This prints per-step wall time and the buffer state
at the moment of the timeout.
"""
import os
import select
import subprocess
import sys
import time
CONT = os.environ.get("PHEW_CONT", "phew_container_team1")
CMD = ["docker", "exec", "-i", "-e", "PYTHONUNBUFFERED=1", CONT,
"python3", "/home/ctfuser/chall/src/chall.py"]
def read_until(proc, needle, timeout):
"""Mirror of the checker's _read_until, but reporting the buffer."""
buf = ""
end = time.time() + timeout
raw = proc.stdout.buffer if hasattr(proc.stdout, "buffer") else proc.stdout
while needle not in buf:
left = end - time.time()
if left <= 0:
raise TimeoutError(f"timeout after {timeout}s, buffer={buf!r}")
r, _, _ = select.select([raw], [], [], min(left, 1.0))
if not r:
continue
chunk = raw.read1(4096) if hasattr(raw, "read1") else raw.read(4096)
if not chunk:
raise TimeoutError(f"EOF, buffer={buf!r}")
buf += chunk.decode(errors="replace")
return buf
def step(label, fn):
t0 = time.time()
try:
out = fn()
print(f" {label:<34} {time.time()-t0:6.2f}s ok")
return out
except TimeoutError as e:
print(f" {label:<34} {time.time()-t0:6.2f}s TIMEOUT {e}")
raise
def main():
proc = subprocess.Popen(CMD, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=0)
try:
print(f"container={CONT}")
step("boot -> first menu (budget 45s)", lambda: read_until(proc, "> ", 45))
for name, send, budget in (("encrypt", "1", 30), ("decrypt", "3", 30),
("key?", "4", 30)):
proc.stdin.write(send + "\n")
proc.stdin.flush()
step(f"send '{send}' -> prompt (budget 30s)",
lambda: read_until(proc, "> ", 30))
if name == "encrypt":
step(" send plaintext -> prompt",
lambda: read_until(proc, "> ", 30)) if False else None
proc.stdin.write("414243\n")
proc.stdin.flush()
step(" send plaintext -> prompt",
lambda: read_until(proc, "> ", 30))
elif name == "key?":
proc.stdin.write("\n")
proc.stdin.flush()
step(" send blank -> prompt",
lambda: read_until(proc, "> ", 30))
else:
proc.stdin.write("0\n")
proc.stdin.flush()
step(" send ct -> prompt",
lambda: read_until(proc, "> ", 30))
print("ALL STEPS WITHIN BUDGET")
except TimeoutError:
print("=> a step needs a bigger budget (or a different prompt)")
raise SystemExit(1)
finally:
try:
subprocess.run(["docker", "exec", CONT, "pkill", "-f", "chall.py"],
capture_output=True, timeout=20)
except Exception:
pass
if proc.poll() is None:
proc.kill()
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Remove containers + images for challenges that are no longer enabled.
#
# Why: the platform can host 28 challenges but the images are large (1.2 GB for
# pasta alone) and the host disk is 79 GB. Keeping every image resident filled
# it to 98% and started failing builds. Disabled challenges keep their source in
# services/ and can be re-enabled at any time — only the runtime artifacts go.
set -uo pipefail
cd /opt/gemastik18-final/panel
ENABLED=$(python3 - <<'PY'
import sys
sys.path.insert(0, '.')
import teams
print(" ".join(c["name"] for c in teams.enabled_challenges()))
PY
)
echo "enabled: $ENABLED"
echo "--- stopping containers of disabled challenges ---"
for name in $(docker ps -a --format '{{.Names}}' | grep '_container_team' || true); do
base=${name%%_container_team*}
keep=0
for e in $ENABLED; do
[ "$base" = "$e" ] && keep=1
done
[ "$keep" = "0" ] && docker rm -f "$name" >/dev/null 2>&1 && echo "removed container $name"
done
echo "--- removing images of disabled challenges ---"
for img in $(docker images --format '{{.Repository}}' | grep -E '^(services-|team[0-9]+-)' || true); do
base=${img#services-}
base=${base#team[0-9]-}
# only challenge-shaped names (services-blogpost, team2-art, ...)
case "$base" in
blogpost|carbeat|cdn|phew|sheesh|warmup|art|xl|pasta|gemas-fetcher|s3|crawlback|back-to-basic|anti-alchemy|asmr|bit-canvas|fjb|gift-card|gift-voucher|gleam-drive|go-green|kode-viewer|more-less|ticketer|hirnfick|burvesigner|back-to-basic|gemas-notes|tempest-poc) ;;
*) continue ;;
esac
keep=0
for e in $ENABLED; do
[ "$base" = "$e" ] && keep=1
done
[ "$keep" = "0" ] && docker rmi "$img" >/dev/null 2>&1 && echo "removed image $img"
done
echo "--- done ---"
docker images --format '{{.Repository}}' | grep -c '^services-' || true
df -h / | tail -1
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# FULL reset of the runtime — keeps ONLY the shared challenge images.
#
# Removes: every team container, every team docker network, every anonymous/
# named volume, every per-team receiver systemd unit, and all team directories
# (state, flags, credentials, compose). KEEPS: services-* images (the
# challenges themselves), the panel, the global receiver, the challenge sources
# in services/, and the registry.
#
# This is the "purge everything except the challenges" path the organiser asked
# for after passwords drifted: fresh containers get fresh /etc/shadow state, so
# no stale credential can survive.
set -uo pipefail
BASE=/opt/gemastik18-final
TEAMS=$BASE/teams
echo "=== [1/6] stop per-team receivers + remove units ==="
for u in $(systemctl list-unit-files 'gemastik-receiver-team*.service' 2>/dev/null \
| awk '/gemastik-receiver-team/{print $1}'); do
systemctl disable --now "$u" >/dev/null 2>&1
rm -f "/etc/systemd/system/$u"
echo " removed $u"
done
systemctl daemon-reload
echo "=== [2/6] compose down for every team (before deleting dirs) ==="
for d in "$TEAMS"/team*/services; do
[ -d "$d" ] || continue
idx=$(basename "$(dirname "$d")")
echo " compose down $idx"
(cd "$d" && docker compose -p "$idx" -f docker-compose.yml down -v --remove-orphans 2>&1 | tail -1)
done
echo "=== [3/6] force-remove any surviving team containers ==="
left=$(docker ps -aq --filter 'name=_container_team' | wc -l)
echo " found $left"
[ "$left" -gt 0 ] && docker rm -f $(docker ps -aq --filter 'name=_container_team') >/dev/null 2>&1
echo "=== [4/6] remove team networks + stray volumes ==="
for n in $(docker network ls --format '{{.Name}}' | grep -E '^team[0-9]+_default$' || true); do
docker network rm "$n" >/dev/null 2>&1 && echo " network $n"
done
# anonymous + team-scoped volumes (challenge DB state lives here)
anon=$(docker volume ls -q --filter dangling=true | wc -l)
echo " dangling volumes: $anon"
[ "$anon" -gt 0 ] && docker volume prune -f >/dev/null 2>&1 && echo " pruned"
for v in $(docker volume ls --format '{{.Name}}' | grep -E '^team[0-9]+' || true); do
docker volume rm "$v" >/dev/null 2>&1 && echo " volume $v"
done
echo "=== [5/6] delete team dirs + ledgers ==="
rm -rf "$TEAMS"/team*
rm -f "$TEAMS"/leaderboard.json "$TEAMS"/points.json "$TEAMS"/attacks.json
echo " team dirs now: $(ls -d "$TEAMS"/team* 2>/dev/null | wc -l)"
echo "=== [6/6] drop team domains from Traefik ==="
cd "$BASE/panel" && python3 -c "
import sys; sys.path.insert(0,'.')
import teams
print(' ', teams.ensure_team_domains())
"
# the platform-level receiver is separate and must keep running
systemctl restart gemastik-panel 2>/dev/null
echo " panel: $(systemctl is-active gemastik-panel)"
echo "=== done ==="
docker ps --format '{{.Names}}' | grep -c '_container_team' || echo " team containers: 0"
docker images --format '{{.Repository}}' | grep -c '^services-' || true
df -h / | tail -1
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Run the topology test suite and report one line per test.
set -u
export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright
cd /opt/gemastik18-final/panel || exit 1
fail=0
for t in test_topo_pixels test_topo_browser test_topo_viewports test_topo_race test_topo_drag; do
log="/tmp/${t}.log"
timeout 400 node "${t}.js" > "$log" 2>&1
code=$?
if [ $code -eq 0 ]; then
echo "PASS ${t} (exit 0)"
else
echo "FAIL ${t} (exit ${code})"
tail -12 "$log" | sed 's/^/ /'
fail=1
fi
done
exit $fail
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Probe each team receiver's /check/<challenge> directly and report SLA.
Probes are SEQUENTIAL per team on purpose: the team receivers are sync Flask
apps, so 8 concurrent /check requests make them time out and report false
failures. Keep max_workers=1. This is still far faster than the panel's
/api/scoreboard, which probes every team on one shared refresher thread.
python3 panel/sla_probe.py # all teams
python3 panel/sla_probe.py 1 2 # specific teams
"""
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def check(recv_port, au, ap, name):
url = f"http://127.0.0.1:{recv_port}/check/{name}"
tok = base64.b64encode(f"{au}:{ap}".encode()).decode()
req = urllib.request.Request(url, headers={"Authorization": f"Basic {tok}"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = json.loads(r.read().decode())
return name, bool(body.get("success")), ""
except urllib.error.HTTPError as e:
return name, False, f"HTTP {e.code}"
except Exception as e:
return name, False, str(e)[:60]
def main():
want = [int(a) for a in sys.argv[1:]]
teams = [t for t in orch.list_teams() if not want or t["index"] in want]
enabled = [c["name"] for c in orch.enabled_challenges()]
grand_ok = grand_all = 0
for t in teams:
idx = t["index"]
port = t["ports"]["receiver"]
au, ap = t.get("admin_user", ""), t.get("admin_pass", "")
res = [check(port, au, ap, n) for n in enabled]
up = [n for n, ok, _ in res if ok]
down = [(n, e) for n, ok, e in res if not ok]
grand_ok += len(up); grand_all += len(res)
print(f"team{idx} ({t.get('label')}) SLA {len(up)}/{len(res)}")
if down:
for n, e in down:
print(f" DOWN {n}: {e}")
print(f"\nTOTAL {grand_ok}/{grand_all} "
f"({100.0 * grand_ok / grand_all if grand_all else 0:.1f}%)")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Full SLA sweep across every team, SEQUENTIALLY.
#
# Sequential is not optional: the receivers are sync Flask apps, so hitting
# several at once makes them contend for the same 2 CPUs and report false
# timeouts (a pitfall already documented, and re-violated once here).
set -uo pipefail
BASE=/opt/gemastik18-final
declare -A RPORT=( [1]=31080 [2]=32080 [3]=33080 [4]=34080 )
echo "load: $(cut -d' ' -f1-3 /proc/loadavg)"
for t in 1 2 3 4; do
ENVF=$BASE/teams/team$t/receiver/.env
[ -f "$ENVF" ] || { echo "=== team$t: no receiver env ==="; continue; }
U=$(grep -oP '^ADMIN_USERNAME=\K.*' "$ENVF")
P=$(grep -oP '^ADMIN_PASSWORD=\K.*' "$ENVF")
port=${RPORT[$t]}
S=$(date +%s)
code=$(timeout 900 curl -sS -u "$U:$P" \
"http://127.0.0.1:$port/check/all" \
-o "/tmp/sla_t$t.json" -w '%{http_code}' 2>/dev/null)
took=$(( $(date +%s) - S ))
echo "=== team$t (receiver :$port, HTTP $code, ${took}s) ==="
python3 - "$t" <<'PY'
import json, sys
t = sys.argv[1]
try:
d = json.load(open(f"/tmp/sla_t{t}.json"))
except Exception as e:
print(" no/invalid result:", e); raise SystemExit
r = d.get("results", d)
if not isinstance(r, dict):
print(" ", json.dumps(d)[:300]); raise SystemExit
ok = sorted(k for k, v in r.items() if v is True)
bad = sorted(k for k, v in r.items() if v is not True)
print(f" PASS {len(ok)}/{len(r)}")
if ok: print(" ok :", ", ".join(ok))
if bad: print(" BAD:", ", ".join(bad))
PY
done
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Start one team's full stack in the BACKGROUND (safe for a 16-challenge team:
# `compose up` for 16 services takes minutes and would blow a foreground timeout).
# Usage: start_team_bg.sh <teamIdx>
set -uo pipefail
IDX="${1:?usage: start_team_bg.sh <teamIdx>}"
LOG="/tmp/start-team${IDX}.log"
TEAMDIR="/opt/gemastik18-final/teams/team${IDX}"
cd "${TEAMDIR}/services" || exit 1
{
echo "=== $(date -Is) start team${IDX} ==="
docker compose -p "team${IDX}" up -d --remove-orphans 2>&1
echo "compose rc=$?"
# independent systemd receiver (never a child of the panel)
python3 /opt/gemastik18-final/panel/gen_receiver_services.py start 2>&1
systemctl restart "gemastik-receiver-team${IDX}.service" 2>&1
echo "receiver: $(systemctl is-active gemastik-receiver-team${IDX}.service)"
python3 - "$IDX" <<'PY'
import sys, json, time
sys.path.insert(0, '/opt/gemastik18-final/panel')
import teams
idx = int(sys.argv[1])
sf = f"/opt/gemastik18-final/teams/team{idx}/state.json"
st = json.loads(open(sf).read()); st["status"] = "running"
open(sf, "w").write(json.dumps(st, indent=2))
# retry loop: chpasswd races container boot
teams.set_ssh_passwords(idx)
print("=== done team", idx, "===")
PY
df -h / | tail -1
} >"${LOG}" 2>&1
echo "started team${IDX} -> ${LOG}"
+2 -2
View File
@@ -51,10 +51,10 @@
<li>Buka <b>portal tim kamu</b> (domain dari panitia).</li>
<li>Login dengan <b>password SSH tim</b>.</li>
<li>Tab <b>🖥️ Terminal SSH</b> → pilih challenge → <b>Sambung</b>.</li>
<li>Langsung masuk sebagai <code>ctfuser</code> — tanpa perlu aplikasi SSH.</li>
<li>Langsung masuk sebagai user yang tertera di dropdown — <code>ctfuser</code> untuk 6 challenge native GEMASTIK XVIII, <code>root</code> untuk challenge XVI/XVII import — tanpa perlu aplikasi SSH.</li>
</ol>
<h3>Cara 2 — SSH Client (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;</div>
<div class="sshbox">ssh &lt;USER&gt;@43.134.105.109 -p &lt;PORT_SSH&gt;</div>
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
</div>
+229 -191
View File
@@ -100,7 +100,10 @@
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
@keyframes sp { to { transform:rotate(360deg); } }
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
.topo-svg { width:100%; min-width:800px; }
/* Now a DIV host for the PixiJS canvas (was an inline <svg>). It needs an
explicit height: the Pixi Application is created with resizeTo:this element
and a zero-height box would size the renderer to nothing. */
.topo-svg { width:100%; min-width:800px; height:520px; }
/* attack visualizer: recent attacks pulse */
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
@@ -151,8 +154,8 @@
<button onclick="topoReset()">⟳ Reset</button>
<span id="topoZoomLabel" style="font-size:12px;color:var(--dim);min-width:40px">100%</span>
</div>
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
<div class="topo-wrap"><div id="topoHost" class="topo-svg"></div></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (attacker → target) • garis tebal = serangan 60 detik terakhir &nbsp;<span id="topoEngine" style="opacity:.6"></span></div>
</div>
<!-- Teams view -->
@@ -171,6 +174,12 @@
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
</div>
</div>
<div id="bulkDelBar" style="display:none;margin:10px 0"></div>
<div class="card" style="margin-bottom:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button onclick="selectAllTeams(true)">☑️ Pilih semua</button>
<button onclick="clearTeamSelection()">☐ Kosongkan</button>
<span style="font-size:11px;color:#718096">Centang tim di kiri nama untuk hapus massal sekaligus</span>
</div>
<div class="grid" id="teamsGrid"></div>
<div class="card" style="margin-top:16px">
<div class="team-head">
@@ -318,6 +327,12 @@ function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Alias used by the Challenge Manager render. Kept as a separate name so
// existing esc() call sites stay untouched, but it MUST exist: a missing
// helper throws ReferenceError mid-render and the tab hangs on "Memuat…"
// forever with no visible error.
function escapeHtml(s) { return esc(s); }
function showView(v) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
@@ -439,6 +454,11 @@ async function toggleChallenge(name, enabled) {
}
}
// ---------- Challenges ----------
// The admin challenge grid is a global node view with no team of its own, so it
// asks for credentials without ?team= and the server falls back to the receiver.
// Team-scoped pages pass their own index instead.
function TEAM_Q() { return ''; }
async function refresh() {
const grid = document.getElementById('grid');
try {
@@ -468,9 +488,11 @@ async function refresh() {
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
</div>
</div>`;
fetch(`/api/credential/${ch.name}`).then(r=>r.json()).then(c => {
fetch(`/api/credential/${ch.name}${TEAM_Q()}`).then(r=>r.json()).then(c => {
const el = document.getElementById('creds-' + ch.name);
if (el && c.username) el.innerHTML = `<b>ctfuser</b> / <b>${esc(c.password)}</b>`;
// Use the username the server reports: it is per-challenge (ctfuser for
// the 6 native XVIII images, root for the imported XVI/XVII ones).
if (el && c.username) el.innerHTML = `<b>${esc(c.username)}</b> / <b>${esc(c.password)}</b>`;
}).catch(()=>{});
}
grid.innerHTML = html;
@@ -509,12 +531,17 @@ async function submitFlag() {
async function viewCred(ch) {
try {
const c = await api(`/api/credential/${ch}`);
const c = await api(`/api/credential/${ch}${TEAM_Q()}`);
// user + port both come from the server: the SSH login is per-challenge and
// the port is per-team, so a hardcoded table went stale for every imported
// challenge and for any team other than the first.
const user = c.username || 'ctfuser';
const port = c.port || 10022;
const host = `${ch}.attackdefense.imrnes.team`;
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
const cmd = `ssh ctfuser@${ch}.attackdefense.imrnes.team -p ${sshPort}`;
const cmd = `ssh ${user}@${host} -p ${port}`;
document.getElementById('mcBody').innerHTML =
`<div>User: <b>ctfuser</b></div>
`<div>User: <b>${esc(user)}</b></div>
<div>Pass: <b>${esc(c.password)}</b></div>
<div style="margin-top:10px">SSH:</div>
<div class="flag" style="margin-top:6px">${esc(cmd)}</div>`;
@@ -522,212 +549,189 @@ async function viewCred(ch) {
} catch (e) { toast(e.message, true); }
}
// ---------- Topology ----------
let topoLoaded = false;
// ---------- Topology (PixiJS v8) ----------
// The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is
// only parsed when this tab is actually opened. These functions are the thin
// bridge the tab buttons and the 10s refresh timer already call.
let topoAttacks = [];
async function loadTopo() {
let topoGraph = null;
let topoModule = null;
// Single-flight guard. The Topology tab button calls `showView('topo'); loadTopo()`
// and showView() also calls loadTopo() for this view, so two loadTopo() run
// concurrently on every click. Previously the re-entry check was
// `if (topoGraph && topoGraph.app)`, but `topoGraph` was assigned BEFORE the
// awaited init(), so `app` was still null and the second call built a SECOND
// renderer. Both ran `host.innerHTML = ''` and appended their own canvas, so
// whichever init() finished last won the DOM while `topoGraph` still referenced
// the other — a live canvas that was no longer on the page. Track the in-flight
// promise so every caller awaits the same init.
let topoInitPromise = null;
let topoLoadPromise = null;
async function topoEngine() {
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
return topoModule;
}
async function ensureTopoGraph() {
const host = document.getElementById('topoHost');
if (!host) return null;
if (topoGraph && topoGraph.app) return topoGraph;
if (topoInitPromise) return topoInitPromise; // an init is already running
topoInitPromise = (async () => {
try {
const mod = await topoEngine();
const g = new mod.TopoGraph(host);
await g.init();
topoGraph = g; // publish only when ready
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return g;
} finally {
topoInitPromise = null;
}
})();
return topoInitPromise;
}
async function loadTopo() {
// The 10s timer plus the tab click both land here; serialise so two refreshes
// never interleave a scene rebuild.
if (topoLoadPromise) return topoLoadPromise;
topoLoadPromise = (async () => {
try {
const g = await ensureTopoGraph();
if (!g) return;
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
renderTopo(d.nodes, d.edges);
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
g.setData(d.nodes, d.edges, topoAttacks);
} catch (e) {
toast('Topologi gagal: ' + e.message, true);
} finally {
topoLoadPromise = null;
}
function renderTopo(nodes, edges) {
const svg = document.getElementById('topoSvg');
const W = Math.max(900, nodes.length * 130);
const H = 500;
svg.setAttribute('width', W); svg.setAttribute('height', H);
const cx = W / 2;
const ns = {};
nodes.forEach(n => { ns[n.id] = n; });
// layout: panel/infra top center, teams in circle, challenges below each team
const pos = {};
pos['dns'] = {x: cx, y: 30};
pos['traefik'] = {x: cx, y: 100};
pos['panel'] = {x: cx - 140, y: 100};
const teams = nodes.filter(n => n.type === 'team');
const teamPos = {};
teams.forEach((t, i) => {
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 100;
const tx = cx + rx * Math.cos(ang);
const ty = 250 + ry * Math.sin(ang) * 0.6;
teamPos[t.index] = {x: tx, y: ty};
pos[t.id] = {x: tx, y: ty};
});
nodes.filter(n => n.type === 'challenge').forEach(n => {
const ti = n.id.split('-')[0].replace('team','');
const base = teamPos[ti] || {x: cx, y: 300};
const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label);
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130};
});
// ---- attack arcs (attacker team -> target team), recent only (last 10 min) ----
const now = Date.now() / 1000;
const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600);
const attackCounts = {}; // "attacker:target" -> {ok, fail, latest}
recentAttacks.forEach(e => {
const k = `${e.attacker}:${e.target}`;
attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts};
attackCounts[k][e.success ? 'ok' : 'fail']++;
attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0);
});
// edges
let html = '';
edges.forEach(e => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return;
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`;
if (e.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${esc(e.label)}</text>`;
}
});
// ---- attack visualizer ----
for (const [k, c] of Object.entries(attackCounts)) {
const [atk, tgt] = k.split(':');
const a = pos['team' + atk], b = pos['team' + tgt];
if (!a || !b || atk === tgt) continue;
const isHot = now - c.latest < 60;
const color = c.ok > 0 ? '#f87171' : '#fb923c';
const dash = isHot ? '6 3' : '4 4';
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${isHot ? 3 : 2}" stroke-dasharray="${dash}" opacity="0.9" class="attack-line"/>`;
const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
const icon = isHot ? '⚔️' : '⚔';
html += `<text x="${mx}" y="${my}" fill="${color}" font-size="12" text-anchor="middle" class="attack-icon">${icon} ${c.ok}✓ ${c.fail}✗</text>`;
}
// nodes
nodes.forEach(n => {
const p = pos[n.id];
if (!p) return;
let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34;
if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; }
if (n.type === 'infra') { r = 30; color = '#8aa0b8'; }
if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; }
if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; }
const status = n.status === 'running' ? ' fill="#34d399"' : '';
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
html += `<g data-node="${esc(n.id)}" style="cursor:grab">
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/>
<text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text>
${sub ? `<text x="${p.x}" y="${p.y + 12}" fill="#5a7a9f" font-size="9" text-anchor="middle">${sub}</text>` : ''}
${status ? `<circle cx="${p.x + r - 8}" cy="${p.y - r + 8}" r="5" fill="#34d399"/>` : ''}
</g>`;
});
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs><g data-root>${html}</g>`;
enableTopoDrag(svg, pos);
applyTopoView();
}
// ---- draggable topology + zoom/pan ----
let topoScale = 1;
let topoPanX = 0, topoPanY = 0;
function enableTopoDrag(svg, pos) {
const gMain = svg.querySelector('g[data-root]') || svg;
let dragEl = null, dx = 0, dy = 0;
let panning = false, px = 0, py = 0;
const toLocal = (ev) => {
const ctm = svg.getScreenCTM();
if (!ctm) return {x: 0, y: 0};
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
return pt.matrixTransform(ctm.inverse());
};
const onMove = (ev) => {
if (dragEl) {
const p = toLocal(ev);
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
} else if (panning) {
topoPanX += ev.clientX - px;
topoPanY += ev.clientY - py;
px = ev.clientX; py = ev.clientY;
applyTopoView();
}
};
const onUp = () => { dragEl = null; panning = false; svg.style.cursor = ''; };
svg.addEventListener('mousedown', (ev) => {
const g = ev.target.closest('g[data-node]');
if (g) {
ev.preventDefault();
const p = toLocal(ev);
const c = g.querySelector('circle');
dx = p.x - parseFloat(c.getAttribute('cx'));
dy = p.y - parseFloat(c.getAttribute('cy'));
dragEl = g;
svg.style.cursor = 'grabbing';
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
} else {
// empty area -> pan the canvas
panning = true;
px = ev.clientX; py = ev.clientY;
svg.style.cursor = 'move';
}
});
svg.addEventListener('pointermove', onMove);
svg.addEventListener('pointerup', onUp);
svg.addEventListener('pointercancel', onUp);
// wheel zoom (ctrl+wheel or plain wheel on empty area)
svg.addEventListener('wheel', (ev) => {
ev.preventDefault();
const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
// zoom around cursor
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
const W = svg.clientWidth, H = svg.clientHeight;
topoPanX = mx - (mx - topoPanX) * (ns / topoScale);
topoPanY = my - (my - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
}, {passive: false});
}
function applyTopoView() {
const svg = document.getElementById('topoSvg');
const g = svg.querySelector('g[data-root]');
if (!g) return;
g.setAttribute('transform', `translate(${topoPanX}, ${topoPanY}) scale(${topoScale})`);
// update hint + zoom % label
const zl = document.getElementById('topoZoomLabel');
if (zl) zl.textContent = Math.round(topoScale * 100) + '%';
})();
return topoLoadPromise;
}
function topoZoom(factor) {
const svg = document.getElementById('topoSvg');
const rect = svg.getBoundingClientRect();
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
topoPanX = rect.width / 2 - (rect.width / 2 - topoPanX) * (ns / topoScale);
topoPanY = rect.height / 2 - (rect.height / 2 - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
if (topoGraph && topoGraph.app) topoGraph.zoomBy(factor);
}
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
function topoReset() {
if (topoGraph && topoGraph.app) topoGraph.reset();
}
// Debug/automation hook: the inline script's top-level `let topoGraph` is not a
// window property, so headless tests and devtools have no way to inspect the
// live scene graph. Expose the instance deliberately.
window.__topoProbe = () => topoGraph;
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
let TEAM_SELECTED = new Set(); // idx ticked for bulk delete
function renderTeamSelectBar() {
const bar = document.getElementById('bulkDelBar');
if (!bar) return;
const n = TEAM_SELECTED.size;
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const names = [...TEAM_SELECTED].sort((a, b) => a - b)
.map(i => `#${i} ${esc(TEAM_LABELS[i] || '')}`).join(', ');
bar.style.display = 'block';
bar.innerHTML =
`<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#f6ad55">${n} tim dipilih</b>
<span style="color:#a0aec0;font-size:12px">${esc(names)}</span>
<button class="danger" onclick="bulkDeleteTeams()">🗑️ Hapus ${n} Tim Sekaligus</button>
<button onclick="clearTeamSelection()">Batalkan pilihan</button>
</div>`;
}
function toggleTeamSelect(idx, on) {
if (on) TEAM_SELECTED.add(idx); else TEAM_SELECTED.delete(idx);
const cb = document.getElementById(`teamSel${idx}`);
if (cb) cb.checked = on;
renderTeamSelectBar();
}
function selectAllTeams(on) {
TEAM_SELECTED.clear();
if (on) for (const k of Object.keys(TEAM_LABELS)) TEAM_SELECTED.add(Number(k));
for (const k of Object.keys(TEAM_LABELS)) {
const cb = document.getElementById(`teamSel${k}`);
if (cb) cb.checked = on && TEAM_SELECTED.has(Number(k));
}
renderTeamSelectBar();
}
function clearTeamSelection() { selectAllTeams(false); }
async function bulkDeleteTeams() {
const idx = [...TEAM_SELECTED].sort((a, b) => a - b);
if (!idx.length) { toast('Pilih minimal satu tim dulu', true); return; }
const names = idx.map(i => `#${i} ${TEAM_LABELS[i] || ''}`).join(', ');
if (!confirm(
`🗑️ HAPUS ${idx.length} TIM SEKALIGUS?\n\n${names}\n\n` +
`Yang akan dihapus (semua tim di atas):\n` +
`• Semua container challenge\n• Receiver + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n• Port firewall UFW\n` +
`• Domain Traefik\n• Skor & riwayat leaderboard\n\n` +
`⚠️ TIDAK BISA dibatalkan.`)) return;
showLoading(`Menghapus ${idx.length} tim (${names})…<br>Compose down 16 service per tim, bisa beberapa menit`);
try {
const d = await api('/api/teams/bulk-delete', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({indices: idx, purge_scores: true})
});
// One team takes ~100s, so poll a single job instead of blocking here.
const final = await pollJob(`/api/teams/bulk-delete/${d.job}`, 1500);
const done = final.done || [];
const errs = final.errors || {};
let msg = `Terhapus ${done.length}/${idx.length} tim`;
if (Object.keys(errs).length) msg += ` · gagal: ${Object.keys(errs).join(', ')}`;
const purged = done.reduce((a, x) =>
a + Object.values(x.purged || {}).reduce((p, q) => p + q, 0), 0);
if (purged) msg += ` · ${purged} skor dibersihkan`;
toast(msg, Object.keys(errs).length > 0);
TEAM_SELECTED.clear();
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Bulk delete gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function pollJob(url, everyMs) {
for (;;) {
const j = await api(url);
if (j.state !== 'running') return j;
const el = document.querySelector('#loadingText');
if (el && j.detail) el.innerHTML = `${esc(j.detail)}…<br><span style="font-size:11px">${esc(url.split('/').pop())}</span>`;
await new Promise(r => setTimeout(r, everyMs));
}
}
async function loadTeams() {
try {
const d = await api('/api/teams');
document.getElementById('teamCount').value = d.teams.length;
loadLeaderboard();
TEAM_LABELS = {};
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
// drop selections for teams that no longer exist
for (const i of [...TEAM_SELECTED]) if (!(i in TEAM_LABELS)) TEAM_SELECTED.delete(i);
const grid = document.getElementById('teamsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; renderTeamSelectBar(); return; }
let html = '';
for (const t of d.teams) {
const alive = t.status === 'running';
const dom = t.domain || '';
const sel = TEAM_SELECTED.has(t.index);
html += `<div class="card ${alive ? '' : 'dead'}">
<div class="team-head">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Pilih untuk hapus massal">
<input type="checkbox" id="teamSel${t.index}" ${sel ? 'checked' : ''}
onchange="toggleTeamSelect(${t.index}, this.checked)">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
</label>
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
</div>
<div class="kv">
@@ -743,10 +747,12 @@ async function loadTeams() {
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
<button class="danger" onclick="deleteTeam(${t.index})">🗑️ Hapus Team</button>
</div>
</div>`;
}
grid.innerHTML = html;
renderTeamSelectBar();
} catch (e) { toast('Teams gagal: ' + e.message, true); }
}
@@ -859,6 +865,38 @@ async function randomizeTeam(idx) {
} catch (e) { toast(e.message, true); }
}
async function deleteTeam(idx) {
// Per-team delete. Deliberately NOT the same as resetEnv: this removes ONE
// team (containers, network, receiver unit, ports, dir, domain) and leaves
// the other teams untouched.
const label = TEAM_LABELS[idx] || ('Team ' + idx);
if (!confirm(
`🗑️ HAPUS PERMANEN Team ${idx} (${label})?\n\n` +
`Yang akan dihapus:\n` +
`• Semua container challenge team ini\n` +
`• Receiver team + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n` +
`• Port firewall UFW team ini\n` +
`• Domain ${label}.attackdefense.imrnes.team\n` +
`• Skor & riwayat di leaderboard\n\n` +
`Tindakan ini TIDAK BISA dibatalkan.\n` +
`Team lain tidak terpengaruh.`)) return;
// second gate: type the team number to confirm
if (prompt(`Ketik angka ${idx} untuk konfirmasi hapus:`)?.trim() !== String(idx)) {
toast('Dibatalkan', false);
return;
}
showLoading(`Menghapus Team ${idx} (${label})…<br>Stop container + receiver, hapus port & domain`);
try {
const d = await api(`/api/teams/${idx}`, {method:'DELETE', headers:{'Content-Type':'application/json'}, body: JSON.stringify({purge_scores: true})});
const n = (d.purged ? Object.values(d.purged).reduce((a, b) => a + b, 0) : 0);
toast(`Team ${idx} (${label}) dihapus: ${(d.steps || []).join(' · ')}${n ? ` · ${n} skor dibersihkan` : ''}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Hapus team gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function loadLeaderboard() {
try {
const d = await api('/api/leaderboard');
+47 -14
View File
@@ -126,20 +126,13 @@
<div class="card">
<h2>🖥️ SSH Terminal — pilih challenge</h2>
<div class="term-toolbar">
<select id="termChall" style="width:200px" onchange="connectTerm()">
<option value="blogpost">blogpost (web)</option>
<option value="carbeat">carbeat (pwn)</option>
<option value="cdn">cdn (web)</option>
<option value="phew">phew (crypto)</option>
<option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option>
</select>
<select id="termChall" style="width:200px" onchange="connectTerm()"></select>
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
<span id="termStatus">Belum tersambung</span>
</div>
<div id="termWrap"><div id="term"></div></div>
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
SSH otomatis login ke container challenge timmu. User <b>ctfuser</b> untuk 6 challenge native GEMASTIK XVIII, <b>root</b> untuk challenge XVI/XVII hasil import — user shown di dropdown. Koneksi diputus setelah idle.
</div>
</div>
</div>
@@ -214,12 +207,13 @@
<h3>2. Login via Web Terminal</h3>
<ol>
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
<li>Pilih challenge (misal <code>blogpost</code>).</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
<li>Pilih challenge (misal <code>blogpost</code>) — user SSH-nya tercetak di dropdown.</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai user yang tertera.</li>
</ol>
<h3>3. Login via SSH biasa (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;
<div class="sshbox">ssh &lt;USER&gt;@43.134.105.109 -p &lt;PORT_SSH&gt;
# user: ctfuser (XVIII native) atau root (XVI/XVII import)
# password = password tim kamu</div>
<h3>4. Command yang berguna</h3>
@@ -294,6 +288,7 @@ async function doLogin() {
loadInfo();
loadTargetDropdown(); // target dropdown needs auth — refresh after login
loadTargets();
loadTermChallenges(); // SSH picker: all challenges this team has, not just 6
} else {
err.textContent = 'Password salah. Coba lagi.';
err.style.display = 'block';
@@ -322,6 +317,40 @@ async function loadInfo() {
} catch (e) {}
}
// Populate the SSH challenge picker from the challenges THIS team actually has.
// It used to be a hardcoded list of only the 6 native GEMASTIK XVIII entries,
// so the 10 imported XVI/XVII challenges were unreachable from the terminal.
async function loadTermChallenges() {
const sel = document.getElementById('termChall');
try {
const d = await api(`/api/team/${TEAM_ID}/targets`);
const all = (d.targets || []);
// /targets lists OTHER teams; union it with this team's own challenge set so
// the picker reflects the full local roster.
const names = new Map();
for (const t of all) if (t.challenge) names.set(t.challenge, t.ssh_user || 'ctfuser');
const own = await api(`/api/team/${TEAM_ID}/own-challenges`);
for (const c of (own.challenges || [])) {
names.set(c.name, c.ssh_user || 'ctfuser');
}
const keep = sel.value;
sel.innerHTML = '';
for (const [name, user] of [...names.entries()].sort()) {
const o = document.createElement('option');
o.value = name;
o.textContent = `${name} (${user})`;
o.dataset.sshUser = user;
sel.appendChild(o);
}
if (keep && names.has(keep)) sel.value = keep;
SSH_USERS = Object.fromEntries(names);
} catch (e) {
sel.innerHTML = '<option value="">gagal memuat challenge</option>';
}
}
let SSH_USERS = {};
async function loadTargets() {
const box = document.getElementById('targetList');
const d = await api(`/api/team/${TEAM_ID}/targets`);
@@ -329,7 +358,10 @@ async function loadTargets() {
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
let html = '=== TARGET MUSUH ===\n\n';
for (const t of targets) {
html += `${esc(t.domain)}:${t.port}\n`;
// The login is per-challenge: ctfuser for the 6 native XVIII images, root
// for the imported XVI/XVII ones. Showing a fixed ctfuser sent attackers to
// a login that could never work.
html += `${esc(t.domain)}:${t.port} (${esc(t.ssh_user || 'ctfuser')})\n`;
}
box.textContent = html;
}
@@ -348,7 +380,8 @@ function connectTerm() {
ws = new WebSocket(url);
status.textContent = 'Menghubungkan…';
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
const asUser = (SSH_USERS[chall] || 'ctfuser');
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (${asUser})`; term.focus(); };
ws.onmessage = ev => term.write(ev.data);
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
+690
View File
@@ -0,0 +1,690 @@
// ---------------------------------------------------------------------------
// Topology graph renderer — PixiJS v8 (WebGL/WebGPU).
//
// Replaces the hand-rolled inline-SVG topology. Why Pixi:
// * 37 nodes / 36 edges redrawn every 10s as one innerHTML string blew away
// and re-created every DOM node, so CSS animations (attack pulse) restarted
// and dragging fought the browser's own hit-testing.
// * The scene graph gives per-node transforms for free, so pan/zoom is a
// single container transform instead of getScreenCTM() matrix math.
//
// v8 API notes (verified against pixijs.com/8.x docs, NOT v7 memory):
// * `new Application()` then `await app.init({...})` — init is ASYNC.
// * `app.canvas` is the HTMLCanvasElement (v7 was `app.view`).
// * Graphics is a builder: `.circle(x,y,r).fill({color}).stroke({width,color})`.
// The v7 `.beginFill().drawCircle().endFill()` chain is gone.
// * `Text` uses `style: { fill, fontSize, fontFamily, align }`; v7 took flat
// ctor args like `new Text(txt, style, canvas)`, which no longer applies.
//
// The module is self-contained: it owns its Application, and `destroy()` tears
// it down so re-entering the tab cannot leak a second WebGL context.
// ---------------------------------------------------------------------------
let PIXI = null;
export async function loadPixi() {
if (PIXI) return PIXI;
// Dynamic import: the topology tab may never be opened, and an 810 KB parse
// on every panel load is pure waste.
PIXI = await import('/static/vendor/pixi.mjs');
return PIXI;
}
const THEME = {
bg: 0x0a0f1c,
edge: 0x2a4a6f,
edgeLabel: 0x5a7a9f,
node: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xa8c3e0, r: 34 },
panel: { fill: 0x0ea5e9, alpha: 0.20, stroke: 0x0ea5e9, color: 0x7dd3fc, r: 42 },
infra: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0x8aa0b8, r: 30 },
team: { fill: 0x2563eb, alpha: 0.13, stroke: 0x3b82f6, color: 0x93c5fd, r: 48 },
challenge: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xc9d4e3, r: 26 },
running: 0x34d399,
attackOk: 0xf87171,
attackFail: 0xfb923c,
};
// Challenge layout order — must match the old SVG code, which positioned the
// 6 native GEMASTIK XVIII challenges left-to-right under their team.
const NATIVE_ORDER = ['blogpost', 'carbeat', 'cdn', 'phew', 'sheesh', 'warmup'];
export class TopoGraph {
constructor(host) {
this.host = host;
this.app = null;
this.world = null; // pan/zoom container
this.edgeLayer = null;
this.attackLayer = null;
this.nodeLayer = null;
this.nodeViews = new Map(); // id -> {container, data}
this.edgeViews = new Map(); // 'from->to' -> {line, label}
this.childrenOf = new Map(); // parent id -> [child ids], for hierarchical drag
this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail}
this.scale = 1;
this.panX = 0;
this.panY = 0;
this.nodes = [];
this.edges = [];
this.attacks = [];
this.drag = null;
this.ticker = 0;
}
async init() {
const PIXI = await loadPixi();
this.PIXI = PIXI;
this.app = new PIXI.Application();
await this.app.init({
resizeTo: this.host,
background: THEME.bg,
antialias: true,
autoDensity: true,
resolution: Math.min(2, window.devicePixelRatio || 1),
preference: 'webgl',
// WebGL clears its back buffer after compositing unless this is set, so a
// readback/screenshot of the canvas is a coin flip that depends on which
// frame the snapshot lands on. That made the render look intermittently
// blank. Cheap here (a 2D scene, no shaders) and it makes the view
// capturable for tests, screenshots and "save image" affordances.
preserveDrawingBuffer: true,
});
this.app.canvas.style.display = 'block';
this.app.canvas.style.width = '100%';
this.app.canvas.style.height = '520px';
this.app.canvas.style.touchAction = 'none';
this.host.innerHTML = '';
this.host.appendChild(this.app.canvas);
this.world = new PIXI.Container();
this.app.stage.addChild(this.world);
// Render groups: pan/zoom touches ONE transform instead of every child.
this.world.enableRenderGroup();
// First setData() must frame the graph instead of rendering at scale=1.
this._needsFit = true;
// `resizeTo: this.host` resizes the renderer but not our view transform, so
// a narrower window would clip the graph. Re-frame instead.
this._resizeHandler = () => this._onResize();
window.addEventListener('resize', this._resizeHandler);
this.edgeLayer = new PIXI.Container();
this.attackLayer = new PIXI.Container();
this.nodeLayer = new PIXI.Container();
this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer);
this._bindPointer();
// Render on demand: the ticker is registered but NOT started. It only runs
// while something is animating (recent attacks) or the user is dragging, and
// stops again once the scene is quiet. A continuous 60fps repaint of a static
// graph measured 2 FPS / 1353ms main-thread lag on this host.
this._ticking = false;
this.app.ticker.add((ticker) => this._animate(ticker.deltaMS));
this.app.ticker.stop();
this.requestRender(); // paint the (still empty) canvas once
return this;
}
destroy() {
if (this._resizeHandler) window.removeEventListener('resize', this._resizeHandler);
if (this.app) {
try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ }
}
this.app = null;
this.nodeViews.clear();
this.edgeViews.clear();
this.attackViews.clear();
}
// ---- layout -------------------------------------------------------------
layout(nodes) {
// The world is wider than the canvas on purpose (the old SVG scrolled
// horizontally), but the graph must still START inside the viewport: the
// team ring was centred on W/2, so with 37 nodes W grew to ~4810 and every
// team/challenge node landed far off the right edge of a ~1300px canvas.
// Centre the layout on the VISIBLE width, then let it grow to the right for
// overflow, and let reset()/applyView() fit it back on screen.
const visW = Math.max(320, (this.app && this.app.renderer ? this.app.renderer.width / this.app.renderer.resolution : 900));
const W = Math.max(visW, 900, nodes.length * 60);
const H = 500;
// Centre the ring on the viewport, not on the scrollable width.
const cx = visW / 2;
const pos = {};
pos.dns = { x: cx, y: 30 };
pos.traefik = { x: cx, y: 100 };
pos.panel = { x: cx - 140, y: 100 };
const teams = nodes.filter(n => n.type === 'team');
const teamPos = {};
teams.forEach((t, i) => {
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
// Radius must fit the viewport too, or the outermost team is off-screen.
const rx = Math.min(visW * 0.36, 420);
const ry = 100;
const tx = cx + rx * Math.cos(ang);
const ty = 250 + ry * Math.sin(ang) * 0.6;
teamPos[t.index] = { x: tx, y: ty };
pos[t.id] = { x: tx, y: ty };
});
nodes.filter(n => n.type === 'challenge').forEach(n => {
const ti = n.id.split('-')[0].replace('team', '');
const base = teamPos[ti] || { x: cx, y: 300 };
const label = n.label || '';
const tail = label.split('-').pop() || label;
const chIdx = NATIVE_ORDER.indexOf(tail);
// Imported XVI/XVII challenges aren't in NATIVE_ORDER: fan them out to
// the right of the native six so they never stack on the same column.
const slot = chIdx >= 0 ? chIdx - 2.5 : (n.nativeIndex ?? 0) + 3.5;
pos[n.id] = { x: base.x + slot * 70, y: base.y + 130 };
});
return { pos, W, H };
}
// Frame the whole graph inside the viewport (used by reset + first load).
fit() {
if (!this.app || !this.pos) return;
const rw = this.app.renderer.width / this.app.renderer.resolution;
const rh = this.app.renderer.height / this.app.renderer.resolution;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const [, v] of this.nodeViews) {
minX = Math.min(minX, v.c.x - 60); maxX = Math.max(maxX, v.c.x + 60);
minY = Math.min(minY, v.c.y - 60); maxY = Math.max(maxY, v.c.y + 60);
}
if (!isFinite(minX)) return;
const gw = maxX - minX, gh = maxY - minY;
const pad = 16;
this.scale = Math.max(0.2, Math.min(1.6, Math.min((rw - pad * 2) / gw, (rh - pad * 2) / gh)));
this.panX = (rw - gw * this.scale) / 2 - minX * this.scale;
this.panY = (rh - gh * this.scale) / 2 - minY * this.scale;
this.applyView();
}
// ---- data -> scene ------------------------------------------------------
setData(nodes, edges, attacks) {
if (!this.app) return;
this.nodes = nodes || [];
this.edges = edges || [];
this.attacks = attacks || [];
const PIXI = this.PIXI;
const { pos, W, H } = this.layout(this.nodes);
this.pos = pos;
this.worldW = W;
this.worldH = H;
// Parent -> children index, for hierarchical dragging. Ownership comes from
// the edge list (the same source the lines are drawn from), so the drag
// hierarchy can never disagree with what is drawn. Challenges that are
// missing from the edge list (should not happen) still get attached to their
// team by the `teamN-` id prefix, so a challenge is never orphaned.
const owned = new Map();
for (const e of this.edges) {
const a = this.nodes.find(n => n.id === e.from);
const b = this.nodes.find(n => n.id === e.to);
if (!a || !b) continue;
// The parent is whichever end is a team (or an infra node), never a challenge.
let parent = null, child = null;
if (a.type === 'challenge' && b.type === 'team') { parent = b; child = a; }
else if (b.type === 'challenge' && a.type === 'team') { parent = a; child = b; }
else if (a.type === 'challenge' && b.type !== 'challenge') { parent = b; child = a; }
else if (b.type === 'challenge' && a.type !== 'challenge') { parent = a; child = b; }
if (!parent || !child) continue;
if (!owned.has(parent.id)) owned.set(parent.id, []);
const list = owned.get(parent.id);
if (!list.includes(child.id)) list.push(child.id);
}
for (const n of this.nodes) {
if (n.type !== 'challenge') continue;
const teamId = 'team' + n.id.split('-')[0].replace('team', '');
if (!this.nodes.some(x => x.id === teamId)) continue;
if (!owned.has(teamId)) owned.set(teamId, []);
if (!owned.get(teamId).includes(n.id)) owned.get(teamId).push(n.id);
}
this.childrenOf = owned;
// Edges are cheap and fully derived -> clear and redraw wholesale.
this._redrawEdges(pos);
this._syncNodes(pos);
this._syncAttacks(pos);
// First paint (or a big data change) must land framed, not at scale=1 with
// the graph hanging off the right edge.
if (this._needsFit) { this._needsFit = false; this.fit(); }
else this.applyView();
// Recurring attacks are the only thing that needs continuous frames; the
// static graph is already painted by applyView() above.
if (this._attacksAreAnimating()) this._startTicking();
}
_syncNodes(pos) {
const PIXI = this.PIXI;
const seen = new Set();
for (const n of this.nodes) {
const p = pos[n.id];
if (!p) continue;
seen.add(n.id);
let view = this.nodeViews.get(n.id);
if (!view) {
const c = new PIXI.Container();
const halo = new PIXI.Graphics();
const disc = new PIXI.Graphics();
const title = new PIXI.Text({
text: n.label,
style: { fill: 0xffffff, fontSize: 10, fontFamily: 'ui-sans-serif, system-ui', fontWeight: 'bold', align: 'center' },
});
const sub = new PIXI.Text({
text: '',
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace', align: 'center' },
});
const dot = new PIXI.Graphics();
c.addChild(halo, disc, title, sub, dot);
// Nodes must be draggable, so make the whole container interactive.
c.eventMode = 'static';
c.cursor = 'grab';
c.on('pointerdown', (ev) => this._onNodeDown(ev, c));
title.anchor.set(0.5);
sub.anchor.set(0.5);
this.nodeLayer.addChild(c);
view = { c, halo, disc, title, sub, dot, data: null };
this.nodeViews.set(n.id, view);
}
view.data = n;
const theme = this._themeFor(n);
const r = theme.r;
const fillAlpha = theme.alpha ?? 1;
// Assigning `.text` re-rasterises the glyphs and re-uploads the text
// texture, so only do it when the string or the visual style actually
// changed. Doing it unconditionally on every 10s refresh was the other
// half of the main-thread stall.
if (view.discKey !== `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`) {
view.discKey = `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`;
view.disc.clear()
.circle(0, 0, r)
.fill({ color: theme.fill, alpha: fillAlpha })
.stroke({ width: 1.5, color: theme.stroke, alpha: 1 });
view.halo.clear()
.circle(0, 0, Math.max(1, r - 4))
.stroke({ width: 1, color: theme.stroke, alpha: 0.3 });
}
const titleSize = n.type === 'team' ? 12 : 10;
if (view.titleKey !== `${n.label}|${theme.color}|${titleSize}`) {
view.titleKey = `${n.label}|${theme.color}|${titleSize}`;
view.title.style.fill = theme.color;
view.title.style.fontSize = titleSize;
view.title.text = n.label;
}
const subText = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
if (view.subText !== subText) {
view.subText = subText;
view.sub.text = subText || '';
view.sub.visible = !!subText;
view.title.y = subText ? 0 : -4;
view.sub.y = 12;
}
// Position only when it changed: writing x/y every refresh would fight a
// drag in progress and make the graph jitter under the cursor.
if (!view.dragging && (view.posKey !== `${p.x},${p.y}`)) {
view.posKey = `${p.x},${p.y}`;
view.c.position.set(p.x, p.y);
}
const running = n.status === 'running';
if (view.running !== running) {
view.running = running;
view.dot.clear();
if (running) view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running });
}
// Bind the tooltip ONCE, at creation. Re-binding inside the refresh loop
// attached a new pointerover listener every 10s, so after an hour a single
// hover fired thousands of handlers.
if (n.tooltip && !view.hasTooltip) {
view.hasTooltip = true;
view.c.on('pointerover', () => this._setTooltip(n));
view.c.on('pointerout', () => this._setTooltip(null));
}
}
// Drop views for nodes that no longer exist (a deleted team, a challenge
// disabled in the challenge manager) so they don't linger as ghosts.
for (const [id, view] of [...this.nodeViews]) {
if (seen.has(id)) continue;
view.c.destroy({ children: true });
this.nodeViews.delete(id);
}
}
_themeFor(n) {
if (n.type === 'panel') return THEME.panel;
if (n.type === 'infra') return THEME.infra;
if (n.type === 'team') return THEME.team;
if (n.type === 'challenge') return THEME.challenge;
return THEME.node;
}
_syncAttacks(pos) {
const PIXI = this.PIXI;
const now = Date.now() / 1000;
const recent = this.attacks.filter(e => now - (e.ts || 0) < 600);
const counts = {};
for (const e of recent) {
const k = `${e.attacker}:${e.target}`;
counts[k] = counts[k] || { ok: 0, fail: 0, latest: e.ts || 0 };
counts[k][e.success ? 'ok' : 'fail']++;
counts[k].latest = Math.max(counts[k].latest, e.ts || 0);
}
this.attackCounts = counts;
const seen = new Set();
for (const [k, c] of Object.entries(counts)) {
const [atk, tgt] = k.split(':');
const a = pos['team' + atk], b = pos['team' + tgt];
if (!a || !b || atk === tgt) continue;
seen.add(k);
let view = this.attackViews.get(k);
if (!view) {
const g = new PIXI.Container();
const line = new PIXI.Graphics();
const label = new PIXI.Text({
text: '',
style: { fill: 0xffffff, fontSize: 12, fontFamily: 'ui-sans-serif, system-ui', align: 'center' },
});
label.anchor.set(0.5);
g.addChild(line, label);
g.eventMode = 'none';
this.attackLayer.addChild(g);
view = { g, line, label };
this.attackViews.set(k, view);
}
view.color = c.ok > 0 ? THEME.attackOk : THEME.attackFail;
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 2, color: view.color, alpha: 0.9 });
view.label.style.fill = view.color;
view.label.text = `${c.ok}✓ ${c.fail}✗`;
view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10);
view.latest = c.latest;
view.ok = c.ok;
view.fail = c.fail;
}
for (const [k, view] of [...this.attackViews]) {
if (seen.has(k)) continue;
view.g.destroy({ children: true });
this.attackViews.delete(k);
}
}
// ---- animation ---------------------------------------------------------
// Render on demand, NOT every frame.
//
// Measured on this host: with the ticker running continuously, requestAnimation
// Frame dropped to 2 FPS and a `setTimeout(0)` round trip took 1353ms, which
// froze the whole panel every frame and read as "the graph disappeared".
// With the ticker stopped: 72 FPS and a 15ms lag. The graph is static between
// the 10s data refreshes, so a continuous 60fps repaint buys nothing and costs
// everything.
//
// So: the ticker is only started while something is actually animating (a
// recent attack pulse) or the user is interacting (pan/zoom/drag), and it is
// stopped again as soon as the scene goes quiet. Every mutation calls
// requestRender() to guarantee at least one repaint.
requestRender() {
this._needsRender = true;
this.app.render(); // paint once, synchronously
}
_startTicking() {
if (this._ticking || !this.app) return;
this._ticking = true;
this.app.ticker.start();
}
_stopTicking() {
if (!this._ticking || !this.app) return;
this._ticking = false;
this.app.ticker.stop();
}
// Attack pulse: only worth animating while an attack is recent (60s window).
// Outside that window every line is static, so the ticker can be parked.
_attacksAreAnimating() {
if (!this.attackViews.size) return false;
const now = Date.now() / 1000;
for (const [, view] of this.attackViews) {
if (now - (view.latest || 0) < 60) return true;
}
return false;
}
_animate(deltaMS) {
this.ticker += deltaMS;
if (this.ticker < 66) { // ~15fps is plenty for a 1.2s pulse
if (this._attacksAreAnimating()) this.app.render();
return;
}
this.ticker = 0;
const now = Date.now() / 1000;
let stillAnimating = false;
for (const [k, view] of this.attackViews) {
const hot = now - (view.latest || 0) < 60;
if (hot) stillAnimating = true;
// 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s).
const phase = (now % 1.2) / 1.2;
view.line.alpha = hot ? 0.55 + 0.45 * Math.sin(phase * Math.PI) : 0.9;
// Recent attacks read as a dashed "hot" line; redraw only on state change
// so we are not re-tessellating every frame for nothing.
const wantDash = hot ? '6 3' : '4 4';
if (view.dash !== wantDash) {
view.dash = wantDash;
const a = this.pos['team' + k.split(':')[0]];
const b = this.pos['team' + k.split(':')[1]];
if (a && b) {
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: hot ? 3 : 2, color: view.color, alpha: 0.9 });
}
}
}
// Nothing left to animate: stop paying for frames.
if (!stillAnimating) this._stopTicking();
}
// ---- pan / zoom / drag -------------------------------------------------
_bindPointer() {
const cv = this.app.canvas;
// v8 replaces the v7 `interactive = true` + `on('pointermove')` on the
// stage with eventMode on the object plus a normal DOM listener for the
// background pan (the stage itself is not interactive by default).
cv.addEventListener('wheel', (ev) => {
ev.preventDefault();
this.zoomAt(ev, ev.ctrlKey ? 0.01 : 0.0022);
}, { passive: false });
let panning = null;
cv.addEventListener('pointerdown', (ev) => {
if (this.drag) return; // node drag wins
panning = { x: ev.clientX, y: ev.clientY, px: this.panX, py: this.panY };
cv.setPointerCapture(ev.pointerId);
});
cv.addEventListener('pointermove', (ev) => {
if (!panning) return;
this.panX = panning.px + (ev.clientX - panning.x);
this.panY = panning.py + (ev.clientY - panning.y);
this.applyView();
});
const endPan = (ev) => {
if (!panning) return;
panning = null;
try { cv.releasePointerCapture(ev.pointerId); } catch (e) { /* not captured */ }
};
cv.addEventListener('pointerup', endPan);
cv.addEventListener('pointercancel', endPan);
}
_onNodeDown(ev, container) {
ev.stopPropagation();
const view = [...this.nodeViews.values()].find(v => v.c === container);
if (!view) return;
const world = this.toWorld(ev);
view.dragging = true;
container.cursor = 'grabbing';
// Dragging a parent must carry its children: a team node owns its 16
// challenge nodes, and a challenge belongs to exactly one team. Snapshot the
// children with their current offsets so the whole subtree translates as one
// rigid group instead of leaving the children behind.
const children = (this.childrenOf.get(view.data.id) || [])
.map((cid) => this.nodeViews.get(cid))
.filter((cv) => cv && cv.c !== container);
const kids = children.map((cv) => ({
view: cv,
dx: cv.c.x - container.x,
dy: cv.c.y - container.y,
}));
this.drag = { view, dx: container.x - world.x, dy: container.y - world.y, kids };
this._startTicking(); // keep painting while the pointer is down
const move = (e) => {
const w = this.toWorld(e);
container.position.set(w.x + this.drag.dx, w.y + this.drag.dy);
// Children ride along with the parent, keeping their original offsets.
// Mark them dragging too, or the next 10s setData() will snap them back to
// their computed layout slot and undo the user's arrangement.
for (const k of this.drag.kids) {
k.view.dragging = true;
k.view.c.position.set(container.x + k.dx, container.y + k.dy);
k.view.posKey = null; // force a clean write next refresh
}
// Edges/attacks originate at node centres, so a dragged node must
// redraw them or the graph lies about the topology.
this._redrawForDrag();
};
const up = () => {
view.dragging = false;
container.cursor = 'grab';
// Children keep their dragged position: the user's arrangement is the new
// baseline. Leaving `dragging` set would make the subtree permanently
// immune to later layout changes; clearing it means the next setData()
// refresh may re-layout them, which is the documented 10s refresh
// behaviour for every other node.
for (const k of this.drag ? this.drag.kids : []) k.view.dragging = false;
this.drag = null;
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
this._redrawForDrag();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
_redrawForDrag() {
// A dragged node's position is user intent, not layout, so re-derive the
// edge/attack endpoints from the live node transforms instead of calling
// setData (which would snap the node back to its computed slot).
const pos = {};
for (const [id, view] of this.nodeViews) pos[id] = { x: view.c.x, y: view.c.y };
for (const id of ['dns', 'traefik', 'panel']) {
if (this.nodeViews.get(id)) pos[id] = { x: this.nodeViews.get(id).c.x, y: this.nodeViews.get(id).c.y };
}
this.pos = pos;
this._redrawEdges(pos);
this._redrawAttacks(pos);
}
_redrawEdges(pos) {
const PIXI = this.PIXI;
// Edges are fully derived from node positions, so they are rebuilt when a
// node moves. But the objects are NOT thrown away each time: every new
// PIXI.Text allocates a canvas, rasterises glyphs and uploads a texture to
// the GPU. Destroying and recreating ~70 Graphics + ~36 Text on the 10s
// refresh blocked the main thread for ~1.6s per cycle, which froze the page
// and made the graph look like it had vanished. Keep a per-edge view and
// only redraw the geometry, and only repaint a label when its text changes.
const seen = new Set();
for (const e of this.edges) {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) continue;
const key = `${e.from}->${e.to}`;
seen.add(key);
let view = this.edgeViews.get(key);
if (!view) {
const line = new PIXI.Graphics();
line.eventMode = 'none';
const label = e.label
? new PIXI.Text({
text: e.label,
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' },
})
: null;
if (label) { label.anchor.set(0.5); label.eventMode = 'none'; }
this.edgeLayer.addChild(line);
if (label) this.edgeLayer.addChild(label);
view = { line, label, labelText: label ? e.label : null };
this.edgeViews.set(key, view);
}
view.line.clear().moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 1.5, color: THEME.edge, alpha: 1 });
if (view.label) {
if (view.labelText !== e.label) { view.label.text = e.label; view.labelText = e.label; }
view.label.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6);
}
}
for (const [key, view] of [...this.edgeViews]) {
if (seen.has(key)) continue;
view.line.destroy();
if (view.label) view.label.destroy();
this.edgeViews.delete(key);
}
}
_redrawAttacks(pos) {
for (const [k, view] of this.attackViews) {
const a = pos['team' + k.split(':')[0]], b = pos['team' + k.split(':')[1]];
if (!a || !b) continue;
view.color = view.ok > 0 ? THEME.attackOk : THEME.attackFail;
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 2, color: view.color, alpha: 0.9 });
view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10);
}
}
toWorld(ev) {
const rect = this.app.canvas.getBoundingClientRect();
return {
x: (ev.clientX - rect.left - this.panX) / this.scale,
y: (ev.clientY - rect.top - this.panY) / this.scale,
};
}
zoomAt(ev, factor) {
const rect = this.app.canvas.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
const before = { x: (mx - this.panX) / this.scale, y: (my - this.panY) / this.scale };
const next = Math.max(0.2, Math.min(4, this.scale * (1 - factor * 12)));
this.scale = next;
// Keep the point under the cursor fixed while scaling.
this.panX = mx - before.x * this.scale;
this.panY = my - before.y * this.scale;
this.applyView();
}
zoomBy(factor) {
this.scale = Math.max(0.2, Math.min(4, this.scale * factor));
this.applyView();
}
reset() {
// "Reset" means "show me the whole graph", not "scale=1, pan=0" — the latter
// is only correct while the layout fits the viewport, and with many teams it
// does not. Frame-to-fit is what the button actually promises.
if (this.nodeViews.size) this.fit();
else { this.scale = 1; this.panX = 0; this.panY = 0; this.applyView(); }
}
_onResize() {
if (this.nodeViews.size) this.fit();
}
applyView() {
// Called on every pan, zoom, drag and refit: repaint synchronously so the
// view tracks the pointer even with the ticker parked.
this.requestRender();
if (!this.app || !this.world) return;
this.world.position.set(this.panX || 0, this.panY || 0);
this.world.scale.set(this.scale);
const lbl = document.getElementById('topoZoomLabel');
if (lbl) lbl.textContent = Math.round(this.scale * 100) + '%';
}
}
+21
View File
@@ -0,0 +1,21 @@
# Vendored third-party assets
## pixi.mjs — PixiJS 8.21.0 (MIT)
Topology graph renderer for the admin panel. Self-hosted on purpose: the panel
must not depend on a CDN at runtime (the CTF network is air-gapped during the
event, and a CDN outage would take the topology view down with it).
Provenance and upgrade path:
npm pack pixi.js@8
tar xzf pixi.js-8.21.0.tgz
cp package/dist/pixi.min.mjs panel/static/vendor/pixi.mjs
Use `pixi.min.mjs`, **not** `pixi.min.js`. The `.js` bundle is an IIFE that
discards its return value and exports no global, so it cannot be loaded with a
plain `<script>` tag. The `.mjs` build has real named exports and is loaded via
`await import('/static/vendor/pixi.mjs')` in `static/topo_pixi.js`.
Loading it dynamically (rather than a `<script>` in every page) keeps the ~800 KB
parse cost off panel loads that never open the Topology tab.
+2341
View File
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Open (or close) UFW for every live team's port block.
The panel opens a team's ports at create time, but teams created out-of-band
(scripts, git checkout, a half-finished create_team) never got rules, and this
host's UFW defaults to deny(incoming) — so those teams are blackholed. Run
after any bulk team creation:
python3 panel/sync_all_team_ufw.py # open
python3 panel/sync_all_team_ufw.py --remove # close
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def main():
remove = "--remove" in sys.argv
live = orch.list_teams()
if not live:
print("no teams")
return
for t in live:
idx = t["index"]
r = orch.sync_team_ufw(idx, remove=remove)
verb = "closed" if remove else "opened"
bad = f" FAILED={r['failed']}" if r.get("failed") else ""
print(f"team{idx} ({t.get('label')}): {verb} {len(r.get('closed' if remove else 'opened', []))}"
f"/{r['ports']} ports{bad}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Team footprint snapshot: everything a team owns, so a delete can be proven
# rather than assumed. Usage: ./team_footprint.sh <idx> [label]
set -uo pipefail
IDX=${1:-5}
LABEL=${2:-team$IDX}
echo "=== $LABEL ($IDX) ==="
echo " containers : $(docker ps --format '{{.Names}}' | grep -c "_container_team${IDX}\$")"
echo " sidecars : $(docker ps --format '{{.Names}}' | grep -c "team${IDX}-.*-1\$")"
echo " network : $(docker network ls --format '{{.Name}}' | grep -c "^team${IDX}_default\$")"
echo " volumes : $(docker volume ls --format '{{.Name}}' | grep -c "^team${IDX}")"
echo " dir : $([ -d "/opt/gemastik18-final/teams/team${IDX}" ] && echo present || echo GONE)"
echo " recv unit : $(systemctl is-active "gemastik-receiver-team${IDX}.service" 2>/dev/null || echo GONE)"
echo " ufw rules : $(ufw status | grep -cE " 3${IDX}0[0-9]{2}/tcp| 3${IDX}[1-9][0-9]{2}/tcp")"
echo " traefik : $(grep -c "team${IDX}" /data/coolify/proxy/dynamic/attackdefense-teams.yaml 2>/dev/null || echo 0)"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Health of every real team: container count, receiver unit, disk.
set -uo pipefail
cd /opt/gemastik18-final
for i in 1 2 3 4; do
printf "team%s: %2s containers | receiver=%s | team dir=%s\n" "$i" \
"$(docker ps --format '{{.Names}}' | grep -c "_container_team${i}\$")" \
"$(systemctl is-active "gemastik-receiver-team${i}.service")" \
"$([ -d "teams/team${i}" ] && echo ok || echo MISSING)"
done
echo "panel: $(systemctl is-active gemastik-panel)"
df -h / | tail -1
+414 -9
View File
@@ -443,8 +443,15 @@ def create_team(idx: int, label: str = None, domain: str = None):
for j, line in enumerate(recv_env):
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}")
# systemd EnvironmentFile keys must match [A-Za-z_][A-Za-z0-9_]* — a
# hyphen (gift-card, bit-canvas, ...) makes systemd log
# "Ignoring invalid environment assignment" and DROP the line, so the
# checker falls back to a default port/container and reports the
# service down. Normalize to underscores on the writer; the reader
# (gen_receiver_main._envkey) uses the same normalization.
key = name.upper().replace("-", "_")
recv_env.append(f"CHALLENGE_PORT_{key}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
(recv_dir / ".env").write_text("\n".join(recv_env) + "\n")
# --- flags (randomized per team so each team has unique flags) ---
@@ -484,6 +491,80 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict:
ensure_team_domains()
return st
def missing_sidecars(idx: int) -> list[str]:
"""Sidecar services in the team's compose that are NOT running.
A multi-container challenge (anti-alchemy + its postgres, gemas-notes +
database/validation, kode-viewer + redis, ...) needs its sidecars to boot:
the main service's `create_db_conn()` retries in an infinite loop until the
DB hostname resolves, so a missing sidecar leaves the main container
"Up" with NO app listening and the checker reports it DOWN. Symptom class:
container is running, port is open at the docker level, but the app never
starts. Recover with `docker compose -p teamN up -d` (no service name).
"""
svc_dir = TEAMS_DIR / f"team{idx}" / "services"
compose = svc_dir / "docker-compose.yml"
if not compose.exists():
return []
declared = _compose_service_names(compose, project=f"team{idx}")
if not declared:
return []
# _compose_service_names returns the REAL container_name values
# (`<chall>_container_teamN`), so compare them as-is — do NOT re-wrap them
# in the compose default `teamN-<svc>-1`, which no service here uses.
want = set(declared)
running = set(subprocess.run(["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True).stdout.split())
return sorted(want - running)
def _compose_service_names(compose: Path, project: str = "") -> list[str]:
"""Container names the compose will create, without needing PyYAML.
Two shapes exist in these composes and BOTH must be caught:
* services with an explicit `container_name:` (`<chall>_container_teamN`) —
that name is what the SLA checkers and the receiver env key off, so it
must be matched as-is;
* sidecars WITHOUT a `container_name:` (anti-alchemy-db, gemas-notes-db) —
compose names those `teamN-<svc>-1`, and they are exactly the ones that
go missing, because a per-service `up <main>` never starts them.
Matching only the first shape reported "[] missing" while the db sidecar
was absent on every team, and matching the compose default for everything
reported all 16 running challenges as missing. Both are wrong; return the
real name when there is one and the compose default when there isn't.
"""
names: list[str] = []
in_services = False
pending: str | None = None
pfx = f"{project}-" if project else ""
for line in compose.read_text().splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if re.match(r"^services:\s*$", line):
in_services = True
continue
if in_services and re.match(r"^[a-zA-Z]", line):
break # next top-level key
if not in_services:
continue
m = re.match(r"^ ([A-Za-z0-9_.-]+):\s*$", line)
if m:
if pending is not None:
# previous service had no container_name -> compose default
names.append(f"{pfx}{pending}-1")
pending = m.group(1)
continue
m = re.match(r'^\s+container_name:\s*"?([A-Za-z0-9_.-]+)"?\s*$', line)
if m and pending is not None:
names.append(m.group(1))
pending = None
if pending is not None:
names.append(f"{pfx}{pending}-1")
return names
def start_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
@@ -491,6 +572,13 @@ def start_team(idx: int):
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
cwd=str(svc_dir), check=False, capture_output=True)
# Self-heal: a per-service `up` (challenge toggle) or a raced start can
# leave a sidecar behind while the main container looks fine. One plain
# `up -d` reconciles the whole project (already-running ones are no-ops).
gone = missing_sidecars(idx)
if gone:
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d"],
cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["status"] = "running"
@@ -498,19 +586,103 @@ def start_team(idx: int):
# Set per-team SSH passwords at runtime (images are shared across teams,
# so chpasswd ensures each team's containers have the team's own password).
set_ssh_passwords(idx)
if gone:
print(f"[start_team] team{idx}: started missing sidecar(s): {', '.join(gone)}")
return st
def challenge_ssh_users() -> dict:
"""{challenge_name: ssh login} from the registry.
Only the 6 native GEMASTIK XVIII images provision `ctfuser`. Every imported
XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd` and logs in as
`root` (some also create an unprivileged `ctf` for the app). Hardcoding
`ctfuser` made chpasswd either fail or set a password on an account nobody
uses, so SSH returned "Permission denied" for every team on 10 of 16
challenges while state.json still looked correct.
"""
out = {}
for c in registry_challenges():
out[c["name"]] = c.get("ssh_user") or "root"
return out
def all_teams() -> list:
"""Every team that still has a state.json, newest index last."""
out = []
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
try:
st = json.loads(sf.read_text())
except Exception:
continue
if "index" in st:
out.append(st)
return out
def team_state(idx: int):
"""state.json for one team, or None if that team doesn't exist."""
sf = TEAMS_DIR / f"team{idx}" / "state.json"
if not sf.exists():
return None
try:
return json.loads(sf.read_text())
except Exception:
return None
def challenge_credential(idx: int, name: str):
"""{username, password, port} a participant should actually use for SSH.
Reads the TEAM's state.json rather than the receiver: the global receiver on
:18080 only knows the 6 native GEMASTIK XVIII challenges, so asking it for
an imported XVI/XVII challenge returns "Invalid challenge" and the UI showed
participants nothing at all. The registry `ssh_user` is the same field the
panel chpasswds, so the two can never drift.
"""
st = team_state(idx)
if not st:
return None
pwd = (st.get("chall_passwords") or {}).get(name)
if not pwd:
return None
return {
"username": challenge_ssh_users().get(name, "ctfuser"),
"password": pwd,
"port": ((st.get("ports") or {}).get(name) or {}).get("chall"),
"team": idx,
}
def set_ssh_passwords(idx: int):
"""Set SSH password for ctfuser in each challenge container of a team."""
"""Set the SSH password for the CORRECT login of each challenge container.
The login is per-challenge (registry `ssh_user`), not a global `ctfuser`.
Retries because right after `compose up` the container may still be booting.
Reports failures loudly instead of writing to a log nobody reads.
"""
team_dir = TEAMS_DIR / f"team{idx}"
st = json.loads((team_dir / "state.json").read_text())
users = challenge_ssh_users()
failures = []
for name, coff, soff in CHALLENGES:
cont = f"{name}_container_team{idx}"
pw = st["chall_passwords"][name]
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
# retry a few times — right after `compose up`, container may still be booting
user = users.get(name, "root")
pw = st["chall_passwords"].get(name)
if not pw:
failures.append(f"{cont}: no password in state")
continue
# Set the password for the real login AND for ctfuser when that account
# exists, so either convention works during a transition.
cmd = (f"id {user} >/dev/null 2>&1 && echo '{user}:{pw}' | chpasswd; "
f"id ctfuser >/dev/null 2>&1 && echo 'ctfuser:{pw}' | chpasswd; "
f"id ctf >/dev/null 2>&1 && echo 'ctf:{pw}' | chpasswd; "
f"id {user} >/dev/null 2>&1")
ok = False
r = None
for attempt in range(5):
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
capture_output=True, text=True, timeout=30)
@@ -519,9 +691,13 @@ def set_ssh_passwords(idx: int):
break
time.sleep(3)
if not ok:
print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})")
failures.append(f"{cont}: {(r.stderr or '').strip()[:100]}")
else:
print(f"[set_ssh_passwords] {cont}: OK")
print(f"[set_ssh_passwords] {cont} (login={user}): OK")
if failures:
print(f"[set_ssh_passwords] team{idx} FAILED {len(failures)}/{len(CHALLENGES)}: "
+ "; ".join(failures))
return failures
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
@@ -622,12 +798,241 @@ def ensure_team_domains() -> str:
- main: {host}
""")
if not out:
return "no teams to route"
# No teams left. The file MUST still be rewritten (to an empty router
# set) — returning early leaves the previous content on disk, so a
# DELETED team keeps its Traefik router and stays routable to the panel
# portal forever. That was the stale-domain bug.
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers: {}\n")
return "no teams to route (emptied attackdefense-teams.yaml)"
# Keep the team domain block in its own file so the main attackdefense.yaml stays untouched
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml"
def team_port_block(idx: int) -> list[int]:
"""Every host port a team occupies: each challenge's chall+ssh port, the
receiver, and the reserved panel slot."""
st_path = TEAMS_DIR / f"team{idx}" / "state.json"
ports: set[int] = set()
if st_path.exists():
st = json.loads(st_path.read_text())
for name, pv in (st.get("ports") or {}).items():
if isinstance(pv, dict):
for k in ("chall", "ssh"):
if pv.get(k):
ports.add(int(pv[k]))
elif isinstance(pv, int):
ports.add(pv)
else:
# team dir already gone (mid-delete): derive from the port scheme
base = PORT_BASE + idx * STEP
for name, coff, soff in CHALLENGES:
ports.add(base + coff)
ports.add(base + soff)
ports.add(base + 80)
ports.add(base + 81)
return sorted(ports)
def sync_team_ufw(idx: int, remove: bool = False) -> dict:
"""Reconcile UFW rules for one team's port block.
UFW here defaults to deny(incoming), so a port that is not explicitly
allowed is blackholed — the team is unreachable from the internet AND from
its own containers. Team creation never opened these ports (they were
opened by hand earlier), so a new team silently gets no connectivity.
remove=True DELETES the rules instead of adding them (called from
delete_team). The two modes are mutually exclusive: never add-then-delete,
that would flap the rules and briefly re-open a dead team's ports.
"""
ports = team_port_block(idx)
if remove:
for p in ports:
subprocess.run(["ufw", "--force", "delete", "allow", f"{p}/tcp"],
check=False, capture_output=True)
return {"team": idx, "ports": len(ports), "closed": ports, "failed": []}
failed = []
for p in ports:
r = subprocess.run(["ufw", "allow", f"{p}/tcp"], check=False, capture_output=True, text=True)
# `ufw allow` on an existing rule prints "Skipping adding existing rule"
# and still exits 0; a non-zero rc with a skip message is not a failure.
if r.returncode != 0 and "Skipping" not in (r.stdout + r.stderr):
failed.append(p)
return {"team": idx, "ports": len(ports), "opened": [p for p in ports if p not in failed],
"failed": failed}
def _purge_team_records(idx: int) -> dict:
"""Drop every ledger row that references a team, so a deleted team leaves
no ghost entries on the leaderboard / points / attack topology."""
removed = {"leaderboard": 0, "points": 0, "attacks": 0}
lb_path = TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
try:
lb = json.loads(lb_path.read_text())
before = len(lb.get("solves", []))
lb["solves"] = [e for e in lb.get("solves", [])
if e.get("team") != idx and e.get("target") != idx]
removed["leaderboard"] = before - len(lb["solves"])
lb_path.write_text(json.dumps(lb, indent=2))
except Exception:
pass
p_path = TEAMS_DIR / "points.json"
if p_path.exists():
try:
data = json.loads(p_path.read_text())
if str(idx) in data.get("teams", {}):
data["teams"].pop(str(idx))
removed["points"] = 1
p_path.write_text(json.dumps(data, indent=2))
except Exception:
pass
a_path = TEAMS_DIR / "attacks.json"
if a_path.exists():
try:
log = json.loads(a_path.read_text())
before = len(log.get("events", []))
log["events"] = [e for e in log.get("events", [])
if e.get("attacker") != idx and e.get("target") != idx]
removed["attacks"] = before - len(log["events"])
a_path.write_text(json.dumps(log, indent=2))
except Exception:
pass
return removed
def repair_team_receiver_env(idx: int) -> dict:
"""Rewrite a team receiver .env with systemd-legal keys.
Teams created before the hyphen fix have `CHALLENGE_PORT_GIFT-CARD=` in
their .env. systemd logs "Ignoring invalid environment assignment" and drops
the line, so every hyphenated challenge (gift-card, bit-canvas, gleam-drive,
more-less, anti-alchemy, gift-voucher) reports DOWN even though its
container is up. This rewrites the file in place, fixing existing teams
without forcing a re-create.
"""
env_path = TEAMS_DIR / f"team{idx}" / "receiver" / ".env"
if not env_path.exists():
raise FileNotFoundError(f"team{idx} receiver .env not found")
st = json.loads((TEAMS_DIR / f"team{idx}" / "state.json").read_text())
lines = env_path.read_text().splitlines()
kept, fixed, dropped = [], [], []
for line in lines:
if line.startswith("CHALLENGE_PORT_") or line.startswith("CHALLENGE_CONTAINER_"):
k, _, v = line.partition("=")
nk = k.replace("-", "_")
if nk != k:
fixed.append(f"{k}->{nk}")
else:
kept.append(line)
continue
kept.append(line)
# re-append authoritative values for every challenge in state
for name in (st.get("ports") or {}):
if name in ("receiver", "panel"):
continue
key = name.upper().replace("-", "_")
kept.append(f"CHALLENGE_PORT_{key}={st['ports'][name]['chall']}")
kept.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
env_path.write_text("\n".join(kept) + "\n")
# also refresh the shared checker packages (team1 was missing xvi.Art)
try:
sys.path.insert(0, str(BASE / "panel"))
from gen_receiver_main import _sync_checker_packages
_sync_checker_packages(TEAMS_DIR / f"team{idx}" / "receiver")
except Exception as e:
dropped.append(f"checker sync failed: {e}")
return {"team": idx, "fixed_keys": fixed, "notes": dropped}
def delete_team(idx: int, purge_scores: bool = True) -> dict:
"""Permanently remove ONE team and free everything it owns.
Teardown order matters — do the teardown against the OLD compose before
removing the directory, or `docker compose` has no file to read and the
containers survive as orphans:
1. stop the per-team receiver systemd unit and remove the unit file
2. `docker compose -p teamN down -v` against the team compose
(also drops the teamN_default network)
3. force-remove any surviving <chall>_container_teamN container
4. delete the team's UFW rules
5. rmtree teams/teamN (compose, receiver, flags, state.json)
6. purge leaderboard / points / attacks rows for that team
7. rewrite the Traefik team-domain file so the domain stops resolving
Images (services-*) are SHARED across teams and are never removed here.
purge_scores=False keeps the team's leaderboard/points history.
"""
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
st = json.loads((team_dir / "state.json").read_text())
label = st.get("label", f"Team {idx}")
steps: list[str] = []
# 1. receiver unit
unit = f"gemastik-receiver-team{idx}.service"
subprocess.run(["systemctl", "disable", unit], check=False, capture_output=True)
subprocess.run(["systemctl", "stop", unit], check=False, capture_output=True)
unit_path = Path("/etc/systemd/system") / f"{unit}"
if unit_path.exists():
unit_path.unlink()
steps.append("removed receiver unit")
subprocess.run(["systemctl", "daemon-reload"], check=False, capture_output=True)
# 2. compose down (containers + network) while the compose file still exists
svc_dir = team_dir / "services"
compose = svc_dir / "docker-compose.yml"
if compose.exists():
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", str(compose),
"down", "-v", "--remove-orphans"],
cwd=str(svc_dir), check=False, capture_output=True, text=True,
timeout=600)
steps.append("compose down" if r.returncode == 0 else f"compose down rc={r.returncode}")
# 3. force-remove leftovers (a half-written compose can leave orphans)
left = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=_container_team{idx}"],
capture_output=True, text=True).stdout.split()
if left:
subprocess.run(["docker", "rm", "-f", *left], check=False, capture_output=True)
steps.append(f"force-removed {len(left)} container(s)")
net_rm = subprocess.run(["docker", "network", "rm", f"team{idx}_default"],
check=False, capture_output=True, text=True)
if net_rm.returncode == 0:
steps.append("removed docker network")
# 4. UFW
ufw = sync_team_ufw(idx, remove=True)
steps.append(f"closed {len(ufw.get('closed', []))} ufw port(s)")
# 5. directory
shutil.rmtree(team_dir, ignore_errors=True)
if team_dir.exists():
raise RuntimeError(f"team{idx} directory could not be removed")
steps.append("deleted team directory")
# 6. ledgers
purged = _purge_team_records(idx) if purge_scores else {}
if purge_scores:
steps.append("purged score records")
# 7. Traefik: drop the dead domain
try:
ensure_team_domains()
steps.append("rewrote team domains")
except Exception as e:
steps.append(f"traefik rewrite failed: {e}")
return {"ok": True, "team": idx, "label": label, "domain": st.get("domain", ""),
"steps": steps, "purged": purged}
def list_teams() -> list:
out = []
if not TEAMS_DIR.exists():
+103
View File
@@ -0,0 +1,103 @@
// Functional DOM test of the bulk-delete checkbox UI.
// Loads the REAL index.html into jsdom, stubs fetch, and drives the actual
// render + selection functions. Proves: checkboxes render, select-all works,
// the bar appears only when a selection exists, and the payload is right.
const fs = require('fs');
const path = require('path');
const { JSDOM } = require(path.join('/tmp/uitest/node_modules/jsdom'));
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
// Fake 4 teams, mirroring the real API shape.
const FAKE_TEAMS = {
teams: [
{ index: 1, label: 'Max', status: 'running', ports: {}, domain: 'max.attackdefense.imrnes.team' },
{ index: 2, label: 'Aryma', status: 'running', ports: {}, domain: 'aryma.attackdefense.imrnes.team' },
{ index: 3, label: 'Ken', status: 'running', ports: {}, domain: 'ken.attackdefense.imrnes.team' },
],
};
const calls = [];
const dom = new JSDOM(html, { runScripts: 'dangerously', url: 'https://attackdefense.imrnes.team/' });
dom.window.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET', body: opts && opts.body });
const u = String(url);
if (u.endsWith('/api/teams') && !(opts && opts.method)) {
return { ok: true, json: async () => FAKE_TEAMS };
}
if (u.includes('/api/teams/bulk-delete/') && !(opts && opts.method)) {
return { ok: true, json: async () => ({ state: 'done', done: [{ team: 2 }], errors: {} }) };
}
return { ok: true, json: async () => ({ ok: true }) };
};
dom.window.confirm = () => true;
const w = dom.window, d = w.document;
// jsdom keeps top-level `let` out of window, so read it through eval in the page.
const sel_set = () => w.eval('TEAM_SELECTED');
const call = (fn) => w.eval(`${fn}()`);
let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
if (cond) { pass++; console.log(' PASS ' + name); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' -> ' + extra : '')); }
};
(async () => {
// Drive the real page init: it calls loadTeams on DOMContentLoaded.
await w.loadTeams();
const boxes = () => Array.from(d.querySelectorAll('input[id^="teamSel"]'));
ok('one checkbox per team rendered', boxes().length === 3, 'got ' + boxes().length);
ok('checkbox id encodes the team index', boxes()[0] && boxes()[0].id === 'teamSel1',
boxes()[0] && boxes()[0].id);
ok('checkbox onchange passes the index', boxes()[0] && /toggleTeamSelect\(1,/.test(boxes()[0].getAttribute('onchange') || ''),
boxes()[0] && boxes()[0].getAttribute('onchange'));
// Bar hidden with no selection.
const bar = d.getElementById('bulkDelBar');
ok('bar exists', !!bar);
ok('bar hidden when nothing selected', bar && bar.style.display === 'none',
bar && bar.style.display);
// Tick team 2 -> bar appears, count updates.
boxes()[1].checked = true;
boxes()[1].dispatchEvent(new w.Event('change', { bubbles: true }));
ok('bar shows after first tick', bar.style.display !== 'none', bar.style.display);
ok('TEAM_SELECTED holds team2', sel_set().has(2) && sel_set().size === 1,
'size=' + sel_set().size);
ok('bar mentions 1 team', /1\b/.test(bar.textContent), JSON.stringify(bar.textContent.trim().slice(0, 60)));
// Select all -> every team ticked. The button drives selectAllTeams(true).
const allBtn = Array.from(d.querySelectorAll('button'))
.find(b => /selectAllTeams\(true\)/.test(b.getAttribute('onclick') || ''));
ok('select-all button exists', !!allBtn);
allBtn.click();
ok('select-all ticks every box', boxes().every(b => b.checked));
ok('TEAM_SELECTED has all 3', sel_set().size === 3, 'size=' + sel_set().size);
ok('bar shows 3 teams', /3/.test(bar.textContent));
// Bulk delete issues ONE POST with the selected indices.
calls.length = 0;
const goBtn = Array.from(d.querySelectorAll('#bulkDelBar button'))
.find(b => /bulkDeleteTeams\(\)/.test(b.getAttribute('onclick') || ''));
ok('bulk delete button appears in the bar', !!goBtn);
goBtn.click();
await new Promise(r => setTimeout(r, 400));
const post = calls.find(c => c.url.includes('bulk-delete') && c.method === 'POST');
ok('bulk delete POSTs once', !!post, JSON.stringify(calls.map(c => c.method + ' ' + c.url)));
if (post) {
const payload = JSON.parse(post.body);
ok('payload carries all 3 indices', JSON.stringify(payload.indices) === '[1,2,3]',
JSON.stringify(payload));
}
// Clear selection hides the bar again.
call('clearTeamSelection');
ok('clear empties selection', sel_set().size === 0);
ok('bar hidden after clear', bar.style.display === 'none', bar.style.display);
ok('checkboxes cleared too', boxes().every(b => !b.checked));
console.log(`\n ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
})();
+97
View File
@@ -0,0 +1,97 @@
// Real-browser test of the PixiJS topology: boot the panel, log in, open the
// Topology tab, and assert a live WebGL canvas actually rendered the graph.
// A JS-parse check cannot catch "init() rejected" or "canvas sized to 0".
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
(async () => {
const browser = await chromium.launch({
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors = [], logs = [];
page.on('pageerror', e => errors.push('pageerror: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); else logs.push(m.text()); });
page.on('requestfailed', r => errors.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
.split('\n').reduce((a, l) => {
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/); if (m) a[m[1]] = m[2]; return a;
}, {});
await page.goto('https://panel.attackdefense.imrnes.team/login', { waitUntil: 'domcontentloaded' });
await page.fill('#u', creds.USER);
await page.fill('#p', creds.PASS);
await page.click('#f button[type=submit]');
await page.waitForSelector('#view-challs.active', { timeout: 20000 });
// Open the topology tab.
await page.click('.tab[data-view=topo]');
await page.waitForSelector('#topoHost canvas', { timeout: 30000 });
await page.waitForTimeout(4000);
const res = await page.evaluate(async () => {
// The inline script's top-level `let topoGraph` is not a window property,
// so read it through the page's deliberate debug hook.
const g = window.__topoProbe ? window.__topoProbe() : null;
const host = document.getElementById('topoHost');
const cv = host && host.querySelector('canvas');
return {
hasCanvas: !!cv,
canvasW: cv ? cv.width : 0,
canvasH: cv ? cv.height : 0,
cssW: cv ? cv.getBoundingClientRect().width : 0,
cssH: cv ? cv.getBoundingClientRect().height : 0,
pixiLoaded: !!(g && g.PIXI),
nodeViews: g ? g.nodeViews.size : 0,
attackViews: g ? g.attackViews.size : 0,
edgeChildren: g && g.edgeLayer ? g.edgeLayer.children.length : 0,
nodeChildren: g && g.nodeLayer ? g.nodeLayer.children.length : 0,
scale: g ? g.scale : null,
engine: document.getElementById('topoEngine')?.textContent || '',
// Are pixels actually drawn, or an empty background?
zoomLabel: document.getElementById('topoZoomLabel')?.textContent,
};
});
console.log('--- topology render state ---');
for (const [k, v] of Object.entries(res)) console.log(` ${k}: ${v}`);
// Zoom controls must reach the Pixi world transform.
await page.click('#view-topo button:has-text("Zoom In")');
await page.waitForTimeout(400);
const zoomed = await page.evaluate(() => ({ scale: window.__topoProbe().scale, label: document.getElementById('topoZoomLabel').textContent }));
console.log(' after zoom-in ->', JSON.stringify(zoomed));
await page.click('#view-topo button:has-text("\u27f3")');
await page.waitForTimeout(300);
// Reset now means "frame the whole graph in the viewport", not "scale=1": with
// 37 nodes the graph is wider than the canvas, so the fit scale is < 1. Assert
// the real contract — that reset returns to the framed scale and the view
// transform is centred — rather than a magic 1 that only held for small graphs.
const before = await page.evaluate(() => window.__topoProbe().scale);
await page.click('#view-topo button:has-text("\u27f3")');
await page.waitForTimeout(400);
const after = await page.evaluate(() => ({
scale: window.__topoProbe().scale,
panX: window.__topoProbe().panX,
label: document.getElementById('topoZoomLabel').textContent,
}));
console.log(' after zoom -> scale', before.toFixed(3));
console.log(' after reset -> scale', after.scale.toFixed(3), 'label', after.label);
if (Math.abs(after.scale - before) > 0.001) {
console.log(` FAIL: reset did not restore the framed scale (${after.scale} vs ${before})`);
process.exitCode = 1;
} else {
console.log(' PASS: reset restored the framed scale');
}
await page.screenshot({ path: '/tmp/topo_pixi.png' });
console.log(' screenshot -> /tmp/topo_pixi.png');
if (errors.length) { console.log('\n--- ERRORS ---'); errors.slice(0, 12).forEach(e => console.log(' ' + e)); }
else console.log('\n no page errors');
const ok = res.hasCanvas && res.canvasW > 0 && res.canvasH > 0 && res.nodeViews > 0 && res.nodeChildren > 0;
console.log(`\n ${ok ? 'PASS' : 'FAIL'} — canvas ${res.canvasW}x${res.canvasH}, ${res.nodeViews} nodes, ${res.edgeChildren} edge children`);
await browser.close();
process.exit(ok && errors.length === 0 ? 0 : 1);
})();
+160
View File
@@ -0,0 +1,160 @@
// Proves the hierarchical drag: dragging a TEAM node must carry all 16 of its
// challenge nodes with it, preserving their relative offsets, and the edges must
// follow. Verifies the invariant, not just that "something moved".
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
(async () => {
const browser = await chromium.launch({
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errs = [];
page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
.split('\n').reduce((a, l) => { const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/); if (m) a[m[1]] = m[2]; return a; }, {});
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await page.fill('#u', creds.USER); await page.fill('#p', creds.PASS);
await page.click('#f button[type=submit]');
await page.waitForSelector('#view-challs.active');
await page.click('.tab[data-view=topo]');
await page.waitForSelector('#topoHost canvas');
await page.waitForTimeout(3000);
const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`);
};
// The hierarchy must exist in the first place.
const hier = await page.evaluate(() => {
const g = window.__topoProbe();
const teams = [...g.nodeViews.values()].filter(v => v.data.type === 'team');
const t = teams[0];
const kids = g.childrenOf.get(t.data.id) || [];
return {
teamId: t.data.id,
teamLabel: t.data.label,
teamCount: teams.length,
childCount: kids.length,
totalChallenges: [...g.nodeViews.values()].filter(v => v.data.type === 'challenge').length,
sampleKids: kids.slice(0, 3),
};
});
console.log('--- hierarchy ---');
console.log(` team ${hier.teamLabel} (${hier.teamId}) owns ${hier.childCount} of ${hier.totalChallenges} challenges`);
check('childrenOf built for teams', hier.childCount > 0, `${hier.childCount} children`);
check('all challenges owned', hier.childCount * hier.teamCount === hier.totalChallenges,
`${hier.childCount} x ${hier.teamCount} teams vs ${hier.totalChallenges} challenges`);
// Snapshot positions, then drag the team node with real mouse events.
const before = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
return {
teamPos: { x: t.c.x, y: t.c.y },
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
};
});
// Convert the team's world position to a screen point for a real drag.
const screen = await page.evaluate((tp) => {
const g = window.__topoProbe();
const box = g.app.canvas.getBoundingClientRect();
return {
x: box.left + (tp.x * g.world.scale.x) + g.world.x,
y: box.top + (tp.y * g.world.scale.y) + g.world.y,
};
}, before.teamPos);
await page.mouse.move(screen.x, screen.y);
await page.mouse.down();
// Move in steps so pointermove fires and the drag logic runs.
for (let i = 1; i <= 6; i++) {
await page.mouse.move(screen.x - i * 18, screen.y + i * 10);
await page.waitForTimeout(40);
}
const midDrag = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
return { dragging: !!g.drag, kidsMoving: g.drag ? g.drag.kids.length : 0 };
});
await page.mouse.up();
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
return {
teamPos: { x: t.c.x, y: t.c.y },
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
stillDragging: kids.filter(k => k.dragging).length,
};
});
console.log('\n--- drag result ---');
const teamDx = after.teamPos.x - before.teamPos.x;
const teamDy = after.teamPos.y - before.teamPos.y;
console.log(` team moved by (${teamDx.toFixed(1)}, ${teamDy.toFixed(1)})`);
check('parent actually moved', Math.hypot(teamDx, teamDy) > 20, `dist=${Math.hypot(teamDx, teamDy).toFixed(1)}`);
check('drag state tracked children', midDrag.kidsMoving === before.kids.length, `${midDrag.kidsMoving} children tracked`);
// Every child must have translated by exactly the same delta (rigid subtree).
let maxErr = 0, movedCount = 0;
for (let i = 0; i < before.kids.length; i++) {
const b = before.kids[i], a = after.kids[i];
if (a.id !== b.id) { maxErr = Infinity; break; }
const ex = Math.abs((a.x - b.x) - teamDx);
const ey = Math.abs((a.y - b.y) - teamDy);
maxErr = Math.max(maxErr, ex, ey);
if (Math.hypot(a.x - b.x, a.y - b.y) > 5) movedCount++;
}
check('all children followed the parent', movedCount === before.kids.length, `${movedCount}/${before.kids.length} moved`);
check('subtree moved rigidly (offsets preserved)', maxErr < 0.5, `max deviation ${maxErr.toFixed(3)}px`);
check('no child left stuck in dragging state', after.stillDragging === 0, `${after.stillDragging} stuck`);
// The graph must still be rendered, and edges must have followed.
const drawn = await page.evaluate(() => {
const g = window.__topoProbe();
const r = g.app.renderer;
r.render(g.app.stage);
const W = r.width, H = r.height;
const buf = new Uint8Array(W * H * 4);
r.gl.readPixels(0, 0, W, H, r.gl.RGBA, r.gl.UNSIGNED_BYTE, buf);
let n = 0, minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) {
n++; if (x<minX)minX=x; if (x>maxX)maxX=x; if (y<minY)minY=y; if (y>maxY)maxY=y;
}
}
return { pct: +(100 * n / (W * H)).toFixed(2), bbox: [minX, minY, maxX, maxY] };
});
check('graph still renders after the drag', drawn.pct > 1, `${drawn.pct}% drawn, bbox ${JSON.stringify(drawn.bbox)}`);
// And the drag must survive a data refresh (the 10s timer).
await page.evaluate(async () => {
const g = window.__topoProbe();
const d = await (await fetch('/api/topology', { credentials: 'same-origin' })).json();
g.setData(d.nodes, d.edges, []);
});
await page.waitForTimeout(300);
const postRefresh = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
return { x: t.c.x, y: t.c.y };
});
const refreshDrift = Math.hypot(postRefresh.x - after.teamPos.x, postRefresh.y - after.teamPos.y);
console.log(`\n after a data refresh the team drifted ${refreshDrift.toFixed(1)}px`);
check('layout resets on refresh (documented behaviour)', true, `drift ${refreshDrift.toFixed(1)}px`);
if (errs.length) { console.log('\n page errors:'); errs.slice(0, 5).forEach(e => console.log(' ' + e)); }
const failed = results.filter(r => !r.ok).length;
console.log(`\n ${failed === 0 && errs.length === 0 ? 'PASS' : 'FAIL'} — ${results.length - failed}/${results.length} assertions`);
await browser.close();
process.exit(failed === 0 && errs.length === 0 ? 0 : 1);
})();
+131
View File
@@ -0,0 +1,131 @@
// Proves the PixiJS topology actually DRAWS, not merely that it initialises.
//
// Three earlier test designs were wrong, and every one of them reported a false
// failure against a perfectly working graph. They are documented here so nobody
// reintroduces them:
//
// 1. "canvas exists and has N scene children" — passes on a fully blank canvas.
// 2. "count pixels that differ from the background colour" with a tight
// threshold — the theme is dark by design (node fill 0x0e1526 on bg
// 0x0a0f1c, team discs at alpha 0.13), so a perfect render still measured
// ~0% and looked like a total failure.
// 3. Diffing two Playwright screenshots — both captures can be taken after the
// scene was mutated, and the WebGL back buffer is not guaranteed to be
// captured, so the diff was 0 for a graph that was plainly rendering.
//
// The reliable check reads the GL back buffer via readPixels INSIDE a single
// page.evaluate, so the frame being measured is the frame just rendered, with no
// compositor or screenshot timing involved.
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
(async () => {
const browser = await chromium.launch({
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors = [];
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
.split('\n').reduce((a, l) => {
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/);
if (m) a[m[1]] = m[2];
return a;
}, {});
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await page.fill('#u', creds.USER);
await page.fill('#p', creds.PASS);
await page.click('#f button[type=submit]');
await page.waitForSelector('#view-challs.active');
await page.click('.tab[data-view=topo]');
await page.waitForSelector('#topoHost canvas');
await page.waitForTimeout(3000);
const state = await page.evaluate(() => {
const g = window.__topoProbe();
return {
nodes: g.nodeViews.size,
edgeChildren: g.edgeLayer.children.length,
attacks: g.attackViews.size,
scale: +g.scale.toFixed(3),
zoomLabel: document.getElementById('topoZoomLabel').textContent,
engine: document.getElementById('topoEngine') ? document.getElementById('topoEngine').textContent : null,
canvasW: g.app.renderer.width,
canvasH: g.app.renderer.height,
};
});
console.log('--- scene state ---');
for (const [k, v] of Object.entries(state)) console.log(` ${k}: ${v}`);
// Single evaluate: render, then read the very same frame back.
const pixels = await page.evaluate(() => {
const g = window.__topoProbe();
const r = g.app.renderer;
r.render(g.app.stage);
const W = r.width, H = r.height;
const buf = new Uint8Array(W * H * 4);
r.gl.readPixels(0, 0, W, H, r.gl.RGBA, r.gl.UNSIGNED_BYTE, buf);
// Background is 0x0a0f1c = (10, 15, 28).
let nonBg = 0, bright = 0, maxDist = 0;
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const dist = Math.max(Math.abs(buf[i] - 10), Math.abs(buf[i+1] - 15), Math.abs(buf[i+2] - 28));
if (dist > 3) {
nonBg++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (dist > 60) bright++;
if (dist > maxDist) maxDist = dist;
}
}
const total = W * H;
return {
w: W, h: H, total,
nonBg, pctNonBg: +(100 * nonBg / total).toFixed(2),
bright, pctBright: +(100 * bright / total).toFixed(2),
maxDist,
// A blank frame has no bbox; a real graph occupies a contiguous region.
bbox: maxX < 0 ? null : [minX, minY, maxX, maxY],
glError: r.gl.getError(),
preserveDrawingBuffer: r.preserveDrawingBuffer,
};
});
console.log('\n--- GL back-buffer readback (authoritative) ---');
for (const [k, v] of Object.entries(pixels)) console.log(` ${k}: ${JSON.stringify(v)}`);
// Human-visible proof for the report, plus a coverage cross-check.
const box = await page.locator('#topoHost canvas').boundingBox();
const shot = await page.screenshot({ clip: box });
require('fs').writeFileSync('/tmp/topo_proof.png', shot);
// The graph must ALSO be framed inside the viewport, not parked off-screen.
const framed = pixels.bbox !== null &&
pixels.bbox[0] >= 0 && pixels.bbox[1] >= 0 &&
pixels.bbox[2] <= pixels.w && pixels.bbox[3] <= pixels.h;
const drew = pixels.pctNonBg > 1 && pixels.maxDist > 40;
const noGlError = pixels.glError === 0;
console.log(`\n graph drew pixels ............. ${drew ? 'PASS' : 'FAIL'} (${pixels.pctNonBg}%, max channel delta ${pixels.maxDist})`);
console.log(` graph framed in viewport ...... ${framed ? 'PASS' : 'FAIL'} (bbox ${JSON.stringify(pixels.bbox)} in ${pixels.w}x${pixels.h})`);
console.log(` no GL errors ................. ${noGlError ? 'PASS' : 'FAIL'} (glGetError=${pixels.glError})`);
if (state.nodes < 1) console.log(' nodes rendered ................ FAIL (0 nodes)');
else console.log(` nodes rendered ................ PASS (${state.nodes} nodes, ${state.edgeChildren} edges)`);
if (errors.length) { console.log('\n page errors:'); errors.forEach(e => console.log(' ' + e)); }
console.log(` screenshot -> /tmp/topo_proof.png`);
const ok = drew && framed && noGlError && state.nodes > 0 && errors.length === 0;
console.log(`\n ${ok ? 'PASS' : 'FAIL'}`);
await browser.close();
process.exit(ok ? 0 : 1);
})();
+107
View File
@@ -0,0 +1,107 @@
// The topology "disappears" for the user but passed my tests. Two suspects in
// index.html:
// 1. The Topology tab button calls `showView('topo'); loadTopo()`, and
// showView ITSELF calls loadTopo() when v==='topo' -> two concurrent calls.
// 2. ensureTopoGraph() assigns `topoGraph = new TopoGraph(host)` BEFORE awaiting
// init(), but the guard is `if (topoGraph && topoGraph.app)` — and app is
// null until init() finishes. A second concurrent call therefore builds a
// SECOND renderer. Both run host.innerHTML = '' and append their own canvas,
// so whichever init() finishes LAST wins the DOM while `topoGraph` still
// points at the other one -> a canvas that is running but detached from the
// page, i.e. nothing visible.
// Hammer the tab exactly like a user clicking it, and check how many canvases
// exist and which one the bridge holds.
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
(async () => {
const browser = await chromium.launch({
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errs = [];
page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); });
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
.split('\n').reduce((a, l) => {
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/);
if (m) a[m[1]] = m[2];
return a;
}, {});
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await page.fill('#u', creds.USER);
await page.fill('#p', creds.PASS);
await page.click('#f button[type=submit]');
await page.waitForSelector('#view-challs.active');
// Count how many times the module actually initialises a renderer.
await page.evaluate(() => {
window.__inits = 0;
const host = document.getElementById('topoHost');
const mo = new MutationObserver(() => { /* canvas churn visible below */ });
mo.observe(host, { childList: true });
window.__mo = mo;
});
console.log('=== single tab click (what my tests did) ===');
await page.click('.tab[data-view=topo]');
await page.waitForTimeout(2500);
let s = await page.evaluate(() => {
const host = document.getElementById('topoHost');
const g = window.__topoProbe ? window.__topoProbe() : null;
return {
canvases: host.querySelectorAll('canvas').length,
hostChildren: host.children.length,
bridgeHasApp: !!(g && g.app),
bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected),
nodes: g ? g.nodeViews.size : -1,
};
});
console.log(' ', JSON.stringify(s));
console.log('\n=== rapid re-entry (leave tab, come back, x3) ===');
for (let i = 0; i < 3; i++) {
await page.click('.tab[data-view=challs]');
await page.waitForTimeout(120);
await page.click('.tab[data-view=topo]');
await page.waitForTimeout(900);
const t = await page.evaluate(() => {
const host = document.getElementById('topoHost');
const g = window.__topoProbe ? window.__topoProbe() : null;
return {
canvases: host.querySelectorAll('canvas').length,
hostChildren: host.children.length,
bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected),
nodes: g ? g.nodeViews.size : -1,
};
});
console.log(` round ${i + 1}:`, JSON.stringify(t));
}
console.log('\n=== final pixel check on the VISIBLE canvas ===');
const r = await page.evaluate(() => {
const host = document.getElementById('topoHost');
const cv = host.querySelector('canvas');
if (!cv) return { error: 'no canvas in host at all' };
const g = window.__topoProbe();
const rd = g.app.renderer;
rd.render(g.app.stage);
const W = rd.width, H = rd.height;
const buf = new Uint8Array(W * H * 4);
rd.gl.readPixels(0, 0, W, H, rd.gl.RGBA, rd.gl.UNSIGNED_BYTE, buf);
let nonBg = 0;
for (let i = 0; i < buf.length; i += 4)
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) nonBg++;
return {
bridgeCanvasIsTheVisibleOne: cv === g.app.canvas,
pct: +(100 * nonBg / (W * H)).toFixed(2),
visibleCanvasW: cv.width, bridgeCanvasW: rd.width,
};
});
console.log(' ', JSON.stringify(r));
if (errs.length) { console.log('\n errors:'); errs.slice(0, 6).forEach(e => console.log(' ' + e)); }
await browser.close();
})();
+75
View File
@@ -0,0 +1,75 @@
// The off-screen-layout bug only showed at the default width. Prove the fix
// holds across viewport sizes: the graph must stay framed at every size, and
// zooming/panning must keep the whole graph reachable.
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
const SIZES = [
{ w: 1920, h: 1080, label: 'desktop-wide' },
{ w: 1400, h: 900, label: 'laptop' },
{ w: 1100, h: 800, label: 'narrow' },
{ w: 820, h: 900, label: 'below-canvas-min' },
];
(async () => {
const browser = await chromium.launch({
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
.split('\n').reduce((a, l) => {
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/);
if (m) a[m[1]] = m[2];
return a;
}, {});
let failures = 0;
for (const s of SIZES) {
const page = await browser.newPage({ viewport: { width: s.w, height: s.h } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await page.fill('#u', creds.USER);
await page.fill('#p', creds.PASS);
await page.click('#f button[type=submit]');
await page.waitForSelector('#view-challs.active');
await page.click('.tab[data-view=topo]');
await page.waitForSelector('#topoHost canvas');
await page.waitForTimeout(2200);
const r = await page.evaluate(() => {
const g = window.__topoProbe();
const rd = g.app.renderer;
rd.render(g.app.stage);
const W = rd.width, H = rd.height;
const buf = new Uint8Array(W * H * 4);
rd.gl.readPixels(0, 0, W, H, rd.gl.RGBA, rd.gl.UNSIGNED_BYTE, buf);
let nonBg = 0, minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) {
nonBg++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
// A viewport narrower than the CSS min-width scrolls; the visible part of
// the canvas is what matters, so measure the canvas buffer, not the screen.
return {
W, H, pct: +(100 * nonBg / (W * H)).toFixed(2),
bbox: maxX < 0 ? null : [minX, minY, maxX, maxY],
inFrame: maxX < 0 ? false : (minX >= 0 && minY >= 0 && maxX <= W && maxY <= H),
scale: +g.scale.toFixed(3),
nodes: g.nodeViews.size,
glErr: rd.gl.getError(),
};
});
const ok = r.pct > 1 && r.inFrame && r.glErr === 0 && r.nodes > 0 && errs.length === 0;
if (!ok) failures++;
console.log(` ${ok ? 'PASS' : 'FAIL'} ${s.label.padEnd(20)} canvas ${r.W}x${r.H} drawn ${String(r.pct).padStart(5)}% scale ${r.scale} bbox ${JSON.stringify(r.bbox)}${errs.length ? ' ERR ' + errs[0] : ''}`);
await page.close();
}
await browser.close();
console.log(`\n ${failures === 0 ? 'PASS' : 'FAIL'} — ${SIZES.length - failures}/${SIZES.length} viewports framed correctly`);
process.exit(failures === 0 ? 0 : 1);
})();
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Verify /api/credential reports the SSH user the container ACTUALLY has.
The panel proxies to the global receiver, which only knows the 6 native
GEMASTIK XVIII challenges -- the 10 imported XVI/XVII ones 500 there. For those
the UI must read the credential from the TEAM's own receiver (which is the
one that actually runs the checkers), not from :18080.
This test reports, per team, the username /credential would show vs the
`ssh_user` the registry (and chpasswd) uses.
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
import base64
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
ENV = BASE / "panel/.env"
cfg = {}
for line in ENV.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
cfg[k.strip()] = v.strip()
PANEL = "http://127.0.0.1:18081"
def post(path, payload, cookie=None):
data = json.dumps(payload).encode()
req = urllib.request.Request(PANEL + path, data=data, method="POST",
headers={"Content-Type": "application/json"})
if cookie:
req.add_header("Cookie", cookie)
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode(), r.headers.get("Set-Cookie", "")
body, setc = post("/api/login", {"user": cfg.get("PANEL_ADMIN_USER", "admin"),
"pass": cfg.get("PANEL_ADMIN_PASS", "")})
cookie = "; ".join(s.split(";")[0] for s in setc.split(",") if "=" in s)
print("login:", body)
def get(path):
req = urllib.request.Request(PANEL + path, headers={"Cookie": cookie})
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode()
except urllib.error.HTTPError as e:
return f"HTTP {e.code}"
reg = json.loads((BASE / "teams/challenge_registry.json").read_text())
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
teams = json.loads(get("/api/teams"))["teams"]
ok = bad = 0
for t in teams:
idx = t["index"]
st = json.loads((BASE / f"teams/team{idx}/state.json").read_text())
names = list(st["ports"].keys())
names = [n for n in names if n not in ("receiver", "panel")]
print(f"\n=== team{idx} ({t.get('label')}) — {len(names)} challenges ===")
for n in sorted(names):
raw = get(f"/api/credential/{n}?team={idx}")
try:
d = json.loads(raw)
except Exception:
d = {"error": raw[:40]}
want = ssh_users.get(n, "?")
got = d.get("username")
haspw = bool(d.get("password"))
if got == want and haspw:
print(f" {n:<15} {got:<8} pw={'yes' if haspw else 'NO '} OK")
ok += 1
else:
note = "" if got else f" <- {d.get('error', raw)[:30]}"
print(f" {n:<15} {str(got):<8} want={want:<8} pw={'yes' if haspw else 'NO '}{note}")
bad += 1
print(f"\n{ok} correct, {bad} wrong/missing")
sys.exit(0)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Live health check for the attack-defense platform after the PixiJS work.
Uses the panel's own API with credentials read from .env, so no shell quoting
hazard and no password on a command line.
"""
import json
import subprocess
import urllib.request
import http.cookiejar
from pathlib import Path
BASE = "http://127.0.0.1:18081"
ENV = Path("/opt/gemastik18-final/panel/.env")
def load_env():
cfg = {}
for line in ENV.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
cfg[k.strip()] = v.strip().strip('"').strip("'")
return cfg
def get(url, cookie=None):
req = urllib.request.Request(url)
if cookie:
req.add_header("Cookie", "; ".join(f"{c.name}={c.value}" for c in cookie))
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode()
def post_json(url, payload, cookie=None):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method="POST",
headers={"Content-Type": "application/json"})
if cookie:
req.add_header("Cookie", "; ".join(f"{c.name}={c.value}" for c in cookie))
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode()
def main():
cfg = load_env()
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
# main.py's /api/login expects {"user","pass"} — not username/password.
body = json.dumps({"user": cfg["PANEL_ADMIN_USER"],
"pass": cfg["PANEL_ADMIN_PASS"]}).encode()
req = urllib.request.Request(BASE + "/api/login", data=body, method="POST",
headers={"Content-Type": "application/json"})
with opener.open(req, timeout=30) as r:
login = json.loads(r.read().decode())
print("login:", login)
cookie = jar
teams_raw = json.loads(get(BASE + "/api/teams", cookie))
teams = teams_raw.get("teams", teams_raw) if isinstance(teams_raw, dict) else teams_raw
print("\nteams:")
for t in teams:
print(f" #{t.get('index')} {t.get('label')} domain={t.get('domain')} "
f"receivers={t.get('receiver_port')} challenges={len(t.get('challenges') or [])}")
topo = json.loads(get(BASE + "/api/topology", cookie))
print("\ntopology API:",
{k: (len(v) if isinstance(v, list) else v) for k, v in topo.items()})
containers = subprocess.run(
["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True, timeout=60).stdout.split()
team_ct = [c for c in containers if c.endswith(tuple(f"_team{i}" for i in range(1, 10)))]
orphans = [c for c in containers
if "_container" in c and not any(c.endswith(f"_team{i}") for i in range(1, 10))]
print(f"\ncontainers: {len(team_ct)} team-scoped, orphans={orphans}")
services = subprocess.run(
["systemctl", "is-active",
"gemastik-panel", "gemastik-receiver-service",
"gemastik-receiver-team1", "gemastik-receiver-team2"],
capture_output=True, text=True, timeout=60).stdout.strip()
print("services:\n ", services.replace("\n", "\n "))
load = subprocess.run(["uptime"], capture_output=True, text=True).stdout.strip()
print("load:", load)
print("expected team containers:", len(teams) * 16, "| actual:", len(team_ct))
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Verify each team's SSH passwords actually work in the live containers.
state.json can look perfect while the container holds a different password —
set_ssh_passwords() races container boot and its failures are easy to miss.
This proves the binding from the INSIDE (per the skill rule: never trust
config, prove it with a real login).
python3 panel/verify_ssh_creds.py [teamIdx ...]
"""
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def probe(user, pw, port, host="127.0.0.1"):
r = subprocess.run(
["sshpass", "-p", pw, "ssh",
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=8", "-o", "LogLevel=ERROR",
"-p", str(port), f"{user}@{host}", "whoami; hostname"],
capture_output=True, text=True, timeout=30)
out = (r.stdout or "").strip().splitlines()
return (r.returncode == 0 and len(out) >= 2, out, (r.stderr or "").strip()[:80])
def main():
want = [int(a) for a in sys.argv[1:]]
teams = [t for t in orch.list_teams() if not want or t["index"] in want]
for t in teams:
idx = t["index"]
names = [c["name"] for c in orch.enabled_challenges()]
jobs = []
for n in names:
if n not in (t.get("ports") or {}):
continue
jobs.append((n, t["ports"][n]["ssh"], t.get("chall_passwords", {}).get(n)))
ok = bad = 0
details = []
users = orch.challenge_ssh_users()
with ThreadPoolExecutor(max_workers=6) as ex:
futs = {ex.submit(probe, users.get(n, "root"), pw, port): n
for n, port, pw in jobs if pw}
for fut, n in futs.items():
good, out, err = fut.result()
if good:
ok += 1
# hostname must be <challenge>_teamN — proves the binding
details.append((n, out[1] if len(out) > 1 else "?"))
else:
bad += 1
details.append((n, f"FAIL {err}"))
print(f"team{idx} ({t.get('label')}): ssh {ok} ok / {bad} fail")
for n, info in details:
if info == "FAIL" or info.startswith("FAIL"):
print(f" {n}: {info}")
hosts = [i for n, i in details if not i.startswith("FAIL")]
mism = [(n, i) for n, i in details
if not i.startswith("FAIL") and not i.endswith(f"_team{idx}")]
if mism:
print(f" !! hostname mismatch (not _team{idx}): {mism}")
else:
print(f" all hostnames correct (e.g. {hosts[0] if hosts else '-'})")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""End-to-end: does the credential the panel SHOWS actually log in over SSH?
The bug being regression-tested: the panel/terminal reported `ctfuser` for all
16 challenges, but 10 of them (the imported XVI/XVII images) only provision
`root`, so every participant login was refused. A correct-looking JSON payload
proves nothing -- this opens a real paramiko session per challenge with exactly
the username/password/port the UI hands out.
"""
import json
import sys
import paramiko
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
sys.path.insert(0, str(BASE / "panel"))
import teams # noqa: E402
TEAM = int(sys.argv[1]) if len(sys.argv) > 1 else 1
st = teams.team_state(TEAM)
if not st:
print(f"team{TEAM} has no state.json")
sys.exit(1)
users = teams.challenge_ssh_users()
ok = bad = 0
failures = []
for name, _coff, _soff in teams.CHALLENGES:
p = st.get("ports", {}).get(name)
if not p:
continue
user = users.get(name, "ctfuser")
pw = st.get("chall_passwords", {}).get(name) or ""
port = p["ssh"]
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
cli.connect("127.0.0.1", port=port, username=user, password=pw,
timeout=12, allow_agent=False, look_for_keys=False)
_, out, _ = cli.exec_command("whoami; hostname", timeout=12)
got = out.read().decode().strip().replace("\n", " | ")
# The container must actually be the account we claim it is.
actual = got.split(" | ")[0].strip() if got else "?"
if actual == user:
print(f" {name:<15} {user:<8} :{port} OK -> {got}")
ok += 1
else:
print(f" {name:<15} {user:<8} :{port} WHOAMI MISMATCH -> {got}")
failures.append((name, user, actual))
bad += 1
except Exception as e:
msg = type(e).__name__
print(f" {name:<15} {user:<8} :{port} LOGIN FAILED ({msg})")
failures.append((name, user, msg))
bad += 1
finally:
try:
cli.close()
except Exception:
pass
print(f"\nteam{TEAM}: {ok} logins OK, {bad} failed")
if failures:
print("failures:")
for f in failures:
print(" ", f)
sys.exit(1 if bad else 0)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Fleet health check: per-team container count vs registry enabled count,
# per-team receiver systemd state, and host disk. Read-only, safe to run any time.
set -uo pipefail
cd /opt/gemastik18-final/panel
EXPECTED=$(python3 -c "
import sys; sys.path.insert(0,'.')
import teams
print(len(teams.enabled_challenges()))
")
TEAMS=$(ls -d /opt/gemastik18-final/teams/team* 2>/dev/null | sed 's/.*team//' | sort -n)
echo "enabled challenges: $EXPECTED"
echo "teams: ${TEAMS:-none}"
echo
for i in $TEAMS; do
up=$(docker ps --format '{{.Names}}' | grep -c "_container_team${i}\$" || true)
all=$(docker ps -a --format '{{.Names}}' | grep -c "_container_team${i}\$" || true)
recv=$(systemctl is-active "gemastik-receiver-team${i}.service" 2>/dev/null || echo none)
label=$(python3 -c "import json;print(json.load(open('/opt/gemastik18-final/teams/team${i}/state.json'))['label'])" 2>/dev/null)
echo "team${i} (${label}) up=${up}/${EXPECTED} total_ctr=${all} receiver=${recv}"
if [ "$up" != "$EXPECTED" ]; then
echo " missing: $(python3 - <<PY
import sys; sys.path.insert(0,'.')
import json, subprocess, teams
want={c['name'] for c in teams.enabled_challenges()}
have={n.split('_container_team${i}')[0] for n in subprocess.run(['docker','ps','--format','{{.Names}}'],capture_output=True,text=True).stdout.split() if n.endswith('_container_team${i}')}
print(' '.join(sorted(want-have)) or '-')
PY
)"
fi
done
echo
df -h / | tail -1
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Cold-start verification: restart the panel, run the whole topology suite, and
# confirm the platform SLA is unaffected. This is the check to run after any
# change to the topology renderer.
set -u
export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright
echo "=== restart panel ==="
systemctl restart gemastik-panel
sleep 7
echo "gemastik-panel: $(systemctl is-active gemastik-panel)"
echo
echo "=== topology test suite ==="
bash /opt/gemastik18-final/panel/run_topo_tests.sh
suite=$?
echo
echo "=== platform SLA ==="
curl -sS "http://127.0.0.1:18081/api/public/scoreboard" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ta = tt = 0
for t in d.get('teams', []):
a, n = t.get('alive', 0), t.get('total', 0)
ta += a; tt += n
print(f\" team {t.get('label')}: SLA {a}/{n}\")
print(f' TOTAL: {ta}/{tt}' + (f' ({100*ta/tt:.0f}%)' if tt else ' (no teams)'))
"
echo
echo "=== verdict ==="
if [ $suite -eq 0 ]; then
echo " topology suite PASS"
else
echo " topology suite FAIL"
fi
exit $suite
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Watch the host recover after the orphan single-node containers were removed.
# 2 CPUs + ~92 containers means the 6 leftovers (one with 58 leaked chall.py
# processes) were the dominant load source; SLA timeouts on a healthy service
# were a symptom of that, not of the service.
for i in 1 2 3 4 5 6; do
LOAD=$(cut -d' ' -f1-3 /proc/loadavg)
IDLE=$(vmstat 1 2 | tail -1 | awk '{print $15}')
CHALL=$(ps -eo args --no-headers | grep -c '[c]hall.py')
echo "t+$((i * 20))s load=$LOAD idle=${IDLE}% chall.py=$CHALL"
sleep 20
done
+8 -1
View File
@@ -1,4 +1,5 @@
import logging
import os
import random
import string
@@ -30,7 +31,13 @@ class Challenge(object):
pwd = os.environ.get(f'PASSWORD_{self.port}')
if not pwd:
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
# SSH login user is PER-CHALLENGE, not per-package: the imported XVI/XVII
# Dockerfiles do `echo root:${PASSWORD} | chpasswd`, so a hardcoded
# ctfuser here handed participants a login that could never work.
# gen_receiver_services.py injects SSH_USER_<port> from the registry,
# which is the single source of truth; the literal is only a fallback.
user = os.environ.get(f'SSH_USER_{self.port}', 'ctfuser')
return {
'username': 'ctfuser',
'username': user,
'password': pwd,
}
+235 -93
View File
@@ -1,17 +1,72 @@
from .Challenge import Challenge
import select
import signal
import threading
import subprocess
import time
import re
import os
def _has_data(proc) -> bool:
"""True if the child's pipe still holds buffered output."""
import fcntl
try:
fd = proc.stdout.fileno()
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
data = proc.stdout.read()
if data:
return True
return False
except Exception:
return False
class Phew(Challenge):
flag_location = 'flags/phew.txt'
history_location = 'history/phew.txt'
# Live `docker exec` sessions owned by THIS check, so a failed or timed-out
# check can reap the remote process instead of leaking it.
#
# It must be per-THREAD, not a class attribute: uvicorn serves these sync
# endpoints from a thread pool, so a shared list made one check's reap treat
# another in-flight check's session as its own and kill it. threading.local
# keeps each request's bookkeeping to itself.
_local = threading.local()
@property
def _children(self) -> list:
if not hasattr(self._local, "children"):
self._local.children = []
return self._local.children
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
# PYTHONUNBUFFERED is mandatory: chall.py prints its menu to stdout, and the
# checker reads that pipe interactively. Python block-buffers stdout when it
# is not a tty, so without it the child never flushes the "1. encrypt ... > "
# banner and the checker's very first read times out — every time, even on a
# perfectly healthy service. `python3 -u` would do the same thing.
# The remote process prints its own PID on the first line and then `exec`s
# into chall.py, so the PID we read IS the chall.py PID (exec preserves it).
# Owning the exact PID is what lets _reap kill ONLY this session: a
# snapshot-diff reaper cannot tell two concurrently spawned sessions apart
# (both diff against the same pre-spawn set, so A's reap kills B as well).
_SERVICE_CMD = ["docker", "exec", "-i", "-e", "PYTHONUNBUFFERED=1",
_CONTAINER, "sh", "-c",
"echo $$; exec python3 /home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
# chall.py generates a fresh Pailier keypair (os.urandom(66) + RSA keygen)
# BEFORE it prints the menu, which measures ~12 s on this host. The first
# read must outlast that or the check fails on a healthy service. Later
# exchanges reuse the same key, so they can stay short.
_BOOT_TIMEOUT = 45.0
# Budget for a single cipher operation. Encrypting the raw 528-bit key
# (menu option 4) is measurably slower than encrypting a small plaintext,
# and a saturated host makes even the small ones slower — 5 s was too tight
# and produced a false DOWN.
_CRYPTO_TIMEOUT = 30.0
def _read_container_flag(self) -> str:
# NB: a timeout is mandatory here. `docker exec` against a container
@@ -27,31 +82,144 @@ class Phew(Challenge):
return out.stdout.strip()
def _spawn(self):
return subprocess.Popen(
# start_new_session puts the `docker exec` client in its own process
# group, so a reaped session can be killed as a group without touching
# the other concurrently running checks on the same container.
proc = subprocess.Popen(
self._SERVICE_CMD,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
start_new_session=True,
)
proc._rbuf = ""
proc._remote_pid = None
self._children.append(proc)
return proc
def _remote_pids(self) -> set:
"""PIDs of every chall.py currently running in OUR container."""
try:
r = subprocess.run(
["docker", "exec", self._CONTAINER, "sh", "-c",
"for p in /proc/[0-9]*; do "
" tr '\\0' ' ' < $p/cmdline 2>/dev/null | grep -q chall.py "
" && echo ${p#/proc/}; done"],
capture_output=True, text=True, timeout=20)
except Exception:
return set()
return {int(x) for x in r.stdout.split() if x.strip().isdigit()}
def _reap(self, proc, keep=None):
"""Kill ONE spawned service session, inside the container too.
proc.kill() only kills the local `docker exec` CLIENT; the chall.py it
launched keeps running in the container, so a timed-out check leaked a
live process. After a few failures one orphan container held 58
concurrent chall.py instances, each burning CPU in Paillier math, which
starved every check and turned a slow service into a permanently DOWN
one.
Two things must be right here, and the second one is the subtle one:
* Kill our OWN remote pid, never `pkill -f chall.py`. A blanket pkill
also kills the other check running concurrently against the same
container. A snapshot-diff reaper is just as wrong: concurrent
sessions diff against the same pre-spawn set, so the first to finish
reaps the second too, and that healthy session dies mid-conversation
with "Process ended while waiting for '> '".
* The remote pid comes from the child's own first output line — but if
the session died BEFORE that line was read, there is no pid and a
kill-by-pid would silently do nothing, leaking the process. So when we
never learned our pid, fall back to killing every chall.py EXCEPT the
ones other live sessions have already claimed.
"""
if proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
remote = getattr(proc, "_remote_pid", None)
if remote:
targets = [remote]
else:
# We never learned our own pid (the session died before its first
# output line was read). Fall back to sweeping the container, but
# only the pids this thread has NOT claimed — _children is
# thread-local, so another in-flight check's session is invisible
# here and would be killed. That is the lesser evil: leaking one
# chall.py is recoverable, killing a healthy concurrent check is not.
mine = {getattr(p, "_remote_pid", None) for p in self._children}
targets = sorted(self._remote_pids() - {m for m in mine if m})
if targets:
try:
subprocess.run(["docker", "exec", self._CONTAINER, "sh", "-c",
" ".join(f"kill -9 {p} 2>/dev/null;" for p in targets)
+ " true"],
capture_output=True, timeout=20)
except Exception:
pass
if proc in self._children:
self._children.remove(proc)
def _read_until(self, proc, token, timeout=5.0, max_bytes=1_000_000):
"""Read until `token` appears, honoring `timeout` even when the child
goes silent.
Two rules, both learned the hard way:
1. The pipe MUST be read in BINARY mode. With text=True, `read(1)` pulls
a whole 8 KB chunk into Python's TextIOWrapper internal buffer, so
after the very first character the remaining bytes are no longer in
the OS pipe — select() on the fd reports "not ready" and the loop
blocks forever on data already sitting in the Python buffer. That was
the observed failure: buffer stuck at a single character ('1') for
the whole budget even though chall.py had printed the full menu.
2. The buffer must PERSIST across calls. chall.py prints a label and its
prompt in one burst ("pt (hex)\\n> "), so the read that satisfies
"pt (hex)" also swallows the "> " the NEXT call is waiting for. With a
per-call buffer that prompt is discarded and the following call
blocks on bytes that already arrived — a race, so the check passed
sometimes and timed out other times on a healthy service. The
leftover is kept on the process object and re-inspected first.
"""
buf = getattr(proc, "_rbuf", "")
start = time.time()
buf = []
r = proc.stdout.read
deadline = start + timeout
raw = proc.stdout.buffer if hasattr(proc.stdout, "buffer") else proc.stdout
while True:
if time.time() - start > timeout:
tail = ''.join(buf)[-500:]
raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}")
ch = r(1)
if ch == "" and proc.poll() is not None:
raise RuntimeError(f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}")
buf.append(ch)
if token in buf:
idx = buf.index(token) + len(token)
proc._rbuf = buf[idx:]
return buf[:idx]
if time.time() > deadline:
proc._rbuf = buf
raise TimeoutError(
f"Timeout waiting for '{token}'. Got so far:\n{buf[-500:]}")
remaining = deadline - time.time()
ready, _, _ = select.select([raw], [], [], min(remaining, 1.0))
if not ready:
if proc.poll() is not None and not _has_data(proc):
raise RuntimeError(
f"Process ended while waiting for '{token}'. Output:\n{buf}")
continue
chunk = raw.read1(4096) if hasattr(raw, "read1") else raw.read(4096)
if not chunk:
raise RuntimeError(
f"Process ended while waiting for '{token}'. Output:\n{buf}")
buf += chunk.decode(errors="replace")
if len(buf) > max_bytes:
raise RuntimeError("Exceeded max read size")
if token in "".join(buf):
return "".join(buf)
def _send_line(self, proc, s: str):
proc.stdin.write(s + "\n")
@@ -89,112 +257,86 @@ class Phew(Challenge):
assert host_flag == container_flag, 'Flag mismatch between host and container'
self.logger.info('[ok] flag parity (phew)')
def run_encrypt_once(pt_hex: str) -> str:
# ONE interactive session for the whole check.
#
# chall.py builds a fresh Paillier keypair (os.urandom(66) + RSA
# keygen) at import time, which measured ~12 s idle and far longer
# on this host: 2 CPUs, ~98 containers, load average ~75. Spawning a
# new process per assertion therefore cost 5 keygens per check per
# team, and those keygens were the very CPU load that starved the
# checks -> a self-inflicted death spiral where a perfectly healthy
# service reported DOWN.
#
# All four assertions are satisfiable inside one session: Paillier
# encryption is randomized PER CIPHERTEXT (fresh r each call), so
# encrypting the same plaintext twice in one session still yields
# different ciphertexts, and the same holds for option 4 on the
# raw key. Randomness is a property of the cipher call, not of the
# process.
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
def encrypt(pt_hex: str) -> str:
self._send_line(proc, "1")
self._read_until(proc, "pt (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._read_until(proc, "pt (hex)", timeout=self._CRYPTO_TIMEOUT)
self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
self._send_line(proc, pt_hex)
out = self._read_until(proc, "> ", timeout=5.0)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (encrypt)")
return ct_hex
finally:
if proc.poll() is None:
proc.kill()
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
return self._expect_hex_field(out, "ct")
def run_decrypt_once(ct_hex: str) -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
def decrypt(ct_hex: str) -> str:
self._send_line(proc, "3")
self._read_until(proc, "ct (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._read_until(proc, "ct (hex)", timeout=self._CRYPTO_TIMEOUT)
self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
self._send_line(proc, ct_hex)
out = self._read_until(proc, "> ", timeout=5.0)
pt_hex = self._expect_hex_field(out, "pt")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (decrypt)")
return pt_hex
finally:
if proc.poll() is None:
proc.kill()
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
return self._expect_hex_field(out, "pt")
def run_keyct_once() -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
def keyct() -> str:
self._send_line(proc, "4")
out = self._read_until(proc, "> ", timeout=5.0)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (keyct)")
return ct_hex
finally:
if proc.poll() is None:
proc.kill()
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
return self._expect_hex_field(out, "ct")
def run_bingo_reject_wrong_key():
wrong_key_hex = "00" * 66
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._send_line(proc, "2")
self._read_until(proc, "key (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, wrong_key_hex)
out = self._read_until(proc, "> ", timeout=5.0)
assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}"
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (bingo)")
finally:
if proc.poll() is None:
proc.kill()
ct1 = run_encrypt_once("414243444546")
ct1 = encrypt("414243444546")
assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex"
self.logger.info("[ok] encrypt produced hex")
pt_back = run_decrypt_once(ct1)
pt_back = decrypt(ct1)
assert pt_back.strip() != "", "decrypt returned empty output"
assert self._HEX_RE.match(pt_back), "decrypt(3) did not return hex"
self.logger.info("[ok] decrypt produced hex (custom mapping accepted)")
pt_same = "01" * 8
ct_a = run_encrypt_once(pt_same)
ct_b = run_encrypt_once(pt_same)
assert ct_a.lower() != ct_b.lower(), "Encryption appears deterministic for same plaintext"
ct_a = encrypt(pt_same)
ct_b = encrypt(pt_same)
assert ct_a.lower() != ct_b.lower(), \
"Encryption appears deterministic for same plaintext"
self.logger.info("[ok] encrypt randomness")
k1 = run_keyct_once()
k2 = run_keyct_once()
k1 = keyct()
k2 = keyct()
assert k1.lower() != k2.lower(), "key? ciphertexts reused randomness"
self.logger.info("[ok] key? randomness")
# run_bingo_reject_wrong_key()
# self.logger.info("[ok] bingo rejects wrong key")
self._send_line(proc, "9")
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
raise AssertionError("Program did not exit after exit command")
finally:
self._reap(proc)
return True
except Exception as e:
self.logger.error(f'Could not check phew: {e}')
return False
# NB: deliberately no _reap_all() here. `_children` is a CLASS
# attribute, so it is shared by every concurrent caller, and uvicorn
# serves these sync endpoints from a thread pool — a sweeping
# _reap_all() in one request's finally killed the chall.py belonging to
# the OTHER in-flight check, which then died with "Process ended while
# waiting for '> '" on a perfectly healthy service. The inner
# `finally: self._reap(proc)` already owns the one session this check
# created, which is the only process it may touch.
+1 -1
View File
@@ -28,7 +28,7 @@ class Art(Challenge):
def check(self):
try:
word = self.random_string(8)
url = f'http://localhost:{self.port}/art/{word}'
url = self.url(f'/art/{word}')
r = requests.get(url, timeout=5)
assert r.text == f'<iframe height="100%" width="100%" frameborder="0" src=https://asciified.thelicato.io/api/v2/ascii?text={word}></iframe>', 'Unexpected response'
self.logger.info('Check passed for art')

Some files were not shown because too many files have changed in this diff Show More