mirror of
				https://github.com/caddyserver/caddy.git
				synced 2025-10-31 02:27:19 -04:00 
			
		
		
		
	Signed-off-by: joemicky <joemickychang@outlook.com> Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
		
			
				
	
	
		
			930 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			930 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright 2015 Matthew Holt and The Caddy Authors
 | |
| //
 | |
| // Licensed under the Apache License, Version 2.0 (the "License");
 | |
| // you may not use this file except in compliance with the License.
 | |
| // You may obtain a copy of the License at
 | |
| //
 | |
| //     http://www.apache.org/licenses/LICENSE-2.0
 | |
| //
 | |
| // Unless required by applicable law or agreed to in writing, software
 | |
| // distributed under the License is distributed on an "AS IS" BASIS,
 | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| // See the License for the specific language governing permissions and
 | |
| // limitations under the License.
 | |
| 
 | |
| package caddy
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"crypto/x509"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"maps"
 | |
| 	"net/http"
 | |
| 	"net/http/httptest"
 | |
| 	"reflect"
 | |
| 	"sync"
 | |
| 	"testing"
 | |
| 
 | |
| 	"github.com/caddyserver/certmagic"
 | |
| 	"github.com/prometheus/client_golang/prometheus"
 | |
| 	dto "github.com/prometheus/client_model/go"
 | |
| )
 | |
| 
 | |
| var testCfg = []byte(`{
 | |
| 			"apps": {
 | |
| 				"http": {
 | |
| 					"servers": {
 | |
| 						"myserver": {
 | |
| 							"listen": ["tcp/localhost:8080-8084"],
 | |
| 							"read_timeout": "30s"
 | |
| 						},
 | |
| 						"yourserver": {
 | |
| 							"listen": ["127.0.0.1:5000"],
 | |
| 							"read_header_timeout": "15s"
 | |
| 						}
 | |
| 					}
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 		`)
 | |
| 
 | |
| func TestUnsyncedConfigAccess(t *testing.T) {
 | |
| 	// each test is performed in sequence, so
 | |
| 	// each change builds on the previous ones;
 | |
| 	// the config is not reset between tests
 | |
| 	for i, tc := range []struct {
 | |
| 		method    string
 | |
| 		path      string // rawConfigKey will be prepended
 | |
| 		payload   string
 | |
| 		expect    string // JSON representation of what the whole config is expected to be after the request
 | |
| 		shouldErr bool
 | |
| 	}{
 | |
| 		{
 | |
| 			method:  "POST",
 | |
| 			path:    "",
 | |
| 			payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value
 | |
| 			expect:  `{"foo": "bar", "list": ["a", "b", "c"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "POST",
 | |
| 			path:    "/foo",
 | |
| 			payload: `"jet"`,
 | |
| 			expect:  `{"foo": "jet", "list": ["a", "b", "c"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "POST",
 | |
| 			path:    "/bar",
 | |
| 			payload: `{"aa": "bb", "qq": "zz"}`,
 | |
| 			expect:  `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method: "DELETE",
 | |
| 			path:   "/bar/qq",
 | |
| 			expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:    "DELETE",
 | |
| 			path:      "/bar/qq",
 | |
| 			expect:    `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
 | |
| 			shouldErr: true,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "POST",
 | |
| 			path:    "/list",
 | |
| 			payload: `"e"`,
 | |
| 			expect:  `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "PUT",
 | |
| 			path:    "/list/3",
 | |
| 			payload: `"d"`,
 | |
| 			expect:  `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method: "DELETE",
 | |
| 			path:   "/list/3",
 | |
| 			expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "PATCH",
 | |
| 			path:    "/list/3",
 | |
| 			payload: `"d"`,
 | |
| 			expect:  `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`,
 | |
| 		},
 | |
| 		{
 | |
| 			method:  "POST",
 | |
| 			path:    "/list/...",
 | |
| 			payload: `["e", "f", "g"]`,
 | |
| 			expect:  `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e", "f", "g"]}`,
 | |
| 		},
 | |
| 	} {
 | |
| 		err := unsyncedConfigAccess(tc.method, rawConfigKey+tc.path, []byte(tc.payload), nil)
 | |
| 
 | |
| 		if tc.shouldErr && err == nil {
 | |
| 			t.Fatalf("Test %d: Expected error return value, but got: %v", i, err)
 | |
| 		}
 | |
| 		if !tc.shouldErr && err != nil {
 | |
| 			t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err)
 | |
| 		}
 | |
| 
 | |
| 		// decode the expected config so we can do a convenient DeepEqual
 | |
| 		var expectedDecoded any
 | |
| 		err = json.Unmarshal([]byte(tc.expect), &expectedDecoded)
 | |
| 		if err != nil {
 | |
| 			t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err)
 | |
| 		}
 | |
| 
 | |
| 		// make sure the resulting config is as we expect it
 | |
| 		if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) {
 | |
| 			t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v",
 | |
| 				i, expectedDecoded, rawCfg[rawConfigKey])
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // TestLoadConcurrent exercises Load under concurrent conditions
 | |
| // and is most useful under test with `-race` enabled.
 | |
| func TestLoadConcurrent(t *testing.T) {
 | |
| 	var wg sync.WaitGroup
 | |
| 
 | |
| 	for i := 0; i < 100; i++ {
 | |
| 		wg.Add(1)
 | |
| 		go func() {
 | |
| 			_ = Load(testCfg, true)
 | |
| 			wg.Done()
 | |
| 		}()
 | |
| 	}
 | |
| 	wg.Wait()
 | |
| }
 | |
| 
 | |
| type fooModule struct {
 | |
| 	IntField int
 | |
| 	StrField string
 | |
| }
 | |
| 
 | |
| func (fooModule) CaddyModule() ModuleInfo {
 | |
| 	return ModuleInfo{
 | |
| 		ID:  "foo",
 | |
| 		New: func() Module { return new(fooModule) },
 | |
| 	}
 | |
| }
 | |
| func (fooModule) Start() error { return nil }
 | |
| func (fooModule) Stop() error  { return nil }
 | |
| 
 | |
| func TestETags(t *testing.T) {
 | |
| 	RegisterModule(fooModule{})
 | |
| 
 | |
| 	if err := Load([]byte(`{"admin": {"listen": "localhost:2999"}, "apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
 | |
| 		t.Fatalf("loading: %s", err)
 | |
| 	}
 | |
| 
 | |
| 	const key = "/" + rawConfigKey + "/apps/foo"
 | |
| 
 | |
| 	// try update the config with the wrong etag
 | |
| 	err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), fmt.Sprintf(`"/%s not_an_etag"`, rawConfigKey), false)
 | |
| 	if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
 | |
| 		t.Fatalf("expected precondition failed; got %v", err)
 | |
| 	}
 | |
| 
 | |
| 	// get the etag
 | |
| 	hash := etagHasher()
 | |
| 	if err := readConfig(key, hash); err != nil {
 | |
| 		t.Fatalf("reading: %s", err)
 | |
| 	}
 | |
| 
 | |
| 	// do the same update with the correct key
 | |
| 	err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), makeEtag(key, hash), false)
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("expected update to work; got %v", err)
 | |
| 	}
 | |
| 
 | |
| 	// now try another update. The hash should no longer match and we should get precondition failed
 | |
| 	err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), makeEtag(key, hash), false)
 | |
| 	if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
 | |
| 		t.Fatalf("expected precondition failed; got %v", err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func BenchmarkLoad(b *testing.B) {
 | |
| 	for b.Loop() {
 | |
| 		Load(testCfg, true)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestAdminHandlerErrorHandling(t *testing.T) {
 | |
| 	initAdminMetrics()
 | |
| 
 | |
| 	handler := adminHandler{
 | |
| 		mux: http.NewServeMux(),
 | |
| 	}
 | |
| 
 | |
| 	handler.mux.Handle("/error", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | |
| 		err := fmt.Errorf("test error")
 | |
| 		handler.handleError(w, r, err)
 | |
| 	}))
 | |
| 
 | |
| 	req := httptest.NewRequest(http.MethodGet, "/error", nil)
 | |
| 	rr := httptest.NewRecorder()
 | |
| 
 | |
| 	handler.ServeHTTP(rr, req)
 | |
| 
 | |
| 	if rr.Code == http.StatusOK {
 | |
| 		t.Error("expected error response, got success")
 | |
| 	}
 | |
| 
 | |
| 	var apiErr APIError
 | |
| 	if err := json.NewDecoder(rr.Body).Decode(&apiErr); err != nil {
 | |
| 		t.Fatalf("decoding response: %v", err)
 | |
| 	}
 | |
| 	if apiErr.Message != "test error" {
 | |
| 		t.Errorf("expected error message 'test error', got '%s'", apiErr.Message)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func initAdminMetrics() {
 | |
| 	if adminMetrics.requestErrors != nil {
 | |
| 		prometheus.Unregister(adminMetrics.requestErrors)
 | |
| 	}
 | |
| 	if adminMetrics.requestCount != nil {
 | |
| 		prometheus.Unregister(adminMetrics.requestCount)
 | |
| 	}
 | |
| 
 | |
| 	adminMetrics.requestErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
 | |
| 		Namespace: "caddy",
 | |
| 		Subsystem: "admin_http",
 | |
| 		Name:      "request_errors_total",
 | |
| 		Help:      "Number of errors that occurred handling admin endpoint requests",
 | |
| 	}, []string{"handler", "path", "method"})
 | |
| 
 | |
| 	adminMetrics.requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{
 | |
| 		Namespace: "caddy",
 | |
| 		Subsystem: "admin_http",
 | |
| 		Name:      "requests_total",
 | |
| 		Help:      "Count of requests to the admin endpoint",
 | |
| 	}, []string{"handler", "path", "code", "method"}) // Added code and method labels
 | |
| 
 | |
| 	prometheus.MustRegister(adminMetrics.requestErrors)
 | |
| 	prometheus.MustRegister(adminMetrics.requestCount)
 | |
| }
 | |
| 
 | |
| func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
 | |
| 	initAdminMetrics()
 | |
| 
 | |
| 	cfg := &Config{
 | |
| 		Admin: &AdminConfig{
 | |
| 			Listen: "localhost:2019",
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	err := replaceLocalAdminServer(cfg, Context{})
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("setting up admin server: %v", err)
 | |
| 	}
 | |
| 	defer func() {
 | |
| 		stopAdminServer(localAdminServer)
 | |
| 	}()
 | |
| 
 | |
| 	tests := []struct {
 | |
| 		name           string
 | |
| 		path           string
 | |
| 		method         string
 | |
| 		expectedStatus int
 | |
| 	}{
 | |
| 		{
 | |
| 			name:           "stop endpoint wrong method",
 | |
| 			path:           "/stop",
 | |
| 			method:         http.MethodGet,
 | |
| 			expectedStatus: http.StatusMethodNotAllowed,
 | |
| 		},
 | |
| 		{
 | |
| 			name:           "config endpoint wrong content-type",
 | |
| 			path:           "/config/",
 | |
| 			method:         http.MethodPost,
 | |
| 			expectedStatus: http.StatusBadRequest,
 | |
| 		},
 | |
| 		{
 | |
| 			name:           "config ID missing ID",
 | |
| 			path:           "/id/",
 | |
| 			method:         http.MethodGet,
 | |
| 			expectedStatus: http.StatusBadRequest,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for _, test := range tests {
 | |
| 		t.Run(test.name, func(t *testing.T) {
 | |
| 			req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil)
 | |
| 			rr := httptest.NewRecorder()
 | |
| 
 | |
| 			localAdminServer.Handler.ServeHTTP(rr, req)
 | |
| 
 | |
| 			if rr.Code != test.expectedStatus {
 | |
| 				t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code)
 | |
| 			}
 | |
| 
 | |
| 			metricValue := testGetMetricValue(map[string]string{
 | |
| 				"path":    test.path,
 | |
| 				"handler": "admin",
 | |
| 				"method":  test.method,
 | |
| 			})
 | |
| 			if metricValue != 1 {
 | |
| 				t.Errorf("expected error metric to be incremented once, got %v", metricValue)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func testGetMetricValue(labels map[string]string) float64 {
 | |
| 	promLabels := prometheus.Labels{}
 | |
| 	maps.Copy(promLabels, labels)
 | |
| 
 | |
| 	metric, err := adminMetrics.requestErrors.GetMetricWith(promLabels)
 | |
| 	if err != nil {
 | |
| 		return 0
 | |
| 	}
 | |
| 
 | |
| 	pb := &dto.Metric{}
 | |
| 	metric.Write(pb)
 | |
| 	return pb.GetCounter().GetValue()
 | |
| }
 | |
| 
 | |
| type mockRouter struct {
 | |
| 	routes []AdminRoute
 | |
| }
 | |
| 
 | |
| func (m mockRouter) Routes() []AdminRoute {
 | |
| 	return m.routes
 | |
| }
 | |
| 
 | |
| type mockModule struct {
 | |
| 	mockRouter
 | |
| }
 | |
| 
 | |
| func (m *mockModule) CaddyModule() ModuleInfo {
 | |
| 	return ModuleInfo{
 | |
| 		ID: "admin.api.mock",
 | |
| 		New: func() Module {
 | |
| 			mm := &mockModule{
 | |
| 				mockRouter: mockRouter{
 | |
| 					routes: m.routes,
 | |
| 				},
 | |
| 			}
 | |
| 			return mm
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestNewAdminHandlerRouterRegistration(t *testing.T) {
 | |
| 	originalModules := make(map[string]ModuleInfo)
 | |
| 	maps.Copy(originalModules, modules)
 | |
| 	defer func() {
 | |
| 		modules = originalModules
 | |
| 	}()
 | |
| 
 | |
| 	mockRoute := AdminRoute{
 | |
| 		Pattern: "/mock",
 | |
| 		Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
 | |
| 			w.WriteHeader(http.StatusOK)
 | |
| 			return nil
 | |
| 		}),
 | |
| 	}
 | |
| 
 | |
| 	mock := &mockModule{
 | |
| 		mockRouter: mockRouter{
 | |
| 			routes: []AdminRoute{mockRoute},
 | |
| 		},
 | |
| 	}
 | |
| 	RegisterModule(mock)
 | |
| 
 | |
| 	addr, err := ParseNetworkAddress("localhost:2019")
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("Failed to parse address: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	admin := &AdminConfig{
 | |
| 		EnforceOrigin: false,
 | |
| 	}
 | |
| 	handler := admin.newAdminHandler(addr, false, Context{})
 | |
| 
 | |
| 	req := httptest.NewRequest("GET", "/mock", nil)
 | |
| 	req.Host = "localhost:2019"
 | |
| 	rr := httptest.NewRecorder()
 | |
| 
 | |
| 	handler.ServeHTTP(rr, req)
 | |
| 
 | |
| 	if rr.Code != http.StatusOK {
 | |
| 		t.Errorf("Expected status code %d but got %d", http.StatusOK, rr.Code)
 | |
| 		t.Logf("Response body: %s", rr.Body.String())
 | |
| 	}
 | |
| 
 | |
| 	if len(admin.routers) != 1 {
 | |
| 		t.Errorf("Expected 1 router to be stored, got %d", len(admin.routers))
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type mockProvisionableRouter struct {
 | |
| 	mockRouter
 | |
| 	provisionErr error
 | |
| 	provisioned  bool
 | |
| }
 | |
| 
 | |
| func (m *mockProvisionableRouter) Provision(Context) error {
 | |
| 	m.provisioned = true
 | |
| 	return m.provisionErr
 | |
| }
 | |
| 
 | |
| type mockProvisionableModule struct {
 | |
| 	*mockProvisionableRouter
 | |
| }
 | |
| 
 | |
| func (m *mockProvisionableModule) CaddyModule() ModuleInfo {
 | |
| 	return ModuleInfo{
 | |
| 		ID: "admin.api.mock_provision",
 | |
| 		New: func() Module {
 | |
| 			mm := &mockProvisionableModule{
 | |
| 				mockProvisionableRouter: &mockProvisionableRouter{
 | |
| 					mockRouter:   m.mockRouter,
 | |
| 					provisionErr: m.provisionErr,
 | |
| 				},
 | |
| 			}
 | |
| 			return mm
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestAdminRouterProvisioning(t *testing.T) {
 | |
| 	tests := []struct {
 | |
| 		name         string
 | |
| 		provisionErr error
 | |
| 		wantErr      bool
 | |
| 		routersAfter int // expected number of routers after provisioning
 | |
| 	}{
 | |
| 		{
 | |
| 			name:         "successful provisioning",
 | |
| 			provisionErr: nil,
 | |
| 			wantErr:      false,
 | |
| 			routersAfter: 0,
 | |
| 		},
 | |
| 		{
 | |
| 			name:         "provisioning error",
 | |
| 			provisionErr: fmt.Errorf("provision failed"),
 | |
| 			wantErr:      true,
 | |
| 			routersAfter: 1,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for _, test := range tests {
 | |
| 		t.Run(test.name, func(t *testing.T) {
 | |
| 			originalModules := make(map[string]ModuleInfo)
 | |
| 			maps.Copy(originalModules, modules)
 | |
| 			defer func() {
 | |
| 				modules = originalModules
 | |
| 			}()
 | |
| 
 | |
| 			mockRoute := AdminRoute{
 | |
| 				Pattern: "/mock",
 | |
| 				Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
 | |
| 					return nil
 | |
| 				}),
 | |
| 			}
 | |
| 
 | |
| 			// Create provisionable module
 | |
| 			mock := &mockProvisionableModule{
 | |
| 				mockProvisionableRouter: &mockProvisionableRouter{
 | |
| 					mockRouter: mockRouter{
 | |
| 						routes: []AdminRoute{mockRoute},
 | |
| 					},
 | |
| 					provisionErr: test.provisionErr,
 | |
| 				},
 | |
| 			}
 | |
| 			RegisterModule(mock)
 | |
| 
 | |
| 			admin := &AdminConfig{}
 | |
| 			addr, err := ParseNetworkAddress("localhost:2019")
 | |
| 			if err != nil {
 | |
| 				t.Fatalf("Failed to parse address: %v", err)
 | |
| 			}
 | |
| 
 | |
| 			_ = admin.newAdminHandler(addr, false, Context{})
 | |
| 			err = admin.provisionAdminRouters(Context{})
 | |
| 
 | |
| 			if test.wantErr {
 | |
| 				if err == nil {
 | |
| 					t.Error("Expected error but got nil")
 | |
| 				}
 | |
| 			} else {
 | |
| 				if err != nil {
 | |
| 					t.Errorf("Expected no error but got: %v", err)
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			if len(admin.routers) != test.routersAfter {
 | |
| 				t.Errorf("Expected %d routers after provisioning, got %d", test.routersAfter, len(admin.routers))
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestAllowedOriginsUnixSocket(t *testing.T) {
 | |
| 	// see comment in allowedOrigins() as to why we do not fill out allowed origins for UDS
 | |
| 	tests := []struct {
 | |
| 		name          string
 | |
| 		addr          NetworkAddress
 | |
| 		origins       []string
 | |
| 		expectOrigins []string
 | |
| 	}{
 | |
| 		{
 | |
| 			name: "unix socket with default origins",
 | |
| 			addr: NetworkAddress{
 | |
| 				Network: "unix",
 | |
| 				Host:    "/tmp/caddy.sock",
 | |
| 			},
 | |
| 			origins:       nil, // default origins
 | |
| 			expectOrigins: []string{},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "unix socket with custom origins",
 | |
| 			addr: NetworkAddress{
 | |
| 				Network: "unix",
 | |
| 				Host:    "/tmp/caddy.sock",
 | |
| 			},
 | |
| 			origins: []string{"example.com"},
 | |
| 			expectOrigins: []string{
 | |
| 				"example.com",
 | |
| 			},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "tcp socket on localhost gets all loopback addresses",
 | |
| 			addr: NetworkAddress{
 | |
| 				Network:   "tcp",
 | |
| 				Host:      "localhost",
 | |
| 				StartPort: 2019,
 | |
| 				EndPort:   2019,
 | |
| 			},
 | |
| 			origins: nil,
 | |
| 			expectOrigins: []string{
 | |
| 				"localhost:2019",
 | |
| 				"[::1]:2019",
 | |
| 				"127.0.0.1:2019",
 | |
| 			},
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for i, test := range tests {
 | |
| 		t.Run(test.name, func(t *testing.T) {
 | |
| 			admin := AdminConfig{
 | |
| 				Origins: test.origins,
 | |
| 			}
 | |
| 
 | |
| 			got := admin.allowedOrigins(test.addr)
 | |
| 
 | |
| 			var gotOrigins []string
 | |
| 			for _, u := range got {
 | |
| 				gotOrigins = append(gotOrigins, u.Host)
 | |
| 			}
 | |
| 
 | |
| 			if len(gotOrigins) != len(test.expectOrigins) {
 | |
| 				t.Errorf("%d: Expected %d origins but got %d", i, len(test.expectOrigins), len(gotOrigins))
 | |
| 				return
 | |
| 			}
 | |
| 
 | |
| 			expectMap := make(map[string]struct{})
 | |
| 			for _, origin := range test.expectOrigins {
 | |
| 				expectMap[origin] = struct{}{}
 | |
| 			}
 | |
| 
 | |
| 			gotMap := make(map[string]struct{})
 | |
| 			for _, origin := range gotOrigins {
 | |
| 				gotMap[origin] = struct{}{}
 | |
| 			}
 | |
| 
 | |
| 			if !reflect.DeepEqual(expectMap, gotMap) {
 | |
| 				t.Errorf("%d: Origins mismatch.\nExpected: %v\nGot: %v", i, test.expectOrigins, gotOrigins)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestReplaceRemoteAdminServer(t *testing.T) {
 | |
| 	const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
 | |
| BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
 | |
| MTAwMDAwMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
 | |
| AAOCAQ8AMIIBCgKCAQEA4O4S6BSoYcoxvRqI+h7yPOjF6KjntjzVVm9M+uHK4lzX
 | |
| F1L3pSxJ2nDD4wZEV3FJ5yFOHVFqkG2vXG3BIczOlYG7UeNmKbQnKc5kZj3HGUrS
 | |
| VGEktA4OJbeZhhWP15gcXN5eDM2eH3g9BFXVX6AURxLiUXzhNBUEZuj/OEyH9yEF
 | |
| /qPCE+EjzVvWxvBXwgz/io4r4yok/Vq/bxJ6FlV6R7DX5oJSXyO0VEHZPi9DIyNU
 | |
| kK3F/r4U1sWiJGWOs8i3YQWZ2ejh1C0aLFZpPcCGGgMNpoF31gyYP6ZuPDUyCXsE
 | |
| g36UUw1JHNtIXYcLhnXuqj4A8TybTDpgXLqvwA9DBQIDAQABo1MwUTAdBgNVHQ4E
 | |
| FgQUc13z30pFC63rr/HGKOE7E82vjXwwHwYDVR0jBBgwFoAUc13z30pFC63rr/HG
 | |
| KOE7E82vjXwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHO3j
 | |
| oeiUXXJ7xD4P8Wj5t9d+E8lE1Xv1Dk3Z+EdG5+dan+RcToE42JJp9zB7FIh5Qz8g
 | |
| W77LAjqh5oyqz3A2VJcyVgfE3uJP1R1mJM7JfGHf84QH4TZF2Q1RZY4SZs0VQ6+q
 | |
| 5wSlIZ4NXDy4Q4XkIJBGS61wT8IzYFXYBpx4PCP1Qj0PIE4sevEGwjsBIgxK307o
 | |
| BxF8AWe6N6e4YZmQLGjQ+SeH0iwZb6vpkHyAY8Kj2hvK+cq2P7vU3VGi0t3r1F8L
 | |
| IvrXHCvO2BMNJ/1UK1M4YNX8LYJqQhg9hEsIROe1OE/m3VhxIYMJI+qZXk9yHfgJ
 | |
| vq+SH04xKhtFudVBAQ==`
 | |
| 
 | |
| 	tests := []struct {
 | |
| 		name    string
 | |
| 		cfg     *Config
 | |
| 		wantErr bool
 | |
| 	}{
 | |
| 		{
 | |
| 			name:    "nil config",
 | |
| 			cfg:     nil,
 | |
| 			wantErr: false,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "nil admin config",
 | |
| 			cfg: &Config{
 | |
| 				Admin: nil,
 | |
| 			},
 | |
| 			wantErr: false,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "nil remote config",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{},
 | |
| 			},
 | |
| 			wantErr: false,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "invalid listen address",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Remote: &RemoteAdmin{
 | |
| 						Listen: "invalid:address",
 | |
| 					},
 | |
| 				},
 | |
| 			},
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "valid config",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Identity: &IdentityConfig{},
 | |
| 					Remote: &RemoteAdmin{
 | |
| 						Listen: "localhost:2021",
 | |
| 						AccessControl: []*AdminAccess{
 | |
| 							{
 | |
| 								PublicKeys:  []string{testCert},
 | |
| 								Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
 | |
| 							},
 | |
| 						},
 | |
| 					},
 | |
| 				},
 | |
| 			},
 | |
| 			wantErr: false,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "invalid certificate",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Identity: &IdentityConfig{},
 | |
| 					Remote: &RemoteAdmin{
 | |
| 						Listen: "localhost:2021",
 | |
| 						AccessControl: []*AdminAccess{
 | |
| 							{
 | |
| 								PublicKeys:  []string{"invalid-cert-data"},
 | |
| 								Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
 | |
| 							},
 | |
| 						},
 | |
| 					},
 | |
| 				},
 | |
