Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello kommentarlos ab: sie nehmen die TCP-Verbindung an, lesen den ClientHello und setzen zurueck, ohne ein Zertifikat zu schicken. Herunterhandeln hilft dann nicht — TLS 1.3 darf gar nicht erst angeboten werden. max_tls_version (Default 1.3) deckelt die Hoechstversion; die Validierung lehnt eine Hoechstversion unterhalb der Mindestversion ab. check erkennt zusaetzlich zurueckgesetzte Verbindungen und nennt die beiden plausiblen Ursachen: fehlendes LDAPS-Zertifikat auf dem Server oder TLS-1.3-Inkompatibilitaet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package check
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
|
|
)
|
|
|
|
type fakePinger struct {
|
|
at time.Time
|
|
err error
|
|
}
|
|
|
|
func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err }
|
|
|
|
type fakeDir struct {
|
|
dn string
|
|
dnErr error
|
|
lookup *auth.LookupResult
|
|
lookErr error
|
|
}
|
|
|
|
func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr }
|
|
func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) {
|
|
return f.lookup, f.lookErr
|
|
}
|
|
|
|
var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
|
|
func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
cfgPath := filepath.Join(tmp, "config.yaml")
|
|
if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Chmod(cfgPath, 0o600)
|
|
|
|
logDir := filepath.Join(tmp, "log")
|
|
os.MkdirAll(logDir, 0o750)
|
|
os.Chmod(logDir, 0o750)
|
|
|
|
cfg := config.Defaults()
|
|
cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt")
|
|
cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key")
|
|
os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644)
|
|
os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600)
|
|
os.Chmod(cfg.Portal.TLSKey, 0o600)
|
|
cfg.OPNsense.URL = "https://fw01.firma.local"
|
|
cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s"
|
|
cfg.AD.Domain = "firma.local"
|
|
cfg.AD.Servers = []string{"dc01.firma.local"}
|
|
cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw"
|
|
cfg.AD.VPNGroup = "VPN-Users"
|
|
cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log")
|
|
|
|
return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir,
|
|
Now: func() time.Time { return checkNow }}
|
|
}
|
|
|
|
func report(t *testing.T, rep *Report) string {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
if _, err := rep.WriteTo(&b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func TestAllChecksPass(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow},
|
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
|
rep := c.Run(context.Background(), "")
|
|
|
|
if !rep.OK() {
|
|
t.Fatalf("alle Prüfungen sollten bestehen:\n%s", report(t, rep))
|
|
}
|
|
out := report(t, rep)
|
|
for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("Prüfpunkt %q fehlt:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFailedAPISecretIsNamed(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized},
|
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
|
rep := c.Run(context.Background(), "")
|
|
|
|
if rep.OK() {
|
|
t.Fatal("falsches Secret muss auffallen")
|
|
}
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, "✗") {
|
|
t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out)
|
|
}
|
|
if !strings.Contains(strings.ToLower(out), "zugangsdaten") {
|
|
t.Errorf("Ursache muss benannt werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestMissingAPIPermissionIsDistinguished(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden}, &fakeDir{dn: "CN=x"})
|
|
rep := c.Run(context.Background(), "")
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, "Client Export") {
|
|
t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestDirectoryTLSErrorsGiveActionableHints(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err string
|
|
want string // Textbaustein, der in der Ausgabe stehen muss
|
|
}{
|
|
{
|
|
"unbekannte CA",
|
|
`LDAPS-Verbindung zu dc01.firma.local:636: x509: certificate signed by unknown authority`,
|
|
"ad.ca_file",
|
|
},
|
|
{
|
|
"Hostname passt nicht",
|
|
`LDAPS-Verbindung zu dc01.firma.local:636: x509: certificate is valid for DC01, not dc01.firma.local`,
|
|
"ad.servers",
|
|
},
|
|
{
|
|
"SHA-1-Signatur",
|
|
`LDAPS-Verbindung zu dc01:636: x509: cannot verify signature: insecure algorithm SHA1-RSA`,
|
|
"SHA-256",
|
|
},
|
|
{
|
|
"Zertifikat abgelaufen",
|
|
`LDAPS-Verbindung zu dc01:636: x509: certificate has expired or is not yet valid`,
|
|
"abgelaufen",
|
|
},
|
|
{
|
|
"TLS-Version zu alt",
|
|
`LDAPS-Verbindung zu dc01:636: tls: protocol version not supported`,
|
|
"min_tls_version",
|
|
},
|
|
{
|
|
"keine gemeinsame Cipher-Suite",
|
|
`LDAPS-Verbindung zu dc01:636: tls: no cipher suite supported by both client and server`,
|
|
"tlsrsakex",
|
|
},
|
|
{
|
|
"Verbindung zurueckgesetzt",
|
|
`LDAPS-Verbindung zu dc01:636: read tcp 10.0.0.5->10.0.0.250:636: read: connection reset by peer`,
|
|
"max_tls_version",
|
|
},
|
|
{
|
|
"Verbindung sofort geschlossen",
|
|
`LDAPS-Verbindung zu dc01:636: EOF`,
|
|
"LDAPS-Zertifikat",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dnErr: errors.New(tc.err)})
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("TLS-Fehler muss auffallen")
|
|
}
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, tc.want) {
|
|
t.Errorf("Hinweis %q fehlt in der Ausgabe:\n%s", tc.want, out)
|
|
}
|
|
// Der technische Originalfehler muss erhalten bleiben.
|
|
if !strings.Contains(out, "dc01") {
|
|
t.Errorf("der Originalfehler darf nicht verloren gehen:\n%s", out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlainNetworkErrorGetsNoMisleadingHint(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow},
|
|
&fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})
|
|
rep := c.Run(context.Background(), "")
|
|
out := report(t, rep)
|
|
for _, unwanted := range []string{"ad.ca_file", "min_tls_version", "SHA-256"} {
|
|
if strings.Contains(out, unwanted) {
|
|
t.Errorf("ein reiner Netzwerkfehler darf keinen TLS-Hinweis erzeugen (%q):\n%s", unwanted, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnreachableDCIsNamed(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow},
|
|
&fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("nicht erreichbarer DC muss auffallen")
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "dc01.firma.local") {
|
|
t.Errorf("betroffener DC muss genannt werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestLooseConfigPermissionsAreDetected(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
os.Chmod(c.ConfigPath, 0o644)
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("0644-Config muss auffallen")
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "chmod") {
|
|
t.Errorf("Reparaturbefehl muss genannt werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestLooseTLSKeyIsDetected(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
os.Chmod(c.Cfg.Portal.TLSKey, 0o644)
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("world-readable TLS-Key muss auffallen")
|
|
}
|
|
}
|
|
|
|
func TestMissingLogDirectoryIsDetected(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
// Auch das übergeordnete Verzeichnis existiert nicht — das ist ein
|
|
// echter Konfigurationsfehler, meist ein Tippfehler im Pfad.
|
|
c.Cfg.Logging.AuditLog = "/gibt/es/nicht/audit.log"
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("fehlendes Log-Verzeichnis mit fehlendem Elternverzeichnis muss auffallen")
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "/gibt/es/nicht") {
|
|
t.Errorf("der beanstandete Pfad muss genannt werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestLogDirectoryIsAcceptedIfItCanBeCreated(t *testing.T) {
|
|
// Der Audit-Logger legt sein Verzeichnis beim Start selbst an. Der Check
|
|
// darf deshalb nicht strenger sein als die Laufzeit: existiert das
|
|
// Elternverzeichnis, ist alles in Ordnung.
|
|
c := newChecker(t, &fakePinger{at: checkNow},
|
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
|
parent := filepath.Dir(filepath.Dir(c.Cfg.Logging.AuditLog))
|
|
c.Cfg.Logging.AuditLog = filepath.Join(parent, "nochnichtda", "audit.log")
|
|
|
|
rep := c.Run(context.Background(), "")
|
|
if !rep.OK() {
|
|
t.Fatalf("noch nicht angelegtes Log-Verzeichnis darf nicht scheitern:\n%s", report(t, rep))
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "wird beim Start angelegt") {
|
|
t.Errorf("der Hinweis auf die spätere Anlage fehlt:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestLogDirectoryWithLoosePermissionsStillFails(t *testing.T) {
|
|
// Ein existierendes, zu offenes Verzeichnis bleibt ein Fehler.
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
os.Chmod(filepath.Dir(c.Cfg.Logging.AuditLog), 0o777)
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("0777-Log-Verzeichnis muss weiterhin auffallen")
|
|
}
|
|
}
|
|
|
|
func TestClockSkewWarns(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"})
|
|
rep := c.Run(context.Background(), "")
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, "NTP") {
|
|
t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out)
|
|
}
|
|
if rep.OK() {
|
|
t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen")
|
|
}
|
|
}
|
|
|
|
func TestSmallClockSkewIsFine(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)},
|
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
|
if rep := c.Run(context.Background(), ""); !rep.OK() {
|
|
t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", report(t, rep))
|
|
}
|
|
}
|
|
|
|
func TestClockCheckSkippedWhenFirewallDown(t *testing.T) {
|
|
// Ohne Antwort der Firewall lässt sich die Uhr nicht vergleichen; das darf
|
|
// nicht als eigenständiger Zeitfehler dastehen.
|
|
c := newChecker(t, &fakePinger{err: opnsense.ErrUnreachable}, &fakeDir{dn: "CN=x"})
|
|
rep := c.Run(context.Background(), "")
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, "nicht prüfbar") {
|
|
t.Errorf("Zeitprüfung muss als nicht prüfbar ausgewiesen werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestTestAuthShowsLookupResult(t *testing.T) {
|
|
dir := &fakeDir{
|
|
dn: "CN=VPN-Users,DC=firma,DC=local",
|
|
lookup: &auth.LookupResult{
|
|
DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true},
|
|
}
|
|
c := newChecker(t, &fakePinger{at: checkNow}, dir)
|
|
rep := c.Run(context.Background(), "mmueller")
|
|
|
|
out := report(t, rep)
|
|
for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTestAuthReportsMissingGroupMembership(t *testing.T) {
|
|
dir := &fakeDir{
|
|
dn: "CN=VPN-Users,DC=firma,DC=local",
|
|
lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false},
|
|
}
|
|
c := newChecker(t, &fakePinger{at: checkNow}, dir)
|
|
rep := c.Run(context.Background(), "jdoe")
|
|
if rep.OK() {
|
|
t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten")
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "NEIN") {
|
|
t.Errorf("das Ergebnis muss deutlich benannt werden:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestTestAuthSkippedWhenEmpty(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
rep := c.Run(context.Background(), "")
|
|
if strings.Contains(report(t, rep), "Testanmeldung") {
|
|
t.Error("ohne --test-auth darf kein Testanmelde-Punkt erscheinen")
|
|
}
|
|
}
|
|
|
|
func TestReportOutputFormat(t *testing.T) {
|
|
rep := &Report{Results: []Result{
|
|
{Name: "Konfiguration", OK: true},
|
|
{Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"},
|
|
}}
|
|
out := report(t, rep)
|
|
if !strings.Contains(out, "✓ Konfiguration") {
|
|
t.Errorf("Erfolgszeile falsch:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") {
|
|
t.Errorf("Fehlerzeile falsch:\n%s", out)
|
|
}
|
|
if rep.OK() {
|
|
t.Error("ein ✗ muss OK() falsch machen")
|
|
}
|
|
}
|
|
|
|
func TestInvalidConfigIsReported(t *testing.T) {
|
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
|
c.Cfg.AD.Servers = []string{"10.1.1.10"} // IP statt Hostname
|
|
rep := c.Run(context.Background(), "")
|
|
if rep.OK() {
|
|
t.Fatal("ungültige Konfiguration muss auffallen")
|
|
}
|
|
if out := report(t, rep); !strings.Contains(out, "10.1.1.10") {
|
|
t.Errorf("der beanstandete Wert muss genannt werden:\n%s", out)
|
|
}
|
|
}
|