Compare commits
8
Commits
ef385c3397
...
2da6305626
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da6305626 | ||
|
|
cf47770798 | ||
|
|
30f8052e79 | ||
|
|
1461c874c3 | ||
|
|
50cb782ded | ||
|
|
0a56906b18 | ||
|
|
aa0bb45633 | ||
|
|
ae50acfe40 |
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']])")"
|
||||
@@ -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);
|
||||
@@ -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.`);
|
||||
@@ -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 ==="
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
"
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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", "")
|
||||
|
||||
Executable
+68
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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 <PORT_SSH></div>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH></div>
|
||||
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
|
||||
</div>
|
||||
|
||||
|
||||
+229
-191
@@ -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 <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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); }
|
||||
}
|
||||
|
||||
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>`;
|
||||
g.setData(d.nodes, d.edges, topoAttacks);
|
||||
} catch (e) {
|
||||
toast('Topologi gagal: ' + e.message, true);
|
||||
} finally {
|
||||
topoLoadPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 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
@@ -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 <PORT_SSH>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH>
|
||||
# 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'; };
|
||||
|
||||
@@ -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) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+2341
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
@@ -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)"
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Executable
+35
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user