| 			},
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for _, test := range tests {
 | |
| 		t.Run(test.name, func(t *testing.T) {
 | |
| 			ctx := Context{
 | |
| 				Context: context.Background(),
 | |
| 				cfg:     test.cfg,
 | |
| 			}
 | |
| 
 | |
| 			if test.cfg != nil {
 | |
| 				test.cfg.storage = &certmagic.FileStorage{Path: t.TempDir()}
 | |
| 			}
 | |
| 
 | |
| 			if test.cfg != nil && test.cfg.Admin != nil && test.cfg.Admin.Identity != nil {
 | |
| 				identityCertCache = certmagic.NewCache(certmagic.CacheOptions{
 | |
| 					GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) {
 | |
| 						return &certmagic.Config{}, nil
 | |
| 					},
 | |
| 				})
 | |
| 			}
 | |
| 
 | |
| 			err := replaceRemoteAdminServer(ctx, test.cfg)
 | |
| 
 | |
| 			if test.wantErr {
 | |
| 				if err == nil {
 | |
| 					t.Error("Expected error but got nil")
 | |
| 				}
 | |
| 			} else {
 | |
| 				if err != nil {
 | |
| 					t.Errorf("Expected no error but got: %v", err)
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			// Clean up
 | |
| 			if remoteAdminServer != nil {
 | |
| 				_ = stopAdminServer(remoteAdminServer)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type mockIssuer struct {
 | |
| 	configSet *certmagic.Config
 | |
| }
 | |
| 
 | |
| func (m *mockIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) {
 | |
| 	return &certmagic.IssuedCertificate{
 | |
| 		Certificate: []byte(csr.Raw),
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| func (m *mockIssuer) SetConfig(cfg *certmagic.Config) {
 | |
| 	m.configSet = cfg
 | |
| }
 | |
| 
 | |
| func (m *mockIssuer) IssuerKey() string {
 | |
| 	return "mock"
 | |
| }
 | |
| 
 | |
| type mockIssuerModule struct {
 | |
| 	*mockIssuer
 | |
| }
 | |
| 
 | |
| func (m *mockIssuerModule) CaddyModule() ModuleInfo {
 | |
| 	return ModuleInfo{
 | |
| 		ID: "tls.issuance.acme",
 | |
| 		New: func() Module {
 | |
| 			return &mockIssuerModule{mockIssuer: new(mockIssuer)}
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestManageIdentity(t *testing.T) {
 | |
| 	originalModules := make(map[string]ModuleInfo)
 | |
| 	maps.Copy(originalModules, modules)
 | |
| 	defer func() {
 | |
| 		modules = originalModules
 | |
| 	}()
 | |
| 
 | |
| 	RegisterModule(&mockIssuerModule{})
 | |
| 
 | |
| 	certPEM := []byte(`-----BEGIN CERTIFICATE-----
 | |
| MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
 | |
| BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
 | |
| cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw
 | |
| WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
 | |
| TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp
 | |
| bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3lcub2pUwkjC
 | |
| 5GJQA2ZZfJJi6d1QHhEmkX9VxKYGp6gagZuRqJWy9TXP6++1ZzQQxqZLD0TkuxZ9
 | |
| 8i9Nz00000CCBjCCAQQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMGgG
 | |
| CCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29t
 | |
| L0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5j
 | |
| b20vb2NzcDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/
 | |
| BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHREEEDAO
 | |
| ggxtYWlsLmdvb2dsZTANBgkqhkiG9w0BAQUFAAOCAQEAMP6IWgNGZE8wP9TjFjSZ
 | |
| 3mmW3A1eIr0CuPwNZ2LJ5ZD1i70ojzcj4I9IdP5yPg9CAEV4hNASbM1LzfC7GmJE
 | |
| tPzW5tRmpKVWZGRgTgZI8Hp/xZXMwLh9ZmXV4kESFAGj5G5FNvJyUV7R5Eh+7OZX
 | |
| 7G4jJ4ZGJh+5jzN9HdJJHQHGYNIYOzC7+HH9UMwCjX9vhQ4RjwFZJThS2Yb+y7pb
 | |
| 9yxTJZoXC6J0H5JpnZb7kZEJ+Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
 | |
| -----END CERTIFICATE-----`)
 | |
| 
 | |
| 	keyPEM := []byte(`-----BEGIN PRIVATE KEY-----
 | |
| MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
 | |
| ...
 | |
| -----END PRIVATE KEY-----`)
 | |
| 
 | |
| 	testStorage := certmagic.FileStorage{Path: t.TempDir()}
 | |
| 	err := testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
 | |
| 	if err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 	err = testStorage.Store(context.Background(), "localhost/localhost.key", keyPEM)
 | |
| 	if err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	tests := []struct {
 | |
| 		name       string
 | |
| 		cfg        *Config
 | |
| 		wantErr    bool
 | |
| 		checkState func(*testing.T, *Config)
 | |
| 	}{
 | |
| 		{
 | |
| 			name: "nil config",
 | |
| 			cfg:  nil,
 | |
| 		},
 | |
| 		{
 | |
| 			name: "nil admin config",
 | |
| 			cfg: &Config{
 | |
| 				Admin: nil,
 | |
| 			},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "nil identity config",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{},
 | |
| 			},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "default issuer when none specified",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Identity: &IdentityConfig{
 | |
| 						Identifiers: []string{"localhost"},
 | |
| 					},
 | |
| 				},
 | |
| 				storage: &testStorage,
 | |
| 			},
 | |
| 			checkState: func(t *testing.T, cfg *Config) {
 | |
| 				if len(cfg.Admin.Identity.issuers) == 0 {
 | |
| 					t.Error("Expected at least 1 issuer to be configured")
 | |
| 					return
 | |
| 				}
 | |
| 				if _, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule); !ok {
 | |
| 					t.Error("Expected mock issuer to be configured")
 | |
| 				}
 | |
| 			},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "custom issuer",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Identity: &IdentityConfig{
 | |
| 						Identifiers: []string{"localhost"},
 | |
| 						IssuersRaw: []json.RawMessage{
 | |
| 							json.RawMessage(`{"module": "acme"}`),
 | |
| 						},
 | |
| 					},
 | |
| 				},
 | |
| 				storage: &certmagic.FileStorage{Path: "testdata"},
 | |
| 			},
 | |
| 			checkState: func(t *testing.T, cfg *Config) {
 | |
| 				if len(cfg.Admin.Identity.issuers) != 1 {
 | |
| 					t.Fatalf("Expected 1 issuer, got %d", len(cfg.Admin.Identity.issuers))
 | |
| 				}
 | |
| 				mockIss, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule)
 | |
| 				if !ok {
 | |
| 					t.Fatal("Expected mock issuer")
 | |
| 				}
 | |
| 				if mockIss.configSet == nil {
 | |
| 					t.Error("Issuer config was not set")
 | |
| 				}
 | |
| 			},
 | |
| 		},
 | |
| 		{
 | |
| 			name: "invalid issuer module",
 | |
| 			cfg: &Config{
 | |
| 				Admin: &AdminConfig{
 | |
| 					Identity: &IdentityConfig{
 | |
| 						Identifiers: []string{"localhost"},
 | |
| 						IssuersRaw: []json.RawMessage{
 | |
| 							json.RawMessage(`{"module": "doesnt_exist"}`),
 | |
| 						},
 | |
| 					},
 | |
| 				},
 | |
| 			},
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for _, test := range tests {
 | |
| 		t.Run(test.name, func(t *testing.T) {
 | |
| 			if identityCertCache != nil {
 | |
| 				// Reset the cert cache before each test
 | |
| 				identityCertCache.Stop()
 | |
| 				identityCertCache = nil
 | |
| 			}
 | |
| 
 | |
| 			ctx := Context{
 | |
| 				Context:         context.Background(),
 | |
| 				cfg:             test.cfg,
 | |
| 				moduleInstances: make(map[string][]Module),
 | |
| 			}
 | |
| 
 | |
| 			err := manageIdentity(ctx, test.cfg)
 | |
| 
 | |
| 			if test.wantErr {
 | |
| 				if err == nil {
 | |
| 					t.Error("Expected error but got nil")
 | |
| 				}
 | |
| 				return
 | |
| 			}
 | |
| 			if err != nil {
 | |
| 				t.Fatalf("Expected no error but got: %v", err)
 | |
| 			}
 | |
| 
 | |
| 			if test.checkState != nil {
 | |
| 				test.checkState(t, test.cfg)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 |