software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+6 -6
View File
@@ -3,12 +3,12 @@
//
// - Server: selbstsigniertes ECDSA-P-256-Zertifikat, einmalig erzeugt und
// dauerhaft im Datenverzeichnis abgelegt; nur TLS 1.3.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint
// (known_servers). Einen blinden Erstkontakt gibt es nicht: Beim ersten
// Verbinden muss der Fingerprint aus dem Enrollment-Token passen.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>. Der Server
// speichert nur das Secret (gehasht), der Agent nutzt den Fingerprint
// für den Erstkontakt.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint, unabhängig
// von der Adresse. Einen blinden Erstkontakt gibt es nicht: Der
// Fingerprint kommt aus der Einladung, danach aus node.json.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>[.<server-url>].
// Der Server speichert nur das Secret (gehasht), der Agent nutzt
// Fingerprint und Adresse für den Erstkontakt.
package crypto
import (
+21 -118
View File
@@ -1,142 +1,45 @@
package crypto
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"os"
"strings"
"sync"
)
var (
// ErrUnknownServer: Kein Pin und kein erwarteter Fingerprint – ein
// ungeprüfter Erstkontakt wird verweigert.
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus dem Enrollment-Token erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin bzw.
// zum erwarteten Fingerprint (möglicher Man-in-the-Middle).
// ErrUnknownServer: Kein Fingerprint bekannt – ein ungeprüfter Kontakt
// wird verweigert (kein blindes TOFU).
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus der Einladung erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin
// (möglicher Man-in-the-Middle).
ErrFingerprintMismatch = errors.New("crypto: Server-Fingerprint stimmt nicht – möglicher Man-in-the-Middle")
// ErrCorruptKnownServers: Die known_servers-Datei ist nicht lesbar.
ErrCorruptKnownServers = errors.New("crypto: known_servers beschädigt")
)
// KnownServers ist die Pin-Datei des Agenten (analog zu SSH known_hosts).
// Format: eine Zeile "<server> <fingerprint>", # für Kommentare. Einträge
// werden nur angehängt, nie stillschweigend geändert.
type KnownServers struct {
Path string
mu sync.Mutex
}
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.lookup(server)
}
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
data, err := os.ReadFile(k.Path)
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
sc := bufio.NewScanner(bytes.NewReader(data))
for line := 1; sc.Scan(); line++ {
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
fields := strings.Fields(text)
if len(fields) != 2 {
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
}
fp, err := ParseFingerprint(fields[1])
if err != nil {
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
}
if fields[0] == server {
return fp, true, nil
}
}
return "", false, sc.Err()
}
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
// liefert Add ErrFingerprintMismatch und ändert nichts.
func (k *KnownServers) Add(server string, fp Fingerprint) error {
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
return fmt.Errorf("crypto: ungültiger Servername %q", server)
}
if _, err := ParseFingerprint(string(fp)); err != nil {
return err
}
k.mu.Lock()
defer k.mu.Unlock()
pinned, ok, err := k.lookup(server)
if err != nil {
return err
}
if ok {
if pinned != fp {
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
}
return nil
}
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
// (host:port aus der --server-URL). Statt einer CA prüft sie den
// Fingerprint:
// PinnedTLSConfig liefert die TLS-Konfiguration des Agenten. Statt einer CA
// wird der Fingerprint des Server-Zertifikats geprüft – unabhängig von der
// Adresse, über die der Server erreicht wird (DNS, interne IP, NetBird):
// Derselbe Server hat überall denselben Fingerprint.
//
// - Pin vorhanden: Das Zertifikat muss dazu passen (expected wird ignoriert).
// - Kein Pin, expected gesetzt (Fingerprint aus dem Enrollment-Token):
// Das Zertifikat muss dazu passen und wird dann gepinnt.
// - Kein Pin, kein expected: Verbindung wird verweigert (ErrUnknownServer).
//
// Die Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte
// (Token, Signatur, Body) gesendet wird. Nur TLS 1.3.
func ClientTLSConfig(server string, ks *KnownServers, expected Fingerprint) *tls.Config {
// Beim Erstkontakt kommt want aus der Einladung, danach aus node.json. Die
// Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte (Token,
// Signatur, Body) gesendet wird. Nur TLS 1.3.
func PinnedTLSConfig(want Fingerprint) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie
// durch die strengere Fingerprint-Prüfung.
// Die CA- und Hostnamen-Prüfung entfällt bewusst; VerifyConnection
// ersetzt sie durch die strengere Fingerprint-Prüfung.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
if want == "" {
return ErrUnknownServer
}
if len(cs.PeerCertificates) == 0 {
return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch)
}
actual := FingerprintOf(cs.PeerCertificates[0])
pinned, ok, err := ks.Lookup(server)
if err != nil {
return err
if got := FingerprintOf(cs.PeerCertificates[0]); got != want {
return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want)
}
if ok {
if actual != pinned {
return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
}
return nil
}
if expected == "" {
return fmt.Errorf("%w (%s präsentiert %s)", ErrUnknownServer, server, actual)
}
if actual != expected {
return fmt.Errorf("%w: %s präsentiert %s, Token erwartet %s", ErrFingerprintMismatch, server, actual, expected)
}
return ks.Add(server, actual)
return nil
},
}
}
+33 -134
View File
@@ -7,78 +7,10 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func newKnownServers(t *testing.T) *KnownServers {
t.Helper()
return &KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
}
func TestKnownServers(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
other, _ := LoadOrCreateServerCert(t.TempDir())
if _, ok, err := ks.Lookup("ingress:8443"); ok || err != nil {
t.Fatalf("leere Datei: ok=%v err=%v", ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Fatalf("Add: %v", err)
}
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Errorf("gleichen Eintrag erneut hinzufügen: %v", err)
}
if err := ks.Add("ingress:8443", other.Fingerprint); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("Pin überschreiben: erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if err := ks.Add("anderer:8443", other.Fingerprint); err != nil {
t.Errorf("zweiter Server: %v", err)
}
data, _ := os.ReadFile(ks.Path)
if n := strings.Count(string(data), "ingress:8443"); n != 1 {
t.Errorf("ingress:8443 steht %d-mal in der Datei", n)
}
info, _ := os.Stat(ks.Path)
if info.Mode().Perm() != 0o600 {
t.Errorf("Dateirechte %o, erwartet 600", info.Mode().Perm())
}
}
func TestKnownServers_FileFormat(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
content := "# Kommentar\n\n ingress:8443 " + string(sc.Fingerprint) + " \n"
os.WriteFile(ks.Path, []byte(content), 0o600)
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
for name, bad := range map[string]string{
"ein Feld": "ingress:8443\n",
"drei Felder": "ingress:8443 " + string(sc.Fingerprint) + " x\n",
"kaputter Fingerprint": "ingress:8443 abc\n",
} {
t.Run(name, func(t *testing.T) {
os.WriteFile(ks.Path, []byte(bad), 0o600)
if _, _, err := ks.Lookup("ingress:8443"); !errors.Is(err, ErrCorruptKnownServers) {
t.Errorf("erwartet ErrCorruptKnownServers, bekommen %v", err)
}
})
}
if err := ks.Add("mit leerzeichen", sc.Fingerprint); err == nil {
t.Error("Servername mit Leerzeichen akzeptiert")
}
}
// tlsServer startet einen Test-Server mit dem Zertifikat aus einem eigenen
// Datenverzeichnis und zählt, ob ein Request ihn erreicht.
func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
@@ -98,96 +30,63 @@ func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
return srv, sc, &hits
}
func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error {
func get(t *testing.T, url string, cfg *tls.Config) error {
t.Helper()
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
resp, err := c.Get(srv.URL)
resp, err := c.Get(url)
if err == nil {
resp.Body.Close()
}
return err
}
func TestClientTLS_FirstContactWithExpectedFingerprint(t *testing.T) {
func TestPinnedTLS_Matches(t *testing.T) {
srv, sc, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, sc.Fingerprint)); err != nil {
t.Fatalf("erster Kontakt mit korrektem Fingerprint: %v", err)
if err := get(t, srv.URL, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Fatalf("passender Fingerprint: %v", err)
}
if hits.Load() != 1 {
t.Error("Request kam nicht an")
}
if fp, ok, _ := ks.Lookup(addr); !ok || fp != sc.Fingerprint {
t.Errorf("Pin nicht gespeichert: %q %v", fp, ok)
}
// Folgekontakt ohne Token-Fingerprint: der Pin genügt.
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); err != nil {
t.Errorf("Folgekontakt mit Pin: %v", err)
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter einer
// anderen Adresse (hier localhost statt 127.0.0.1) ist weiterhin vertrauenswürdig.
func TestPinnedTLS_AddressIndependent(t *testing.T) {
srv, sc, _ := tlsServer(t)
port := srv.Listener.Addr().(interface{ String() string }).String()
port = port[len("127.0.0.1"):]
if err := get(t, "https://localhost"+port, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Errorf("andere Adresse, gleicher Server: %v", err)
}
}
// Kein blindes TOFU: Ohne Pin und ohne erwarteten Fingerprint wird nicht verbunden.
func TestClientTLS_NoBlindFirstContact(t *testing.T) {
srv, _, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request hat den Server erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("unbekannter Server wurde gepinnt")
}
}
// Angreifer beim Erstkontakt: Er präsentiert sein eigenes Zertifikat, der
// Token enthält aber den Fingerprint des echten Servers.
func TestClientTLS_MITMOnFirstContact(t *testing.T) {
// Angreifer mit eigenem Zertifikat: Abbruch im Handshake, kein Request
// (also auch kein Token, keine Signatur) erreicht ihn.
func TestPinnedTLS_Mismatch(t *testing.T) {
mitm, _, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := get(t, mitm, ClientTLSConfig(addr, ks, real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
if err := get(t, mitm.URL, PinnedTLSConfig(real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request (inkl. Token) hat den Angreifer erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("Angreifer wurde gepinnt")
}
}
// Angreifer nach dem Erstkontakt: Der Pin gewinnt, auch wenn ein (neuer)
// Token-Fingerprint auf den Angreifer passen würde.
func TestClientTLS_PinWinsLater(t *testing.T) {
mitm, mitmCert, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := ks.Add(addr, real.Fingerprint); err != nil {
t.Fatal(err)
}
for _, expected := range []Fingerprint{"", mitmCert.Fingerprint} {
if err := get(t, mitm, ClientTLSConfig(addr, ks, expected)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("expected=%q: erwartet ErrFingerprintMismatch, bekommen %v", expected, err)
}
}
if hits.Load() != 0 {
t.Error("Request hat den Angreifer erreicht")
}
}
func TestClientTLSConfig_OnlyTLS13(t *testing.T) {
cfg := ClientTLSConfig("x:1", newKnownServers(t), "")
if cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
// Kein blindes Vertrauen: ohne erwarteten Fingerprint keine Verbindung.
func TestPinnedTLS_NoFingerprint(t *testing.T) {
srv, _, hits := tlsServer(t)
if err := get(t, srv.URL, PinnedTLSConfig("")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request erreichte den Server")
}
}
func TestPinnedTLSConfig_OnlyTLS13(t *testing.T) {
if cfg := PinnedTLSConfig("x"); cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x", cfg.MinVersion)
}
}
+89 -12
View File
@@ -5,49 +5,80 @@ import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// TokenPrefix kennzeichnet Enrollment-Tokens.
const TokenPrefix = "sltn_enroll_"
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>.
var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
var (
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>[.<server-url>].
ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
// ErrInvalidServerURL: keine reine https://host[:port]-Adresse.
ErrInvalidServerURL = errors.New("crypto: ungültige Server-URL")
)
const maxServerURLLen = 255
// EnrollmentToken ist die Einladung für einen neuen Agenten.
//
// Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur
// den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment.
// Fingerprint ist nicht geheim; er sagt dem Agenten, welches
// Server-Zertifikat er beim Erstkontakt erwarten muss.
// Server-Zertifikat er erwarten muss. ServerURL (optional, nicht geheim) ist
// die Adresse, unter der der Agent den Server erreicht – eine falsche
// Adresse führt dank Fingerprint-Prüfung höchstens zu einem
// Verbindungsfehler, nie zu einem falschen Server.
type EnrollmentToken struct {
Secret string
Fingerprint Fingerprint
ServerURL string
}
// String liefert die Form, die der Admin an den Agenten weitergibt.
func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) }
// String liefert die Form, die der Admin an den Agenten weitergibt. Alle
// Teile sind Base64url – der Token ist ohne Quoting shell-sicher.
func (t EnrollmentToken) String() string {
s := t.Secret + "." + string(t.Fingerprint)
if t.ServerURL != "" {
s += "." + base64.RawURLEncoding.EncodeToString([]byte(t.ServerURL))
}
return s
}
// NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server
// mit Fingerprint fp.
func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) {
// mit Fingerprint fp; serverURL ist optional ("" = nicht einbetten).
func NewEnrollmentToken(fp Fingerprint, serverURL string) (EnrollmentToken, error) {
if _, err := ParseFingerprint(string(fp)); err != nil {
return EnrollmentToken{}, err
}
if serverURL != "" {
u, err := ValidateServerURL(serverURL)
if err != nil {
return EnrollmentToken{}, err
}
serverURL = u
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return EnrollmentToken{}, err
}
return EnrollmentToken{Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp}, nil
return EnrollmentToken{
Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]),
Fingerprint: fp,
ServerURL: serverURL,
}, nil
}
// ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim
// Kopieren) wird ignoriert.
func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) != 2 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>", ErrInvalidToken, TokenPrefix)
if len(parts) != 2 && len(parts) != 3 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>[.<server-url>]", ErrInvalidToken, TokenPrefix)
}
secret, rawFP := parts[0], parts[1]
body, ok := strings.CutPrefix(secret, TokenPrefix)
@@ -61,5 +92,51 @@ func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
if err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil
tok := EnrollmentToken{Secret: secret, Fingerprint: fp}
if len(parts) == 3 {
raw, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(raw) == 0 {
return EnrollmentToken{}, fmt.Errorf("%w: Server-URL beschädigt", ErrInvalidToken)
}
if tok.ServerURL, err = ValidateServerURL(string(raw)); err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
}
return tok, nil
}
// ValidateServerURL prüft eine Server-Adresse und normalisiert sie: nur
// https, nur Host und optional Port (kein Benutzer, Pfad, Query, Fragment),
// Host klein, ohne Schrägstrich am Ende.
func ValidateServerURL(raw string) (string, error) {
bad := func(why string) (string, error) {
return "", fmt.Errorf("%w: %q – %s (erwartet https://host[:port])", ErrInvalidServerURL, raw, why)
}
if len(raw) > maxServerURLLen {
return bad("zu lang")
}
u, err := url.Parse(strings.TrimSpace(raw))
switch {
case err != nil:
return bad("nicht lesbar")
case u.Scheme != "https":
return bad("nur https")
case u.Hostname() == "":
return bad("Host fehlt")
case u.User != nil:
return bad("keine Zugangsdaten in der URL")
case u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "":
return bad("kein Pfad, keine Query")
}
host := strings.ToLower(u.Hostname())
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return bad("ungültiger Port")
}
return "https://" + net.JoinHostPort(host, port), nil
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return "https://" + host, nil
}
+78 -4
View File
@@ -1,6 +1,7 @@
package crypto
import (
"encoding/base64"
"errors"
"strings"
"testing"
@@ -24,7 +25,7 @@ func TestFingerprint_ParseRoundTrip(t *testing.T) {
func TestEnrollmentToken_RoundTrip(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatalf("NewEnrollmentToken: %v", err)
}
@@ -52,7 +53,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
seen := map[string]bool{}
for range 100 {
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
@@ -65,7 +66,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
func TestEnrollmentToken_Errors(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
good, _ := NewEnrollmentToken(sc.Fingerprint)
good, _ := NewEnrollmentToken(sc.Fingerprint, "")
secretPart := strings.TrimPrefix(good.Secret, TokenPrefix)
fp := string(sc.Fingerprint)
@@ -86,7 +87,80 @@ func TestEnrollmentToken_Errors(t *testing.T) {
}
})
}
if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) {
if _, err := NewEnrollmentToken("kaputt", ""); !errors.Is(err, ErrInvalidFingerprint) {
t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err)
}
}
func TestEnrollmentToken_WithServerURL(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint, "https://Ingress.example.de:8443/")
if err != nil {
t.Fatal(err)
}
if tok.ServerURL != "https://ingress.example.de:8443" {
t.Errorf("URL nicht normalisiert: %q", tok.ServerURL)
}
s := tok.String()
if parts := strings.Split(s, "."); len(parts) != 3 {
t.Fatalf("erwartet drei Teile: %q", s)
}
if strings.ContainsAny(s, ":/?=+ ") {
t.Errorf("Token nicht shell-/kopiersicher: %q", s)
}
parsed, err := ParseEnrollmentToken(s)
if err != nil {
t.Fatalf("ParseEnrollmentToken: %v", err)
}
if parsed != tok {
t.Errorf("Round-Trip: %+v vs %+v", parsed, tok)
}
// Ohne URL: zweiteilig wie bisher.
plain, _ := NewEnrollmentToken(sc.Fingerprint, "")
if parsed, err := ParseEnrollmentToken(plain.String()); err != nil || parsed.ServerURL != "" {
t.Errorf("Token ohne URL: %+v %v", parsed, err)
}
if _, err := NewEnrollmentToken(sc.Fingerprint, "http://ingress:8443"); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("http-URL: %v", err)
}
}
func TestEnrollmentToken_InvalidServerURLPart(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
base, _ := NewEnrollmentToken(sc.Fingerprint, "")
enc := func(u string) string { return base.String() + "." + base64.RawURLEncoding.EncodeToString([]byte(u)) }
for name, s := range map[string]string{
"kein base64url": base.String() + ".!!!",
"leer": base.String() + ".",
"http": enc("http://ingress:8443"),
"mit Pfad": enc("https://ingress:8443/admin"),
"mit Zugangsdaten": enc("https://user:pw@ingress:8443"),
"zu lang": enc("https://" + strings.Repeat("a", 300) + ".de"),
} {
if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) {
t.Errorf("%s: erwartet ErrInvalidToken, bekommen %v", name, err)
}
}
}
func TestValidateServerURL(t *testing.T) {
valid := map[string]string{
"https://ingress.x.de:8443": "https://ingress.x.de:8443",
"https://ingress.x.de:8443/": "https://ingress.x.de:8443",
"https://Ingress.X.de": "https://ingress.x.de",
"https://[fd00::1]:8443": "https://[fd00::1]:8443",
"https://100.64.0.1:8443": "https://100.64.0.1:8443",
}
for in, want := range valid {
if got, err := ValidateServerURL(in); err != nil || got != want {
t.Errorf("ValidateServerURL(%q) = %q, %v; erwartet %q", in, got, err, want)
}
}
for _, bad := range []string{"", "ingress:8443", "http://ingress:8443", "https://", "https://u:p@x:8443",
"https://x:8443/pfad", "https://x:8443?q=1", "https://x:8443#frag", "https://x:99999", "ftp://x"} {
if _, err := ValidateServerURL(bad); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("ValidateServerURL(%q): erwartet ErrInvalidServerURL, bekommen %v", bad, err)
}
}
}