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 @@
+
+ π + Zoom In
+ π Zoom Out
+ β³ Reset
+ 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() {
βΆ Start
βΉ Stop
π SSH & Pass
+ βοΈ Edit Nama/Domain
π Logs
π² Randomize Flag
@@ -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";