// Module Logs — streaming de journalctl via WebSocket + SSH. // Expose : liste des unités journald, streaming WebSocket journalctl. package logs import ( "fmt" "net/http" "strconv" "strings" "time" "git.geronzi.fr/proxmoxPanel/core/backend/internal/api" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" "git.geronzi.fr/proxmoxPanel/core/backend/modules" gorillaws "github.com/gorilla/websocket" gossh "golang.org/x/crypto/ssh" ) // LogsModule gère la lecture des journaux systemd via SSH. type LogsModule struct { db *db.DB pool *sshpool.Pool enc *crypto.Encryptor } // New crée un LogsModule avec les dépendances nécessaires. func New(database *db.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *LogsModule { return &LogsModule{db: database, pool: pool, enc: enc} } func (m *LogsModule) ID() string { return "logs" } // Register enregistre les routes du module dans le registry CORE. func (m *LogsModule) Register(r modules.Registry) error { r.RegisterRoute("GET", "/api/logs/units", m.ListUnits, false) r.RegisterRoute("GET", "/ws/logs", m.StreamLogs, false) return nil } // sshCreds récupère et déchiffre les credentials SSH depuis la configuration. func (m *LogsModule) sshCreds() (host, user, pass string, err error) { host, _, _ = m.db.GetSetting("ssh_host") user, _, _ = m.db.GetSetting("ssh_username") encPass, _, _ := m.db.GetSetting("ssh_password") if encPass != "" { pass, err = m.enc.Decrypt(encPass) if err != nil { return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH") } } if host == "" || user == "" || pass == "" { return "", "", "", fmt.Errorf("SSH non configuré") } return host, user, pass, nil } // buildJournalCmd construit la commande journalctl, en la wrappant via pct exec pour les LXC. func buildJournalCmd(target, journalArgs string) string { if strings.HasPrefix(target, "lxc:") { vmid := strings.TrimPrefix(target, "lxc:") if _, err := strconv.Atoi(vmid); err == nil { return fmt.Sprintf("pct exec %s -- journalctl %s", vmid, journalArgs) } } return "journalctl " + journalArgs } // sanitizeUnit valide un nom d'unité systemd pour éviter l'injection de commandes. func sanitizeUnit(unit string) string { for _, c := range unit { ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '@' || c == ':' if !ok { return "" } } return unit } // ListUnits retourne la liste des unités systemd présentes dans les journaux. // GET /api/logs/units?target=host|lxc:ID func (m *LogsModule) ListUnits(w http.ResponseWriter, r *http.Request) { sshHost, user, pass, err := m.sshCreds() if err != nil { api.JSONError(w, err.Error(), http.StatusServiceUnavailable) return } target := r.URL.Query().Get("target") if target == "" { target = "host" } cmd := buildJournalCmd(target, "--field=_SYSTEMD_UNIT --no-pager 2>/dev/null | sort -u | head -300") out, _ := m.pool.RunCommand(sshHost, user, pass, cmd) units := []string{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line != "" { units = append(units, line) } } api.JSONResponse(w, http.StatusOK, units) } // wsUpgrader est l'upgrader WebSocket pour le module logs. var wsUpgrader = gorillaws.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // StreamLogs ouvre un WebSocket et stream journalctl en temps réel via SSH. // GET /ws/logs?target=host|lxc:ID&unit=sshd.service&lines=100 func (m *LogsModule) StreamLogs(w http.ResponseWriter, r *http.Request) { sshHost, user, pass, err := m.sshCreds() if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() target := r.URL.Query().Get("target") if target == "" { target = "host" } unit := r.URL.Query().Get("unit") linesStr := r.URL.Query().Get("lines") lines := 100 if n, err2 := strconv.Atoi(linesStr); err2 == nil && n > 0 && n <= 2000 { lines = n } // Construction des arguments journalctl args := fmt.Sprintf("-f --no-pager -n %d", lines) if safe := sanitizeUnit(unit); safe != "" { args += " -u " + safe } cmd := buildJournalCmd(target, args) // Connexion SSH directe (hors pool — session longue durée avec journalctl -f) sshConfig := &gossh.ClientConfig{ User: user, Auth: []gossh.AuthMethod{gossh.Password(pass)}, Timeout: 15 * time.Second, HostKeyCallback: gossh.InsecureIgnoreHostKey(), } client, err := gossh.Dial("tcp", sshHost, sshConfig) if err != nil { conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur SSH : "+err.Error())) return } defer client.Close() session, err := client.NewSession() if err != nil { conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur session SSH : "+err.Error())) return } defer session.Close() stdout, err := session.StdoutPipe() if err != nil { return } if err := session.Start(cmd); err != nil { conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur commande : "+err.Error())) return } // Goroutine : SSH stdout → WebSocket done := make(chan struct{}) go func() { defer close(done) buf := make([]byte, 4096) for { n, err := stdout.Read(buf) if n > 0 { conn.WriteMessage(gorillaws.TextMessage, buf[:n]) } if err != nil { break } } }() // Boucle principale : attendre fermeture WebSocket (ou message de contrôle) for { _, _, err := conn.ReadMessage() if err != nil { break } } // Fermer la session SSH → interrompt journalctl -f session.Close() client.Close() <-done }