feat(web): Router, Login/Logout, Übersicht, CSRF-geschützter Download, Anleitungen und /healthz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
bb2bc223b9
commit
eb89d78ce2
8 changed files with 1618 additions and 0 deletions
208
internal/web/handlers_certs.go
Normal file
208
internal/web/handlers_certs.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue