feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck
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
7e23df9fec
commit
2d223c5823
9 changed files with 1158 additions and 6 deletions
|
|
@ -1,17 +1,80 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
|
||||
)
|
||||
|
||||
func runCheck(args []string, stdout, stderr io.Writer) int {
|
||||
fs, path := configFlags("check", stderr)
|
||||
testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen")
|
||||
testAuth := fs.String("test-auth", "",
|
||||
"Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
_ = testAuth
|
||||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
||||
return 1
|
||||
|
||||
cfg, err := config.Load(*path, nil)
|
||||
if err != nil {
|
||||
// Ohne lesbare Konfiguration lässt sich nichts weiter prüfen.
|
||||
fmt.Fprintf(stdout, "✗ Konfiguration\n %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fw, ad, err := buildBackends(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stdout, "✗ Initialisierung\n %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}
|
||||
report := c.Run(ctx, *testAuth)
|
||||
report.WriteTo(stdout)
|
||||
|
||||
if !report.OK() {
|
||||
fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.")
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration.
|
||||
// Wird von serve und check gemeinsam genutzt.
|
||||
func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
|
||||
fw, err := opnsense.New(opnsense.Options{
|
||||
BaseURL: cfg.OPNsense.URL,
|
||||
APIKey: cfg.OPNsense.APIKey,
|
||||
APISecret: cfg.OPNsense.APISecret,
|
||||
CAFile: cfg.OPNsense.CAFile,
|
||||
InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify,
|
||||
Timeout: 20 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ad, err := auth.NewAD(auth.Options{
|
||||
Servers: cfg.AD.Servers,
|
||||
Port: cfg.AD.Port,
|
||||
TLSMode: cfg.AD.TLSMode,
|
||||
BaseDN: cfg.EffectiveBaseDN(),
|
||||
BindUser: cfg.AD.BindUser,
|
||||
BindPassword: cfg.AD.BindPassword,
|
||||
VPNGroup: cfg.AD.VPNGroup,
|
||||
CAFile: cfg.AD.CAFile,
|
||||
Timeout: time.Duration(cfg.AD.Timeout),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return fw, ad, nil
|
||||
}
|
||||
|
|
|
|||
62
cmd/vpnportal/health.go
Normal file
62
cmd/vpnportal/health.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||
)
|
||||
|
||||
// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und
|
||||
// Verzeichnis belasten.
|
||||
const healthCacheTTL = 30 * time.Second
|
||||
|
||||
// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients.
|
||||
type directoryPinger interface {
|
||||
ResolveGroupDN(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen.
|
||||
type healthChecker struct {
|
||||
fw check.Pinger
|
||||
dir directoryPinger
|
||||
clock func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached web.HealthReport
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker {
|
||||
return &healthChecker{fw: fw, dir: dir, clock: time.Now}
|
||||
}
|
||||
|
||||
func (h *healthChecker) Check(ctx context.Context) web.HealthReport {
|
||||
now := h.clock()
|
||||
h.mu.Lock()
|
||||
if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL {
|
||||
rep := h.cached
|
||||
h.mu.Unlock()
|
||||
return rep
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var rep web.HealthReport
|
||||
_, fwErr := h.fw.Ping(ctx)
|
||||
rep.OPNsense = fwErr == nil
|
||||
_, dirErr := h.dir.ResolveGroupDN(ctx)
|
||||
rep.Directory = dirErr == nil
|
||||
rep.OK = rep.OPNsense && rep.Directory
|
||||
|
||||
h.mu.Lock()
|
||||
h.cached, h.cachedAt = rep, now
|
||||
h.mu.Unlock()
|
||||
return rep
|
||||
}
|
||||
|
||||
var _ web.HealthChecker = (*healthChecker)(nil)
|
||||
|
|
@ -1,13 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/portal"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||
)
|
||||
|
||||
const defaultConfigPath = "/etc/vpnportal/config.yaml"
|
||||
|
||||
// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme: die Liste der
|
||||
// VPN-Instanzen. Zertifikate werden nie gecacht.
|
||||
const providerCacheTTL = 5 * time.Minute
|
||||
|
||||
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
|
|
@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
|||
return fs, path
|
||||
}
|
||||
|
||||
// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei
|
||||
// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu
|
||||
// unterscheiden, ohne Inhalte zu protokollieren.
|
||||
func configChecksum(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])[:12], nil
|
||||
}
|
||||
|
||||
// newTLSConfig lädt Zertifikat und Schlüssel des Portals.
|
||||
func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err)
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runServe(args []string, stdout, stderr io.Writer) int {
|
||||
fs, path := configFlags("serve", stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
||||
return 1
|
||||
|
||||
cfg, err := config.Load(*path, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if cfg.OPNsense.InsecureSkipVerify {
|
||||
// Bewusst bei jedem Start, nicht nur einmalig.
|
||||
fmt.Fprintln(stderr,
|
||||
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
|
||||
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
|
||||
}
|
||||
|
||||
fw, ad, err := buildBackends(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Startprüfung: das Portal startet nicht mit kaputter Umgebung.
|
||||
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
|
||||
startCancel()
|
||||
report.WriteTo(stdout)
|
||||
if !report.OK() {
|
||||
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
|
||||
return 1
|
||||
}
|
||||
|
||||
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
|
||||
MaxSizeMB: cfg.Logging.MaxSizeMB,
|
||||
MaxBackups: cfg.Logging.MaxBackups,
|
||||
Compress: cfg.Logging.Compress,
|
||||
Stdout: stdout,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer auditLog.Close()
|
||||
|
||||
// Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch
|
||||
// kein Audit-Log, in das er hätte schreiben können.
|
||||
ad.SetFailoverHook(func(server string, err error) {
|
||||
auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server})
|
||||
})
|
||||
|
||||
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
|
||||
|
||||
renderer, err := web.NewRenderer(web.Brand{
|
||||
Title: cfg.Portal.Title,
|
||||
SupportContact: cfg.Portal.SupportContact,
|
||||
Version: version,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
|
||||
limiter := web.NewLimiter(time.Now)
|
||||
|
||||
srv, err := web.NewServer(web.Deps{
|
||||
Auth: ad,
|
||||
Certs: certs,
|
||||
Audit: auditLog,
|
||||
Sessions: sessions,
|
||||
Limiter: limiter,
|
||||
Renderer: renderer,
|
||||
Health: newHealthChecker(fw, ad),
|
||||
Clock: time.Now,
|
||||
LogoPath: cfg.Portal.LogoFile,
|
||||
SupportContact: cfg.Portal.SupportContact,
|
||||
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
sum, _ := configChecksum(*path)
|
||||
auditLog.Log(audit.Event{
|
||||
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
|
||||
Detail: "listen=" + cfg.Portal.Listen,
|
||||
})
|
||||
|
||||
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
|
||||
fmt.Fprintln(stdout, msg)
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.Portal.Listen,
|
||||
Handler: srv.Handler(),
|
||||
TLSConfig: tlsCfg,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Hintergrundaufräumen für Sessions und Rate-Limiter.
|
||||
stopGC := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
sessions.GC()
|
||||
limiter.GC()
|
||||
case <-stopGC:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer close(stopGC)
|
||||
|
||||
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
defer signal.Stop(hup)
|
||||
go func() {
|
||||
for range hup {
|
||||
if err := auditLog.Reopen(); err != nil {
|
||||
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Geordnetes Herunterfahren.
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(shutdown)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
|
||||
errCh <- httpSrv.ListenAndServeTLS("", "")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case sig := <-shutdown:
|
||||
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
cmd/vpnportal/serve_test.go
Normal file
82
cmd/vpnportal/serve_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServeRejectsMissingConfig(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut)
|
||||
if code == 0 {
|
||||
t.Fatal("fehlende Config muss zum Abbruch führen")
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "nicht.yaml") {
|
||||
t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRejectsLooseConfigPermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644)
|
||||
os.Chmod(p, 0o644)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 {
|
||||
t.Fatal("zu offene Dateirechte müssen den Start verhindern")
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "chmod") {
|
||||
t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsUnknownConfigField(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(p, []byte("portal:\n tls_cert_typo: \"x\"\n"), 0o600)
|
||||
os.Chmod(p, 0o600)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
code := runCheck([]string{"--config", p}, &out, &errOut)
|
||||
if code == 0 {
|
||||
t.Fatal("Tippfehler in der Config muss zum Abbruch führen")
|
||||
}
|
||||
combined := out.String() + errOut.String()
|
||||
if !strings.Contains(combined, "tls_cert_typo") {
|
||||
t.Errorf("das unbekannte Feld muss genannt werden: %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigChecksumIsStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(p, []byte("inhalt"), 0o600)
|
||||
os.Chmod(p, 0o600)
|
||||
|
||||
a, err := configChecksum(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := configChecksum(p)
|
||||
if a != b || a == "" {
|
||||
t.Fatalf("Prüfsumme instabil: %q vs %q", a, b)
|
||||
}
|
||||
if len(a) != 12 {
|
||||
t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a))
|
||||
}
|
||||
|
||||
os.WriteFile(p, []byte("anderer inhalt"), 0o600)
|
||||
if c, _ := configChecksum(p); c == a {
|
||||
t.Error("geänderte Datei muss eine andere Prüfsumme ergeben")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTLSConfigRejectsMissingFiles(t *testing.T) {
|
||||
if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil {
|
||||
t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden")
|
||||
}
|
||||
}
|
||||
94
cmd/vpnportal/uidemo_test.go
Normal file
94
cmd/vpnportal/uidemo_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//go:build uidemo
|
||||
|
||||
// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf
|
||||
// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends begutachtet
|
||||
// werden kann. Aufruf:
|
||||
//
|
||||
// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
|
||||
//
|
||||
// Anmeldung mit beliebigem Benutzernamen und Passwort.
|
||||
// Browser erlauben Secure-Cookies auf 127.0.0.1, deshalb funktioniert der
|
||||
// gesamte Ablauf auch ohne TLS.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
||||
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||
)
|
||||
|
||||
type demoAuth struct{}
|
||||
|
||||
func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
|
||||
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil
|
||||
}
|
||||
|
||||
type demoCerts struct{}
|
||||
|
||||
func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
|
||||
return []web.CertEntry{
|
||||
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
|
||||
ValidTo: time.Now().AddDate(1, 2, 0)},
|
||||
{Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller",
|
||||
ValidTo: time.Now().AddDate(0, 0, 12)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) {
|
||||
return &web.ExportStream{
|
||||
Filename: "vpn-demo-mmueller.ovpn",
|
||||
ContentType: "application/x-openvpn-profile",
|
||||
Body: io.NopCloser(strings.NewReader("client\nremote fw01.testfirma.local 1194\n")),
|
||||
InstanceName: "VPN Homeoffice",
|
||||
CommonName: "mmueller",
|
||||
ValidTo: time.Now().AddDate(1, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (demoCerts) Pattern(user string) string { return user }
|
||||
|
||||
// leereCerts zeigt den Zustand "kein Zertifikat hinterlegt".
|
||||
type leereCerts struct{ demoCerts }
|
||||
|
||||
func (leereCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type demoAudit struct{ t *testing.T }
|
||||
|
||||
func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) }
|
||||
|
||||
func TestUIDemo(t *testing.T) {
|
||||
rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Mit VPNPORTAL_UIDEMO_EMPTY=1 den Zustand "kein Zertifikat" begutachten.
|
||||
var certs web.CertSource = demoCerts{}
|
||||
if os.Getenv("VPNPORTAL_UIDEMO_EMPTY") != "" {
|
||||
certs = leereCerts{}
|
||||
}
|
||||
srv, err := web.NewServer(web.Deps{
|
||||
Auth: demoAuth{}, Certs: certs, Audit: demoAudit{t},
|
||||
Sessions: web.NewSessionStore(30*time.Minute, nil),
|
||||
Limiter: web.NewLimiter(nil), Renderer: rn,
|
||||
SupportContact: "it-helpdesk@testfirma.de",
|
||||
SessionTTL: 30 * time.Minute,
|
||||
MinLoginDuration: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden")
|
||||
if err := http.ListenAndServe("127.0.0.1:8080", srv.Handler()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
61
cmd/vpnportal/updatecheck.go
Normal file
61
cmd/vpnportal/updatecheck.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt,
|
||||
// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus).
|
||||
const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest"
|
||||
|
||||
// checkForUpdate fragt read-only die neueste Version ab und liefert einen
|
||||
// Hinweistext, falls sich die veröffentlichte Version unterscheidet.
|
||||
// Jeder Fehler führt zu einer leeren Rückgabe — der Check darf den Start
|
||||
// unter keinen Umständen stören.
|
||||
func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string {
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "vpnportal/"+current)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil || json.Unmarshal(raw, &release) != nil {
|
||||
return ""
|
||||
}
|
||||
// Reiner Gleichheitsvergleich, keine Semver-Ordnung: das Portal meldet nur,
|
||||
// dass sich die veröffentlichte Version unterscheidet, und urteilt nicht
|
||||
// selbst über Versionsordnungen.
|
||||
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
|
||||
if latest == "" || latest == strings.TrimPrefix(strings.TrimSpace(current), "v") {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current)
|
||||
}
|
||||
78
cmd/vpnportal/updatecheck_test.go
Normal file
78
cmd/vpnportal/updatecheck_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" {
|
||||
t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckReportsNewerVersion(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"tag_name":"v1.4.0"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client())
|
||||
if msg == "" {
|
||||
t.Fatal("abweichende Version muss gemeldet werden")
|
||||
}
|
||||
if !strings.Contains(msg, "1.4.0") {
|
||||
t.Errorf("Meldung nennt die neue Version nicht: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckSilentWhenCurrent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"tag_name":"v1.2.0"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
// Auch mit "v"-Präfix auf der einen und ohne auf der anderen Seite.
|
||||
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
|
||||
t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
url := srv.URL
|
||||
client := srv.Client()
|
||||
srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
// Ein nicht erreichbarer Endpunkt darf den Start niemals stören.
|
||||
if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" {
|
||||
t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckIgnoresGarbage(t *testing.T) {
|
||||
for _, body := range []string{`nicht json`, `{}`, `{"tag_name":""}`, `{"tag_name":" "}`} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, body)
|
||||
}))
|
||||
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
|
||||
t.Errorf("Antwort %q darf nichts melden, got %q", body, msg)
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue