patchcenter/app/routers/settings.py
Admin MPCZ edec1f7db5 feat(teams): mode SharePoint sync (calque .exe Sanef Patch Manager) + rules-based routing
- Migration: ajoute sp_route/mode/is_reboot_channel/is_dynamic_dm sur teams_channels,
  cree table teams_channel_rules (match resp/domain/env/msg_type/hostname pattern)
- Service teams_service.py: format texte plat compatible workflows existants,
  write_sharepoint_notification (ecrit fichier .txt dans <sp_base>/<sp_route>/),
  resolve_channel_for_server rules-based avec priorite reboot,
  send_notification orchestre resolution + envoi
- Settings UI: CRUD canaux etendu (mode SP/webhook + flags reboot/dyn_dm),
  CRUD regles avec match conditions, sharepoint_notif_path en secret app,
  bouton Test ecrit fichier .txt en mode SP
- Mode is_dynamic_dm: prefixe le contenu par 'TO: <email>' pour permettre
  un workflow PA unique qui dispatch dynamiquement aux responsables
- Pas d'OAuth requis: PatchCenter ecrit fichiers, Workflows PA cote SharePoint
  (deja en place pour le .exe) declenchent et postent sur Teams

Mode webhook conserve mais inactif tant qu'OAuth Entra ID pas mis en place chez SANEF
2026-05-06 09:57:42 +02:00

750 lines
33 KiB
Python

