core/backend/internal/api/terminal.go
enzo 07af66ad81 fix: SSHAuthenticator vide après installation + logs debug
Bug principal : l'SSHAuthenticator est créé au démarrage avec host=""
(DB vide avant installation). Après configure, il gardait host vide.
Le login lisait maintenant le ssh_host depuis la DB à chaque requête.

Logs ajoutés :
- ssh_auth.go : dial SSH, succès, échec avec détail d'erreur
- auth.go : host SSH utilisé, résultat auth à chaque login
- updates.go : credentials SSH, démarrage/fin de job
- terminal.go : ouverture/échec session SSH

Frontend :
- auth.store.ts : gère les réponses non-JSON sur erreur HTTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:39:52 +01:00

156 lines
4.4 KiB
Go

// Handler pour le terminal SSH interactif via WebSocket + PTY.
// Utilise golang.org/x/crypto/ssh pour la connexion et gorilla/websocket pour le transport.
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
gorillaws "github.com/gorilla/websocket"
gossh "golang.org/x/crypto/ssh"
)
// TerminalHandler gère les sessions de terminal SSH interactif.
type TerminalHandler struct {
db *db.DB
auditLogger *audit.Logger
encryptor *crypto.Encryptor
}
// NewTerminalHandler crée un TerminalHandler.
func NewTerminalHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *TerminalHandler {
return &TerminalHandler{db: database, auditLogger: auditLog, encryptor: enc}
}
// terminalCmd représente un message de contrôle envoyé via WebSocket.
type terminalCmd struct {
Type string `json:"type"` // "resize" | "data"
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
// WebSocket ouvre un terminal SSH interactif via WebSocket.
// GET /ws/terminal
// Query params: host (optionnel, défaut = ssh_host depuis config)
func (h *TerminalHandler) WebSocket(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
// Connexion WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Récupérer les params SSH
sshHost := r.URL.Query().Get("host")
if sshHost == "" {
sshHost, _, _ = h.db.GetSetting("ssh_host")
}
sshUser, _, _ := h.db.GetSetting("ssh_username")
encryptedPass, _, _ := h.db.GetSetting("ssh_password")
sshPass, _ := h.encryptor.Decrypt(encryptedPass)
if sshHost == "" {
log.Printf("[terminal] SSH non configuré — user=%s", claims.Username)
conn.WriteMessage(gorillaws.TextMessage, []byte("\r\nErreur : SSH non configuré\r\n"))
return
}
log.Printf("[terminal] Ouverture session — user=%s ssh_host=%s", claims.Username, sshHost)
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_open", sshHost, nil, clientIP(r))
// Établir la connexion SSH
sshConfig := &gossh.ClientConfig{
User: sshUser,
Auth: []gossh.AuthMethod{
gossh.Password(sshPass),
},
Timeout: 15 * time.Second,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
}
sshClient, err := gossh.Dial("tcp", sshHost, sshConfig)
if err != nil {
log.Printf("[terminal] Échec connexion SSH %s@%s : %v", sshUser, sshHost, err)
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur SSH : %v\r\n", err)))
return
}
log.Printf("[terminal] Connecté — %s@%s", sshUser, sshHost)
defer sshClient.Close()
// Créer une session SSH avec pseudo-terminal
session, err := sshClient.NewSession()
if err != nil {
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur session SSH : %v\r\n", err)))
return
}
defer session.Close()
// Configurer le PTY (terminal 80x24 par défaut)
modes := gossh.TerminalModes{
gossh.ECHO: 1,
gossh.TTY_OP_ISPEED: 14400,
gossh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur PTY : %v\r\n", err)))
return
}
// Pipes stdin/stdout entre WebSocket et SSH
stdinPipe, err := session.StdinPipe()
if err != nil {
return
}
stdoutPipe, err := session.StdoutPipe()
if err != nil {
return
}
if err := session.Shell(); err != nil {
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur shell : %v\r\n", err)))
return
}
// Goroutine : SSH stdout → WebSocket
go func() {
buf := make([]byte, 4096)
for {
n, err := stdoutPipe.Read(buf)
if err != nil {
break
}
conn.WriteMessage(gorillaws.BinaryMessage, buf[:n])
}
conn.Close()
}()
// Boucle principale : WebSocket → SSH stdin
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
// Détecter les messages de contrôle JSON (ex: resize)
if len(msg) > 0 && msg[0] == '{' {
var cmd terminalCmd
if json.Unmarshal(msg, &cmd) == nil && cmd.Type == "resize" && cmd.Cols > 0 && cmd.Rows > 0 {
session.WindowChange(cmd.Rows, cmd.Cols)
continue
}
}
// Données brutes → stdin SSH
stdinPipe.Write(msg)
}
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_close", sshHost, nil, clientIP(r))
}