package web import ( "errors" "fmt" "io" "math" "net/http" "strings" "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" ) // expiryWarningDays ist die Schwelle, ab der vor dem Ablauf gewarnt wird. const expiryWarningDays = 30 // downloadFormats sind die in der Oberfläche angebotenen Formate. // Alle weiteren Exporteinstellungen kommen von der Firewall. var downloadFormats = []struct { Format string LabelKey string }{ {"ovpn", "download_ovpn"}, {"viscosity", "download_viscosity"}, } // overviewData füllt overview.html. type overviewData struct { Entries []overviewEntry SupportHint string } type overviewEntry struct { InstanceName string CommonName string ValidUntil string ExpiryWarning string Formats []overviewFormat } type overviewFormat struct { Token string Format string Label string } func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) { user := sess.Identity.Username entries, err := s.d.Certs.EntriesFor(r.Context(), user) if err != nil { if errors.Is(err, ErrBackendUnavailable) { s.log(audit.Event{ Event: audit.EventOPNsenseUnreachable, User: user, SrcIP: ClientIP(r), Session: audit.ShortSession(sess.Token), }) } // Bewusst eigene Seite: "Firewall weg" ist etwas anderes als // "kein Zertifikat vorhanden". s.renderError(w, r, http.StatusServiceUnavailable, sess, "error_service_unavailable", "error_service_unavailable_body") return } if len(entries) == 0 { s.log(audit.Event{ Event: audit.EventNoCertFound, User: user, SrcIP: ClientIP(r), Session: audit.ShortSession(sess.Token), Pattern: s.pattern(user), }) } var data overviewData if s.d.SupportContact != "" { data.SupportHint = fmt.Sprintf(T("overview_no_certs_contact"), s.d.SupportContact) } now := s.d.Clock() for _, e := range entries { oe := overviewEntry{InstanceName: e.InstanceName, CommonName: e.CommonName} if !e.ValidTo.IsZero() { oe.ValidUntil = e.ValidTo.Format("02.01.2006") // Aufrunden: ein Zertifikat mit 11 Tagen und 23 Stunden Restlaufzeit // als "11 Tage" anzuzeigen wäre für den Benutzer irreführend. if days := int(math.Ceil(e.ValidTo.Sub(now).Hours() / 24)); days < expiryWarningDays { if days <= 0 { oe.ExpiryWarning = T("overview_expired_today") } else { oe.ExpiryWarning = fmt.Sprintf(T("overview_expiry_warning"), days) } } } for _, f := range downloadFormats { oe.Formats = append(oe.Formats, overviewFormat{ Token: e.Token, Format: f.Format, Label: T(f.LabelKey)}) } data.Entries = append(data.Entries, oe) } s.renderPage(w, r, http.StatusOK, "overview", sess, PageData{Data: data}) } // pattern liefert die angewendete Zuordnungsregel für das Audit-Log, sofern // die CertSource sie kennt. func (s *Server) pattern(username string) string { if p, ok := s.d.Certs.(interface{ Pattern(string) string }); ok { return p.Pattern(username) } return "" } func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) { user := sess.Identity.Username ip := ClientIP(r) short := audit.ShortSession(sess.Token) deny := func(detail string) { s.log(audit.Event{Event: audit.EventDownloadDenied, User: user, SrcIP: ip, Session: short, Detail: detail}) s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_forbidden_body") } if err := r.ParseForm(); err != nil { deny("formular_unlesbar") return } if !sess.ValidCSRF(r.PostFormValue("csrf_token")) { s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf") return } vpnID, refID, ok := parseCertToken(r.PostFormValue("cert")) if !ok { deny("token_ungueltig") return } format := r.PostFormValue("format") if !isKnownFormat(format) { deny("format_unbekannt") return } // Serverseitige Autorisierung: die CertSource prüft live, ob das // Zertifikat dem angemeldeten Benutzer gehört und nutzbar ist. stream, err := s.d.Certs.Export(r.Context(), user, vpnID, refID, format) if err != nil { if errors.Is(err, ErrCertNotOwned) { deny("nicht_zugeordnet") return } if errors.Is(err, ErrBackendUnavailable) { s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user, SrcIP: ip, Session: short}) } s.renderError(w, r, http.StatusServiceUnavailable, sess, "error_service_unavailable", "error_service_unavailable_body") return } defer stream.Body.Close() expiry := "" if !stream.ValidTo.IsZero() { expiry = stream.ValidTo.Format("2006-01-02") } s.log(audit.Event{ Event: audit.EventConfigDownload, User: user, SrcIP: ip, Session: short, VPNInstance: stream.InstanceName, CertCN: stream.CommonName, CertExpiry: expiry, Format: format, }) NoStore(w) w.Header().Set("Content-Type", stream.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, stream.Filename)) w.WriteHeader(http.StatusOK) // Durchstreamen — nichts auf Platte, nichts vollständig im Speicher. if _, err := io.Copy(w, stream.Body); err != nil { // Der Header ist bereits raus; mehr als Protokollieren bleibt nicht. s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user, SrcIP: ip, Session: short, Detail: "abbruch_beim_streamen"}) } } // parseCertToken zerlegt den Auswahl-Token aus dem Formular. func parseCertToken(s string) (vpnID, refID string, ok bool) { vpnID, refID, found := strings.Cut(s, ":") if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") { return "", "", false } return vpnID, refID, true } func isKnownFormat(f string) bool { for _, d := range downloadFormats { if d.Format == f { return true } } return false }