Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
208 lines
5.7 KiB
Go
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
|
|
}
|