feat: option clé pré-partagée et refonte des onglets du panneau admin
La présence d'une clé pré-partagée se devinait au fait que le champ soit rempli. Un serveur qui n'en utilise pas — le cas par défaut de WireGuard — déclenchait donc un avertissement à chaque diagnostic, et une clé oubliée dans le champ finissait écrite dans la configuration alors que le serveur n'en attendait aucune, ce qui casse le handshake aussi sûrement qu'une clé manquante. Une case à cocher tranche désormais, et le diagnostic distingue les quatre états au lieu d'un avertissement unique. Le drapeau vaut None par défaut et non False : _merge() recopie le défaut dans toute configuration antérieure, et False aurait désactivé la PSK d'un tunnel qui fonctionnait. Refonte des onglets, qui passaient de sept à cinq : - WireGuard et Clés se chevauchaient — la clé publique du serveur se saisissait dans l'un pendant que l'assistant qui la produit vivait dans l'autre, et les quatre façons de sortir une configuration étaient réparties entre les deux. Fusionnés en sous-onglets Tunnel, Clés, Profils & fichiers. - Les trois tests s'empilaient sur une page unique où le rapport de diagnostic se retrouvait comprimé. Chacun a son sous-onglet. - MFA et Sécurité répondent à la même question et sont regroupés. - La section des droits parlait de sudo et de setup-sudoers sous Windows, où rien de tout cela n'existe, et concluait « privilèges root » sur un rapport simplement vide. Elle vérifie maintenant l'élévation UAC et WireGuard. Chaque onglet s'ouvre sur un encart expliquant ce qu'on y décide, et les couleurs — vingt-quatre valeurs en dur, cinq bandeaux sans rapport de teinte, des blocs de résultat au fond incohérent — passent par app/ui/theme.py.
This commit is contained in:
+11
-1
@@ -15,6 +15,16 @@ _WG_DEFAULT: dict[str, Any] = {
|
||||
"server_endpoint": "",
|
||||
"server_port": 51820,
|
||||
"server_public_key": "",
|
||||
# Drapeau explicite : une PSK vide ne veut pas dire « oubliée ». Sans lui,
|
||||
# le diagnostic criait au champ manquant sur tout serveur qui n'en utilise
|
||||
# pas — c'est-à-dire la configuration WireGuard par défaut.
|
||||
#
|
||||
# Le défaut est `None`, pas `False` : `_merge()` recopie le défaut dans
|
||||
# toute configuration antérieure au drapeau, et `False` aurait désactivé
|
||||
# la clé pré-partagée d'un tunnel qui fonctionnait. `None` signifie
|
||||
# « jamais renseigné » et laisse `uses_preshared_key()` déduire l'état de
|
||||
# la présence de la clé.
|
||||
"use_preshared_key": None,
|
||||
"preshared_key": "",
|
||||
"client_private_key": "",
|
||||
"client_public_key": "",
|
||||
@@ -26,7 +36,7 @@ _WG_DEFAULT: dict[str, Any] = {
|
||||
}
|
||||
|
||||
_DEFAULT: dict[str, Any] = {
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.0",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
|
||||
+60
-12
@@ -35,6 +35,19 @@ def generate_preshared_key() -> str:
|
||||
return base64.b64encode(os.urandom(32)).decode()
|
||||
|
||||
|
||||
def uses_preshared_key(wg: dict) -> bool:
|
||||
"""La configuration met-elle une clé pré-partagée en jeu ?
|
||||
|
||||
Le drapeau `use_preshared_key` fait foi. Une configuration importée avant
|
||||
son introduction n'a que la clé : une PSK non vide vaut alors activation,
|
||||
sinon un import de .conf perdrait sa PSK au premier enregistrement.
|
||||
"""
|
||||
if wg.get("use_preshared_key"):
|
||||
return True
|
||||
return bool(wg.get("use_preshared_key") is None
|
||||
and (wg.get("preshared_key") or "").strip())
|
||||
|
||||
|
||||
def build_client_config(cfg: Config) -> str:
|
||||
wg = cfg.wg
|
||||
lines = [
|
||||
@@ -62,8 +75,12 @@ def build_client_config(cfg: Config) -> str:
|
||||
# existe, l'application affiche « Connecté » — et le handshake n'aboutit
|
||||
# jamais. La PSK était générée par l'assistant puis perdue, ni écrite dans
|
||||
# la config client ni relue à l'import d'un .conf.
|
||||
#
|
||||
# Symétriquement, écrire une PSK que le serveur n'attend pas casse le
|
||||
# handshake tout aussi silencieusement : le drapeau tranche, la présence
|
||||
# d'un reliquat dans le champ ne suffit pas.
|
||||
psk = (wg.get("preshared_key") or "").strip()
|
||||
if psk:
|
||||
if psk and uses_preshared_key(wg):
|
||||
lines.append(f"PresharedKey = {psk}")
|
||||
lines += [
|
||||
f"AllowedIPs = {wg['allowed_ips']}",
|
||||
@@ -529,19 +546,36 @@ def _diag_config(cfg: Config) -> list[dict]:
|
||||
steps.append(_step(_OK, "Paire de clés du client",
|
||||
f"Clé publique déclarée au serveur : {derived[:20]}…"))
|
||||
|
||||
# L'ancien message avertissait sur toute PSK vide : sur un serveur qui
|
||||
# n'en utilise pas — le cas par défaut de WireGuard — le diagnostic
|
||||
# pointait un coupable inexistant à chaque exécution.
|
||||
psk = (wg.get("preshared_key") or "").strip()
|
||||
if not uses_preshared_key(wg):
|
||||
if psk:
|
||||
steps.append(_step(
|
||||
_WARN, "Clé pré-partagée",
|
||||
"Désactivée, mais une clé subsiste dans le champ : elle n'est "
|
||||
"pas écrite dans la configuration. Cochez l'option si le "
|
||||
"serveur l'attend."))
|
||||
else:
|
||||
steps.append(_step(
|
||||
_OK, "Clé pré-partagée",
|
||||
"Non utilisée — le serveur ne doit pas en déclarer une pour "
|
||||
"ce pair"))
|
||||
elif not psk:
|
||||
steps.append(_step(
|
||||
_FAIL, "Clé pré-partagée",
|
||||
"Option activée mais champ vide : le pair déclare une PSK au "
|
||||
"serveur sans en fournir. Renseignez-la, ou décochez l'option si "
|
||||
"le serveur n'en utilise pas."))
|
||||
else:
|
||||
try:
|
||||
valid = len(base64.b64decode(psk)) == 32
|
||||
except Exception:
|
||||
valid = False
|
||||
steps.append(_step(_OK if valid else _FAIL, "Clé pré-partagée",
|
||||
"Présente (doit être identique côté serveur)" if valid
|
||||
"Activée (doit être identique côté serveur)" if valid
|
||||
else "Format invalide : 32 octets en base64 attendus"))
|
||||
else:
|
||||
steps.append(_step(_WARN, "Clé pré-partagée",
|
||||
"Absente — à renseigner si le serveur en attend une, "
|
||||
"sinon le handshake n'aboutira jamais"))
|
||||
return steps
|
||||
|
||||
|
||||
@@ -685,12 +719,19 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
|
||||
+ ("administrateur" if is_windows() else "root")))
|
||||
return steps
|
||||
if age < 0:
|
||||
if uses_preshared_key(wg):
|
||||
psk_cause = ("clé pré-partagée différente de celle du serveur (ou "
|
||||
"absente côté serveur)")
|
||||
else:
|
||||
psk_cause = ("clé pré-partagée attendue par le serveur alors que "
|
||||
"l'option est décochée ici")
|
||||
steps.append(_step(
|
||||
_FAIL, "Handshake",
|
||||
"Jamais négocié. Le tunnel est monté mais le serveur ne répond "
|
||||
"pas à nos paquets. Causes usuelles : clé publique du client "
|
||||
"absente côté serveur, clé publique du serveur erronée, clé "
|
||||
"pré-partagée manquante ou différente, port UDP bloqué."))
|
||||
f"absente côté serveur, clé publique du serveur erronée, "
|
||||
f"{psk_cause}, port UDP du serveur différent de celui configuré, "
|
||||
"port UDP bloqué en chemin."))
|
||||
return steps
|
||||
if age > 180:
|
||||
steps.append(_step(_WARN, "Handshake",
|
||||
@@ -811,8 +852,10 @@ def parse_conf_file(path: str) -> dict | None:
|
||||
result["server_public_key"] = val
|
||||
elif key == "PresharedKey":
|
||||
# Ignorée jusqu'ici : le .conf importé perdait sa PSK
|
||||
# et le tunnel ne négociait plus rien.
|
||||
# et le tunnel ne négociait plus rien. La présence de
|
||||
# la directive vaut activation de l'option.
|
||||
result["preshared_key"] = val
|
||||
result["use_preshared_key"] = True
|
||||
elif key == "Endpoint" and ":" in val:
|
||||
host, port = val.rsplit(":", 1)
|
||||
result["server_endpoint"] = host.strip("[]")
|
||||
@@ -964,14 +1007,19 @@ def generate_server_config(
|
||||
server_address: str = "10.8.0.1/24",
|
||||
client_address: str = "10.8.0.2/24",
|
||||
client_allowed_ips: str = "10.8.0.2/32",
|
||||
use_psk: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Génère une paire complète (server_conf, client_conf) avec de nouvelles clés.
|
||||
Retourne un dict avec server_priv/pub, client_priv/pub, server_conf, client_conf.
|
||||
|
||||
`use_psk=False` produit les deux configurations sans clé pré-partagée :
|
||||
l'assistant en imposait une des deux côtés, sans moyen de s'en passer.
|
||||
"""
|
||||
srv_priv, srv_pub = generate_keypair()
|
||||
cli_priv, cli_pub = generate_keypair()
|
||||
psk = generate_preshared_key()
|
||||
psk = generate_preshared_key() if use_psk else ""
|
||||
psk_line = [f"PresharedKey = {psk}"] if use_psk else []
|
||||
|
||||
server_conf = "\n".join([
|
||||
"[Interface]",
|
||||
@@ -984,7 +1032,7 @@ def generate_server_config(
|
||||
"# === Peer client ===",
|
||||
"[Peer]",
|
||||
f"PublicKey = {cli_pub}",
|
||||
f"PresharedKey = {psk}",
|
||||
*psk_line,
|
||||
f"AllowedIPs = {client_allowed_ips}",
|
||||
])
|
||||
|
||||
@@ -996,7 +1044,7 @@ def generate_server_config(
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {srv_pub}",
|
||||
f"PresharedKey = {psk}",
|
||||
*psk_line,
|
||||
"AllowedIPs = 10.8.0.0/24",
|
||||
"Endpoint = <SERVER_IP>:" + str(server_port),
|
||||
"PersistentKeepalive = 25",
|
||||
|
||||
+495
-294
File diff suppressed because it is too large
Load Diff
+190
@@ -0,0 +1,190 @@
|
||||
"""Jetons de couleur et fragments de feuille de style partagés.
|
||||
|
||||
Les couleurs étaient écrites en dur au fil des widgets : vingt-quatre valeurs
|
||||
distinctes dans le seul panneau d'administration, cinq bandeaux d'onglets sans
|
||||
rapport de teinte entre eux, et des blocs de résultat dont le fond ne
|
||||
correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe
|
||||
désormais par ce module — une couleur ne se choisit plus au cas par cas.
|
||||
"""
|
||||
|
||||
# ── Fonds ────────────────────────────────────────────────────────────────────
|
||||
BG = "#1c2833" # fond de l'application et de toutes les pages
|
||||
BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code
|
||||
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
|
||||
BORDER = "rgba(255,255,255,0.18)"
|
||||
BORDER_SOFT = "rgba(255,255,255,0.10)"
|
||||
|
||||
# ── Textes ───────────────────────────────────────────────────────────────────
|
||||
TEXT = "white"
|
||||
TEXT_MUTED = "rgba(255,255,255,0.70)"
|
||||
TEXT_FAINT = "rgba(255,255,255,0.50)"
|
||||
|
||||
# ── Accent ───────────────────────────────────────────────────────────────────
|
||||
ACCENT = "#2471a3"
|
||||
ACCENT_HOVER = "#1a5276"
|
||||
ACCENT_ACTIVE = "#154360"
|
||||
ACCENT_LIGHT = "#5dade2"
|
||||
|
||||
# ── États ────────────────────────────────────────────────────────────────────
|
||||
# Chaque état porte un trio : teinte du texte, fond translucide, bouton plein.
|
||||
OK_TEXT, OK_BG, OK_SOLID = "#a9dfbf", "rgba(39,174,96,0.18)", "#1e8449"
|
||||
WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b"
|
||||
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
|
||||
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
|
||||
|
||||
_STATE = {
|
||||
"ok": (OK_TEXT, OK_BG),
|
||||
"warn": (WARN_TEXT, WARN_BG),
|
||||
"fail": (FAIL_TEXT, FAIL_BG),
|
||||
"skip": (SKIP_TEXT, SKIP_BG),
|
||||
"idle": (TEXT_MUTED, "transparent"),
|
||||
}
|
||||
|
||||
# ── Bandeaux d'onglets ───────────────────────────────────────────────────────
|
||||
# Cinq teintes calées sur la même luminosité et la même saturation : les
|
||||
# onglets restent distinguables sans que l'un paraisse plus clair ou plus
|
||||
# saturé que les autres, ce qui était le cas des valeurs choisies à la main.
|
||||
BANNER = {
|
||||
"wireguard": "#1a5276",
|
||||
"keys": "#1a4a6e",
|
||||
"mfa": "#4a2a6e",
|
||||
"test": "#1a4a7a",
|
||||
"settings": "#155e52",
|
||||
"security": "#7a3b1a",
|
||||
"about": "#243b53",
|
||||
}
|
||||
|
||||
|
||||
def result_style(state: str, font_size: int = 11) -> str:
|
||||
"""Feuille de style d'un bloc de résultat, selon son état.
|
||||
|
||||
Les blocs affichaient chacun leur propre fond rgba, recopié à la main :
|
||||
même état, teintes différentes d'un widget à l'autre.
|
||||
"""
|
||||
color, bg = _STATE.get(state, _STATE["idle"])
|
||||
return (f"padding: 8px; border-radius: 6px; font-size: {font_size}px;"
|
||||
f" background: {bg}; color: {color};")
|
||||
|
||||
|
||||
def result_view_style(state: str = "idle", font_size: int = 12) -> str:
|
||||
"""Même chose pour un QTextEdit en lecture seule (rapport défilant)."""
|
||||
color, bg = _STATE.get(state, _STATE["idle"])
|
||||
return (
|
||||
f"QTextEdit {{ background: {bg}; color: {color};"
|
||||
f" border: 1px solid {BORDER_SOFT}; border-radius: 6px;"
|
||||
f" padding: 8px; font-size: {font_size}px; }}"
|
||||
f"QScrollBar:vertical {{ background: transparent; width: 8px;"
|
||||
f" border: none; }}"
|
||||
f"QScrollBar::handle:vertical {{ background: {BG_RAISED};"
|
||||
f" border-radius: 4px; }}"
|
||||
"QScrollBar::add-line:vertical,"
|
||||
"QScrollBar::sub-line:vertical { height: 0; }"
|
||||
)
|
||||
|
||||
|
||||
def scrollarea_style() -> str:
|
||||
return (
|
||||
f"QScrollArea {{ border: none; background: {BG}; }}"
|
||||
f"QScrollBar:vertical {{ background: {BG}; width: 8px; border: none; }}"
|
||||
f"QScrollBar::handle:vertical {{ background: {BG_RAISED};"
|
||||
f" border-radius: 4px; }}"
|
||||
"QScrollBar::add-line:vertical,"
|
||||
"QScrollBar::sub-line:vertical { height: 0; }"
|
||||
)
|
||||
|
||||
|
||||
def banner_style(color: str) -> str:
|
||||
return (f"background: {color}; color: {TEXT}; font-size: 13px;"
|
||||
" font-weight: bold; padding-left: 8px;")
|
||||
|
||||
|
||||
#: CSS sombre commun à toutes les pages.
|
||||
TAB_CSS = f"""
|
||||
QWidget {{ background-color: {BG}; color: {TEXT}; }}
|
||||
QLabel {{ color: {TEXT}; background: transparent; }}
|
||||
QCheckBox {{ color: {TEXT}; background: transparent; }}
|
||||
QGroupBox {{
|
||||
color: {TEXT};
|
||||
background: transparent;
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 4px;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLineEdit {{
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
}}
|
||||
QLineEdit:read-only {{ background: rgba(255,255,255,0.06); }}
|
||||
QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
|
||||
color: {TEXT_FAINT}; }}
|
||||
QSpinBox {{
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
}}
|
||||
QSpinBox::up-button, QSpinBox::down-button {{
|
||||
background: rgba(255,255,255,0.15);
|
||||
}}
|
||||
QTextEdit {{
|
||||
background: {BG_SUNKEN};
|
||||
color: #a8d8ea;
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton {{
|
||||
background: {ACCENT};
|
||||
color: {TEXT};
|
||||
border-radius: 5px;
|
||||
padding: 7px 12px;
|
||||
border: none;
|
||||
}}
|
||||
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
|
||||
QPushButton:checked {{ background: {ACCENT_ACTIVE}; }}
|
||||
QPushButton:disabled {{ background: rgba(255,255,255,0.08);
|
||||
color: {TEXT_FAINT}; }}
|
||||
"""
|
||||
|
||||
#: Barre d'onglets, utilisée par le panneau principal et les sous-onglets.
|
||||
TABBAR_CSS = f"""
|
||||
QTabWidget::pane {{ border: none; background: {BG}; }}
|
||||
QTabBar::tab {{
|
||||
background: {BG_RAISED};
|
||||
color: {TEXT_MUTED};
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
}}
|
||||
QTabBar::tab:selected {{ background: {ACCENT}; color: {TEXT}; }}
|
||||
QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER};
|
||||
color: {TEXT}; }}
|
||||
"""
|
||||
|
||||
|
||||
def primary_button_style(solid: str = OK_SOLID, hover: str = "#27ae60") -> str:
|
||||
"""Bouton d'action principale d'une page (lancer un test, par exemple)."""
|
||||
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
|
||||
f" border-radius: 5px; font-weight: bold; border: none; }}"
|
||||
f"QPushButton:hover {{ background: {hover}; }}")
|
||||
|
||||
|
||||
def hint_style() -> str:
|
||||
"""Encart d'introduction posé en tête de chaque onglet.
|
||||
|
||||
Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas
|
||||
concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein.
|
||||
"""
|
||||
return (f"background: rgba(36,113,163,0.16);"
|
||||
f" border-left: 3px solid {ACCENT_LIGHT};"
|
||||
f" border-radius: 4px; padding: 9px 11px;"
|
||||
f" color: {TEXT_MUTED}; font-size: 11px;")
|
||||
Reference in New Issue
Block a user