Codex vpn plan
Context
Il widget VPN nella dashboard supporta OpenConnect ma il flusso OTP non funziona correttamente. Il problema principale è che l'autenticazione multi-prompt (password + OTP come prompt separati) non è gestita — il backend manda solo una riga su stdin. Inoltre mancano: supporto authgroup, selezione protocollo, TOTP automatico, e feedback di connessione nel widget.
File da modificare
┌─────┬──────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ File │ Scopo │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 1 │ dashboard/runner/runner.py │ Core logic: command builder, VPN start, profile listing │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 2 │ dashboard/backend/app/schemas.py │ Pydantic schema: VpnStartRequest │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 3 │ dashboard/backend/app/services/runner_client.py │ Proxy client: passa otp, aumenta timeout │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 4 │ dashboard/backend/app/routers/runner.py │ Router: passa otp al client │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 5 │ dashboard/frontend/src/api/types.ts │ TypeScript: VpnProfile metadata │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 6 │ dashboard/frontend/src/api/runner.ts │ Hook: useVpnStart con otp │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 7 │ dashboard/frontend/src/components/layout/VpnStatusWidget.tsx │ Widget: UI campi OTP/password condizionali │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 8 │ dashboard/backend/tests/test_runner_vpn.py │ Test: 7 nuovi test case │ ├─────┼──────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 9 │ docs/operations/vpn.md │ Documentazione aggiornata │ └─────┴──────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┘
Step 1 — Estendere lo schema del profilo JSON OpenConnect
I profili *.openconnect.json accettano nuovi campi opzionali (backward-compatible):
┌─────────────┬─────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐ │ Campo │ Tipo │ Default │ Scopo │ ├─────────────┼─────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ authgroup │ string │ "" │ --authgroup VALUE per selezione realm/gruppo │ ├─────────────┼─────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ protocol │ string │ "" │ --protocol=VALUE (anyconnect, nc, gp, f5, fortinet, array) │ ├─────────────┼─────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ totp_secret │ string │ "" │ Base32 secret → --token-mode=totp --token-secret=base32:VALUE │ ├─────────────┼─────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ otp │ boolean │ false │ Dichiara che il gateway richiede un OTP separato (secondo prompt stdin) │ └─────────────┴─────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘
Esempio profilo con OTP manuale: { "type": "openconnect", "server": "vpn.example.it/SecurityPTVA", "username": "J52598", "servercert": "pin-sha256:...", "authgroup": "SecurityTeam", "otp": true, "routes": ["10.213.0.0/16"], "vpn_dns": ["10.213.48.178"] }
Step 2 — Backend: _build_openconnect_command() (runner.py:1911)
2a. Aggiungere parametro runtime_otp alla firma
def _build_openconnect_command( ... runtime_password: str = "", runtime_otp: str = "", # NUOVO ) -> tuple[list[str], bytes | None]:
2b. Supporto authgroup (dopo riga 1944)
authgroup = str(payload.get("authgroup", "")).strip() if authgroup: command.extend(["--authgroup", authgroup])
2c. Supporto protocol (sostituire logica f5-only a riga 1945-1946)
protocol = str(payload.get("protocol", "")).strip() if protocol and not any(arg.startswith("--protocol") for arg in extra_args): command.append(f"--protocol={protocol}") elif profile_type == "f5" and not any(arg.startswith("--protocol") for arg in extra_args): command.append("--protocol=f5")
2d. Supporto TOTP automatico (dopo blocco password_file, ~riga 1958)
totp_secret = str(payload.get("totp_secret", "")).strip() if totp_secret and not any(arg.startswith("--token-mode") for arg in extra_args): command.insert(-1, "--token-mode=totp") command.insert(-1, f"--token-secret=base32:{totp_secret}")
2e. Multi-prompt stdin (riscrivere blocco stdin_data righe 1950-1958)
Logica: se c'è password (runtime o file) → prima riga stdin. Se c'è runtime_otp e non c'è totp_secret → seconda riga stdin. Aggiungere --passwd-on-stdin.
stdin_data: bytes | None = None runtime_password = runtime_password.strip() runtime_otp = runtime_otp.strip() password_file = _profile_secret_path(str(payload.get("password_file", "")).strip()) totp_secret = str(payload.get("totp_secret", "")).strip()
pw = "" if runtime_password: pw = runtime_password elif password_file and password_file.is_file(): pw = password_file.read_text(encoding="utf-8").strip()
if pw: stdin_lines = [pw] if runtime_otp and not totp_secret: stdin_lines.append(runtime_otp) stdin_data = ("\n".join(stdin_lines) + "\n").encode("utf-8") command.insert(-1, "--passwd-on-stdin")
Step 3 — Backend: VpnStartRequest + _start_vpn() + handler (runner.py)
3a. Aggiungere campo otp a VpnStartRequest (riga 1721)
class VpnStartRequest(BaseModel): profile: str wireguard: bool = False password: str = "" otp: str = "" # NUOVO
3b. Aggiungere parametro otp a _start_vpn() (riga 2010)
async def _start_vpn(profile: str, wireguard: bool, password: str = "", otp: str = "") -> tuple[bool, str]:
Passare runtime_otp=otp alla chiamata _build_openconnect_command() (riga 2074-2082).
3c. Aggiornare handler vpn_start (riga 1747-1749)
ok, msg = await _start_vpn(body.profile, body.wireguard, password=body.password, otp=body.otp)
3d. Aumentare timeout subprocess.run (riga 2088)
Da timeout=45 a timeout=60 — serve più tempo per multi-prompt auth.
3e. Migliorare messaggi errore (riga 2090-2092)
Estrarre le righe più significative dall'output openconnect invece di troncare a 500 char:
if result.returncode != 0: raw = (result.stdout + result.stderr).decode("utf-8", errors="replace") lines = [ln.strip() for ln in raw.strip().splitlines() if ln.strip()] err_kw = ("failed", "error", "denied", "invalid", "timeout", "refused", "incorrect") err_lines = [ln for ln in lines if any(kw in ln.lower() for kw in err_kw)] display = "\n".join(err_lines[-3:]) if err_lines else "\n".join(lines[-5:]) return False, f"{profile_type} auth failed:\n{display}"
Step 4 — Backend: list_vpn_profiles() con metadata (runner.py:1679)
Arricchire l'output per i profili JSON con flag che il frontend usa per mostrare/nascondere campi:
def list_vpn_profiles() -> list[dict[str, Any]]: profiles_dir = _vpn_profiles_dir() profiles: list[dict[str, Any]] = [] if not profiles_dir.is_dir(): return profiles for f in sorted(profiles_dir.iterdir()): entry = _profile_from_path(f) if entry: if entry["type"] in {"openconnect", "f5"}: try: payload = _load_json_profile(f) entry["has_totp_secret"] = bool(str(payload.get("totp_secret", "")).strip()) entry["has_otp"] = bool(payload.get("otp")) entry["has_password_file"] = bool( _profile_secret_path(str(payload.get("password_file", "")).strip()) ) entry["authgroup"] = str(payload.get("authgroup", "")).strip() entry["protocol"] = str(payload.get("protocol", "")).strip() except Exception: pass profiles.append(entry) profiles.sort(key=lambda p: p["name"]) return profiles
Nota: cambiare return type annotation da list[dict[str, str]] a list[dict[str, Any]].
Step 5 — Backend Proxy Layer
5a. schemas.py (riga 637-640)
Aggiungere otp: str = "" a VpnStartRequest.
5b. runner_client.py (riga 114-122)
def vpn_start(self, profile: str, wireguard: bool = False, password: str = "", otp: str = "") -> dict[str, Any]: url = self._get_runner_url() r = requests.post( f"{url}/vpn/start", json={"profile": profile, "wireguard": wireguard, "password": password, "otp": otp}, timeout=65, # era 35, deve superare il subprocess timeout di 60s ) r.raise_for_status() return r.json()
5c. routers/runner.py (riga 108-115)
return client.vpn_start(body.profile, body.wireguard, body.password, body.otp)
Step 6 — Frontend: TypeScript Types (types.ts)
Estendere VpnProfile:
export interface VpnProfile { name: string type: 'openvpn' | 'wireguard' | 'openconnect' | 'f5' file: string has_totp_secret?: boolean // TOTP auto → nascondi campo OTP has_otp?: boolean // richiede OTP separato → mostra campo OTP has_password_file?: boolean // password da file → nascondi campo password authgroup?: string // info, mostrato nel widget protocol?: string // info, mostrato nel widget }
Step 7 — Frontend: API Hook (runner.ts)
Aggiornare useVpnStart tipo parametro:
export function useVpnStart() {
const qc = useQueryClient()
return useMutation
Step 8 — Frontend: VpnStatusWidget.tsx
8a. Nuovi state
const [vpnOtp, setVpnOtp] = useState('')
8b. Logica campi condizionale (sostituire needsRuntimePassword)
const selectedProfileData = useMemo(() => { if (!selectedProfile || !profiles) return null const [name] = selectedProfile.split('|') return profiles.find(p => p.name === name) ?? null }, [selectedProfile, profiles])
const isOcOrF5 = selectedType === 'openconnect' || selectedType === 'f5' const hasAutoTotp = selectedProfileData?.has_totp_secret ?? false const hasPasswordFile = selectedProfileData?.has_password_file ?? false const needsOtpField = isOcOrF5 && !hasAutoTotp && (selectedProfileData?.has_otp ?? false) const needsPasswordField = isOcOrF5 && !hasPasswordFile
const canConnect = !!selectedProfile && (!needsPasswordField || !!vpnPassword.trim()) && (!needsOtpField || !!vpnOtp.trim())
8c. Campi UI condizionali (sostituire blocco righe 171-186)
- Se needsPasswordField → mostra input password
- Se needsOtpField → mostra input OTP numerico con inputMode="numeric", autoComplete="one-time-code", placeholder "Codice OTP (6 cifre)", messaggio amber "Inserisci l'OTP appena generato. Il codice scade entro 30 secondi."
- Se hasAutoTotp → mostra messaggio emerald "TOTP automatico configurato. Nessun codice OTP necessario."
- Se needsPasswordField → mostra nota "Le credenziali non vengono salvate."
8d. Metadata profilo (dopo select dropdown)
Se selectedProfileData ha authgroup o protocol, mostrare: Gruppo: SecurityTeam | Protocollo: gp
8e. Aggiornare handler Connetti (riga 190-213)
Passare otp: vpnOtp.trim() alla mutazione. Aggiornare testo bottone: - needsOtpField && !vpnOtp.trim() → "Inserisci OTP per connettere" - needsPasswordField && !vpnPassword.trim() → "Inserisci password per connettere"
8f. Error display migliorato (riga 219-221)
Mostrare errore in box rosso con
per preservare newline dall'output openconnect:Errore connessione VPN
{vpnStart.data.message}8g. Reset su cambio profilo (riga 157-159)
Aggiungere setVpnOtp('') nel onChange del select.
Step 9 — Test (test_runner_vpn.py)
7 nuovi test:
- test_build_openconnect_command_with_authgroup — payload con authgroup: "Realm" → comando contiene --authgroup Realm
- test_build_openconnect_command_with_protocol — payload con protocol: "gp" → comando contiene --protocol=gp (anche per tipo f5 non aggiunge --protocol=f5)
- test_build_openconnect_command_with_totp_secret — payload con totp_secret: "JBSWY3DPEHPK3PXP" → comando contiene --token-mode=totp e --token-secret=base32:JBSWY3DPEHPK3PXP
- test_build_openconnect_command_multi_prompt_stdin — runtime_password="pass" + runtime_otp="123456" → stdin_data == b"pass\n123456\n"
- test_build_openconnect_command_otp_suppressed_when_totp_secret — totp_secret set + runtime_otp="123456" → stdin_data contiene solo password, no OTP
- test_build_openconnect_command_password_file_with_otp — password da file + runtime_otp → stdin == file_pw\notp\n
- test_list_vpn_profiles_includes_metadata — profilo JSON con otp: true, totp_secret, authgroup → metadata restituito da list_vpn_profiles()
Step 10 — Documentazione (docs/operations/vpn.md)
Aggiungere dopo "OpenConnect / F5 JSON Example":
- Sezione "Multi-Factor Authentication (OTP)" con esempi per OTP manuale e TOTP automatico
- Sezione "Auth Group / Protocollo" con esempio JSON e lista protocolli supportati
- Aggiornare tabella troubleshooting con: "OTP scaduto → inserire OTP fresco e riconnettere entro 30s"
Ordine di esecuzione
- runner.py — _build_openconnect_command() (funzione pura, testabile in isolamento)
- runner.py — VpnStartRequest, _start_vpn(), vpn_start(), timeout
- runner.py — list_vpn_profiles() con metadata
- test_runner_vpn.py — scrivere ed eseguire tutti i test
- schemas.py + runner_client.py + routers/runner.py — proxy layer
- types.ts + runner.ts — frontend types e hooks
- VpnStatusWidget.tsx — widget UI
- vpn.md — documentazione
Verifica end-to-end
- Creare un profilo test vpn/profiles/test.openconnect.json con "otp": true
- Avviare il runner + dashboard (/start-dashboard)
- Nel widget VPN: selezionare il profilo → verificare che appaiano i campi Password e OTP separati
- Creare un profilo con "totp_secret": "..." → verificare che il campo OTP sia nascosto e appaia il messaggio TOTP automatico
- Creare un profilo con "authgroup": "Test" + "protocol": "gp" → verificare che i metadata appaiano sotto il dropdown
- Testare connessione con OTP errato → verificare che l'errore openconnect appaia nel box rosso con dettaglio
- Eseguire pytest dashboard/backend/tests/test_runner_vpn.py -v → tutti i test passano