From 15536a0998f0f3df3cc8b0d07dd13439794b9938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Sun, 17 May 2026 19:44:28 -0600 Subject: [PATCH 1/7] feat(security): AuthRegistry transporte des capabilities via verify_full issue() accepte maintenant des kwargs supplementaires (ex: can_broadcast) qui sont stockes tels quels dans l'entree du ticket. verify_full(), nouveau, retourne l'entree complete plutot que juste le nom : permet au serveur audio de lire les capabilities par-joueur fixees au moment de l'auth sur le serveur positions. verify() (qui retourne juste le nom) est conservee pour les appelants existants - elle s'appuie maintenant sur verify_full() en interne. --- server/circusvoip_security.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/server/circusvoip_security.py b/server/circusvoip_security.py index e9f0b7b..662e925 100644 --- a/server/circusvoip_security.py +++ b/server/circusvoip_security.py @@ -181,13 +181,19 @@ def __init__(self, path: Path, ttl_sec: float = 120.0): # ---- cote serveur positions : emission ---- - def issue(self, name: str, ticket: str): + def issue(self, name: str, ticket: str, **extra): """Enregistre un ticket valide pour `name`. Appele par le serveur - positions juste avant d'envoyer le welcome au client.""" + positions juste avant d'envoyer le welcome au client. + + Les kwargs supplementaires (ex: `can_broadcast=True`) sont stockes + tels quels dans l'entree du ticket et lisibles cote serveur audio + via verify_full(). Permet de propager des capabilities par-joueur + sans schema fixe.""" data = self._read_all() data[ticket] = { "name": name, "expires": time.time() + self.ttl_sec, + **extra, } self._prune(data) self._write_all(data) @@ -207,6 +213,14 @@ def revoke(self, ticket: str): def verify(self, ticket: str): """Verifie un ticket presente au serveur audio. Retourne le nom associe si le ticket est valide et non expire, sinon None.""" + entry = self.verify_full(ticket) + return None if entry is None else entry.get("name") + + def verify_full(self, ticket: str): + """Comme verify() mais retourne l'entree complete (nom, expires, + + tout champ supplementaire ajoute via issue(**extra)) ou None si + invalide/expire. Utilise quand le serveur audio doit lire des + capabilities par-joueur (ex: can_broadcast).""" if not ticket or not isinstance(ticket, str): return None data = self._read_all() @@ -215,7 +229,7 @@ def verify(self, ticket: str): return None if time.time() >= entry.get("expires", 0): return None - return entry.get("name") + return entry # ---- interne ---- From 83cd7a8f6ce332638edf1ea331211b7a7352f211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Sun, 17 May 2026 19:45:10 -0600 Subject: [PATCH 2/7] feat(server): role broadcaster + PTT diffusion globale (flag 0x03) Ajoute un role 'broadcaster' distinct du role admin : un joueur qui le porte peut emettre des trames audio avec le flag 0x03, relayees a tous les autres clients quels que soient leurs canaux radio. Cas d'usage : moderateurs et organisateurs d'evenements RP. Positions server (circusvoip_server.py): - _broadcasters: set persiste dans circusvoip_broadcasters.json - Helpers grant_broadcaster, revoke_broadcaster, list_broadcasters - Commandes admin associees dans _admin_handle_cmd - Le ticket emis a l'auth porte can_broadcast=(name in _broadcasters) - Le welcome ajoute 'server_caps': ['broadcast_all'] (capability discovery) + 'is_broadcaster' (etat actuel pour ce joueur) Audio server (circusvoip_audio_server.py): - state.client_caps stocke la capability lue dans le ticket au join - Avant relais, drop des trames flag 0x03 dont l'emetteur n'a pas can_broadcast=True. Log rate-limite a 1/minute par ws pour ne pas flooder si un client tient la touche en continu. Revocation : effective au prochain ticket (TTL <= 120s, soit ~2 min en pratique apres reconnexion du joueur au serveur positions). Documente dans grant_broadcaster()/revoke_broadcaster(). --- server/circusvoip_audio_server.py | 56 +++++++++++++++- server/circusvoip_server.py | 104 +++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/server/circusvoip_audio_server.py b/server/circusvoip_audio_server.py index fca6bcb..8ddd6ff 100644 --- a/server/circusvoip_audio_server.py +++ b/server/circusvoip_audio_server.py @@ -102,6 +102,14 @@ class State: clients = {} # websocket -> name + # [BROADCAST_ALL] Capabilities par-client extraites du ticket positions. + # Dict separe (plutot que de transformer clients en {ws: {name,caps}}) + # pour ne pas casser les lecteurs existants qui parcourent clients comme + # un simple ws -> name. + client_caps = {} # websocket -> {"can_broadcast": bool} + # [BROADCAST_ALL] Dernier timestamp ou un refus 0x03 a ete loggue, par ws. + # Sert a limiter le spam de logs si un client malveillant tient la touche. + _last_refusal_log = {} running = False bytes_total = 0 frames_total = 0 @@ -109,6 +117,13 @@ class State: state = State() +# [BROADCAST_ALL] Flag audio reserve a la PTT diffusion globale (tous canaux). +# Le serveur impose can_broadcast=True (lu dans le ticket) pour relayer +# une trame portant ce flag. Sinon la trame est jetee silencieusement (avec +# un log rate-limite pour aider au debug sans flooder). +FLAG_BROADCAST_ALL = 0x03 +_BROADCAST_REFUSAL_LOG_INTERVAL_SEC = 60.0 + # --------------------------------------------- # Serveur WebSocket # --------------------------------------------- @@ -149,6 +164,18 @@ async def handler(ws, ui): # ponctuel ne doit pas couper un joueur legitime. if not _audio_rate.allow(ws): continue + # [BROADCAST_ALL] Drop des trames flag 0x03 si l'emetteur + # n'a pas la capability. Sans ce filtre, n'importe quel + # client pourrait fabriquer une trame 0x03 et etre entendu + # sur tous les canaux radio simultanement (cf filtrage + # cote receveur dans circusvoip_core.py qui accepte 0x03 + # sans verifier le canal). On enforce ici parce que c'est + # le seul endroit ou on a a la fois la trame ET l'identite + # authentifiee de l'emetteur (via le ticket). + if (len(msg) >= 1 and msg[0] == FLAG_BROADCAST_ALL + and not state.client_caps.get(ws, {}).get("can_broadcast")): + _maybe_log_broadcast_refusal(ws, name, peer_ip, ui) + continue # Trame audio : relayer a tous les autres clients state.bytes_total += len(msg) state.frames_total += 1 @@ -195,8 +222,11 @@ async def handler(ws, ui): # [P4 - auth partagee] Exiger un ticket emis par le # serveur positions. Empeche un client d'arriver sur # l'audio sans etre passe par le serveur principal. + # verify_full() retourne aussi les capabilities embarquees + # dans le ticket (cf. AuthRegistry.issue(can_broadcast=)). ticket = data.get("audio_ticket", "") - ticket_name = _auth_registry.verify(ticket) + ticket_entry = _auth_registry.verify_full(ticket) + ticket_name = (ticket_entry or {}).get("name") if ticket_name is None: banned_now = _auth_lockout.record_failure(peer_ip) ui.log(f"REFUSE audio : ticket invalide ou expire " @@ -241,6 +271,14 @@ async def handler(ws, ui): # client annonce. Ferme l'usurpation de pseudo cote audio. name = ticket_name state.clients[ws] = name + # [BROADCAST_ALL] Stocke la capability au moment du join. + # Pas de re-evaluation pendant la session : si l'admin + # revoke pendant qu'un broadcaster est connecte, la + # revocation s'applique au prochain ticket (apres son + # prochain join au serveur positions). + state.client_caps[ws] = { + "can_broadcast": bool(ticket_entry.get("can_broadcast")), + } ui.log(f"JOIN audio : {name} ({len(state.clients)} client(s))") ui.refresh_clients() @@ -254,11 +292,27 @@ async def handler(ws, ui): finally: # [P5] Libere le bucket de rate limiting de ce client. _audio_rate.forget(ws) + # [BROADCAST_ALL] Libere la capability stockee + l'etat du log + # rate-limit pour ce ws. Sans pop, accumulation lente en memoire. + state.client_caps.pop(ws, None) + state._last_refusal_log.pop(ws, None) if ws in state.clients: n = state.clients.pop(ws) ui.log(f"LEAVE audio : {n} ({len(state.clients)} client(s))") ui.refresh_clients() + +def _maybe_log_broadcast_refusal(ws, name: str, peer_ip: str, ui): + """Loggue le refus d'une trame 0x03 (PTT diffusion globale) d'un client + non-broadcaster, en limitant a 1 log par minute par ws pour ne pas + flooder si un client tient la touche en continu (50 trames/s).""" + now = time.time() + last = state._last_refusal_log.get(ws, 0.0) + if now - last < _BROADCAST_REFUSAL_LOG_INTERVAL_SEC: + return + state._last_refusal_log[ws] = now + ui.log(f"REFUSE broadcast_all : {name} (ip {peer_ip}) - pas de role broadcaster") + async def _broadcast_binary(data: bytes, exclude=None): """Envoie une trame audio a tous les clients sauf l'emetteur.""" dead = [] diff --git a/server/circusvoip_server.py b/server/circusvoip_server.py index f7fd912..322d537 100644 --- a/server/circusvoip_server.py +++ b/server/circusvoip_server.py @@ -325,6 +325,13 @@ def _debug_log_pos(name: str, pos: dict, ts_capture: float = None): _PROFILES_FILE = _BASE_DIR / "circusvoip_profiles.json" _profiles: list = [] +# Liste des broadcasters : joueurs autorises a parler simultanement sur TOUS +# les canaux radio (PTT diffusion globale, flag audio 0x03). L'admin gere la +# liste via grant_broadcaster / revoke_broadcaster. La capability est +# propagee au serveur audio via le ticket (cf. AuthRegistry.issue(can_broadcast=)). +_BROADCASTERS_FILE = _BASE_DIR / "circusvoip_broadcasters.json" +_broadcasters: set = set() + def _load_channels() -> list: """Charge la liste des canaux depuis le fichier JSON (liste de strings). @@ -416,9 +423,33 @@ def _save_profiles(): print(f"[PROFILES] Echec sauvegarde {_PROFILES_FILE.name} : {e}") +def _load_broadcasters() -> set: + """Charge la liste des broadcasters depuis circusvoip_broadcasters.json. + Retourne un set vide si absent (la capability est opt-in).""" + try: + if _BROADCASTERS_FILE.exists(): + with open(_BROADCASTERS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return {n.strip() for n in data if isinstance(n, str) and n.strip()} + except Exception as e: + print(f"[BROADCASTERS] Echec chargement {_BROADCASTERS_FILE.name} : {e}") + return set() + + +def _save_broadcasters(): + """Persiste la liste des broadcasters dans circusvoip_broadcasters.json.""" + try: + with open(_BROADCASTERS_FILE, "w", encoding="utf-8") as f: + json.dump(sorted(_broadcasters), f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"[BROADCASTERS] Echec sauvegarde {_BROADCASTERS_FILE.name} : {e}") + + # Initialisation : charger au demarrage du module _channels = _load_channels() _profiles = _load_profiles() +_broadcasters = _load_broadcasters() if _profiles and not _PROFILES_FILE.exists(): _save_profiles() @@ -600,6 +631,23 @@ async def _admin_handle_cmd(ws, cmd: str, data: dict) -> tuple: ok = assign_profile(data.get("player", ""), data.get("profile")) return (ok, "" if ok else "joueur introuvable ou profil invalide") + if cmd == "grant_broadcaster": + ok = grant_broadcaster(data.get("name", "")) + return (ok, "" if ok else "nom vide") + if cmd == "revoke_broadcaster": + ok = revoke_broadcaster(data.get("name", "")) + return (ok, "" if ok else "nom vide") + if cmd == "list_broadcasters": + try: + await ws.send(json.dumps({ + "type": "admin_response", + "cmd": "list_broadcasters", + "ok": True, + "broadcasters": list_broadcasters(), + })) + except Exception: + pass + return (True, "") if cmd == "set_anonymous_mode": target = bool(data.get("active", False)) global _anonymous_mode @@ -769,7 +817,15 @@ async def handler(ws): "audio_ticket": audio_ticket, } # Enregistre le ticket dans le fichier partage avec l'audio. - _auth_registry.issue(name, audio_ticket) + # can_broadcast est lu cote audio pour autoriser les frames + # avec flag 0x03 (PTT diffusion globale). Si le role est + # revoque pendant que le joueur est connecte, la revocation + # ne s'applique qu'au prochain ticket (TTL <= 120s). + _auth_registry.issue( + name, + audio_ticket, + can_broadcast=(name in _broadcasters), + ) _log(f"JOIN : {name} ({len(clients)} connecté(s))", GREEN) if _ui: _ui.add_player(name) @@ -797,6 +853,15 @@ async def handler(ws): "my_profile": None, # [P4] Ticket a renvoyer au serveur audio lors du join audio. "audio_ticket": audio_ticket, + # Capabilities serveur. Permet au client de detecter + # qu'il parle a un serveur recent et d'activer/desactiver + # les fonctionnalites correspondantes (ex: griser la + # touche PTT diffusion globale sur les vieux serveurs). + "server_caps": ["broadcast_all"], + # Indique si CE joueur a actuellement le role broadcaster. + # Le client peut ainsi activer/desactiver son UI sans avoir + # a interroger un admin. + "is_broadcaster": (name in _broadcasters), })) # Annoncer aux autres l'arrivee du nouveau await _broadcast(ws, json.dumps({ @@ -1230,6 +1295,43 @@ async def _do(): return True +def grant_broadcaster(player_name: str) -> bool: + """Accorde le role broadcaster a `player_name`. Idempotent : renvoie + True meme si deja accorde, False uniquement sur nom vide. La + capability ne devient effective qu'au prochain ticket emis : si le + joueur est deja connecte, il devra se reconnecter au serveur positions + pour que son ticket audio porte can_broadcast=True (TTL ticket = 120s + + cycle reconnect, donc effectif sous ~2 min en pratique).""" + player_name = (player_name or "").strip() + if not player_name: + return False + if player_name not in _broadcasters: + _broadcasters.add(player_name) + _save_broadcasters() + _log(f"Broadcaster accorde : {player_name}", GREEN) + return True + + +def revoke_broadcaster(player_name: str) -> bool: + """Retire le role broadcaster a `player_name`. Idempotent : renvoie + True meme si deja absent, False uniquement sur nom vide. Comme + grant_broadcaster, la revocation n'est effective qu'au prochain + ticket (TTL <= 120s).""" + player_name = (player_name or "").strip() + if not player_name: + return False + if player_name in _broadcasters: + _broadcasters.discard(player_name) + _save_broadcasters() + _log(f"Broadcaster revoque : {player_name}", ORANGE) + return True + + +def list_broadcasters() -> list: + """Retourne la liste triee des broadcasters actuels.""" + return sorted(_broadcasters) + + def assign_profile(player_name: str, profile_name) -> bool: """Assigne un profil a un joueur connecte (admin uniquement).""" if profile_name is not None and profile_name not in _profiles: From 096b0011bf58e4e498ad4907ea95d1c471c00268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Mon, 18 May 2026 20:49:46 -0600 Subject: [PATCH 3/7] feat(admin): UI broadcasters dans la console admin Ajoute un panneau BROADCASTERS dans la console admin (circusvoip_admin.py) entre PROFILS et TOKEN JOUEUR, miroir des panneaux existants : liste des broadcasters actuels avec un boutton + pour grant_broadcaster (askstring) et un croix par ligne pour revoke_broadcaster (confirmation). Aucun changement de protocole cote client joueur. Cote serveur (circusvoip_server.py): - admin_welcome contient maintenant 'broadcasters': sorted(_broadcasters) - Nouveau push 'broadcasters_list' aux admins (pas a tous, contrairement aux canaux/profils : info admin) - grant/revoke_broadcaster declenchent le push via _broadcast_broadcasters_list_threadsafe() Test fixture .test_broadcast_all.py : start positions, attente cert.pem (size > 500), puis start audio. Evite la race condition ou les deux processus generent le cert auto-signe simultanement (corrupt PEM). --- server/circusvoip_admin.py | 76 +++++++++++++++++++++++++++++++++++++ server/circusvoip_server.py | 19 ++++++++++ 2 files changed, 95 insertions(+) diff --git a/server/circusvoip_admin.py b/server/circusvoip_admin.py index 699aa26..f4d2c5a 100644 --- a/server/circusvoip_admin.py +++ b/server/circusvoip_admin.py @@ -95,6 +95,7 @@ class State: # Etat repliquant celui du serveur (recu via admin_welcome + push events) channels = [] # list[str] profiles = [] # list[str] + broadcasters = [] # list[str] - role PTT diffusion globale (flag 0x03) players = {} # {name: {pos, channel, profile, helmet_on, prox_short}} anonymous_mode = False server_token = "" # token joueur (pour l'afficher dans l'UI admin) @@ -171,6 +172,7 @@ def _handle_message(ui, data: dict): # Etat initial state.channels = list(data.get("channels", [])) state.profiles = list(data.get("profiles", [])) + state.broadcasters = list(data.get("broadcasters", [])) state.anonymous_mode = bool(data.get("anonymous_mode", False)) state.server_token = data.get("server_token", "") state.players = {} @@ -276,6 +278,10 @@ def _handle_message(ui, data: dict): state.profiles = list(data.get("profiles", [])) ui.refresh_profiles() + elif msg_type == "broadcasters_list": + state.broadcasters = list(data.get("broadcasters", [])) + ui.refresh_broadcasters() + elif msg_type == "anonymous_mode": state.anonymous_mode = bool(data.get("active", False)) ui.refresh_anonymous() @@ -490,6 +496,19 @@ def _on_p_wheel(event): btn_add_prof.pack(fill="x", pady=(2, 8)) btn_add_prof.bind("", lambda e: self._add_profile()) + # Broadcasters : joueurs autorises a parler sur tous les canaux + # simultanement (PTT diffusion globale, flag audio 0x03). Liste + # tenue par l'admin via grant_broadcaster / revoke_broadcaster. + self._section(right, "BROADCASTERS") + self._broadcasters_frame = tk.Frame(right, bg=BG_PANEL) + self._broadcasters_frame.pack(fill="x", pady=2) + btn_add_bc = tk.Label(right, text="+ Ajouter broadcaster", + bg=BORDER, fg=BLUE, + font=("Courier", 9, "bold"), pady=6, padx=8, + cursor="hand2") + btn_add_bc.pack(fill="x", pady=(2, 8)) + btn_add_bc.bind("", lambda e: self._add_broadcaster()) + # Token serveur en bas (info admin) self._section(right, "TOKEN JOUEUR") self._lbl_server_token = tk.Label(right, text="(non connecte)", @@ -605,6 +624,7 @@ def refresh_all(self): self.refresh_players() self.refresh_channels() self.refresh_profiles() + self.refresh_broadcasters() self.refresh_anonymous() self._lbl_server_token.config(text=state.server_token or "(non recu)") @@ -680,6 +700,35 @@ def _do(): self._refresh_all_player_profile_select() self._safe_after(_do) + def refresh_broadcasters(self): + """Reconstruit le panneau BROADCASTERS a partir de state.broadcasters. + Mirroring exact de refresh_channels/refresh_profiles : meme structure + de rang BG_ROW + bouton ✕ pour retirer.""" + def _do(): + for w in self._broadcasters_frame.winfo_children(): + w.destroy() + if not state.broadcasters: + tk.Label(self._broadcasters_frame, + text="(aucun broadcaster)\nLes broadcasters peuvent\n" + "parler sur tous les canaux\nsimultanement (PTT global).", + bg=BG_PANEL, fg=MUTED, font=("Courier", 8), + anchor="w", justify="left", padx=4).pack(fill="x") + return + for name in state.broadcasters: + row = tk.Frame(self._broadcasters_frame, bg=BG_ROW, + pady=2, padx=6) + row.pack(fill="x", pady=1) + tk.Label(row, text=name, bg=BG_ROW, fg=BLUE, + font=("Courier", 9, "bold"), anchor="w" + ).pack(side="left", fill="x", expand=True) + btn_del = tk.Label(row, text="✕", bg=BG_ROW, fg=RED, + font=("Courier", 9, "bold"), + cursor="hand2", padx=4) + btn_del.pack(side="left") + btn_del.bind("", + lambda e, n=name: self._remove_broadcaster(n)) + self._safe_after(_do) + def refresh_players(self): def _do(): # Synchroniser : ajouter les nouveaux, retirer les partis @@ -876,6 +925,33 @@ def _remove_profile(self, name: str): parent=self.root): self._send_cmd({"cmd": "remove_profile", "name": name}) + def _add_broadcaster(self): + """Accorde le role broadcaster a un joueur (par nom). Le serveur + applique le role idempotemment : si le nom existe deja, no-op. + La capability ne prend effet qu'au prochain ticket du joueur cible + (typiquement 2 min ; lui demander de se reconnecter pour aller plus + vite). Cf doc serveur grant_broadcaster().""" + n = simpledialog.askstring( + "Accorder broadcaster", + "Nom du joueur a qui accorder le role broadcaster :\n" + "(le joueur doit se reconnecter au serveur pour que sa\n" + "capability prenne effet)", + parent=self.root, + ) + if n: + self._send_cmd({"cmd": "grant_broadcaster", "name": n}) + + def _remove_broadcaster(self, name: str): + """Retire le role broadcaster. Effectif au prochain ticket du joueur + cible (~2 min). Pour revoquer immediatement, kick le joueur en plus.""" + if messagebox.askyesno( + "Retirer broadcaster", + f"Retirer le role broadcaster a '{name}' ?\n" + f"Effectif a sa prochaine reconnexion (TTL ticket ≤ 2 min).", + parent=self.root, + ): + self._send_cmd({"cmd": "revoke_broadcaster", "name": name}) + def _toggle_anonymous(self): self._send_cmd({"cmd": "set_anonymous_mode", "active": not state.anonymous_mode}) diff --git a/server/circusvoip_server.py b/server/circusvoip_server.py index 322d537..c680b27 100644 --- a/server/circusvoip_server.py +++ b/server/circusvoip_server.py @@ -573,6 +573,7 @@ async def _admin_session(ws): "type": "admin_welcome", "channels": list(_channels), "profiles": list(_profiles), + "broadcasters": sorted(_broadcasters), "players": players_state, "anonymous_mode": _anonymous_mode, # "server_token" volontairement retire pour ne pas l'exposer @@ -1295,6 +1296,22 @@ async def _do(): return True +def _broadcast_broadcasters_list_threadsafe(): + """Pousse la liste des broadcasters aux admins (et seulement aux admins). + Contrairement aux canaux/profils, la liste des broadcasters est une + info admin : pas la peine de l'exposer a tous les clients.""" + if _loop and _server_running: + async def _do(): + await _broadcast_admins(json.dumps({ + "type": "broadcasters_list", + "broadcasters": sorted(_broadcasters), + })) + try: + asyncio.run_coroutine_threadsafe(_do(), _loop) + except Exception: + pass + + def grant_broadcaster(player_name: str) -> bool: """Accorde le role broadcaster a `player_name`. Idempotent : renvoie True meme si deja accorde, False uniquement sur nom vide. La @@ -1308,6 +1325,7 @@ def grant_broadcaster(player_name: str) -> bool: if player_name not in _broadcasters: _broadcasters.add(player_name) _save_broadcasters() + _broadcast_broadcasters_list_threadsafe() _log(f"Broadcaster accorde : {player_name}", GREEN) return True @@ -1323,6 +1341,7 @@ def revoke_broadcaster(player_name: str) -> bool: if player_name in _broadcasters: _broadcasters.discard(player_name) _save_broadcasters() + _broadcast_broadcasters_list_threadsafe() _log(f"Broadcaster revoque : {player_name}", ORANGE) return True From 2b7e86fd794cc656b84ba7ca1136093cc21a1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Tue, 19 May 2026 10:33:17 -0600 Subject: [PATCH 4/7] feat(client): support PTT diffusion globale (flag 0x03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cote core (circusvoip_core.py) : - Nouveau state : broadcast_all_key, broadcast_all_active, server_supports_broadcast_all, is_broadcaster. - _on_audio_captured : nouvelle branche prioritaire avant les checks canal/profil. Quand broadcast_all_active et le serveur le supporte, emet une trame flag 0x03 (+ duplicate proximite 0x00 inchange). - Inbound handler : reconnait le flag 0x03, bypass les filtres canal/profil, applique la chaine radio (compression + effet). Toujours soumis a mute_radio. - RadioKeyListener : nouveaux helpers _on_broadcast_pressed_impl / _released_impl, miroir du profil radio. Force l'ouverture du gate audio, joue les bips locaux. - Dispatch dans _check_ptt_press / _check_ptt_release pour la touche broadcast_all_key. Cote UI (circusvoip_client.py) : - Handler welcome : lit server_caps + is_broadcaster, les stocke dans state. - Nouvelle ligne de keybind "Diffusion globale (PTT)" dans les paramètres. Desactivee avec tooltip explicatif si server_supports_broadcast_all == False (vieux serveur). - Persiste broadcast_all_key via _load_cfg / _save_cfg. README : nouvelle section "Diffusion globale (rôle broadcaster)" + ligne de keybind associee. --- README.md | 18 +++++ client/circusvoip_client.py | 57 +++++++++----- client/circusvoip_core.py | 149 +++++++++++++++++++++++++++++++++--- 3 files changed, 192 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index bf0732e..77ee315 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,21 @@ choses : - **Radio par profil** : une touche dédiée émet vers tous les joueurs ayant le même profil que vous, peu importe leur canal et leur position. +### Diffusion globale (rôle broadcaster) +Un joueur à qui l'admin a accordé le rôle **broadcaster** dispose d'une +touche PTT supplémentaire qui diffuse sa voix sur **tous les canaux +radio simultanément**, peu importe le canal des destinataires. Utile +pour les modérateurs et organisateurs d'événements RP qui doivent +adresser tout le serveur sans imposer un canal commun. + +L'admin gère la liste via la console admin (commandes +`grant_broadcaster ` / `revoke_broadcaster ` / +`list_broadcasters`). La capability est embarquée dans le ticket +d'authentification audio : la révocation devient effective au prochain +ticket (≤ 2 min en pratique). Un client connecté à un serveur trop +ancien (sans `server_caps: ["broadcast_all"]` dans le welcome) voit +simplement la touche sans effet — aucune trame n'est émise. + ### Mode RP (Roleplay) Quand activé, le filtre radio est appliqué sur la voip de proximité **uniquement si les deux joueurs portent leur casque dans Star @@ -138,6 +153,9 @@ Dans les paramètres du client, définissez : - **Touche radio (PTT)** : à maintenir pour parler sur votre canal radio - **Touche profile radio** : à maintenir pour parler à tous les joueurs de votre profil +- **Touche diffusion globale (PTT)** : à maintenir pour diffuser à tous + les canaux (réservée aux broadcasters — sans le rôle, la touche reste + silencieuse côté serveur) - **Cycle channel key** : pour changer rapidement de canal sans alt-tab N'importe quelle touche ou combinaison de 2 touches clavier ou bouton diff --git a/client/circusvoip_client.py b/client/circusvoip_client.py index 913deee..24ce1b3 100644 --- a/client/circusvoip_client.py +++ b/client/circusvoip_client.py @@ -1062,8 +1062,8 @@ def _load_cfg() -> dict: "gamelog_path", # Mode RP "rp_mode", - # Hotkeys (8 raccourcis) - "radio_key", "profile_radio_key", + # Hotkeys (9 raccourcis) + "radio_key", "profile_radio_key", "broadcast_all_key", "mute_mic_key", "mute_prox_key", "mute_radio_key", "mute_all_key", "proximity_short_key", "cycle_channel_key", }) @@ -1275,6 +1275,11 @@ class State: last_radio_seen_ts: dict = {} profile_radio_key = None profile_radio_active = False + # [BROADCAST_ALL] PTT diffusion globale + capabilities serveur + broadcast_all_key = None + broadcast_all_active = False + server_supports_broadcast_all = False + is_broadcaster = False cycle_channel_key = None # Overlays overlays_show = False @@ -1542,6 +1547,13 @@ def _handle_message(self, data: dict, my_name: str): # reconnecte, on obtient un nouveau ticket et l'ancien est # ecrase (l'ancien ne vaut plus rien cote serveur). state.audio_ticket = data.get("audio_ticket", "") or "" + # [BROADCAST_ALL] Capabilities serveur + role broadcaster. + # Le client n'active sa touche PTT diffusion globale que si + # le serveur l'annonce dans server_caps ET que l'admin a + # accorde le role a ce joueur. Sinon : touche grisee. + server_caps = data.get("server_caps") or [] + state.server_supports_broadcast_all = "broadcast_all" in server_caps + state.is_broadcaster = bool(data.get("is_broadcaster", False)) except Exception as e: if _CORE_AVAILABLE: try: @@ -4873,9 +4885,10 @@ def _make_key_row(parent_layout, label_txt: str, kind_id: str): parent_layout.addLayout(h) return val_lbl - self.lbl_radio_key = _make_key_row(v_radio, "Radio canal (PTT) :", "radio") - self.lbl_profile_key = _make_key_row(v_radio, "Radio profil (PTT) :", "profile") - self.lbl_mute_mic_key = _make_key_row(v_radio, "Mute micro :", "mute_mic") + self.lbl_radio_key = _make_key_row(v_radio, "Radio canal (PTT) :", "radio") + self.lbl_profile_key = _make_key_row(v_radio, "Radio profil (PTT) :", "profile") + self.lbl_broadcast_all_key = _make_key_row(v_radio, "Diffusion globale (PTT) :", "broadcast_all") + self.lbl_mute_mic_key = _make_key_row(v_radio, "Mute micro :", "mute_mic") self.lbl_mute_prox_key = _make_key_row(v_radio, "Mute audio proximite :", "mute_prox") self.lbl_mute_radio_key = _make_key_row(v_radio, "Mute audio radio :", "mute_radio") self.lbl_mute_all_key = _make_key_row(v_radio, "Mute tout :", "mute_all") @@ -5525,14 +5538,15 @@ def _refresh_radio_key_labels(self): return # Liste de tuples (attr_label, attr_state) rows = [ - ("lbl_radio_key", "radio_key"), - ("lbl_profile_key", "profile_radio_key"), - ("lbl_mute_mic_key", "mute_mic_key"), - ("lbl_mute_prox_key", "mute_prox_key"), - ("lbl_mute_radio_key", "mute_radio_key"), - ("lbl_mute_all_key", "mute_all_key"), - ("lbl_prox_short_key", "proximity_short_key"), - ("lbl_cycle_ch_key", "cycle_channel_key"), + ("lbl_radio_key", "radio_key"), + ("lbl_profile_key", "profile_radio_key"), + ("lbl_broadcast_all_key", "broadcast_all_key"), + ("lbl_mute_mic_key", "mute_mic_key"), + ("lbl_mute_prox_key", "mute_prox_key"), + ("lbl_mute_radio_key", "mute_radio_key"), + ("lbl_mute_all_key", "mute_all_key"), + ("lbl_prox_short_key", "proximity_short_key"), + ("lbl_cycle_ch_key", "cycle_channel_key"), ] for lbl_attr, state_attr in rows: lbl = getattr(self, lbl_attr, None) @@ -5727,13 +5741,14 @@ def _capture_key(self, kind: str): return # kind -> (label dialog, attribut state, cle config) kinds = { - "radio": ("Radio canal (PTT)", "radio_key", "radio_key"), - "profile": ("Radio profil (PTT)", "profile_radio_key", "profile_radio_key"), - "mute_mic": ("Mute micro (toggle)", "mute_mic_key", "mute_mic_key"), - "mute_prox": ("Mute audio proximite", "mute_prox_key", "mute_prox_key"), - "mute_radio": ("Mute audio radio", "mute_radio_key", "mute_radio_key"), - "mute_all": ("Mute tout", "mute_all_key", "mute_all_key"), - "prox_short": ("Proximite 30m / 5m", "proximity_short_key", "proximity_short_key"), + "radio": ("Radio canal (PTT)", "radio_key", "radio_key"), + "profile": ("Radio profil (PTT)", "profile_radio_key", "profile_radio_key"), + "broadcast_all": ("Diffusion globale (PTT)", "broadcast_all_key", "broadcast_all_key"), + "mute_mic": ("Mute micro (toggle)", "mute_mic_key", "mute_mic_key"), + "mute_prox": ("Mute audio proximite", "mute_prox_key", "mute_prox_key"), + "mute_radio": ("Mute audio radio", "mute_radio_key", "mute_radio_key"), + "mute_all": ("Mute tout", "mute_all_key", "mute_all_key"), + "prox_short": ("Proximite 30m / 5m", "proximity_short_key", "proximity_short_key"), "cycle_channel": ("Cycle canal radio", "cycle_channel_key", "cycle_channel_key"), } if kind not in kinds: @@ -7504,6 +7519,7 @@ def _canon(k): return k # fallback : laisser la valeur brute state.radio_key = _canon(core_cfg.get("radio_key")) state.profile_radio_key = _canon(core_cfg.get("profile_radio_key")) + state.broadcast_all_key = _canon(core_cfg.get("broadcast_all_key")) state.mute_mic_key = _canon(core_cfg.get("mute_mic_key")) state.mute_prox_key = _canon(core_cfg.get("mute_prox_key")) state.mute_radio_key = _canon(core_cfg.get("mute_radio_key")) @@ -7987,6 +8003,7 @@ def closeEvent(self, event): "zone_source", "radio_key", "profile_radio_key", + "broadcast_all_key", "mute_mic_key", "mute_prox_key", "mute_radio_key", diff --git a/client/circusvoip_core.py b/client/circusvoip_core.py index 461a82c..e15651c 100644 --- a/client/circusvoip_core.py +++ b/client/circusvoip_core.py @@ -519,6 +519,19 @@ class State: # Touche du PTT profil (similaire a radio_key) profile_radio_key = None profile_radio_active = False + # Touche du PTT diffusion globale (broadcaster). Quand maintenue, la + # voix est emise avec flag audio 0x03, relayee par le serveur audio + # a TOUS les clients quels que soient leurs canaux. Reservee aux + # joueurs ayant le role broadcaster (cf welcome.is_broadcaster). + broadcast_all_key = None + broadcast_all_active = False + # Etat negocie au welcome : + # server_supports_broadcast_all : True si le serveur expose 'broadcast_all' + # dans welcome.server_caps. Permet de griser la touche cote UI + # sur les anciens serveurs. + # is_broadcaster : True si l'admin a accorde le role au joueur courant. + server_supports_broadcast_all = False + is_broadcaster = False # Touche pour cycler les canaux (descente, boucle en haut) cycle_channel_key = None # Overlays floating windows @@ -878,6 +891,9 @@ def __init__(self): # Idem pour le PTT profil (independant du release_gen radio classique) self._profile_release_gen = 0 self._profile_release_lock = threading.Lock() + # Idem pour le PTT diffusion globale (broadcaster, flag 0x03) + self._broadcast_release_gen = 0 + self._broadcast_release_lock = threading.Lock() def _on_radio_pressed(self): """Appele quand la touche/bouton radio est enfonce. @@ -1069,6 +1085,80 @@ def _delayed_off(): except Exception: pass + # ---- PTT diffusion globale (broadcaster, flag 0x03) ---- + # Au press : active broadcast_all_active + radio_active (pour que les + # trames audio sortent avec effet radio cote receveur). + # Au release : timer differe avec linger comme la radio classique. + # Pas de switch de canal, pas de callback UI metier : juste un beep et + # un flag d'etat. Le serveur audio enforce la capability ; le client + # n'envoie rien si server_supports_broadcast_all=False ou is_broadcaster=False + # (cf _on_audio_captured). + + def _on_broadcast_pressed_impl(self): + """Press de la touche PTT diffusion globale.""" + with self._broadcast_release_lock: + self._broadcast_release_gen += 1 + state.broadcast_all_active = True + # Activer radio_active : le rendu local (effet radio sur les autres) + # depend de ce flag dans la chaine audio. Eviter qu'une diffusion + # globale arrive en proximity et perde l'effet radio. + state.radio_active = True + try: + _dbg_log( + f"[BROADCAST PTT] press " + f"(supported={state.server_supports_broadcast_all}, " + f"role={state.is_broadcaster})" + ) + except Exception: + pass + if state.audio_io is not None: + try: + state.audio_io.set_gate_force_open(True) + state.audio_io.play_local_beep("press") + except Exception: + pass + + def _on_broadcast_released_impl(self): + """Release de la touche PTT diffusion globale, avec linger pour + ne pas couper net en cas de re-press immediat.""" + with self._broadcast_release_lock: + self._broadcast_release_gen += 1 + my_gen = self._broadcast_release_gen + try: + _dbg_log("[BROADCAST PTT] release") + except Exception: + pass + if state.audio_io is not None: + try: + state.audio_io.set_gate_force_open(False) + state.audio_io.force_gate_close() + state.audio_io.play_local_beep("release") + except Exception: + pass + n_dropped = _flush_audio_send_queue() + if n_dropped > 0: + try: + _dbg_log(f"[BROADCAST PTT] release flush: {n_dropped} trames jetees") + except Exception: + pass + + def _delayed_off(): + time.sleep(RADIO_RELEASE_LINGER_MS / 1000.0) + with self._broadcast_release_lock: + if my_gen != self._broadcast_release_gen: + return + state.broadcast_all_active = False + # Couper radio_active SAUF si une autre PTT est encore tenue + # (PTT radio classique ou PTT profil). Meme logique que pour + # le release du PTT profil. + rk = state.radio_key or "" + pk = state.profile_radio_key or "" + held_radio = rk and _combo_matches_pressed(rk, self._currently_pressed) + held_profile = pk and _combo_matches_pressed(pk, self._currently_pressed) + if not (held_radio or held_profile): + state.radio_active = False + threading.Thread(target=_delayed_off, daemon=True).start() + def _normalize_key(self, key): """Transforme un event pynput clavier en chaine comparable. Delegue a la fonction module-level _normalize_pynput_key pour que la @@ -1153,6 +1243,11 @@ def _check_ptt_press(self, key_str): if pk and _combo_matches_pressed(pk, self._currently_pressed, key_str): if not state.profile_radio_active: self._on_profile_radio_pressed_impl() + # PTT diffusion globale (broadcaster) + bk = state.broadcast_all_key or "" + if bk and _combo_matches_pressed(bk, self._currently_pressed, key_str): + if not state.broadcast_all_active: + self._on_broadcast_pressed_impl() def _check_ptt_release(self, key_str): """Verifie si une combo PTT vient d'etre rompue par le release @@ -1181,6 +1276,11 @@ def _check_ptt_release(self, key_str): if pk and state.profile_radio_active and \ not _combo_matches_pressed(pk, self._currently_pressed): self._on_profile_radio_released_impl() + # PTT diffusion globale : si actif et combo plus satisfaite -> release + bk = state.broadcast_all_key or "" + if bk and state.broadcast_all_active and \ + not _combo_matches_pressed(bk, self._currently_pressed): + self._on_broadcast_released_impl() def _on_press(self, key): n = self._normalize_key(key) @@ -1804,13 +1904,18 @@ async def _audio_ws_loop(ui): # 0x00 = proximity (sans PTT) # 0x01 = radio classique (PTT canal) # 0x02 = radio profil (PTT profil) + # 0x03 = diffusion globale (broadcaster, tous canaux) flag = payload[0] - is_radio_canal = (flag == 1) - is_radio_profil = (flag == 2) - is_radio = is_radio_canal or is_radio_profil # alias pour audio_io + is_radio_canal = (flag == 1) + is_radio_profil = (flag == 2) + is_broadcast_all = (flag == 3) + # Pour le rendu local (effet radio, set_user_volume), une + # diffusion globale est traitee comme une radio. Le seul + # ecart : pas de filtrage par canal/profil cote receveur. + is_radio = is_radio_canal or is_radio_profil or is_broadcast_all frame = payload[1:] - # Mute radio : coupe les 2 modes radio + # Mute radio : coupe les 3 modes radio (canal/profil/broadcast) if is_radio and state.mute_radio: continue # Mute proximity : coupe seulement les trames proximity @@ -1818,7 +1923,15 @@ async def _audio_ws_loop(ui): continue # FILTRAGE selon le mode : - if is_radio_canal: + if is_broadcast_all: + # Diffusion globale : pas de filtrage cote receveur. + # Le serveur audio a deja verifie que l'emetteur a la + # capability can_broadcast (cf circusvoip_audio_server.py + # autour de FLAG_BROADCAST_ALL). On note quand meme + # le timestamp pour dedup proximity (l'emetteur envoie + # aussi une trame 0x00 a cote pour les joueurs proches). + state.last_radio_seen_ts[sender] = time.monotonic() + elif is_radio_canal: # Filtre par CANAL : meme canal sinon on jette sender_ch = state.player_channels.get(sender) if state.my_channel != sender_ch: @@ -1963,16 +2076,21 @@ def _on_audio_captured(frame_np): On se contente de deposer la trame dans la queue ; l'envoi WS est fait par le task _audio_sender dans sa propre boucle asyncio. - 3 modes de transmission selon les PTT actifs : + 4 modes de transmission selon les PTT actifs (priorite descendante) : + - PTT diffusion globale (state.broadcast_all_active) : flag 0x03 + Reserve aux broadcasters. Si le serveur ne supporte pas la feature + (server_supports_broadcast_all=False) ou si le joueur n'a pas le + role (is_broadcaster=False), on ne tente pas : la trame serait + droppee par le serveur audio mais on evite le bruit reseau. - PTT profil (state.profile_radio_active) : flag 0x02 (radio profil) - PTT radio (state.radio_active sans profil) : flag 0x01 (radio canal) - Aucun : flag 0x00 (proximity) - Quand on est en PTT radio OU PTT profil, on envoie en plus une trame - proximity (0x00) en parallele SI un joueur est a portee. Ca permet aux - joueurs a cote (mais pas sur le canal/profil) d'entendre ma voix en - proximite. Le receveur depulique via state.last_radio_seen_ts (50ms). - Optimisation : pas de 2e flux si personne a portee. + Quand on est en PTT radio / profil / diffusion globale, on envoie en + plus une trame proximity (0x00) en parallele SI un joueur est a portee. + Ca permet aux joueurs a cote (mais pas sur le canal/profil) d'entendre + ma voix en proximite. Le receveur depulique via state.last_radio_seen_ts + (50ms). Optimisation : pas de 2e flux si personne a portee. """ if not state.audio_connected: return @@ -1989,7 +2107,14 @@ def _put(data): pass _audio_send_queue.put_nowait(data) - if state.profile_radio_active: + if (state.broadcast_all_active + and state.server_supports_broadcast_all + and state.is_broadcaster): + # PTT diffusion globale : flag 0x03 + (eventuellement) proximity + _put(b"\x03" + frame_bytes) + if _has_player_in_range(): + _put(b"\x00" + frame_bytes) + elif state.profile_radio_active: # PTT profil : flag 0x02 + (eventuellement) proximity _put(b"\x02" + frame_bytes) if _has_player_in_range(): From 3999505a02e3a41eed4b35fc6afe221cb2e3bf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Wed, 20 May 2026 21:03:36 -0600 Subject: [PATCH 5/7] feat(security): broadcaster auth par token push-via-WSS Le modele precedent autorisait n'importe quel client se presentant avec le nom d'un broadcaster connu. Le shared player token + le self-declared name ne suffisaient pas a empecher l'usurpation (un attaquant ayant le player token pouvait se reconnecter en se nommant comme un broadcaster legitime et heriter de can_broadcast=True). Modele d'auth ajoute : - circusvoip_broadcasters.json passe de list[str] a dict {name: sha256_hex}. Ancien format detecte au load : warning + liste videe (re-grant requis). On ne migre pas en silence : sans token push, le client cible n'aurait rien pour se re-authentifier. - AuthRegistry est inchange. La verification se fait en amont, dans circusvoip_server.py via _hash_broadcaster_token / _verify_broadcaster_token (compare_digest pour eviter les leaks par timing). grant_broadcaster est maintenant async et requiert que le joueur cible soit connecte. Le serveur : - Genere un token clair (16 octets d'entropie -> 32 hex chars). - Stocke son sha256, jamais le clair. - Push broadcaster_token_granted directement sur la WebSocket du joueur cible (canal authentifie deja par la connexion). - Pousse la liste mise a jour aux admins (broadcasters_list). Si le joueur est hors ligne : refus avec reason=player_must_be_connected. revoke_broadcaster push broadcaster_revoked au joueur si connecte, pour que son client efface le token local et ne tente plus de l'utiliser. Join handler : - Refuse si le nom est deja en ligne (name_in_use). Ferme aussi le scenario "se reconnecter sous un autre nom pendant que mon ancien WS est encore actif". - Si le nom est dans _broadcasters, exige broadcaster_token verifie. Sans token (vide) ou avec un mauvais : refus broadcaster_token_invalid. Cela protege aussi l'identite : un non-broadcaster ne peut pas non plus prendre le nom d'un broadcaster connu pour usurper son identite dans le chat ou les canaux. - can_broadcast n'est True dans le ticket que si le nom est broadcaster ET le token a ete verifie. Console admin (circusvoip_admin.py) : - "+ Ajouter" remplace simpledialog.askstring par un Combobox des joueurs actuellement connectes. Refleter la nouvelle contrainte cote serveur (player_must_be_connected) plutot que d'afficher une erreur apres coup. --- server/circusvoip_admin.py | 80 ++++++++++--- server/circusvoip_server.py | 218 +++++++++++++++++++++++++++++------- 2 files changed, 243 insertions(+), 55 deletions(-) diff --git a/server/circusvoip_admin.py b/server/circusvoip_admin.py index f4d2c5a..4430e78 100644 --- a/server/circusvoip_admin.py +++ b/server/circusvoip_admin.py @@ -926,20 +926,74 @@ def _remove_profile(self, name: str): self._send_cmd({"cmd": "remove_profile", "name": name}) def _add_broadcaster(self): - """Accorde le role broadcaster a un joueur (par nom). Le serveur - applique le role idempotemment : si le nom existe deja, no-op. - La capability ne prend effet qu'au prochain ticket du joueur cible - (typiquement 2 min ; lui demander de se reconnecter pour aller plus - vite). Cf doc serveur grant_broadcaster().""" - n = simpledialog.askstring( - "Accorder broadcaster", - "Nom du joueur a qui accorder le role broadcaster :\n" - "(le joueur doit se reconnecter au serveur pour que sa\n" - "capability prenne effet)", - parent=self.root, + """Accorde le role broadcaster a un joueur connecte. On affiche un + dropdown des joueurs actuellement connectes (state.players) plutot + qu'une saisie libre : le serveur exige que le joueur soit connecte + au moment du grant pour pouvoir lui pusher son token. Saisir un + nom inexistant donnerait une erreur player_must_be_connected. + + Le serveur cible pousse ensuite le token au client choisi via la + WebSocket existante (jamais affiche en clair ici) ; le client le + sauve, et la capability sera effective des sa prochaine reconnexion.""" + # Filtre : exclut soi-meme et les noms deja broadcasters (no-op + # serveur de toute facon, mais evite de polluer le dropdown). + already = set(state.broadcasters) + connected = sorted(n for n in state.players.keys() + if n and n not in already) + if not connected: + messagebox.showinfo( + "Accorder broadcaster", + "Aucun joueur connecte ne peut etre accorde " + "(soit deja broadcaster, soit personne en ligne).", + parent=self.root, + ) + return + # Petit dialog modale avec un ttk.Combobox. + dlg = tk.Toplevel(self.root) + dlg.title("Accorder broadcaster") + dlg.configure(bg=BG_PANEL) + dlg.transient(self.root) + dlg.grab_set() + dlg.resizable(False, False) + tk.Label( + dlg, + text="Joueur a qui accorder le role broadcaster :\n" + "(le token sera pushed via sa WebSocket ;\n" + "il devra se reconnecter pour activer la touche)", + bg=BG_PANEL, fg=FG, justify="left", + ).pack(padx=12, pady=(12, 8)) + import tkinter.ttk as ttk + var = tk.StringVar(value=connected[0]) + combo = ttk.Combobox( + dlg, textvariable=var, values=connected, state="readonly", width=28 ) - if n: - self._send_cmd({"cmd": "grant_broadcaster", "name": n}) + combo.pack(padx=12, pady=(0, 8)) + combo.focus_set() + btns = tk.Frame(dlg, bg=BG_PANEL) + btns.pack(padx=12, pady=(0, 12), fill="x") + chosen = {"name": None} + + def on_ok(): + chosen["name"] = var.get() + dlg.destroy() + + def on_cancel(): + dlg.destroy() + + tk.Button(btns, text="Accorder", command=on_ok, bg=BG_BTN, fg=FG, + activebackground=BG_BTN_HOVER).pack(side="right", padx=4) + tk.Button(btns, text="Annuler", command=on_cancel, bg=BG_BTN, fg=FG, + activebackground=BG_BTN_HOVER).pack(side="right") + dlg.bind("", lambda e: on_ok()) + dlg.bind("", lambda e: on_cancel()) + # Position : centre approx sur la fenetre admin + self.root.update_idletasks() + x = self.root.winfo_rootx() + (self.root.winfo_width() // 2) - 150 + y = self.root.winfo_rooty() + 120 + dlg.geometry(f"+{max(0, x)}+{max(0, y)}") + dlg.wait_window() + if chosen["name"]: + self._send_cmd({"cmd": "grant_broadcaster", "name": chosen["name"]}) def _remove_broadcaster(self, name: str): """Retire le role broadcaster. Effectif au prochain ticket du joueur diff --git a/server/circusvoip_server.py b/server/circusvoip_server.py index c680b27..2a7fecd 100644 --- a/server/circusvoip_server.py +++ b/server/circusvoip_server.py @@ -19,6 +19,7 @@ """ import asyncio +import hashlib import json import secrets import socket @@ -325,12 +326,39 @@ def _debug_log_pos(name: str, pos: dict, ts_capture: float = None): _PROFILES_FILE = _BASE_DIR / "circusvoip_profiles.json" _profiles: list = [] -# Liste des broadcasters : joueurs autorises a parler simultanement sur TOUS -# les canaux radio (PTT diffusion globale, flag audio 0x03). L'admin gere la -# liste via grant_broadcaster / revoke_broadcaster. La capability est -# propagee au serveur audio via le ticket (cf. AuthRegistry.issue(can_broadcast=)). +# Broadcasters : joueurs autorises a parler simultanement sur TOUS les canaux +# radio (PTT diffusion globale, flag audio 0x03). L'admin gere la liste via +# grant_broadcaster / revoke_broadcaster. La capability est propagee au serveur +# audio via le ticket (cf. AuthRegistry.issue(can_broadcast=)). +# +# Modele d'auth (cf. feat/broadcaster-token-auth) : +# _broadcasters : dict {name: sha256_hex_du_token} +# Le token clair est genere au grant, hashe ici, et pushed une seule fois au +# client cible via sa WebSocket. Le client le sauvegarde dans son config et +# le presente au join (champ "broadcaster_token"). Le serveur compare en +# temps constant. Sans le bon token, le nom est REFUSE au join (anti-impersonation). _BROADCASTERS_FILE = _BASE_DIR / "circusvoip_broadcasters.json" -_broadcasters: set = set() +_broadcasters: dict = {} + + +def _hash_broadcaster_token(token: str) -> str: + """Hash SHA-256 hexa du token broadcaster. + On ne stocke jamais le token en clair cote serveur ; on ne peut donc pas + le re-emettre apres le grant initial (re-grant requis pour le renvoyer).""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _verify_broadcaster_token(name: str, token: str) -> bool: + """Verifie en temps constant que `token` correspond au hash stocke pour `name`. + Renvoie False si le nom n'est pas dans _broadcasters, si le token est vide, + ou si le hash ne correspond pas.""" + if not name or not token or not isinstance(token, str): + return False + stored = _broadcasters.get(name) + if not stored: + return False + candidate = _hash_broadcaster_token(token) + return secrets.compare_digest(candidate, stored) def _load_channels() -> list: @@ -423,25 +451,49 @@ def _save_profiles(): print(f"[PROFILES] Echec sauvegarde {_PROFILES_FILE.name} : {e}") -def _load_broadcasters() -> set: - """Charge la liste des broadcasters depuis circusvoip_broadcasters.json. - Retourne un set vide si absent (la capability est opt-in).""" +def _load_broadcasters() -> dict: + """Charge le mapping {name: hash} depuis circusvoip_broadcasters.json. + Retourne {} si absent (la capability est opt-in). + + Migration : l'ancien format etait une liste de noms (pas de token). Si on + detecte ce format, on log un warning et on retourne un dict vide : l'admin + doit re-grant chaque broadcaster pour generer leurs tokens. On ne migre + PAS en silence (cela donnerait l'illusion d'un setup sûr alors qu'aucun + token n'aurait ete distribue aux joueurs concernes).""" try: if _BROADCASTERS_FILE.exists(): with open(_BROADCASTERS_FILE, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list): - return {n.strip() for n in data if isinstance(n, str) and n.strip()} + # Ancien format detecte : pas de tokens, re-grant requis. + old_names = [n for n in data if isinstance(n, str) and n.strip()] + if old_names: + print( + f"[BROADCASTERS] Ancien format detecte dans " + f"{_BROADCASTERS_FILE.name} ({len(old_names)} entree(s) : " + f"{old_names}). Les broadcasters DOIVENT etre re-grant " + f"pour generer leurs tokens. Liste videe." + ) + return {} + if isinstance(data, dict): + # Format actuel : {name: sha256_hex}. + clean = {} + for name, h in data.items(): + if (isinstance(name, str) and name.strip() + and isinstance(h, str) and len(h) == 64): + clean[name.strip()] = h + return clean except Exception as e: print(f"[BROADCASTERS] Echec chargement {_BROADCASTERS_FILE.name} : {e}") - return set() + return {} def _save_broadcasters(): - """Persiste la liste des broadcasters dans circusvoip_broadcasters.json.""" + """Persiste le mapping {name: hash} dans circusvoip_broadcasters.json. + Le hash uniquement est persiste, jamais le token en clair.""" try: with open(_BROADCASTERS_FILE, "w", encoding="utf-8") as f: - json.dump(sorted(_broadcasters), f, ensure_ascii=False, indent=2) + json.dump(_broadcasters, f, ensure_ascii=False, indent=2, sort_keys=True) except Exception as e: print(f"[BROADCASTERS] Echec sauvegarde {_BROADCASTERS_FILE.name} : {e}") @@ -633,11 +685,11 @@ async def _admin_handle_cmd(ws, cmd: str, data: dict) -> tuple: data.get("profile")) return (ok, "" if ok else "joueur introuvable ou profil invalide") if cmd == "grant_broadcaster": - ok = grant_broadcaster(data.get("name", "")) - return (ok, "" if ok else "nom vide") + ok, reason = await grant_broadcaster(data.get("name", "")) + return (ok, reason) if cmd == "revoke_broadcaster": - ok = revoke_broadcaster(data.get("name", "")) - return (ok, "" if ok else "nom vide") + ok, reason = await revoke_broadcaster(data.get("name", "")) + return (ok, reason) if cmd == "list_broadcasters": try: await ws.send(json.dumps({ @@ -795,6 +847,51 @@ async def handler(ws): name = data.get("name", f"Player_{len(clients)+1}") + # [UNIQUE NAME] Refuse si un client deja connecte porte le + # meme nom. Sans cela, n'importe qui peut se faire passer + # pour un autre joueur dans le chat / les canaux. Combine + # avec la verification du broadcaster_token, cela ferme + # aussi le scenario "deconnecte, reconnecte sous le nom + # d'un broadcaster connu pour usurper le role". + if _find_client_ws_by_name(name) is not None: + _log(f"REFUSE : nom deja utilise '{name}' (ip {peer_ip})", ORANGE) + try: + await ws.send(json.dumps({ + "type": "error", + "reason": "name_in_use", + "message": "Ce nom est deja utilise sur ce serveur", + })) + except Exception: + pass + await ws.close(code=1008, reason="name_in_use") + return + + # [BROADCASTER AUTH] Si le nom figure dans _broadcasters, le + # client DOIT presenter le broadcaster_token correct. Cela + # protege a la fois : + # - l'octroi du role (sans token, can_broadcast=False) + # - l'identite (un non-broadcaster ne peut pas usurper le + # nom d'un broadcaster connu) + # Distinction stricte entre les deux cas n'apporte rien : + # dans les deux cas le join est refuse, le client clarifie + # son setup et ressaie. + client_bcast_token = data.get("broadcaster_token", "") + if name in _broadcasters: + if not _verify_broadcaster_token(name, client_bcast_token): + _log(f"REFUSE : broadcaster_token invalide pour '{name}' " + f"(ip {peer_ip})", RED) + try: + await ws.send(json.dumps({ + "type": "error", + "reason": "broadcaster_token_invalid", + "message": "Ce nom est reserve a un broadcaster ; " + "broadcaster_token manquant ou invalide", + })) + except Exception: + pass + await ws.close(code=1008, reason="broadcaster_token_invalid") + return + # [P4 - auth partagee] Genere un ticket court pour ce # joueur. Le client le recevra dans le welcome et devra le # presenter au serveur audio. Sans ce ticket, le serveur @@ -818,8 +915,8 @@ async def handler(ws): "audio_ticket": audio_ticket, } # Enregistre le ticket dans le fichier partage avec l'audio. - # can_broadcast est lu cote audio pour autoriser les frames - # avec flag 0x03 (PTT diffusion globale). Si le role est + # can_broadcast n'est True que si le nom est broadcaster + # ET que le token a ete verifie ci-dessus. Si le role est # revoque pendant que le joueur est connecte, la revocation # ne s'applique qu'au prochain ticket (TTL <= 120s). _auth_registry.issue( @@ -1312,43 +1409,80 @@ async def _do(): pass -def grant_broadcaster(player_name: str) -> bool: - """Accorde le role broadcaster a `player_name`. Idempotent : renvoie - True meme si deja accorde, False uniquement sur nom vide. La - capability ne devient effective qu'au prochain ticket emis : si le - joueur est deja connecte, il devra se reconnecter au serveur positions - pour que son ticket audio porte can_broadcast=True (TTL ticket = 120s - + cycle reconnect, donc effectif sous ~2 min en pratique).""" +def _find_client_ws_by_name(player_name: str): + """Cherche dans `clients` la WebSocket associee au nom donne. + Renvoie None si aucune connexion active ne porte ce nom.""" + for ws_, info in clients.items(): + if info.get("name") == player_name: + return ws_ + return None + + +async def grant_broadcaster(player_name: str) -> tuple: + """Accorde le role broadcaster a `player_name` et lui push son token + via sa WebSocket. Renvoie (ok, reason). + + Le joueur DOIT etre connecte au moment du grant. C'est le seul canal + par lequel le token est transmis (jamais affiche en clair, jamais + persiste cote serveur autrement que sous forme de hash). Si le joueur + se deconnecte avant d'avoir sauvegarde le token cote client, le grant + doit etre refait.""" player_name = (player_name or "").strip() if not player_name: - return False - if player_name not in _broadcasters: - _broadcasters.add(player_name) - _save_broadcasters() - _broadcast_broadcasters_list_threadsafe() - _log(f"Broadcaster accorde : {player_name}", GREEN) - return True + return (False, "nom_vide") + target_ws = _find_client_ws_by_name(player_name) + if target_ws is None: + return (False, "player_must_be_connected") + # Token clair : 32 hex chars (16 octets d'entropie). Stocke uniquement + # son hash cote serveur ; le clair part vers le client une seule fois. + token = secrets.token_hex(16) + _broadcasters[player_name] = _hash_broadcaster_token(token) + _save_broadcasters() + # Push au client cible. On ne capture pas l'echec : si la WS est cassee + # entre temps, le grant reste valide cote serveur mais le client n'aura + # pas le token (re-grant requis). L'admin verra le succes dans la + # reponse mais le joueur ne pourra pas broadcast tant qu'il n'a pas + # le token sauvegarde et re-presente au join. + try: + await target_ws.send(json.dumps({ + "type": "broadcaster_token_granted", + "token": token, + })) + except Exception as e: + _log(f"Broadcaster grant : echec push WS a {player_name} : {e}", ORANGE) + _broadcast_broadcasters_list_threadsafe() + _log(f"Broadcaster accorde : {player_name} (token pushed)", GREEN) + return (True, "") -def revoke_broadcaster(player_name: str) -> bool: - """Retire le role broadcaster a `player_name`. Idempotent : renvoie - True meme si deja absent, False uniquement sur nom vide. Comme - grant_broadcaster, la revocation n'est effective qu'au prochain - ticket (TTL <= 120s).""" +async def revoke_broadcaster(player_name: str) -> tuple: + """Retire le role broadcaster a `player_name`. Si le joueur est connecte, + lui push aussi un message broadcaster_revoked pour qu'il efface son + token cote client. La capability cessera d'etre accordee au prochain + ticket (TTL <= 120s). Idempotent.""" player_name = (player_name or "").strip() if not player_name: - return False + return (False, "nom_vide") if player_name in _broadcasters: - _broadcasters.discard(player_name) + del _broadcasters[player_name] _save_broadcasters() + target_ws = _find_client_ws_by_name(player_name) + if target_ws is not None: + try: + await target_ws.send(json.dumps({ + "type": "broadcaster_revoked", + })) + except Exception: + pass _broadcast_broadcasters_list_threadsafe() _log(f"Broadcaster revoque : {player_name}", ORANGE) - return True + return (True, "") def list_broadcasters() -> list: - """Retourne la liste triee des broadcasters actuels.""" - return sorted(_broadcasters) + """Retourne la liste triee des noms de broadcasters actuels (hashes + masques).""" + return sorted(_broadcasters.keys()) def assign_profile(player_name: str, profile_name) -> bool: From 4b049cbb1961b1e525b5ae1dddfda56d5be091d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Wed, 20 May 2026 21:04:38 -0600 Subject: [PATCH 6/7] feat(client): presenter broadcaster_token au join + gerer grant/revoke Cote core (circusvoip_core.py) : - Nouveau helpers _get_broadcaster_token / _set_broadcaster_token / _server_key. Stockes dans circusvoip_client_config.json sous la cle broadcaster_tokens : {"host:port": ""}. Per-server : un meme client peut avoir des roles differents (ou n'en avoir aucun) sur plusieurs serveurs sans collision. - state.broadcaster_token_for_current_server : champ d'etat (pas encore utilise UI, sert de cache interne). Cote net (circusvoip_client.py) : - NetWorker._ws_client lit le token sauvegarde pour (server_ip, SERVER_PORT) juste avant d'envoyer le join, et l'ajoute au payload comme broadcaster_token. Cle serveur memorisee pour les pushes ulterieurs. - _handle_message : nouveau cas broadcaster_token_granted (sauve le token via _set_broadcaster_token, met is_broadcaster=True, previent l'UI via sig_log que l'utilisateur doit se reconnecter pour activer la touche). - _handle_message : nouveau cas broadcaster_revoked (efface le token local, met is_broadcaster=False). - _handle_message : deux nouveaux reason d'erreur fatale interceptes (name_in_use, broadcaster_token_invalid) -> stop_requested. Pas de retry auto : l'utilisateur doit changer son nom ou demander un re-grant. --- client/circusvoip_client.py | 59 +++++++++++++++++++++++++++++++++++++ client/circusvoip_core.py | 46 ++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/client/circusvoip_client.py b/client/circusvoip_client.py index 24ce1b3..bf8c7d0 100644 --- a/client/circusvoip_client.py +++ b/client/circusvoip_client.py @@ -1437,6 +1437,24 @@ async def _ws_client(self, server_ip: str, name: str, token: str): except Exception: pass + # [BROADCASTER_AUTH] Si on a un token broadcaster sauvegarde + # pour ce serveur, on le presente. Sinon champ vide : le + # serveur ne donne can_broadcast=True que si nom + token + # correspondent. Le token a ete pushed par broadcaster_token_granted + # (cf _handle_message) lors d'un grant precedent. Per-server : + # indexe par "host:port" de sorte qu'un meme client puisse + # avoir des roles differents sur plusieurs serveurs. + bcast_token = "" + if _CORE_AVAILABLE: + try: + bcast_token = _core._get_broadcaster_token(server_ip, SERVER_PORT) + except Exception: + bcast_token = "" + # On garde aussi en memoire la cle serveur pour les push + # ulterieurs (granted/revoked), evite de re-deviner ip/port. + self._server_key_ip = server_ip + self._server_key_port = SERVER_PORT + # Envoi du join. channel=None car 2a ne gere pas les canaux # (sera ajoute en 2c). await ws.send(json.dumps({ @@ -1444,6 +1462,7 @@ async def _ws_client(self, server_ip: str, name: str, token: str): "name": name, "token": token, "channel": None, + "broadcaster_token": bcast_token, })) # Bug fix 56 : marquer connected=True UNIQUEMENT apres @@ -1511,6 +1530,46 @@ def _handle_message(self, data: dict, my_name: str): if reason == "invalid_token": self.sig_invalid_token.emit() self._stop_requested = True + elif reason in ("name_in_use", "broadcaster_token_invalid"): + # Echec d'auth specifique : on log et on coupe. Pas de retry + # auto (l'utilisateur doit changer son setup : nom different, + # ou demander un re-grant a l'admin). + self._stop_requested = True + return + + if msg_type == "broadcaster_token_granted": + # L'admin a accorde le role broadcaster a ce client. Le token clair + # est dans data["token"]. On le sauve indexe par le serveur courant + # pour qu'il soit represente automatiquement aux prochains join. + token = data.get("token", "") or "" + if token and _CORE_AVAILABLE: + try: + ip = getattr(self, "_server_key_ip", "") + port = getattr(self, "_server_key_port", SERVER_PORT) + _core._set_broadcaster_token(ip, port, token) + state.is_broadcaster = True + self.sig_log.emit( + "[NET] Role broadcaster accorde : token sauvegarde. " + "Reconnecte-toi pour activer la touche." + ) + except Exception as e: + self.sig_log.emit(f"[NET] broadcaster grant : sauvegarde KO : {e}") + return + + if msg_type == "broadcaster_revoked": + # L'admin a revoque le role. On efface le token local pour eviter + # un join refuse a la prochaine reconnexion (le nom redevient + # libre cote serveur). La revocation est aussi appliquee au + # ticket actuel a la prochaine emission par le serveur. + if _CORE_AVAILABLE: + try: + ip = getattr(self, "_server_key_ip", "") + port = getattr(self, "_server_key_port", SERVER_PORT) + _core._set_broadcaster_token(ip, port, "") + state.is_broadcaster = False + self.sig_log.emit("[NET] Role broadcaster revoque.") + except Exception as e: + self.sig_log.emit(f"[NET] broadcaster revoke : nettoyage KO : {e}") return if msg_type == "welcome": diff --git a/client/circusvoip_core.py b/client/circusvoip_core.py index e15651c..f96810d 100644 --- a/client/circusvoip_core.py +++ b/client/circusvoip_core.py @@ -414,6 +414,43 @@ def _save_client_cfg(cfg): CLIENT_CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) +# ----- Broadcaster tokens : per-server, stockes dans le config client ----- +# Format : {"broadcaster_tokens": {"host:port": ""}} +# Le token est emis par le serveur via push broadcaster_token_granted apres +# que l'admin a accorde le role. Le client le sauve, le presente au join, +# et l'efface sur push broadcaster_revoked. Multi-serveur : un token distinct +# par serveur, isole par sa clef "host:port". + +def _server_key(ip: str, port) -> str: + """Clef d'index pour broadcaster_tokens. Stable a travers les reconnect.""" + return f"{(ip or '').strip().lower()}:{port}" + + +def _get_broadcaster_token(ip: str, port) -> str: + """Retourne le token broadcaster sauvegarde pour (ip, port), ou '' si absent.""" + cfg = _load_client_cfg() + tokens = cfg.get("broadcaster_tokens") or {} + if not isinstance(tokens, dict): + return "" + return tokens.get(_server_key(ip, port), "") or "" + + +def _set_broadcaster_token(ip: str, port, token: str): + """Sauvegarde le token broadcaster pour (ip, port). Token vide / None + supprime l'entree.""" + cfg = _load_client_cfg() + tokens = cfg.get("broadcaster_tokens") or {} + if not isinstance(tokens, dict): + tokens = {} + key = _server_key(ip, port) + if token: + tokens[key] = token + else: + tokens.pop(key, None) + cfg["broadcaster_tokens"] = tokens + _save_client_cfg(cfg) + + # ====================================================================== # Etat global partage # ====================================================================== @@ -529,9 +566,16 @@ class State: # server_supports_broadcast_all : True si le serveur expose 'broadcast_all' # dans welcome.server_caps. Permet de griser la touche cote UI # sur les anciens serveurs. - # is_broadcaster : True si l'admin a accorde le role au joueur courant. + # is_broadcaster : True si l'admin a accorde le role au joueur courant + # ET que le token a ete verifie au join. server_supports_broadcast_all = False is_broadcaster = False + # Token broadcaster a presenter au join (recu via push admin + # broadcaster_token_granted, sauve dans le config). Per-server : indexe + # par "host:port". Charge au connect (cf NetWorker) et envoye dans le + # message join. Sans le bon token, un nom present dans la liste des + # broadcasters serveur est REFUSE au join. + broadcaster_token_for_current_server = "" # Touche pour cycler les canaux (descente, boucle en haut) cycle_channel_key = None # Overlays floating windows From 03b7ed23e16d4372c1efaf247426464dd4d071fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Chely?= Date: Wed, 20 May 2026 22:04:39 -0600 Subject: [PATCH 7/7] fix(admin): dropdown grant inline dans le panneau (au lieu d'une popup) L'ancien _add_broadcaster ouvrait une popup tk.Toplevel modale avec un Combobox des joueurs connectes. Deux problemes : - Reference des constantes inexistantes (FG, BG_BTN, BG_BTN_HOVER au lieu de TEXT, BORDER, BG_ROW) -> NameError silencieux -> popup vide. - UX : un clic supplementaire et une popup juste pour selectionner un nom dans une liste qu'on a deja sous les yeux. Refonte : le dropdown vit directement dans le panneau BROADCASTERS, entre la liste des broadcasters et la section TOKEN JOUEUR. Plus de popup ; juste Combobox + bouton "Accorder". Le dropdown auto-sync via _refresh_add_broadcaster_dropdown() appele depuis refresh_players (pour les join/leave) et refresh_broadcasters (pour les grants/revokes). Si aucun joueur connecte n'est candidat -> dropdown disabled + vide. _add_broadcaster (popup) supprime, remplace par _grant_selected_broadcaster (action du bouton) qui envoie juste grant_broadcaster avec le nom selectionne. --- server/circusvoip_admin.py | 141 ++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 74 deletions(-) diff --git a/server/circusvoip_admin.py b/server/circusvoip_admin.py index 4430e78..94b2fee 100644 --- a/server/circusvoip_admin.py +++ b/server/circusvoip_admin.py @@ -499,15 +499,39 @@ def _on_p_wheel(event): # Broadcasters : joueurs autorises a parler sur tous les canaux # simultanement (PTT diffusion globale, flag audio 0x03). Liste # tenue par l'admin via grant_broadcaster / revoke_broadcaster. + # + # Le serveur exige que le joueur soit connecte au moment du grant + # (le token est pushed via sa WS). On expose donc un dropdown des + # joueurs connectes (non deja broadcasters) directement dans le + # panneau, plutot qu'un dialog modal : moins de friction, et l'admin + # voit immediatement qui peut etre promu. self._section(right, "BROADCASTERS") self._broadcasters_frame = tk.Frame(right, bg=BG_PANEL) self._broadcasters_frame.pack(fill="x", pady=2) - btn_add_bc = tk.Label(right, text="+ Ajouter broadcaster", - bg=BORDER, fg=BLUE, - font=("Courier", 9, "bold"), pady=6, padx=8, - cursor="hand2") - btn_add_bc.pack(fill="x", pady=(2, 8)) - btn_add_bc.bind("", lambda e: self._add_broadcaster()) + import tkinter.ttk as ttk + self._add_bc_frame = tk.Frame(right, bg=BG_PANEL) + self._add_bc_frame.pack(fill="x", pady=(4, 8)) + self._add_bc_var = tk.StringVar(value="") + self._add_bc_combo = ttk.Combobox( + self._add_bc_frame, + textvariable=self._add_bc_var, + values=[], + state="disabled", + width=20, + ) + self._add_bc_combo.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._add_bc_btn = tk.Label( + self._add_bc_frame, text="Accorder", + bg=BORDER, fg=BLUE, + font=("Courier", 9, "bold"), + padx=8, pady=4, cursor="hand2", + ) + self._add_bc_btn.pack(side="right") + self._add_bc_btn.bind( + "", lambda e: self._grant_selected_broadcaster() + ) + # Initialise vide ; sera peuple par _refresh_add_broadcaster_dropdown() + # appele depuis refresh_players / refresh_broadcasters. # Token serveur en bas (info admin) self._section(right, "TOKEN JOUEUR") @@ -728,6 +752,9 @@ def _do(): btn_del.bind("", lambda e, n=name: self._remove_broadcaster(n)) self._safe_after(_do) + # Sync du dropdown : un broadcaster en plus / en moins change les + # candidats au grant. + self._refresh_add_broadcaster_dropdown() def refresh_players(self): def _do(): @@ -746,6 +773,8 @@ def _do(): self._build_player_row(name) self._update_player_row(name) self._safe_after(_do) + # Un join/leave change les candidats au grant -> resync. + self._refresh_add_broadcaster_dropdown() def _build_player_row(self, name: str): row = tk.Frame(self._players_frame, bg=BG_ROW, pady=4, padx=8) @@ -925,75 +954,39 @@ def _remove_profile(self, name: str): parent=self.root): self._send_cmd({"cmd": "remove_profile", "name": name}) - def _add_broadcaster(self): - """Accorde le role broadcaster a un joueur connecte. On affiche un - dropdown des joueurs actuellement connectes (state.players) plutot - qu'une saisie libre : le serveur exige que le joueur soit connecte - au moment du grant pour pouvoir lui pusher son token. Saisir un - nom inexistant donnerait une erreur player_must_be_connected. - - Le serveur cible pousse ensuite le token au client choisi via la - WebSocket existante (jamais affiche en clair ici) ; le client le - sauve, et la capability sera effective des sa prochaine reconnexion.""" - # Filtre : exclut soi-meme et les noms deja broadcasters (no-op - # serveur de toute facon, mais evite de polluer le dropdown). - already = set(state.broadcasters) - connected = sorted(n for n in state.players.keys() - if n and n not in already) - if not connected: - messagebox.showinfo( - "Accorder broadcaster", - "Aucun joueur connecte ne peut etre accorde " - "(soit deja broadcaster, soit personne en ligne).", - parent=self.root, - ) + def _refresh_add_broadcaster_dropdown(self): + """Met a jour les valeurs du dropdown "Accorder broadcaster" : + joueurs connectes qui ne sont pas deja broadcasters. Appele depuis + refresh_players et refresh_broadcasters pour rester en sync avec + l'etat. Si la liste est vide, on disable le widget.""" + if not hasattr(self, "_add_bc_combo"): return - # Petit dialog modale avec un ttk.Combobox. - dlg = tk.Toplevel(self.root) - dlg.title("Accorder broadcaster") - dlg.configure(bg=BG_PANEL) - dlg.transient(self.root) - dlg.grab_set() - dlg.resizable(False, False) - tk.Label( - dlg, - text="Joueur a qui accorder le role broadcaster :\n" - "(le token sera pushed via sa WebSocket ;\n" - "il devra se reconnecter pour activer la touche)", - bg=BG_PANEL, fg=FG, justify="left", - ).pack(padx=12, pady=(12, 8)) - import tkinter.ttk as ttk - var = tk.StringVar(value=connected[0]) - combo = ttk.Combobox( - dlg, textvariable=var, values=connected, state="readonly", width=28 - ) - combo.pack(padx=12, pady=(0, 8)) - combo.focus_set() - btns = tk.Frame(dlg, bg=BG_PANEL) - btns.pack(padx=12, pady=(0, 12), fill="x") - chosen = {"name": None} - - def on_ok(): - chosen["name"] = var.get() - dlg.destroy() - - def on_cancel(): - dlg.destroy() - - tk.Button(btns, text="Accorder", command=on_ok, bg=BG_BTN, fg=FG, - activebackground=BG_BTN_HOVER).pack(side="right", padx=4) - tk.Button(btns, text="Annuler", command=on_cancel, bg=BG_BTN, fg=FG, - activebackground=BG_BTN_HOVER).pack(side="right") - dlg.bind("", lambda e: on_ok()) - dlg.bind("", lambda e: on_cancel()) - # Position : centre approx sur la fenetre admin - self.root.update_idletasks() - x = self.root.winfo_rootx() + (self.root.winfo_width() // 2) - 150 - y = self.root.winfo_rooty() + 120 - dlg.geometry(f"+{max(0, x)}+{max(0, y)}") - dlg.wait_window() - if chosen["name"]: - self._send_cmd({"cmd": "grant_broadcaster", "name": chosen["name"]}) + def _do(): + already = set(state.broadcasters) + connected = sorted(n for n in state.players.keys() + if n and n not in already) + try: + self._add_bc_combo["values"] = connected + if connected: + self._add_bc_combo["state"] = "readonly" + if self._add_bc_var.get() not in connected: + self._add_bc_var.set(connected[0]) + else: + self._add_bc_combo["state"] = "disabled" + self._add_bc_var.set("") + except Exception: + pass + self._safe_after(_do) + + def _grant_selected_broadcaster(self): + """Clic sur le bouton 'Accorder'. Le dropdown ne propose que des + joueurs connectes non deja broadcasters, donc on n'a rien a + re-valider cote client : on envoie la commande au serveur, qui + push le token via la WS du joueur cible.""" + name = (self._add_bc_var.get() or "").strip() + if not name: + return + self._send_cmd({"cmd": "grant_broadcaster", "name": name}) def _remove_broadcaster(self, name: str): """Retire le role broadcaster. Effectif au prochain ticket du joueur