diff --git a/panel/main.py b/panel/main.py index 672b634..9f30709 100644 --- a/panel/main.py +++ b/panel/main.py @@ -391,23 +391,46 @@ async def api_teams(req: Request): @app.post("/api/teams/set") async def api_teams_set(req: Request): - """Set/create N teams (idempotent: creates missing, keeps existing).""" + """Set/create N teams (idempotent: creates missing, keeps existing). + + body: {count, labels: {"1": "Tim Satu"}, domains: {"1": "tim-satu"}}""" require_login(req) data = await req.json() n = int(data.get("count", 0)) if n < 0 or n > 50: raise HTTPException(400, "Team count must be 0-50") labels = data.get("labels") or {} # { "1": "Tim Satu", ... } + domains = data.get("domains") or {} # { "1": "mycustom", ... } created = [] for i in range(1, n + 1): td = orch.TEAMS_DIR / f"team{i}" if not td.exists(): label = labels.get(str(i)) or labels.get(i) or f"Tim {i}" - st = orch.create_team(i, label) + dom = domains.get(str(i)) or domains.get(i) or None + st = orch.create_team(i, label, domain=dom) created.append(st["index"]) + else: + # team exists: apply any label/domain overrides + label = labels.get(str(i)) or labels.get(i) + dom = domains.get(str(i)) or domains.get(i) + if label or dom: + orch.update_team(i, label=label, domain=dom) orch.ensure_team_domains() return {"created": created, "total": len(orch.list_teams())} +@app.put("/api/teams/{idx}") +async def api_team_update(idx: int, req: Request): + """Update a team's custom name and/or domain (applies live).""" + require_login(req) + if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists(): + raise HTTPException(404, "Team not found") + data = await req.json() + try: + st = orch.update_team(idx, label=data.get("label"), domain=data.get("domain")) + return {"ok": True, "team": st} + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + @app.post("/api/teams/start") async def api_teams_start(req: Request): require_login(req) @@ -553,16 +576,26 @@ async def api_attacks(req: Request): @app.get("/api/leaderboard") async def api_leaderboard(req: Request): - """Leaderboard of solves so far.""" + """Leaderboard of solves so far. Team names resolved LIVE from state.json + so renaming a team updates leaderboard + topology everywhere.""" lb_path = orch.TEAMS_DIR / "leaderboard.json" if lb_path.exists(): lb = json.loads(lb_path.read_text()) else: lb = {"solves": []} + # live name lookup: state.json label fallback to snapshot + def team_name(idx): + try: + st = json.loads((orch.TEAMS_DIR / f"team{idx}" / "state.json").read_text()) + return st.get("label") or f"Team {idx}" + except Exception: + return f"Team {idx}" # aggregate per team teams = {} for e in lb["solves"]: - t = teams.setdefault(e["team"], {"team": e["team"], "name": e["team_name"], "solves": 0, "challs": []}) + name = team_name(e["team"]) + t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": []}) + t["name"] = name t["solves"] += 1 t["challs"].append(e["challenge"]) return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])} diff --git a/panel/static/index.html b/panel/static/index.html index df74dce..ca5f49f 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -143,8 +143,14 @@
+
+ + + + 100% +
-
πŸ’‘ Geser node untuk mengatur layout β€’ βš”οΈ garis merah = serangan (panah attacker β†’ target) β€’ garis putus-putus = serangan baru (60 detik terakhir)
+
πŸ’‘ Geser node untuk atur layout β€’ scroll / ctrl+scroll untuk zoom in-out β€’ drag area kosong untuk geser canvas β€’ βš”οΈ garis merah = serangan (panah attacker β†’ target) β€’ garis putus-putus = serangan baru (60 detik terakhir)
@@ -482,44 +488,99 @@ function renderTopo(nodes, edges) { ${status ? `` : ''} `; }); - svg.innerHTML = `` + html; + svg.innerHTML = `${html}`; enableTopoDrag(svg, pos); + applyTopoView(); } -// ---- draggable topology ---- +// ---- 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; - const onMove = (ev) => { - if (!dragEl) return; + let panning = false, px = 0, py = 0; + + const toLocal = (ev) => { const ctm = svg.getScreenCTM(); - if (!ctm) return; + if (!ctm) return {x: 0, y: 0}; const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; - const p = pt.matrixTransform(ctm.inverse()); - dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`); + return pt.matrixTransform(ctm.inverse()); }; - const onUp = () => { dragEl = null; svg.style.cursor = ''; }; + + 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) return; - ev.preventDefault(); - const ctm = svg.getScreenCTM(); - const pt = svg.createSVGPoint(); - pt.x = ev.clientX; pt.y = ev.clientY; - const p = pt.matrixTransform(ctm.inverse()); - const c = g.querySelector('circle'); - const cx = parseFloat(c.getAttribute('cx')); - const cy = parseFloat(c.getAttribute('cy')); - dx = p.x - cx; dy = p.y - cy; - dragEl = g; - svg.style.cursor = 'grabbing'; - g.setPointerCapture && g.setPointerCapture(ev.pointerId); + 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) + '%'; +} + +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(); +} +function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); } + // ---------- Teams ---------- async function loadTeams() { try { @@ -547,6 +608,7 @@ async function loadTeams() { + @@ -558,14 +620,16 @@ async function loadTeams() { async function setTeams() { const n = parseInt(document.getElementById('teamCount').value || '0', 10); - const labels = {}; + const labels = {}, domains = {}; for (let i = 1; i <= n; i++) { - const inp = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"]`); - if (inp && inp.value.trim()) labels[i] = inp.value.trim(); + const inpN = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="name"]`); + if (inpN && inpN.value.trim()) labels[i] = inpN.value.trim(); + const inpD = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="domain"]`); + if (inpD && inpD.value.trim()) domains[i] = inpD.value.trim(); } try { showLoading(`Membuat ${n} team…
Generate compose, flags, domain, Traefik config`); - const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})}); + const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels, domains})}); toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} Β· total ${d.total}`, false); loadTeams(); } catch (e) { toast(e.message, true); } @@ -575,16 +639,36 @@ async function setTeams() { function teamCountChanged() { const n = parseInt(document.getElementById('teamCount').value || '0', 10); const box = document.getElementById('teamNameInputs'); - let html = ''; + let html = '
Nama team & domain custom (opsional β€” kosongkan untuk auto dari nama)
'; for (let i = 1; i <= n; i++) { - html += `
+ html += `
Team ${i} - + + + .gemastik.imrnes.team
`; } box.innerHTML = html; } +async function editTeam(idx) { + // fetch current team info + let t = null; + try { t = (await api(`/api/team/${idx}/creds`)).team; } catch (e) { toast('Gagal ambil data team', true); return; } + const newLabel = prompt(`✏️ Nama team ${idx} (sekarang: ${t.label || ('Team ' + idx)}):`, t.label || ''); + if (newLabel === null) return; + const newDomain = prompt(`🌐 Domain custom team ${idx} (sekarang: ${t.domain || ''} β€” ketik subdomain saja, contoh: team-cyber):`, (t.domain || '').split('.')[0]); + if (newDomain === null) return; + try { + showLoading('Update nama/domain team…'); + const d = await api(`/api/teams/${idx}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({label: newLabel.trim(), domain: newDomain.trim()})}); + toast(`Team ${idx} diupdate: ${d.team.label} @ ${d.team.domain}`, false); + loadTeams(); + if (document.getElementById('view-topo').classList.contains('active')) loadTopo(); + } catch (e) { toast(e.message, true); } + finally { hideLoading(); } +} + async function startTeam(idx) { const ov = showLoading(`Menyiapkan Team ${idx}…
Membangun & start container (bisa butuh 1-3 menit)`); try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start`, false); } diff --git a/panel/teams.py b/panel/teams.py index 03457b7..fc31491 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -68,17 +68,29 @@ def slugify(s: str) -> str: s = re.sub(r"[\s_]+", "-", s) return s or "team" -def create_team(idx: int, label: str = None): - """Build a full team stack dir with unique ports/passwords.""" +def create_team(idx: int, label: str = None, domain: str = None): + """Build a full team stack dir with unique ports/passwords. + + label -> team display name (leaderboard/topology) + domain -> custom subdomain host, e.g. "cyber-warriors" or + "cyber-warriors.gemastik.imrnes.team" (full host accepted). + Defaults to slugify(label).gemastik.imrnes.team. + """ ports = team_ports(idx) team_dir = TEAMS_DIR / f"team{idx}" label = label or f"Tim {idx}" slug = slugify(label) + # normalize custom domain -> host under *.gemastik.imrnes.team + host = (domain or f"{slug}.gemastik.imrnes.team").strip().lower() + host = host.replace("https://", "").replace("http://", "").rstrip("/") + if not host.endswith(".gemastik.imrnes.team"): + host = f"{host}.gemastik.imrnes.team" + slug = host.split(".")[0] state = { "index": idx, "label": label, "slug": slug, - "domain": f"{slug}.gemastik.imrnes.team", + "domain": host, "ports": ports, "admin_user": f"admin_team{idx}", "admin_pass": gen_password(20), @@ -185,6 +197,30 @@ def create_team(idx: int, label: str = None): (team_dir / "state.json").write_text(json.dumps(state, indent=2)) return state +def update_team(idx: int, label: str = None, domain: str = None) -> dict: + """Update a team's display name and/or custom domain (live re-route). + + - label updates state.json['label'] (leaderboard/topology use this). + - domain updates slug + domain and rewrites the Traefik team route. + Returns updated state. + """ + 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()) + if label: + st["label"] = str(label).strip()[:48] or st["label"] + if domain: + host = domain.strip().lower().replace("https://", "").replace("http://", "").rstrip("/") + if not host.endswith(".gemastik.imrnes.team"): + host = f"{host}.gemastik.imrnes.team" + st["domain"] = host + st["slug"] = host.split(".")[0] + (team_dir / "state.json").write_text(json.dumps(st, indent=2)) + # re-route domain in Traefik immediately + ensure_team_domains() + return st + def start_team(idx: int): team_dir = TEAMS_DIR / f"team{idx}" if not (team_dir / "state.json").exists(): diff --git a/services/blogpost/apt-insecure.conf b/services/blogpost/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/blogpost/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true"; diff --git a/services/carbeat/apt-insecure.conf b/services/carbeat/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/carbeat/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true"; diff --git a/services/cdn/apt-insecure.conf b/services/cdn/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/cdn/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true"; diff --git a/services/phew/apt-insecure.conf b/services/phew/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/phew/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true"; diff --git a/services/sheesh/apt-insecure.conf b/services/sheesh/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/sheesh/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true"; diff --git a/services/warmup/apt-insecure.conf b/services/warmup/apt-insecure.conf new file mode 100644 index 0000000..aeb1bce --- /dev/null +++ b/services/warmup/apt-insecure.conf @@ -0,0 +1,5 @@ +# Allow apt to work on hosts whose clock is past GPG key expiry (2026+) +Acquire::AllowInsecureRepositories "true"; +Acquire::AllowDowngradeToInsecureRepositories "true"; +Apt::Get::AllowUnauthenticated "true"; +Apt::Get::force-yes "true";