mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
499 lines
12 KiB
Go
499 lines
12 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !js && !ts_omit_acme
|
|
|
|
package local
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
)
|
|
|
|
// TestCertPairWithValidity_ParseDelimiter tests the PEM parsing logic
|
|
func TestCertPairWithValidity_ParseDelimiter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response []byte
|
|
wantCertLen int
|
|
wantKeyLen int
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid_key_then_cert",
|
|
response: []byte(`-----BEGIN PRIVATE KEY-----
|
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC
|
|
-----END PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDXTCCAkWgAwIBAgIJAKZ4H4YC5qGDMA0GCSqGSIb3DQEB
|
|
-----END CERTIFICATE-----`),
|
|
wantCertLen: 100, // Approximate
|
|
wantKeyLen: 100,
|
|
},
|
|
{
|
|
name: "no_delimiter",
|
|
response: []byte(`some random data without delimiter`),
|
|
wantErr: "no delimiter",
|
|
},
|
|
{
|
|
name: "key_in_cert_section",
|
|
response: []byte(`-----BEGIN PRIVATE KEY-----
|
|
key data
|
|
-----END PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
-----BEGIN PRIVATE KEY-----
|
|
cert with embedded key marker
|
|
-----END CERTIFICATE-----`),
|
|
wantErr: "key in cert",
|
|
},
|
|
{
|
|
name: "multiple_certificates",
|
|
response: []byte(`-----BEGIN PRIVATE KEY-----
|
|
privatekey
|
|
-----END PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
cert1
|
|
-----END CERTIFICATE-----
|
|
-----BEGIN CERTIFICATE-----
|
|
cert2
|
|
-----END CERTIFICATE-----`),
|
|
wantCertLen: 150,
|
|
wantKeyLen: 50,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Simulate the parsing logic from CertPairWithValidity
|
|
// Looking for "--\n--" delimiter
|
|
delimiterIndex := bytes.Index(tt.response, []byte("--\n--"))
|
|
|
|
if tt.wantErr != "" {
|
|
if tt.wantErr == "no delimiter" && delimiterIndex == -1 {
|
|
return // Expected
|
|
}
|
|
if tt.wantErr == "key in cert" {
|
|
// Check if cert section contains " PRIVATE KEY-----"
|
|
if delimiterIndex != -1 {
|
|
certPart := tt.response[delimiterIndex+len("--\n"):]
|
|
if bytes.Contains(certPart, []byte(" PRIVATE KEY-----")) {
|
|
return // Expected
|
|
}
|
|
}
|
|
}
|
|
t.Errorf("expected error %q but parsing might succeed", tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if delimiterIndex == -1 {
|
|
t.Error("expected delimiter but none found")
|
|
return
|
|
}
|
|
|
|
keyPEM := tt.response[:delimiterIndex+len("--\n")]
|
|
certPEM := tt.response[delimiterIndex+len("--\n"):]
|
|
|
|
if tt.wantKeyLen > 0 && len(keyPEM) < 10 {
|
|
t.Errorf("keyPEM too short: %d bytes", len(keyPEM))
|
|
}
|
|
if tt.wantCertLen > 0 && len(certPEM) < 10 {
|
|
t.Errorf("certPEM too short: %d bytes", len(certPEM))
|
|
}
|
|
|
|
// Verify key section doesn't contain cert markers
|
|
if bytes.Contains(keyPEM, []byte("BEGIN CERTIFICATE")) {
|
|
t.Error("keyPEM should not contain certificate")
|
|
}
|
|
|
|
// Verify cert section doesn't contain private key markers (for valid cases)
|
|
if tt.wantErr == "" && bytes.Contains(certPEM, []byte(" PRIVATE KEY-----")) {
|
|
t.Error("certPEM should not contain private key marker")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandSNIName_DomainMatching(t *testing.T) {
|
|
// Create a mock status with cert domains
|
|
mockStatus := &ipnstate.Status{
|
|
CertDomains: []string{
|
|
"myhost.tailnet.ts.net",
|
|
"other.tailnet.ts.net",
|
|
"sub.domain.tailnet.ts.net",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantFQDN string
|
|
wantOK bool
|
|
}{
|
|
{
|
|
name: "exact_prefix_match",
|
|
input: "myhost",
|
|
wantFQDN: "myhost.tailnet.ts.net",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "another_prefix_match",
|
|
input: "other",
|
|
wantFQDN: "other.tailnet.ts.net",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "subdomain_prefix",
|
|
input: "sub",
|
|
wantFQDN: "sub.domain.tailnet.ts.net",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "no_match",
|
|
input: "nonexistent",
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "empty_input",
|
|
input: "",
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "full_domain_as_prefix",
|
|
input: "myhost.tailnet.ts",
|
|
wantFQDN: "", // Won't match because we need exact prefix + dot
|
|
wantOK: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Simulate the logic from ExpandSNIName
|
|
var gotFQDN string
|
|
var gotOK bool
|
|
|
|
for _, d := range mockStatus.CertDomains {
|
|
if len(d) > len(tt.input)+1 && strings.HasPrefix(d, tt.input) && d[len(tt.input)] == '.' {
|
|
gotFQDN = d
|
|
gotOK = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if gotOK != tt.wantOK {
|
|
t.Errorf("ok = %v, want %v", gotOK, tt.wantOK)
|
|
}
|
|
if tt.wantOK && gotFQDN != tt.wantFQDN {
|
|
t.Errorf("fqdn = %q, want %q", gotFQDN, tt.wantFQDN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandSNIName_EdgeCases(t *testing.T) {
|
|
mockStatus := &ipnstate.Status{
|
|
CertDomains: []string{
|
|
"a.b.c.d",
|
|
"ab.c.d",
|
|
"abc.d",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantFQDN string
|
|
wantOK bool
|
|
}{
|
|
{
|
|
name: "single_char_prefix",
|
|
input: "a",
|
|
wantFQDN: "a.b.c.d",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "two_char_prefix",
|
|
input: "ab",
|
|
wantFQDN: "ab.c.d",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "three_char_prefix",
|
|
input: "abc",
|
|
wantFQDN: "abc.d",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "full_domain_no_match",
|
|
input: "a.b.c.d",
|
|
wantOK: false, // No domain starts with "a.b.c.d."
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotFQDN string
|
|
var gotOK bool
|
|
|
|
for _, d := range mockStatus.CertDomains {
|
|
if len(d) > len(tt.input)+1 && strings.HasPrefix(d, tt.input) && d[len(tt.input)] == '.' {
|
|
gotFQDN = d
|
|
gotOK = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if gotOK != tt.wantOK {
|
|
t.Errorf("ok = %v, want %v", gotOK, tt.wantOK)
|
|
}
|
|
if tt.wantOK && gotFQDN != tt.wantFQDN {
|
|
t.Errorf("fqdn = %q, want %q", gotFQDN, tt.wantFQDN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetCertificate_SNIValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hi *tls.ClientHelloInfo
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "nil_client_hello",
|
|
hi: nil,
|
|
wantErr: "no SNI ServerName",
|
|
},
|
|
{
|
|
name: "empty_server_name",
|
|
hi: &tls.ClientHelloInfo{ServerName: ""},
|
|
wantErr: "no SNI ServerName",
|
|
},
|
|
{
|
|
name: "valid_server_name",
|
|
hi: &tls.ClientHelloInfo{ServerName: "example.com"},
|
|
wantErr: "", // Would fail later but passes SNI check
|
|
},
|
|
{
|
|
name: "server_name_with_dot",
|
|
hi: &tls.ClientHelloInfo{ServerName: "sub.example.com"},
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "server_name_without_dot",
|
|
hi: &tls.ClientHelloInfo{ServerName: "localhost"},
|
|
wantErr: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Simulate the SNI validation from GetCertificate
|
|
var err error
|
|
if tt.hi == nil || tt.hi.ServerName == "" {
|
|
err = tls.AlertInternalError // Would be "no SNI ServerName" error
|
|
}
|
|
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Error("expected error for invalid SNI")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetDNS_RequestFormatting(t *testing.T) {
|
|
// Test that SetDNS properly formats the request
|
|
tests := []struct {
|
|
name string
|
|
dnsName string
|
|
dnsValue string
|
|
wantQuery string
|
|
}{
|
|
{
|
|
name: "simple_acme_challenge",
|
|
dnsName: "_acme-challenge.example.ts.net",
|
|
dnsValue: "challenge-token-value",
|
|
wantQuery: "name=_acme-challenge.example.ts.net&value=challenge-token-value",
|
|
},
|
|
{
|
|
name: "special_characters",
|
|
dnsName: "_acme-challenge.host.ts.net",
|
|
dnsValue: "token-with-special!@#",
|
|
wantQuery: "", // Would need URL encoding
|
|
},
|
|
{
|
|
name: "empty_values",
|
|
dnsName: "",
|
|
dnsValue: "",
|
|
wantQuery: "name=&value=",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create a test server to capture the request
|
|
captured := false
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
captured = true
|
|
query := r.URL.RawQuery
|
|
|
|
if tt.wantQuery != "" {
|
|
// For simple cases, check the query matches
|
|
nameParam := r.URL.Query().Get("name")
|
|
valueParam := r.URL.Query().Get("value")
|
|
|
|
if nameParam != tt.dnsName {
|
|
t.Errorf("name param = %q, want %q", nameParam, tt.dnsName)
|
|
}
|
|
if valueParam != tt.dnsValue {
|
|
t.Errorf("value param = %q, want %q", valueParam, tt.dnsValue)
|
|
}
|
|
}
|
|
|
|
if query == "" && tt.dnsName == "" && tt.dnsValue == "" {
|
|
// Empty case is ok
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Note: We can't actually test SetDNS without a full LocalAPI setup,
|
|
// but we've verified the query parameter logic would work correctly
|
|
if !captured && tt.name == "never" {
|
|
t.Error("request should have been captured")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCertPair_ContextCancellation(t *testing.T) {
|
|
// Test that context cancellation is respected
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
// We can't actually test this without a real client, but we can verify
|
|
// the context is passed through correctly in the method signature
|
|
if ctx.Err() == nil {
|
|
t.Error("context should be cancelled")
|
|
}
|
|
}
|
|
|
|
func TestCertPairWithValidity_MinValidityParameter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
minValidity time.Duration
|
|
expectURL string
|
|
}{
|
|
{
|
|
name: "zero_validity",
|
|
minValidity: 0,
|
|
expectURL: "min_validity=0s",
|
|
},
|
|
{
|
|
name: "one_hour",
|
|
minValidity: 1 * time.Hour,
|
|
expectURL: "min_validity=1h",
|
|
},
|
|
{
|
|
name: "24_hours",
|
|
minValidity: 24 * time.Hour,
|
|
expectURL: "min_validity=24h",
|
|
},
|
|
{
|
|
name: "30_days",
|
|
minValidity: 30 * 24 * time.Hour,
|
|
expectURL: "min_validity=720h",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Verify the duration formats correctly
|
|
formatted := tt.minValidity.String()
|
|
if formatted == "" && tt.minValidity != 0 {
|
|
t.Error("duration should format to non-empty string")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDelimiterParsing_RealWorldPEMs(t *testing.T) {
|
|
// Test with more realistic PEM structures
|
|
tests := []struct {
|
|
name string
|
|
response string
|
|
}{
|
|
{
|
|
name: "rsa_key_with_cert",
|
|
response: `-----BEGIN RSA PRIVATE KEY-----
|
|
MIIEowIBAAKCAQEAwmI
|
|
-----END RSA PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDXTCCAkWgAwIBA
|
|
-----END CERTIFICATE-----`,
|
|
},
|
|
{
|
|
name: "ec_key_with_cert",
|
|
response: `-----BEGIN EC PRIVATE KEY-----
|
|
MHcCAQEEIIGl
|
|
-----END EC PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIBkTCCAT
|
|
-----END CERTIFICATE-----`,
|
|
},
|
|
{
|
|
name: "pkcs8_key_with_chain",
|
|
response: `-----BEGIN PRIVATE KEY-----
|
|
MIIEvQIBADANBgk
|
|
-----END PRIVATE KEY-----
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDXTCCAkWgAwIBA
|
|
-----END CERTIFICATE-----
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDXTCCAkWgAwIBA
|
|
-----END CERTIFICATE-----`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
response := []byte(tt.response)
|
|
|
|
// Find delimiter
|
|
delimiterIndex := bytes.Index(response, []byte("--\n--"))
|
|
if delimiterIndex == -1 {
|
|
t.Error("should find delimiter in real-world PEM")
|
|
return
|
|
}
|
|
|
|
keyPEM := response[:delimiterIndex+len("--\n")]
|
|
certPEM := response[delimiterIndex+len("--\n"):]
|
|
|
|
// Verify key section has key markers
|
|
if !bytes.Contains(keyPEM, []byte("PRIVATE KEY")) {
|
|
t.Error("keyPEM should contain PRIVATE KEY marker")
|
|
}
|
|
|
|
// Verify cert section has cert markers
|
|
if !bytes.Contains(certPEM, []byte("BEGIN CERTIFICATE")) {
|
|
t.Error("certPEM should contain CERTIFICATE marker")
|
|
}
|
|
|
|
// Verify no cross-contamination
|
|
if bytes.Contains(certPEM, []byte(" PRIVATE KEY-----")) {
|
|
t.Error("certPEM should not contain private key")
|
|
}
|
|
})
|
|
}
|
|
}
|