opnsense-portal/internal/web/handlers_certs.go
2026-08-14 09:25:52 +02:00

208 lines
5.7 KiB
Go

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
}