import logging
logger = logging.getLogger(__name__)
"""Router settings — configuration modules externes + connexions"""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
SECTIONS = {
"qualys": [
("qualys_url", "URL API", False),
("qualys_user", "Utilisateur", False),
("qualys_pass", "Mot de passe", True),
("qualys_proxy", "Proxy", False),
("qualys_bypass_proxy", "Bypass proxy (true/false)", False),
],
"itop": [
("itop_url", "URL API", False),
("itop_user", "Utilisateur", False),
("itop_pass", "Mot de passe", True),
],
"ssh_key": [
("ssh_key_default_user", "User SSH par defaut", False),
("ssh_key_default_port", "Port par defaut", False),
("ssh_key_private_key", "Cle privee (PEM)", True),
],
"ssh_pwd": [
("ssh_pwd_default_user", "User par defaut", False),
("ssh_pwd_default_pass", "Password par defaut", True),
],
"ssh_psmp": [
("psmp_host", "Adresse PSMP", False),
("psmp_port", "Port PSMP", False),
("psmp_user_format", "Format user", False),
("psmp_cyberark_user", "Compte CyberArk", False),
("psmp_target_user", "Utilisateur cible", False),
("psmp_default_safe", "Safe par defaut", False),
],
"rdp_psm": [
("rdp_psm_pvwa_url", "URL PVWA", False),
("rdp_psm_pvwa_user", "User PVWA", False),
("rdp_psm_pvwa_pass", "Password PVWA", True),
("rdp_psm_component", "Connection Component", False),
],
"rdp_pwd": [
("rdp_pwd_default_user", "User par defaut", False),
("rdp_pwd_default_pass", "Password par defaut", True),
("rdp_pwd_default_port", "Port RDP", False),
],
"vsphere": [
("vsphere_user", "Utilisateur vCenter", False),
("vsphere_pass", "Mot de passe vCenter", True),
],
"security": [
("security_session_timeout", "Timeout session (minutes)", False),
("security_max_login_attempts", "Max tentatives login", False),
],
"splunk": [
("splunk_hec_url", "URL HEC", False),
("splunk_hec_token", "Token HEC", True),
("splunk_index", "Index", False),
("splunk_sourcetype", "Sourcetype", False),
("splunk_verify_ssl", "Verifier SSL (true/false)", False),
],
"teams": [
("sharepoint_notif_path", "Chemin local OneDrive sync (ex: C:\\Users\\xxx\\sanefgroupe...\\notifications)", False),
("teams_webhook_url", "Webhook URL globale (legacy, futur OAuth)", False),
("teams_sp_site_url", "SharePoint Site URL (futur webhook)", False),
("teams_sp_library", "SharePoint Library (futur webhook)", False),
("teams_sp_folder", "SharePoint Folder (futur webhook)", False),
("teams_sp_client_id", "App Client ID (futur webhook OAuth)", False),
("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True),
("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False),
],
"ldap": [
("ldap_enabled", "Activer LDAP/AD (true/false)", False),
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
("ldap_base_dn", "Base DN (ex: DC=sanef,DC=com)", False),
("ldap_bind_dn", "Compte de bind (DN complet)", False),
("ldap_bind_pwd", "Mot de passe compte de bind", True),
("ldap_user_filter", "Filtre user (ex: (sAMAccountName={username}))", False),
("ldap_email_attr", "Attribut email (ex: mail)", False),
("ldap_name_attr", "Attribut nom affiché (ex: displayName)", False),
("ldap_tls", "TLS (true/false)", False),
("ldap_required_group", "Groupe AD autorise (DN complet, vide = tous)", False),
("ldap_default_role", "Role par defaut auto-provision (admin/operator/viewer)", False),
],
"itop_contacts": [
("itop_contact_teams", "Teams iTop à synchroniser (séparées par ,)", False),
],
}
def _load_section_values(db):
vals = {}
for section, fields in SECTIONS.items():
for key, label, is_secret in fields:
v = get_secret(db, key)
if is_secret and v:
vals[key] = "********"
else:
vals[key] = v or ""
return vals
# Regles d'acces par section: visible = qui peut voir, editable = qui peut modifier
SECTION_ACCESS = {
"qualys": {"visible": ["admin"], "editable": ["admin"]},
"ssh_key": {"visible": ["admin"], "editable": ["admin"]},
"ssh_pwd": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]},
"ssh_psmp": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]},
"rdp_psm": {"visible": ["admin"], "editable": ["admin"]},
"rdp_pwd": {"visible": [], "editable": []},
"vsphere": {"visible": ["admin", "operator", "coordinator"], "editable": ["admin", "operator"]},
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"itop": {"visible": ["admin"], "editable": ["admin"]},
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
"ldap": {"visible": ["admin"], "editable": ["admin"]},
"security": {"visible": ["admin"], "editable": ["admin"]},
}
def _build_context(db, user, saved=None):
init_secrets_from_config(db)
role = user.get("role", "viewer")
q_tags = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
q_assets = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
q_linked = db.execute(text("SELECT COUNT(*) FROM servers WHERE qualys_asset_id IS NOT NULL")).scalar()
vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall()
allowed_nets = db.execute(text("SELECT * FROM allowed_networks ORDER BY cidr")).fetchall()
teams_channels = db.execute(text("""
SELECT id, name, webhook_url, description, is_active, is_default,
sp_route, mode, is_reboot_channel, is_dynamic_dm, created_at
FROM teams_channels
ORDER BY is_default DESC, is_reboot_channel DESC, name
""")).fetchall()
teams_rules = db.execute(text("""
SELECT r.id, r.priority, r.name,
r.match_responsable_contact_id, r.match_application_domain,
r.match_env_in, r.match_msg_type_in, r.match_hostname_pattern,
r.channel_id, r.is_active, r.created_at,
tc.name AS channel_name,
c.name AS responsable_name
FROM teams_channel_rules r
LEFT JOIN teams_channels tc ON tc.id = r.channel_id
LEFT JOIN contacts c ON c.id = r.match_responsable_contact_id
ORDER BY r.priority ASC, r.id ASC
""")).fetchall()
contacts_list = db.execute(text("""
SELECT id, name, teams_upn FROM contacts WHERE is_active = true ORDER BY name
""")).fetchall()
server_clusters = db.execute(text("""
SELECT c.id, c.name, c.description, c.reboot_strategy, c.is_active, c.created_at,
COUNT(s.id) AS server_count
FROM server_clusters c
LEFT JOIN servers s ON s.cluster_id = c.id
GROUP BY c.id, c.name, c.description, c.reboot_strategy, c.is_active, c.created_at
ORDER BY c.name
""")).fetchall()
# Filtrer les sections visibles selon le role
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS}
editable = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["editable"] for s in SECTIONS}
return {
"user": user, "app_name": APP_NAME, "role": role,
"sections": SECTIONS, "vals": _load_section_values(db),
"allowed_nets": allowed_nets,
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
"vcenters": vcenters, "saved": saved,
"teams_channels": teams_channels,
"teams_rules": teams_rules,
"contacts_list": contacts_list,
"server_clusters": server_clusters,
"visible": visible, "editable": editable,
}
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "settings"):
return RedirectResponse(url="/dashboard")
ctx = _build_context(db, user)
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/{section}", response_class=HTMLResponse)
async def settings_save(request: Request, section: str, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if section not in SECTIONS:
return HTMLResponse("<p>Section inconnue</p>", status_code=400)
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
role = user.get("role", "viewer")
if section in SECTION_ACCESS and role not in SECTION_ACCESS[section]["editable"]:
return RedirectResponse(url="/settings")
form = await request.form()
for key, label, is_secret in SECTIONS[section]:
val = form.get(key, "")
if is_secret and val == "********":
continue
# Checkbox: si absent du form = "false"
if key.endswith("_bypass_proxy") or key.endswith("_verify_ssl"):
val = "true" if val else "false"
set_secret(db, key, val, label)
elif val:
set_secret(db, key, val, label)
ctx = _build_context(db, user, saved=section)
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/ldap/test")
async def settings_ldap_test(request: Request, db=Depends(get_db)):
"""Teste la connexion LDAP avec le compte de bind."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.ldap_service import test_connection
return JSONResponse(test_connection(db))
# --- vCenter CRUD ---
@router.post("/settings/vcenter/add", response_class=HTMLResponse)
async def vcenter_add(request: Request, db=Depends(get_db),
vc_name: str = Form(...), vc_endpoint: str = Form(...),
vc_datacenter: str = Form(""), vc_description: str = Form(""),
vc_responsable: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text(
"INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)"
), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None})
db.commit()
ctx = _build_context(db, user, saved="vsphere")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/vcenter/{vc_id}/delete", response_class=HTMLResponse)
async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id})
db.commit()
ctx = _build_context(db, user, saved="vsphere")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
# --- Teams channels CRUD ---
@router.post("/settings/teams-channel/add", response_class=HTMLResponse)
async def teams_channel_add(request: Request, db=Depends(get_db),
tc_name: str = Form(...),
tc_mode: str = Form("sharepoint"),
tc_sp_route: str = Form(""),
tc_webhook_url: str = Form(""),
tc_description: str = Form(""),
tc_is_default: str = Form(""),
tc_is_reboot_channel: str = Form(""),
tc_is_dynamic_dm: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
mode = tc_mode if tc_mode in ("sharepoint", "webhook") else "sharepoint"
is_def = bool(tc_is_default)
is_reboot = bool(tc_is_reboot_channel)
is_dyn_dm = bool(tc_is_dynamic_dm)
if is_def:
db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=true"))
if is_reboot:
db.execute(text("UPDATE teams_channels SET is_reboot_channel=false WHERE is_reboot_channel=true"))
db.execute(text("""
INSERT INTO teams_channels (name, mode, sp_route, webhook_url, description,
is_default, is_reboot_channel, is_dynamic_dm)
VALUES (:n, :m, :sp, :u, :d, :df, :rb, :dyn)
"""), {"n": tc_name.strip(), "m": mode,
"sp": (tc_sp_route.strip() or None),
"u": (tc_webhook_url.strip() or None),
"d": (tc_description or None),
"df": is_def, "rb": is_reboot, "dyn": is_dyn_dm})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-channel/{tc_id}/edit", response_class=HTMLResponse)
async def teams_channel_edit(request: Request, tc_id: int, db=Depends(get_db),
tc_name: str = Form(...),
tc_mode: str = Form("sharepoint"),
tc_sp_route: str = Form(""),
tc_webhook_url: str = Form(""),
tc_description: str = Form(""),
tc_is_active: str = Form(""),
tc_is_default: str = Form(""),
tc_is_reboot_channel: str = Form(""),
tc_is_dynamic_dm: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
mode = tc_mode if tc_mode in ("sharepoint", "webhook") else "sharepoint"
is_def = bool(tc_is_default)
is_act = bool(tc_is_active)
is_reboot = bool(tc_is_reboot_channel)
is_dyn_dm = bool(tc_is_dynamic_dm)
if is_def:
db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=true AND id<>:id"),
{"id": tc_id})
if is_reboot:
db.execute(text("UPDATE teams_channels SET is_reboot_channel=false WHERE is_reboot_channel=true AND id<>:id"),
{"id": tc_id})
db.execute(text("""
UPDATE teams_channels
SET name=:n, mode=:m, sp_route=:sp, webhook_url=:u, description=:d,
is_default=:df, is_active=:ia,
is_reboot_channel=:rb, is_dynamic_dm=:dyn
WHERE id=:id
"""), {"n": tc_name.strip(), "m": mode,
"sp": (tc_sp_route.strip() or None),
"u": (tc_webhook_url.strip() or None),
"d": (tc_description or None),
"df": is_def, "ia": is_act,
"rb": is_reboot, "dyn": is_dyn_dm, "id": tc_id})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-channel/{tc_id}/delete", response_class=HTMLResponse)
async def teams_channel_delete(request: Request, tc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("DELETE FROM teams_channels WHERE id=:id"), {"id": tc_id})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-channel/{tc_id}/test")
async def teams_channel_test(request: Request, tc_id: int, db=Depends(get_db)):
"""Test du canal selon son mode :
- mode='sharepoint' : écrit un fichier de test dans <sharepoint_notif_path>/<sp_route>/
- mode='webhook' : POST direct sur webhook_url (Adaptive Card)"""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
row = db.execute(text("""
SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm
FROM teams_channels WHERE id=:id
"""), {"id": tc_id}).fetchone()
if not row:
return JSONResponse({"ok": False, "msg": "Canal introuvable"}, status_code=404)
sender = user.get("username") or "PatchCenter"
try:
if row.mode == "sharepoint":
from ..services.teams_service import write_sharepoint_notification
sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip()
if not sp_base:
return JSONResponse({"ok": False,
"msg": "sharepoint_notif_path non configuré (Settings > Teams > Notifications)"},
status_code=400)
if not row.sp_route:
return JSONResponse({"ok": False, "msg": "sp_route non défini sur ce canal"},
status_code=400)
dyn_email = None
if row.is_dynamic_dm:
# Test mode : on met l'email du user lui-même (s'il est connu)
u_row = db.execute(text("""
SELECT email FROM users WHERE username=:u OR email=:u LIMIT 1
"""), {"u": sender}).fetchone()
dyn_email = (u_row.email if u_row else "test.user@example.com")
result = write_sharepoint_notification(
sp_base=sp_base, sp_route=row.sp_route, msg_type="debut",
server_name=f"TEST-PATCHCENTER-{row.name}", intervenant=sender,
dynamic_to_email=dyn_email,
)
return JSONResponse(result)
elif row.mode == "webhook":
if not row.webhook_url:
return JSONResponse({"ok": False, "msg": "webhook_url non défini"}, status_code=400)
from ..services.teams_service import send_test_message
result = send_test_message(row.webhook_url, row.name, sender)
return JSONResponse(result)
else:
return JSONResponse({"ok": False, "msg": f"Mode inconnu: {row.mode}"}, status_code=400)
except Exception as e:
return JSONResponse({"ok": False, "msg": f"Erreur: {e}"}, status_code=500)
# --- Teams channel rules CRUD ---
def _parse_csv_text_array(s: str):
"""'a, b ,c' -> ['a','b','c'] ; vide -> None."""
if not s:
return None
items = [p.strip() for p in s.split(",")]
items = [p for p in items if p]
return items or None
@router.post("/settings/teams-rule/add", response_class=HTMLResponse)
async def teams_rule_add(request: Request, db=Depends(get_db),
tr_priority: int = Form(100),
tr_name: str = Form(...),
tr_channel_id: int = Form(...),
tr_match_responsable_contact_id: str = Form(""),
tr_match_application_domain: str = Form(""),
tr_match_env_in: str = Form(""),
tr_match_msg_type_in: str = Form(""),
tr_match_hostname_pattern: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
resp_id = int(tr_match_responsable_contact_id) if tr_match_responsable_contact_id.strip() else None
db.execute(text("""
INSERT INTO teams_channel_rules
(priority, name, channel_id,
match_responsable_contact_id, match_application_domain,
match_env_in, match_msg_type_in, match_hostname_pattern)
VALUES (:p, :n, :ch, :rc, :dom, :env, :mt, :host)
"""), {
"p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id,
"rc": resp_id,
"dom": (tr_match_application_domain.strip() or None),
"env": _parse_csv_text_array(tr_match_env_in),
"mt": _parse_csv_text_array(tr_match_msg_type_in),
"host": (tr_match_hostname_pattern.strip() or None),
})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-rule/{tr_id}/edit", response_class=HTMLResponse)
async def teams_rule_edit(request: Request, tr_id: int, db=Depends(get_db),
tr_priority: int = Form(100),
tr_name: str = Form(...),
tr_channel_id: int = Form(...),
tr_match_responsable_contact_id: str = Form(""),
tr_match_application_domain: str = Form(""),
tr_match_env_in: str = Form(""),
tr_match_msg_type_in: str = Form(""),
tr_match_hostname_pattern: str = Form(""),
tr_is_active: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
resp_id = int(tr_match_responsable_contact_id) if tr_match_responsable_contact_id.strip() else None
db.execute(text("""
UPDATE teams_channel_rules
SET priority=:p, name=:n, channel_id=:ch,
match_responsable_contact_id=:rc,
match_application_domain=:dom,
match_env_in=:env, match_msg_type_in=:mt,
match_hostname_pattern=:host,
is_active=:ia
WHERE id=:id
"""), {
"p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id,
"rc": resp_id,
"dom": (tr_match_application_domain.strip() or None),
"env": _parse_csv_text_array(tr_match_env_in),
"mt": _parse_csv_text_array(tr_match_msg_type_in),
"host": (tr_match_hostname_pattern.strip() or None),
"ia": bool(tr_is_active), "id": tr_id,
})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-rule/{tr_id}/delete", response_class=HTMLResponse)
async def teams_rule_delete(request: Request, tr_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("DELETE FROM teams_channel_rules WHERE id=:id"), {"id": tr_id})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-rule/test")
async def teams_rule_test(request: Request, db=Depends(get_db),
server_id: int = Form(...),
msg_type: str = Form("debut")):
"""Teste la résolution sans envoyer : retourne quel canal serait choisi pour
(server_id, msg_type) et pourquoi (source de la décision)."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.teams_service import resolve_channel_for_server
result = resolve_channel_for_server(db, server_id, msg_type)
return JSONResponse(result)
# --- Server clusters CRUD ---
@router.post("/settings/server-cluster/add", response_class=HTMLResponse)
async def server_cluster_add(request: Request, db=Depends(get_db),
sc_name: str = Form(...),
sc_description: str = Form(""),
sc_reboot_strategy: str = Form("sequential")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
strat = sc_reboot_strategy if sc_reboot_strategy in ("sequential", "parallel") else "sequential"
try:
db.execute(text("""
INSERT INTO server_clusters (name, description, reboot_strategy)
VALUES (:n, :d, :s)
"""), {"n": sc_name.strip(), "d": (sc_description or None), "s": strat})
db.commit()
except Exception as e:
db.rollback()
# Probablement violation unique sur name
logger.warning(f"server_cluster_add failed: {e}")
ctx = _build_context(db, user, saved="cluster")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/server-cluster/{sc_id}/edit", response_class=HTMLResponse)
async def server_cluster_edit(request: Request, sc_id: int, db=Depends(get_db),
sc_name: str = Form(...),
sc_description: str = Form(""),
sc_reboot_strategy: str = Form("sequential"),
sc_is_active: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
strat = sc_reboot_strategy if sc_reboot_strategy in ("sequential", "parallel") else "sequential"
db.execute(text("""
UPDATE server_clusters
SET name=:n, description=:d, reboot_strategy=:s, is_active=:ia
WHERE id=:id
"""), {"n": sc_name.strip(), "d": (sc_description or None), "s": strat,
"ia": bool(sc_is_active), "id": sc_id})
db.commit()
ctx = _build_context(db, user, saved="cluster")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/server-cluster/{sc_id}/delete", response_class=HTMLResponse)
async def server_cluster_delete(request: Request, sc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
# Check qu'aucun serveur n'y est encore rattaché
cnt = db.execute(text("SELECT COUNT(*) FROM servers WHERE cluster_id=:id"),
{"id": sc_id}).scalar() or 0
if cnt > 0:
# Désactivation seule (ne pas casser les FK et l'historique)
db.execute(text("UPDATE server_clusters SET is_active=false WHERE id=:id"), {"id": sc_id})
else:
db.execute(text("DELETE FROM server_clusters WHERE id=:id"), {"id": sc_id})
db.commit()
ctx = _build_context(db, user, saved="cluster")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.get("/settings/server-cluster/{sc_id}/servers")
async def server_cluster_servers(request: Request, sc_id: int, db=Depends(get_db)):
"""Liste des serveurs du cluster avec leur cluster_order — JSON pour panneau détail."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
rows = db.execute(text("""
SELECT id, hostname, cluster_order, machine_type, etat
FROM servers
WHERE cluster_id = :id
ORDER BY cluster_order NULLS LAST, hostname
"""), {"id": sc_id}).fetchall()
return JSONResponse({"ok": True, "count": len(rows),
"servers": [{"id": r.id, "hostname": str(r.hostname),
"order": r.cluster_order,
"machine_type": r.machine_type,
"etat": r.etat} for r in rows]})
# --- Secret individuel ---
@router.post("/settings/secret/update", response_class=HTMLResponse)
async def secret_update(request: Request, db=Depends(get_db),
secret_key: str = Form(...), secret_value: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
if secret_value and secret_value != "********":
# Recuperer la description existante
existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"),
{"k": secret_key}).fetchone()
desc = existing.description if existing else secret_key
set_secret(db, secret_key, secret_value, desc)
ctx = _build_context(db, user, saved="secret")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
# --- Réseaux autorisés ---
def _regen_nginx_acl(db):
"""Régénère le fichier ACL nginx depuis la base"""
nets = db.execute(text("SELECT cidr FROM allowed_networks WHERE is_active = true ORDER BY cidr")).fetchall()
lines = ["# Généré par PatchCenter — ne pas éditer manuellement"]
for n in nets:
lines.append(f"allow {n.cidr};")
lines.append("deny all;")
try:
with open("/etc/nginx/patchcenter_acl.conf", "w") as f:
f.write("\n".join(lines) + "\n")
import subprocess
subprocess.run(["nginx", "-t"], capture_output=True, check=True)
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True)
return True
except Exception:
return False
@router.post("/settings/network/add")
async def network_add(request: Request, db=Depends(get_db),
cidr: str = Form(...), description: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("INSERT INTO allowed_networks (cidr, description) VALUES (:c, :d)"),
{"c": cidr.strip(), "d": description or None})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/network/{net_id}/delete")
async def network_delete(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("DELETE FROM allowed_networks WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/network/{net_id}/toggle")
async def network_toggle(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("UPDATE allowed_networks SET is_active = NOT is_active WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)
ctx = _build_context(db, user, saved="security")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)