mirror of
https://github.com/caddyserver/caddy.git
synced 2026-05-13 09:06:41 +00:00
Compare commits
24 commits
eeb13f1ca8
...
d0a3cf0a0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a3cf0a0a | ||
|
|
77e9ce7404 | ||
|
|
cc58caa109 | ||
|
|
d80774cb3f | ||
|
|
a4a38c3e88 | ||
|
|
761347aa63 | ||
|
|
4ba16fe82c | ||
|
|
0fab9f0f7d | ||
|
|
5e76b5ee43 | ||
|
|
9c78b97f9e | ||
|
|
fb324331f4 | ||
|
|
0780d4489c | ||
|
|
d2172bea61 | ||
|
|
c7c9f3108a | ||
|
|
7e77eec0ae | ||
|
|
ef496e58ef | ||
|
|
18ab0f955f | ||
|
|
6a64bb2ce5 | ||
|
|
4d6945769d | ||
|
|
2d33271482 | ||
|
|
c653e7d61a | ||
|
|
c1918ff1ad | ||
|
|
fdbef2a6ef | ||
|
|
2a3ed96f8c |
45 changed files with 1827 additions and 266 deletions
89
admin.go
89
admin.go
|
|
@ -120,10 +120,6 @@ type AdminConfig struct {
|
|||
//
|
||||
// EXPERIMENTAL: This feature is subject to change.
|
||||
Remote *RemoteAdmin `json:"remote,omitempty"`
|
||||
|
||||
// Holds onto the routers so that we can later provision them
|
||||
// if they require provisioning.
|
||||
routers []AdminRouter
|
||||
}
|
||||
|
||||
// ConfigSettings configures the management of configuration.
|
||||
|
|
@ -212,8 +208,8 @@ type AdminAccess struct {
|
|||
// AdminPermissions specifies what kinds of requests are allowed
|
||||
// to be made to the admin endpoint.
|
||||
type AdminPermissions struct {
|
||||
// The API paths allowed. Paths are simple prefix matches.
|
||||
// Any subpath of the specified paths will be allowed.
|
||||
// The API paths allowed. A request path must either equal an
|
||||
// allowed path or be a subpath with a path-segment boundary.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
|
||||
// The HTTP methods allowed for the given paths.
|
||||
|
|
@ -222,7 +218,7 @@ type AdminPermissions struct {
|
|||
|
||||
// newAdminHandler reads admin's config and returns an http.Handler suitable
|
||||
// for use in an admin endpoint server, which will be listening on listenAddr.
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Context) adminHandler {
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, ctx Context) (adminHandler, error) {
|
||||
muxWrap := adminHandler{mux: http.NewServeMux()}
|
||||
|
||||
// secure the local or remote endpoint respectively
|
||||
|
|
@ -279,34 +275,21 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Co
|
|||
// register third-party module endpoints
|
||||
for _, m := range GetModules("admin.api") {
|
||||
router := m.New().(AdminRouter)
|
||||
|
||||
// provision the router before registering its routes, so
|
||||
// handlers have access to all provisioned state
|
||||
if provisioner, ok := router.(Provisioner); ok {
|
||||
if err := provisioner.Provision(ctx); err != nil {
|
||||
return adminHandler{}, fmt.Errorf("provisioning admin router module %s: %v", m.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, route := range router.Routes() {
|
||||
addRoute(route.Pattern, handlerLabel, route.Handler)
|
||||
}
|
||||
admin.routers = append(admin.routers, router)
|
||||
}
|
||||
|
||||
return muxWrap
|
||||
}
|
||||
|
||||
// provisionAdminRouters provisions all the router modules
|
||||
// in the admin.api namespace that need provisioning.
|
||||
func (admin *AdminConfig) provisionAdminRouters(ctx Context) error {
|
||||
for _, router := range admin.routers {
|
||||
provisioner, ok := router.(Provisioner)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
err := provisioner.Provision(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// We no longer need the routers once provisioned, allow for GC
|
||||
admin.routers = nil
|
||||
|
||||
return nil
|
||||
return muxWrap, nil
|
||||
}
|
||||
|
||||
// allowedOrigins returns a list of origins that are allowed.
|
||||
|
|
@ -430,11 +413,7 @@ func replaceLocalAdminServer(cfg *Config, ctx Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
handler := cfg.Admin.newAdminHandler(addr, false, ctx)
|
||||
|
||||
// run the provisioners for loaded modules to make sure local
|
||||
// state is properly re-initialized in the new admin server
|
||||
err = cfg.Admin.provisionAdminRouters(ctx)
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, false, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -558,11 +537,7 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
|
|||
|
||||
// make the HTTP handler but disable Host/Origin enforcement
|
||||
// because we are using TLS authentication instead
|
||||
handler := cfg.Admin.newAdminHandler(addr, true, ctx)
|
||||
|
||||
// run the provisioners for loaded modules to make sure local
|
||||
// state is properly re-initialized in the new admin server
|
||||
err = cfg.Admin.provisionAdminRouters(ctx)
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, true, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -718,7 +693,7 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
|
|||
// verify path
|
||||
pathFound := accessPerm.Paths == nil
|
||||
for _, allowedPath := range accessPerm.Paths {
|
||||
if strings.HasPrefix(r.URL.Path, allowedPath) {
|
||||
if adminPathAllowed(r.URL.Path, allowedPath) {
|
||||
pathFound = true
|
||||
break
|
||||
}
|
||||
|
|
@ -747,6 +722,19 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
func adminPathAllowed(reqPath, allowedPath string) bool {
|
||||
if allowedPath == "" || allowedPath == "/" {
|
||||
return strings.HasPrefix(reqPath, allowedPath)
|
||||
}
|
||||
if reqPath == allowedPath {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(allowedPath, "/") {
|
||||
return strings.HasPrefix(reqPath, allowedPath)
|
||||
}
|
||||
return strings.HasPrefix(reqPath, allowedPath+"/")
|
||||
}
|
||||
|
||||
func stopAdminServer(srv *http.Server) error {
|
||||
if srv == nil {
|
||||
return fmt.Errorf("no admin server")
|
||||
|
|
@ -1148,6 +1136,20 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func parseCanonicalArrayIndex(idx string) (int, error) {
|
||||
if idx == "" {
|
||||
return 0, fmt.Errorf("empty index")
|
||||
}
|
||||
i, err := strconv.Atoi(idx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strconv.Itoa(i) != idx {
|
||||
return 0, fmt.Errorf("non-canonical array index")
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// unsyncedConfigAccess traverses into the current config and performs
|
||||
// the operation at path according to method, using body and out as
|
||||
// needed. This is a low-level, unsynchronized function; most callers
|
||||
|
|
@ -1209,11 +1211,12 @@ traverseLoop:
|
|||
var idx int
|
||||
if method != http.MethodPost {
|
||||
idxStr := parts[len(parts)-1]
|
||||
idx, err = strconv.Atoi(idxStr)
|
||||
idx, err = parseCanonicalArrayIndex(idxStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] invalid array index '%s': %v",
|
||||
path, idxStr, err)
|
||||
}
|
||||
|
||||
if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || idx > len(arr) {
|
||||
return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
|
||||
}
|
||||
|
|
@ -1313,7 +1316,7 @@ traverseLoop:
|
|||
}
|
||||
|
||||
case []any:
|
||||
partInt, err := strconv.Atoi(part)
|
||||
partInt, err := parseCanonicalArrayIndex(part)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[/%s] invalid array index '%s': %v",
|
||||
strings.Join(parts[:i+1], "/"), part, err)
|
||||
|
|
|
|||
172
admin_test.go
172
admin_test.go
|
|
@ -15,9 +15,13 @@
|
|||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
|
@ -53,6 +57,13 @@ var testCfg = []byte(`{
|
|||
}
|
||||
`)
|
||||
|
||||
type testAdminPublicKey string
|
||||
|
||||
func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
|
||||
other, ok := x.(testAdminPublicKey)
|
||||
return ok && k == other
|
||||
}
|
||||
|
||||
func TestUnsyncedConfigAccess(t *testing.T) {
|
||||
// each test is performed in sequence, so
|
||||
// each change builds on the previous ones;
|
||||
|
|
@ -329,7 +340,10 @@ func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
handler := cfg.Admin.newAdminHandler(addr, false, Context{})
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, false, Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create admin handler: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -450,7 +464,10 @@ func TestNewAdminHandlerRouterRegistration(t *testing.T) {
|
|||
admin := &AdminConfig{
|
||||
EnforceOrigin: false,
|
||||
}
|
||||
handler := admin.newAdminHandler(addr, false, Context{})
|
||||
handler, err := admin.newAdminHandler(addr, false, Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create admin handler: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/mock", nil)
|
||||
req.Host = "localhost:2019"
|
||||
|
|
@ -462,10 +479,6 @@ func TestNewAdminHandlerRouterRegistration(t *testing.T) {
|
|||
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 {
|
||||
|
|
@ -503,19 +516,16 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
|||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -551,8 +561,7 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
|||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
|
||||
_ = admin.newAdminHandler(addr, false, Context{})
|
||||
err = admin.provisionAdminRouters(Context{})
|
||||
_, err = admin.newAdminHandler(addr, false, Context{})
|
||||
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
|
|
@ -563,10 +572,6 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
|||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -651,6 +656,99 @@ func TestAllowedOriginsUnixSocket(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
|
||||
const authorizedKey testAdminPublicKey = "authorized"
|
||||
peerCert := &x509.Certificate{PublicKey: authorizedKey}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedPath string
|
||||
requestPath string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "exact path",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "subpath",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod/certificates",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "trailing slash subpath",
|
||||
allowedPath: "/pki/ca/prod/",
|
||||
requestPath: "/pki/ca/prod/certificates",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "sibling with shared prefix",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod-backup",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "same segment plus digit",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
allowedPath: "/",
|
||||
requestPath: "/pki/ca/prod",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
remote := RemoteAdmin{
|
||||
AccessControl: []*AdminAccess{
|
||||
{
|
||||
Permissions: []AdminPermissions{
|
||||
{
|
||||
Methods: []string{http.MethodGet},
|
||||
Paths: []string{test.allowedPath},
|
||||
},
|
||||
},
|
||||
publicKeys: []crypto.PublicKey{authorizedKey},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{peerCert}},
|
||||
}
|
||||
|
||||
err := remote.enforceAccessControls(req)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
|
||||
return
|
||||
}
|
||||
var apiErr APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected APIError with HTTP status %d, got %T: %v", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, err, err)
|
||||
return
|
||||
}
|
||||
if apiErr.HTTPStatus != http.StatusForbidden {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected HTTP status %d, got %d", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRemoteAdminServer(t *testing.T) {
|
||||
const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
|
||||
|
|
@ -1003,3 +1101,47 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) {
|
||||
rawCfg = map[string]any{
|
||||
rawConfigKey: map[string]any{
|
||||
"list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantOutput string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"},
|
||||
{name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"},
|
||||
{name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"},
|
||||
{name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true},
|
||||
{name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true},
|
||||
{name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true},
|
||||
{name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotOutput bytes.Buffer
|
||||
err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput)
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d (%s): input path %q: expected no error with output %q, got error %v with output %q", i, tc.name, tc.path, tc.wantOutput, err, gotOutput.String())
|
||||
}
|
||||
if gotOutput.String() != tc.wantOutput {
|
||||
t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
caddy.go
7
caddy.go
|
|
@ -440,13 +440,6 @@ func run(newCfg *Config, start bool) (Context, error) {
|
|||
}
|
||||
}()
|
||||
|
||||
// Provision any admin routers which may need to access
|
||||
// some of the other apps at runtime
|
||||
err = ctx.cfg.Admin.provisionAdminRouters(ctx)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Start
|
||||
err = func() error {
|
||||
started := make([]string, 0, len(ctx.cfg.apps))
|
||||
|
|
|
|||
|
|
@ -550,26 +550,11 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
|
|||
}
|
||||
|
||||
case acmeIssuer != nil:
|
||||
// implicit ACME issuers (from various subdirectives) - use defaults; there might be more than one
|
||||
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
|
||||
|
||||
// if an ACME CA endpoint was set, the user expects to use that specific one,
|
||||
// not any others that may be defaults, so replace all defaults with that ACME CA
|
||||
if acmeIssuer.CA != "" {
|
||||
defaultIssuers = []certmagic.Issuer{acmeIssuer}
|
||||
}
|
||||
|
||||
// implicit ACME issuers (from various subdirectives) should inherit from
|
||||
// any globally-configured ACME issuer templates, then apply the local
|
||||
// shortcut settings as overrides.
|
||||
defaultIssuers := implicitACMEIssuers(h, acmeIssuer)
|
||||
for _, issuer := range defaultIssuers {
|
||||
// apply settings from the implicitly-configured ACMEIssuer to any
|
||||
// default ACMEIssuers, but preserve each default issuer's CA endpoint,
|
||||
// because, for example, if you configure the DNS challenge, it should
|
||||
// apply to any of the default ACMEIssuers, but you don't want to trample
|
||||
// out their unique CA endpoints
|
||||
if iss, ok := issuer.(*caddytls.ACMEIssuer); ok && iss != nil {
|
||||
acmeCopy := *acmeIssuer
|
||||
acmeCopy.CA = iss.CA
|
||||
issuer = &acmeCopy
|
||||
}
|
||||
configVals = append(configVals, ConfigValue{
|
||||
Class: "tls.cert_issuer",
|
||||
Value: issuer,
|
||||
|
|
@ -1068,7 +1053,7 @@ func parseLogHelper(h Helper, globalLogNames map[string]struct{}) ([]ConfigValue
|
|||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
interval, err := time.ParseDuration(d.Val() + "ns")
|
||||
interval, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("failed to parse interval: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,14 +66,14 @@ func TestLogDirectiveSyntax(t *testing.T) {
|
|||
input: `:8080 {
|
||||
log {
|
||||
sampling {
|
||||
interval 2
|
||||
interval 2s
|
||||
first 3
|
||||
thereafter 4
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2000000000,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -484,6 +484,8 @@ func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) {
|
|||
metrics.PerHost = true
|
||||
case "observe_catchall_hosts":
|
||||
metrics.ObserveCatchallHosts = true
|
||||
case "otlp":
|
||||
metrics.OTLP = true
|
||||
default:
|
||||
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package httpcaddyfile
|
|||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/logging"
|
||||
|
|
@ -166,3 +168,126 @@ func TestGlobalResolversOption(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalCertIssuerAppliesToImplicitACMEIssuer(t *testing.T) {
|
||||
adapter := caddyfile.Adapter{
|
||||
ServerType: ServerType{},
|
||||
}
|
||||
|
||||
input := `{
|
||||
cert_issuer acme {
|
||||
disable_tlsalpn_challenge
|
||||
}
|
||||
}
|
||||
report.company.intern {
|
||||
tls {
|
||||
ca https://deglacme01.company.intern/acme/acme/directory
|
||||
ca_root /etc/certs/company_root2.crt
|
||||
}
|
||||
respond "ok"
|
||||
}`
|
||||
|
||||
out, _, err := adapter.Adapt([]byte(input), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("adapting caddyfile: %v", err)
|
||||
}
|
||||
|
||||
var config struct {
|
||||
Apps struct {
|
||||
TLS *caddytls.TLS `json:"tls"`
|
||||
} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &config); err != nil {
|
||||
t.Fatalf("unmarshaling adapted config: %v", err)
|
||||
}
|
||||
if config.Apps.TLS == nil || config.Apps.TLS.Automation == nil {
|
||||
t.Fatal("expected tls automation config")
|
||||
}
|
||||
|
||||
var subjectPolicy *caddytls.AutomationPolicy
|
||||
for _, ap := range config.Apps.TLS.Automation.Policies {
|
||||
if len(ap.SubjectsRaw) == 1 && ap.SubjectsRaw[0] == "report.company.intern" {
|
||||
subjectPolicy = ap
|
||||
break
|
||||
}
|
||||
}
|
||||
if subjectPolicy == nil {
|
||||
t.Fatal("expected subject-specific automation policy")
|
||||
}
|
||||
if len(subjectPolicy.IssuersRaw) != 1 {
|
||||
t.Fatalf("expected one issuer for subject-specific policy, got %d", len(subjectPolicy.IssuersRaw))
|
||||
}
|
||||
|
||||
var issuer caddytls.ACMEIssuer
|
||||
if err := json.Unmarshal(subjectPolicy.IssuersRaw[0], &issuer); err != nil {
|
||||
t.Fatalf("unmarshaling issuer: %v", err)
|
||||
}
|
||||
if issuer.CA != "https://deglacme01.company.intern/acme/acme/directory" {
|
||||
t.Fatalf("expected custom ACME CA, got %q", issuer.CA)
|
||||
}
|
||||
if len(issuer.TrustedRootsPEMFiles) != 1 || issuer.TrustedRootsPEMFiles[0] != "/etc/certs/company_root2.crt" {
|
||||
t.Fatalf("expected trusted roots to include site CA root, got %v", issuer.TrustedRootsPEMFiles)
|
||||
}
|
||||
if issuer.Challenges == nil || issuer.Challenges.TLSALPN == nil || !issuer.Challenges.TLSALPN.Disabled {
|
||||
t.Fatalf("expected tls-alpn challenge to be disabled, got %#v", issuer.Challenges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeACMEIssuers(t *testing.T) {
|
||||
base := &caddytls.ACMEIssuer{
|
||||
Email: "ops@example.com",
|
||||
Challenges: &caddytls.ChallengesConfig{
|
||||
HTTP: &caddytls.HTTPChallengeConfig{
|
||||
AlternatePort: 8080,
|
||||
},
|
||||
TLSALPN: &caddytls.TLSALPNChallengeConfig{
|
||||
Disabled: true,
|
||||
AlternatePort: 8443,
|
||||
},
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
Resolvers: []string{"1.1.1.1"},
|
||||
OverrideDomain: "_acme-challenge.example.net",
|
||||
},
|
||||
},
|
||||
TrustedRootsPEMFiles: []string{"global.pem"},
|
||||
}
|
||||
overrides := &caddytls.ACMEIssuer{
|
||||
CA: "https://deglacme01.company.intern/acme/acme/directory",
|
||||
Challenges: &caddytls.ChallengesConfig{
|
||||
HTTP: &caddytls.HTTPChallengeConfig{
|
||||
Disabled: true,
|
||||
},
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
PropagationTimeout: caddy.Duration(time.Minute),
|
||||
},
|
||||
},
|
||||
TrustedRootsPEMFiles: []string{"site.pem"},
|
||||
}
|
||||
|
||||
merged := mergeACMEIssuers(base, overrides)
|
||||
if merged.CA != overrides.CA {
|
||||
t.Fatalf("expected merged CA %q, got %q", overrides.CA, merged.CA)
|
||||
}
|
||||
if merged.Email != base.Email {
|
||||
t.Fatalf("expected merged email %q, got %q", base.Email, merged.Email)
|
||||
}
|
||||
if len(merged.TrustedRootsPEMFiles) != 2 || merged.TrustedRootsPEMFiles[0] != "global.pem" || merged.TrustedRootsPEMFiles[1] != "site.pem" {
|
||||
t.Fatalf("expected merged roots [global.pem site.pem], got %v", merged.TrustedRootsPEMFiles)
|
||||
}
|
||||
if merged.Challenges == nil || merged.Challenges.HTTP == nil || !merged.Challenges.HTTP.Disabled || merged.Challenges.HTTP.AlternatePort != 8080 {
|
||||
t.Fatalf("expected merged HTTP challenge config to preserve alternate port and apply disable flag, got %#v", merged.Challenges)
|
||||
}
|
||||
if merged.Challenges.TLSALPN == nil || !merged.Challenges.TLSALPN.Disabled || merged.Challenges.TLSALPN.AlternatePort != 8443 {
|
||||
t.Fatalf("expected merged TLS-ALPN challenge config to preserve global settings, got %#v", merged.Challenges)
|
||||
}
|
||||
if merged.Challenges.DNS == nil || merged.Challenges.DNS.PropagationTimeout != caddy.Duration(time.Minute) || len(merged.Challenges.DNS.Resolvers) != 1 || merged.Challenges.DNS.Resolvers[0] != "1.1.1.1" || merged.Challenges.DNS.OverrideDomain != "_acme-challenge.example.net" {
|
||||
t.Fatalf("expected merged DNS challenge config to preserve global values and apply overrides, got %#v", merged.Challenges)
|
||||
}
|
||||
|
||||
if base.CA != "" {
|
||||
t.Fatalf("expected base issuer to remain unchanged, got CA %q", base.CA)
|
||||
}
|
||||
if len(base.TrustedRootsPEMFiles) != 1 || base.TrustedRootsPEMFiles[0] != "global.pem" {
|
||||
t.Fatalf("expected base roots to remain unchanged, got %v", base.TrustedRootsPEMFiles)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -612,6 +612,289 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
|||
return nil
|
||||
}
|
||||
|
||||
// implicitACMEIssuers returns the issuers to use for ACME-related tls
|
||||
// shortcuts such as ca, ca_root, and dns. If any global cert_issuer options
|
||||
// configure ACME issuers, those become the templates for the local shortcut
|
||||
// configuration; otherwise, default ACME issuers are used.
|
||||
func implicitACMEIssuers(h Helper, acmeIssuer *caddytls.ACMEIssuer) []certmagic.Issuer {
|
||||
globalIssuers, _ := h.Option("cert_issuer").([]certmagic.Issuer)
|
||||
|
||||
var implicitIssuers []certmagic.Issuer
|
||||
for _, issuer := range globalIssuers {
|
||||
acmeWrapper, ok := issuer.(acmeCapable)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
baseIssuer := acmeWrapper.GetACMEIssuer()
|
||||
if baseIssuer == nil {
|
||||
continue
|
||||
}
|
||||
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
|
||||
}
|
||||
if len(implicitIssuers) > 0 {
|
||||
return implicitIssuers
|
||||
}
|
||||
|
||||
// If an ACME CA endpoint was set locally, the user expects to use only that
|
||||
// CA rather than the usual default fallback issuers.
|
||||
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
|
||||
if acmeIssuer.CA != "" {
|
||||
defaultIssuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
|
||||
}
|
||||
|
||||
implicitIssuers = make([]certmagic.Issuer, 0, len(defaultIssuers))
|
||||
for _, issuer := range defaultIssuers {
|
||||
acmeWrapper, ok := issuer.(acmeCapable)
|
||||
if !ok {
|
||||
implicitIssuers = append(implicitIssuers, issuer)
|
||||
continue
|
||||
}
|
||||
baseIssuer := acmeWrapper.GetACMEIssuer()
|
||||
if baseIssuer == nil {
|
||||
implicitIssuers = append(implicitIssuers, issuer)
|
||||
continue
|
||||
}
|
||||
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
|
||||
}
|
||||
return implicitIssuers
|
||||
}
|
||||
|
||||
func mergeACMEIssuers(base, overrides *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
|
||||
if base == nil {
|
||||
return cloneACMEIssuer(overrides)
|
||||
}
|
||||
|
||||
merged := cloneACMEIssuer(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.CA != "" {
|
||||
merged.CA = overrides.CA
|
||||
}
|
||||
if overrides.TestCA != "" {
|
||||
merged.TestCA = overrides.TestCA
|
||||
}
|
||||
if overrides.Email != "" {
|
||||
merged.Email = overrides.Email
|
||||
}
|
||||
if overrides.Profile != "" {
|
||||
merged.Profile = overrides.Profile
|
||||
}
|
||||
if overrides.AccountKey != "" {
|
||||
merged.AccountKey = overrides.AccountKey
|
||||
}
|
||||
if overrides.ExternalAccount != nil {
|
||||
merged.ExternalAccount = cloneACMEEAB(overrides.ExternalAccount)
|
||||
}
|
||||
if overrides.ACMETimeout != 0 {
|
||||
merged.ACMETimeout = overrides.ACMETimeout
|
||||
}
|
||||
if len(overrides.TrustedRootsPEMFiles) > 0 {
|
||||
merged.TrustedRootsPEMFiles = appendUniqueStrings(merged.TrustedRootsPEMFiles, overrides.TrustedRootsPEMFiles...)
|
||||
}
|
||||
if overrides.PreferredChains != nil {
|
||||
merged.PreferredChains = cloneChainPreference(overrides.PreferredChains)
|
||||
}
|
||||
if overrides.CertificateLifetime != 0 {
|
||||
merged.CertificateLifetime = overrides.CertificateLifetime
|
||||
}
|
||||
if len(overrides.NetworkProxyRaw) > 0 {
|
||||
merged.NetworkProxyRaw = slices.Clone(overrides.NetworkProxyRaw)
|
||||
}
|
||||
merged.Challenges = mergeChallengesConfig(merged.Challenges, overrides.Challenges)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeChallengesConfig(base, overrides *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
|
||||
if base == nil {
|
||||
return cloneChallengesConfig(overrides)
|
||||
}
|
||||
merged := cloneChallengesConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
merged.HTTP = mergeHTTPChallengeConfig(merged.HTTP, overrides.HTTP)
|
||||
merged.TLSALPN = mergeTLSALPNChallengeConfig(merged.TLSALPN, overrides.TLSALPN)
|
||||
merged.DNS = mergeDNSChallengeConfig(merged.DNS, overrides.DNS)
|
||||
if overrides.BindHost != "" {
|
||||
merged.BindHost = overrides.BindHost
|
||||
}
|
||||
if overrides.Distributed != nil {
|
||||
value := *overrides.Distributed
|
||||
merged.Distributed = &value
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeHTTPChallengeConfig(base, overrides *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneHTTPChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneHTTPChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.Disabled {
|
||||
merged.Disabled = true
|
||||
}
|
||||
if overrides.AlternatePort != 0 {
|
||||
merged.AlternatePort = overrides.AlternatePort
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeTLSALPNChallengeConfig(base, overrides *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneTLSALPNChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneTLSALPNChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.Disabled {
|
||||
merged.Disabled = true
|
||||
}
|
||||
if overrides.AlternatePort != 0 {
|
||||
merged.AlternatePort = overrides.AlternatePort
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeDNSChallengeConfig(base, overrides *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneDNSChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneDNSChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if len(overrides.ProviderRaw) > 0 {
|
||||
merged.ProviderRaw = slices.Clone(overrides.ProviderRaw)
|
||||
}
|
||||
if overrides.PropagationDelay != 0 {
|
||||
merged.PropagationDelay = overrides.PropagationDelay
|
||||
}
|
||||
if overrides.PropagationTimeout != 0 {
|
||||
merged.PropagationTimeout = overrides.PropagationTimeout
|
||||
}
|
||||
if overrides.Resolvers != nil {
|
||||
merged.Resolvers = slices.Clone(overrides.Resolvers)
|
||||
}
|
||||
if overrides.OverrideDomain != "" {
|
||||
merged.OverrideDomain = overrides.OverrideDomain
|
||||
}
|
||||
if overrides.TTL != 0 {
|
||||
merged.TTL = overrides.TTL
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func cloneACMEIssuer(iss *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
|
||||
if iss == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *iss
|
||||
cloned.Challenges = cloneChallengesConfig(iss.Challenges)
|
||||
cloned.ExternalAccount = cloneACMEEAB(iss.ExternalAccount)
|
||||
cloned.TrustedRootsPEMFiles = slices.Clone(iss.TrustedRootsPEMFiles)
|
||||
cloned.PreferredChains = cloneChainPreference(iss.PreferredChains)
|
||||
cloned.NetworkProxyRaw = slices.Clone(iss.NetworkProxyRaw)
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneChallengesConfig(cfg *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
cloned.HTTP = cloneHTTPChallengeConfig(cfg.HTTP)
|
||||
cloned.TLSALPN = cloneTLSALPNChallengeConfig(cfg.TLSALPN)
|
||||
cloned.DNS = cloneDNSChallengeConfig(cfg.DNS)
|
||||
if cfg.Distributed != nil {
|
||||
value := *cfg.Distributed
|
||||
cloned.Distributed = &value
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneHTTPChallengeConfig(cfg *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneTLSALPNChallengeConfig(cfg *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDNSChallengeConfig(cfg *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
cloned.ProviderRaw = slices.Clone(cfg.ProviderRaw)
|
||||
cloned.Resolvers = slices.Clone(cfg.Resolvers)
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneACMEEAB(eab *acme.EAB) *acme.EAB {
|
||||
if eab == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *eab
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneChainPreference(pref *caddytls.ChainPreference) *caddytls.ChainPreference {
|
||||
if pref == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *pref
|
||||
cloned.RootCommonName = slices.Clone(pref.RootCommonName)
|
||||
cloned.AnyCommonName = slices.Clone(pref.AnyCommonName)
|
||||
if pref.Smallest != nil {
|
||||
value := *pref.Smallest
|
||||
cloned.Smallest = &value
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, additions ...string) []string {
|
||||
for _, value := range additions {
|
||||
if !slices.Contains(existing, value) {
|
||||
existing = append(existing, value)
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// newBaseAutomationPolicy returns a new TLS automation policy that gets
|
||||
// its values from the global options map. It should be used as the base
|
||||
// for any other automation policies. A nil policy (and no error) will be
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
log {
|
||||
sampling {
|
||||
interval 300
|
||||
interval 5m
|
||||
first 50
|
||||
thereafter 40
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"logs": {
|
||||
"default": {
|
||||
"sampling": {
|
||||
"interval": 300,
|
||||
"interval": 300000000000,
|
||||
"first": 50,
|
||||
"thereafter": 40
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
:80 {
|
||||
log {
|
||||
sampling {
|
||||
interval 300
|
||||
interval 5m
|
||||
first 50
|
||||
thereafter 40
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"log0": {
|
||||
"sampling": {
|
||||
"interval": 300,
|
||||
"interval": 300000000000,
|
||||
"first": 50,
|
||||
"thereafter": 40
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
metrics {
|
||||
otlp
|
||||
}
|
||||
}
|
||||
:80 {
|
||||
respond "Hello"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"otlp": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -234,7 +234,7 @@ func getModules() (standard, nonstandard, unknown []moduleInfo, err error) {
|
|||
// not sure why), and since New() should return a pointer
|
||||
// value, we need to dereference it first
|
||||
iface := any(modInfo.New())
|
||||
if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Ptr {
|
||||
if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Pointer {
|
||||
iface = reflect.New(reflect.TypeOf(iface).Elem()).Elem().Interface()
|
||||
}
|
||||
modPkgPath := reflect.TypeOf(iface).PkgPath()
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (any, error
|
|||
// value must be a pointer for unmarshaling into concrete type, even if
|
||||
// the module's concrete type is a slice or map; New() *should* return
|
||||
// a pointer, otherwise unmarshaling errors or panics will occur
|
||||
if rv := reflect.ValueOf(val); rv.Kind() != reflect.Ptr {
|
||||
if rv := reflect.ValueOf(val); rv.Kind() != reflect.Pointer {
|
||||
log.Printf("[WARNING] ModuleInfo.New() for module '%s' did not return a pointer,"+
|
||||
" so we are using reflection to make a pointer instead; please fix this by"+
|
||||
" using new(Type) or &Type notation in your module's New() function.", id)
|
||||
|
|
|
|||
10
go.mod
10
go.mod
|
|
@ -20,7 +20,7 @@ require (
|
|||
github.com/klauspost/cpuid/v2 v2.3.0
|
||||
github.com/mholt/acmez/v3 v3.1.6
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quic-go/quic-go v0.59.0
|
||||
github.com/quic-go/quic-go v0.59.1
|
||||
github.com/smallstep/certificates v0.30.2
|
||||
github.com/smallstep/nosql v0.8.0
|
||||
github.com/smallstep/truststore v0.13.0
|
||||
|
|
@ -30,11 +30,13 @@ require (
|
|||
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
|
||||
go.opentelemetry.io/contrib/propagators/autoprop v0.65.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.step.sm/crypto v0.77.1
|
||||
go.uber.org/automaxprocs v1.6.0
|
||||
go.uber.org/zap v1.27.1
|
||||
|
|
@ -70,7 +72,7 @@ require (
|
|||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
|
|
@ -87,7 +89,6 @@ require (
|
|||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/aws v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.43.0 // indirect
|
||||
|
|
@ -104,7 +105,6 @@ require (
|
|||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
|
|
@ -170,7 +170,7 @@ require (
|
|||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -205,8 +205,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
|
||||
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
|
|
@ -280,8 +280,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
|
|||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func reuseUnixSocket(_, _ string) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func listenReusable(ctx context.Context, lnKey string, network, address string, config net.ListenConfig) (any, error) {
|
||||
var socketFile *os.File
|
||||
|
||||
|
|
|
|||
21
listen_reuseUnixSocket.go
Normal file
21
listen_reuseUnixSocket.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// 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.
|
||||
|
||||
//go:build (!unix || solaris) && !windows
|
||||
|
||||
package caddy
|
||||
|
||||
func reuseUnixSocket(_, _ string) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
89
listen_reuseUnixSocket_windows.go
Normal file
89
listen_reuseUnixSocket_windows.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// 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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errUnixSocketAlreadyInUse = errors.New("unix socket is already in use by another process")
|
||||
|
||||
func reuseUnixSocket(network, addr string) (any, error) {
|
||||
if !IsUnixNetwork(network) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Note: This is here mainly for proper compatibility, because Unix sockets with abstract names are in an interesting limbo state on Windows:
|
||||
// Go already translates `@` characters to `\0` for Windows: https://github.com/golang/go/blob/65d5c5f6dd8aa7b221cff6ec3f5101ea2e5f3efa/src/syscall/syscall_windows.go#L910
|
||||
// ...but there still is an open issue about the fact that this is not properly supported: https://github.com/microsoft/WSL/issues/4240#issuecomment-620805115
|
||||
// The main issue is that the original announcement proclaimed support for this feature, but it was (apparently) never implemented: https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/
|
||||
isAbstractUnixSocket := strings.HasPrefix(addr, "@")
|
||||
|
||||
if isAbstractUnixSocket {
|
||||
// Abstract Unix sockets do not require us to remove stale socket files.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// On Windows, we're using the `fakeCloseListener` wrappers around a single, ever-living listener.
|
||||
// So, if there's an active listener entry in the pool, we're the current owner of the Unix socket file.
|
||||
_, socketBelongsToCurrentProcess := listenerPool.References(listenerKey(network, addr))
|
||||
|
||||
if socketBelongsToCurrentProcess {
|
||||
// Reuse/cleanup is entirely handled by the refcounting mechanism in `listenerPool`.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If the socket file does not exist or has no backing server process, this will fail instantly.
|
||||
connection, err := net.DialTimeout("unix", addr, 10*time.Millisecond)
|
||||
|
||||
if err == nil {
|
||||
connection.Close()
|
||||
return nil, fmt.Errorf("cannot reuse socket %v: %w", addr, errUnixSocketAlreadyInUse)
|
||||
}
|
||||
|
||||
// Windows returns this error code both if the socket file does not exist and if it isn't backed by a server process anymore.
|
||||
// See: https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2#wsaeconnrefused
|
||||
const WSAECONNREFUSED syscall.Errno = 10061
|
||||
|
||||
var errno syscall.Errno
|
||||
hasNoListeningServerProcess := errors.As(err, &errno) && errno == WSAECONNREFUSED
|
||||
|
||||
if !hasNoListeningServerProcess {
|
||||
return nil, fmt.Errorf("cannot reuse socket %v: %w", addr, errUnixSocketAlreadyInUse)
|
||||
}
|
||||
|
||||
// If the socket file exists, it hasn't been created by our process, and it seemingly
|
||||
// isn't backed by a server process anymore. Try to delete it so we can bind to it later.
|
||||
err = os.Remove(addr)
|
||||
|
||||
if err == nil {
|
||||
return nil, nil
|
||||
} else if errors.Is(err, fs.ErrNotExist) {
|
||||
// Either the file didn't exist in the first place, or it was deleted before we were able to.
|
||||
return nil, nil
|
||||
} else {
|
||||
// We failed to delete the file. Likely, it belongs to another (active) process.
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ import (
|
|||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -208,6 +207,9 @@ func (app *App) Provision(ctx caddy.Context) error {
|
|||
app.Metrics.httpMetrics = &httpMetrics{}
|
||||
// Scan config for allowed hosts to prevent cardinality explosion
|
||||
app.Metrics.scanConfigForHosts(app)
|
||||
if err := app.Metrics.provisionOTLP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// prepare each server
|
||||
oldContext := ctx.Context
|
||||
|
|
@ -238,12 +240,7 @@ func (app *App) Provision(ctx caddy.Context) error {
|
|||
|
||||
// if no protocols configured explicitly, enable all except h2c
|
||||
if len(srv.Protocols) == 0 {
|
||||
srv.Protocols = []string{"h1", "h2", "h3"}
|
||||
}
|
||||
|
||||
srvProtocolsUnique := map[string]struct{}{}
|
||||
for _, srvProtocol := range srv.Protocols {
|
||||
srvProtocolsUnique[srvProtocol] = struct{}{}
|
||||
srv.Protocols = srv.protocolsWithDefaults()
|
||||
}
|
||||
|
||||
if srv.ListenProtocols != nil {
|
||||
|
|
@ -254,31 +251,7 @@ func (app *App) Provision(ctx caddy.Context) error {
|
|||
|
||||
for i, lnProtocols := range srv.ListenProtocols {
|
||||
if lnProtocols != nil {
|
||||
// populate empty listen protocols with server protocols
|
||||
lnProtocolsDefault := false
|
||||
var lnProtocolsInclude []string
|
||||
srvProtocolsInclude := maps.Clone(srvProtocolsUnique)
|
||||
|
||||
// keep existing listener protocols unless they are empty
|
||||
for _, lnProtocol := range lnProtocols {
|
||||
if lnProtocol == "" {
|
||||
lnProtocolsDefault = true
|
||||
} else {
|
||||
lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol)
|
||||
delete(srvProtocolsInclude, lnProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
// append server protocols to listener protocols if any listener protocols were empty
|
||||
if lnProtocolsDefault {
|
||||
for _, srvProtocol := range srv.Protocols {
|
||||
if _, ok := srvProtocolsInclude[srvProtocol]; ok {
|
||||
lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srv.ListenProtocols[i] = lnProtocolsInclude
|
||||
srv.ListenProtocols[i] = srv.listenerProtocolsWithDefaults(lnProtocols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -817,6 +790,12 @@ func (app *App) Stop() error {
|
|||
}
|
||||
}
|
||||
|
||||
// flush and shut down the OTLP metrics exporter (if configured) so any
|
||||
// last data point reaches the collector before the process exits
|
||||
if err := app.Metrics.shutdown(ctx); err != nil {
|
||||
app.logger.Error("shutting down OTLP metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
app.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
|
|||
for d := range serverDomainSet {
|
||||
echDomains = append(echDomains, d)
|
||||
}
|
||||
app.tlsApp.RegisterServerNames(echDomains)
|
||||
app.tlsApp.RegisterServerNames(echDomains, httpsRRALPNs(srv))
|
||||
|
||||
// nothing more to do here if there are no domains that qualify for
|
||||
// automatic HTTPS and there are no explicit TLS connection policies:
|
||||
|
|
@ -574,6 +574,20 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route {
|
|||
}
|
||||
}
|
||||
|
||||
func httpsRRALPNs(srv *Server) []string {
|
||||
alpn := make(map[string]struct{}, 3)
|
||||
if srv.protocol("h3") {
|
||||
alpn["h3"] = struct{}{}
|
||||
}
|
||||
if srv.protocol("h2") {
|
||||
alpn["h2"] = struct{}{}
|
||||
}
|
||||
if srv.protocol("h1") {
|
||||
alpn["http/1.1"] = struct{}{}
|
||||
}
|
||||
return caddytls.OrderedHTTPSRRALPN(alpn)
|
||||
}
|
||||
|
||||
// createAutomationPolicies ensures that automated certificates for this
|
||||
// app are managed properly. This adds up to two automation policies:
|
||||
// one for the public names, and one for the internal names. If a catch-all
|
||||
|
|
|
|||
|
|
@ -1,44 +1,47 @@
|
|||
package caddyhttp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
func TestRecordAutoHTTPSRedirectAddressPrefersHTTPSPort(t *testing.T) {
|
||||
app := &App{HTTPSPort: 443}
|
||||
redirDomains := make(map[string][]caddy.NetworkAddress)
|
||||
func TestHTTPSRRALPNsDefaultProtocols(t *testing.T) {
|
||||
srv := &Server{}
|
||||
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 2345, EndPort: 2345})
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 443, EndPort: 443})
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 8443, EndPort: 8443})
|
||||
got := httpsRRALPNs(srv)
|
||||
want := []string{"h3", "h2", "http/1.1"}
|
||||
|
||||
got := redirDomains["example.com"]
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 redirect address, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].StartPort != 443 {
|
||||
t.Fatalf("expected redirect to prefer HTTPS port 443, got %#v", got[0])
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected ALPN values: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAutoHTTPSRedirectAddressKeepsAllBindAddressesOnWinningPort(t *testing.T) {
|
||||
app := &App{HTTPSPort: 443}
|
||||
redirDomains := make(map[string][]caddy.NetworkAddress)
|
||||
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 8443, EndPort: 8443})
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 443, EndPort: 443})
|
||||
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "2603:c024:8002:9500:9eb:e5d3:3975:d056", StartPort: 443, EndPort: 443})
|
||||
|
||||
got := redirDomains["example.com"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 redirect addresses for both bind addresses on the winning port, got %d: %#v", len(got), got)
|
||||
func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) {
|
||||
srv := &Server{
|
||||
Protocols: []string{"h1", "h2"},
|
||||
ListenProtocols: [][]string{
|
||||
{"h1"},
|
||||
nil,
|
||||
{},
|
||||
{"h3", ""},
|
||||
},
|
||||
}
|
||||
if got[0].StartPort != 443 || got[1].StartPort != 443 {
|
||||
t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got)
|
||||
}
|
||||
if got[0].Host != "10.0.0.189" || got[1].Host != "2603:c024:8002:9500:9eb:e5d3:3975:d056" {
|
||||
t.Fatalf("expected both bind addresses to be preserved, got %#v", got)
|
||||
|
||||
got := httpsRRALPNs(srv)
|
||||
want := []string{"h3", "h2", "http/1.1"}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected ALPN values: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSRRALPNsIgnoresH2COnly(t *testing.T) {
|
||||
srv := &Server{
|
||||
Protocols: []string{"h2c"},
|
||||
}
|
||||
|
||||
got := httpsRRALPNs(srv)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unexpected ALPN values: got %v want none", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,7 +281,13 @@ func (fsrv *FileServer) browseApplyQueryParams(w http.ResponseWriter, r *http.Re
|
|||
sortParam = sortCookie.Value
|
||||
}
|
||||
case sortByName, sortByNameDirFirst, sortBySize, sortByTime:
|
||||
http.SetCookie(w, &http.Cookie{Name: "sort", Value: sortParam, Secure: r.TLS != nil})
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure depends on whether the request itself used TLS
|
||||
Name: "sort",
|
||||
Value: sortParam,
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// then figure out the order
|
||||
|
|
@ -292,7 +298,13 @@ func (fsrv *FileServer) browseApplyQueryParams(w http.ResponseWriter, r *http.Re
|
|||
orderParam = orderCookie.Value
|
||||
}
|
||||
case sortOrderAsc, sortOrderDesc:
|
||||
http.SetCookie(w, &http.Cookie{Name: "order", Value: orderParam, Secure: r.TLS != nil})
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure depends on whether the request itself used TLS
|
||||
Name: "order",
|
||||
Value: orderParam,
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// finally, apply the sorting and limiting
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/internal/filesystems"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
|
|
@ -188,6 +189,105 @@ func fileMatcherTest(t *testing.T, i int, tc testCase) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTryFilesRewriteEscapesMatchedPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requestTarget string
|
||||
filename string
|
||||
extraFiles []string
|
||||
wantPath string
|
||||
wantRequestURI string
|
||||
skipWindows bool
|
||||
}{
|
||||
{
|
||||
name: "question mark in path",
|
||||
requestTarget: "/%3F.html",
|
||||
filename: "?.html",
|
||||
wantPath: "/?.html",
|
||||
wantRequestURI: "/%3F.html",
|
||||
skipWindows: true,
|
||||
},
|
||||
{
|
||||
name: "percent in path",
|
||||
requestTarget: "/%25.html",
|
||||
filename: "%.html",
|
||||
wantPath: "/%.html",
|
||||
wantRequestURI: "/%25.html",
|
||||
},
|
||||
{
|
||||
name: "encoded question mark remains percent-encoded",
|
||||
requestTarget: "/%253F.html",
|
||||
filename: "%3F.html",
|
||||
wantPath: "/%3F.html",
|
||||
wantRequestURI: "/%253F.html",
|
||||
},
|
||||
{
|
||||
name: "question mark in nested path",
|
||||
requestTarget: "/nested/%3F.html",
|
||||
filename: filepath.Join("nested", "?.html"),
|
||||
wantPath: "/nested/?.html",
|
||||
wantRequestURI: "/nested/%3F.html",
|
||||
skipWindows: true,
|
||||
},
|
||||
{
|
||||
name: "encoded slash in filename does not conflict with nesting",
|
||||
requestTarget: "/nested%252Ffile.html",
|
||||
filename: "nested%2Ffile.html",
|
||||
extraFiles: []string{filepath.Join("nested", "file.html")},
|
||||
wantPath: "/nested%2Ffile.html",
|
||||
wantRequestURI: "/nested%252Ffile.html",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.skipWindows && runtime.GOOS == "windows" {
|
||||
t.Skip("Windows file names cannot contain question marks")
|
||||
}
|
||||
|
||||
for _, name := range append([]string{tc.filename}, tc.extraFiles...) {
|
||||
filename := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatalf("creating test file parent directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(name), 0o600); err != nil {
|
||||
t.Fatalf("writing test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
m := &MatchFile{
|
||||
fsmap: &filesystems.FileSystemMap{},
|
||||
Root: root,
|
||||
TryFiles: []string{"{http.request.uri.path}"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com"+tc.requestTarget, nil)
|
||||
repl := caddyhttp.NewTestReplacer(req)
|
||||
|
||||
matched, err := m.MatchWithError(req)
|
||||
if err != nil {
|
||||
t.Fatalf("matching file: %v", err)
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("expected request %s to match %s", tc.requestTarget, tc.filename)
|
||||
}
|
||||
|
||||
rewrite.Rewrite{URI: "{http.matchers.file.relative}"}.Rewrite(req, repl)
|
||||
|
||||
if req.URL.Path != tc.wantPath {
|
||||
t.Errorf("rewritten path = %q, want %q", req.URL.Path, tc.wantPath)
|
||||
}
|
||||
if req.RequestURI != tc.wantRequestURI {
|
||||
t.Errorf("rewritten request URI = %q, want %q", req.RequestURI, tc.wantRequestURI)
|
||||
}
|
||||
if req.URL.RawQuery != "" {
|
||||
t.Errorf("rewritten raw query = %q, want empty", req.URL.RawQuery)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPHPFileMatcher(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
path string
|
||||
|
|
|
|||
|
|
@ -785,7 +785,7 @@ func redirect(w http.ResponseWriter, r *http.Request, toPath string) error {
|
|||
if r.URL.RawQuery != "" {
|
||||
toPath += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, toPath, http.StatusPermanentRedirect)
|
||||
http.Redirect(w, r, toPath, http.StatusPermanentRedirect) //nolint:gosec // toPath is a same-origin path and leading // is stripped above
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package caddyhttp
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -10,9 +11,14 @@ import (
|
|||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
otelprom "go.opentelemetry.io/contrib/bridges/prometheus"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/internal/metrics"
|
||||
caddymetrics "github.com/caddyserver/caddy/v2/internal/metrics"
|
||||
)
|
||||
|
||||
// Metrics configures metrics observations.
|
||||
|
|
@ -67,10 +73,20 @@ type Metrics struct {
|
|||
// for production environments exposed to the internet).
|
||||
ObserveCatchallHosts bool `json:"observe_catchall_hosts,omitempty"`
|
||||
|
||||
// Enable pushing metrics via OTLP in addition to the existing Prometheus
|
||||
// scrape endpoints. When set, a PeriodicReader is attached to the shared
|
||||
// Prometheus registry (via a Prometheus -> OpenTelemetry bridge), and the
|
||||
// exporter is autoconfigured from the standard OTEL_* environment
|
||||
// variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL,
|
||||
// OTEL_METRICS_EXPORTER, ...). Set OTEL_METRICS_EXPORTER=none or simply
|
||||
// keep this field false to disable OTLP export.
|
||||
OTLP bool `json:"otlp,omitempty"`
|
||||
|
||||
init sync.Once
|
||||
httpMetrics *httpMetrics
|
||||
allowedHosts map[string]struct{}
|
||||
hasHTTPSServer bool
|
||||
meterProvider *sdkmetric.MeterProvider
|
||||
}
|
||||
|
||||
type httpMetrics struct {
|
||||
|
|
@ -147,6 +163,70 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) {
|
|||
}, httpLabels)
|
||||
}
|
||||
|
||||
// provisionOTLP wires a MeterProvider that periodically reads the process-wide
|
||||
// Prometheus registry and pushes the result via OTLP. The exporter and reader
|
||||
// are autoconfigured from the standard OTEL_* environment variables, matching
|
||||
// the ergonomics of the existing `tracing` directive. It is a no-op when
|
||||
// m.OTLP is false, and honors OTEL_METRICS_EXPORTER=none (autoexport
|
||||
// short-circuits to a no-op reader in that case).
|
||||
func (m *Metrics) provisionOTLP(ctx caddy.Context) error {
|
||||
if !m.OTLP {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register a Prometheus -> OpenTelemetry bridge against the process-wide
|
||||
// Prometheus registry as the *default* source the NewMetricReader below
|
||||
// will read from.
|
||||
//
|
||||
// NB: despite the "With*" naming, autoexport.WithFallbackMetricProducer is
|
||||
// a package-level setter (it returns nothing) — it mutates autoexport's
|
||||
// internal producer registry and takes effect on the very next call to
|
||||
// NewMetricReader. It is NOT a MetricOption and must not be passed as one.
|
||||
// Users can still override the source by setting OTEL_METRICS_PRODUCERS.
|
||||
reg := ctx.GetMetricsRegistry()
|
||||
autoexport.WithFallbackMetricProducer(func(context.Context) (sdkmetric.Producer, error) {
|
||||
return otelprom.NewMetricProducer(otelprom.WithGatherer(reg)), nil
|
||||
})
|
||||
|
||||
reader, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating OTLP metric reader: %w", err)
|
||||
}
|
||||
|
||||
version, _ := caddy.Version()
|
||||
res, err := resource.Merge(resource.Default(), resource.NewSchemaless(
|
||||
semconv.WebEngineName(ServerHeader),
|
||||
semconv.WebEngineVersion(version),
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("building OTLP metrics resource: %w", err)
|
||||
}
|
||||
|
||||
m.meterProvider = sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithResource(res),
|
||||
sdkmetric.WithReader(reader),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdown flushes and tears down the OTLP MeterProvider if one was provisioned.
|
||||
// Both ForceFlush and Shutdown are always attempted so that a flush failure
|
||||
// does not prevent the reader goroutines from being stopped; errors from both
|
||||
// are returned joined.
|
||||
func (m *Metrics) shutdown(ctx context.Context) error {
|
||||
if m == nil || m.meterProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceFlush gives the final collection a chance to reach the collector
|
||||
// before the reader goroutine is stopped by Shutdown.
|
||||
return errors.Join(
|
||||
m.meterProvider.ForceFlush(ctx),
|
||||
m.meterProvider.Shutdown(ctx),
|
||||
)
|
||||
}
|
||||
|
||||
// scanConfigForHosts scans the HTTP app configuration to build a set of allowed hosts
|
||||
// for metrics collection, similar to how auto-HTTPS scans for domain names.
|
||||
func (m *Metrics) scanConfigForHosts(app *App) {
|
||||
|
|
@ -234,7 +314,7 @@ func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler
|
|||
func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
server := serverNameFromContext(r.Context())
|
||||
labels := prometheus.Labels{"server": server, "handler": h.handler}
|
||||
method := metrics.SanitizeMethod(r.Method)
|
||||
method := caddymetrics.SanitizeMethod(r.Method)
|
||||
// the "code" value is set later, but initialized here to eliminate the possibility
|
||||
// of a panic
|
||||
statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""}
|
||||
|
|
@ -264,7 +344,7 @@ func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
|||
// being called when the headers are written.
|
||||
// Effectively the same behaviour as promhttp.InstrumentHandlerTimeToWriteHeader.
|
||||
writeHeaderRecorder := ShouldBufferFunc(func(status int, header http.Header) bool {
|
||||
statusLabels["code"] = metrics.SanitizeCode(status)
|
||||
statusLabels["code"] = caddymetrics.SanitizeCode(status)
|
||||
ttfb := time.Since(start).Seconds()
|
||||
h.metrics.httpMetrics.responseDuration.With(statusLabels).Observe(ttfb)
|
||||
return false
|
||||
|
|
@ -280,7 +360,7 @@ func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
|||
if statusLabels["code"] == "" {
|
||||
// we still sanitize it, even though it's likely to be 0. A 200 is
|
||||
// returned on fallthrough so we want to reflect that.
|
||||
statusLabels["code"] = metrics.SanitizeCode(status)
|
||||
statusLabels["code"] = caddymetrics.SanitizeCode(status)
|
||||
}
|
||||
|
||||
h.metrics.httpMetrics.requestDuration.With(statusLabels).Observe(dur)
|
||||
|
|
|
|||
|
|
@ -523,6 +523,56 @@ func TestMetricsInstrumentedRoute(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMetricsProvisionOTLPDisabled(t *testing.T) {
|
||||
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
|
||||
m := &Metrics{OTLP: false}
|
||||
|
||||
if err := m.provisionOTLP(ctx); err != nil {
|
||||
t.Fatalf("provisionOTLP returned unexpected error: %v", err)
|
||||
}
|
||||
if m.meterProvider != nil {
|
||||
t.Fatalf("meterProvider should remain nil when OTLP is disabled")
|
||||
}
|
||||
|
||||
// shutdown must be safe on a never-provisioned Metrics.
|
||||
if err := m.shutdown(context.Background()); err != nil {
|
||||
t.Fatalf("shutdown returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsProvisionOTLPNoopExporter(t *testing.T) {
|
||||
// OTEL_METRICS_EXPORTER=none makes autoexport return its built-in
|
||||
// no-op reader, which avoids any network I/O while still exercising
|
||||
// the full provisionOTLP -> shutdown lifecycle.
|
||||
t.Setenv("OTEL_METRICS_EXPORTER", "none")
|
||||
|
||||
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
|
||||
m := &Metrics{OTLP: true}
|
||||
|
||||
if err := m.provisionOTLP(ctx); err != nil {
|
||||
t.Fatalf("provisionOTLP returned unexpected error: %v", err)
|
||||
}
|
||||
if m.meterProvider == nil {
|
||||
t.Fatalf("provisionOTLP did not create a MeterProvider")
|
||||
}
|
||||
|
||||
if err := m.shutdown(context.Background()); err != nil {
|
||||
t.Fatalf("shutdown returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown on a nil receiver is a convenience so App.Stop can call it
|
||||
// without guarding against app.Metrics being unset.
|
||||
func TestMetricsShutdownNilReceiver(t *testing.T) {
|
||||
var m *Metrics
|
||||
|
||||
if err := m.shutdown(context.Background()); err != nil {
|
||||
t.Fatalf("shutdown on nil Metrics returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMetricsInstrumentedRoute(b *testing.B) {
|
||||
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
m := &Metrics{
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/search"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
|
|
@ -418,14 +416,19 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
|
|||
return env, nil
|
||||
}
|
||||
|
||||
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
|
||||
|
||||
// splitPos returns the index where path should
|
||||
// be split based on t.SplitPath.
|
||||
//
|
||||
// example: if splitPath is [".php"]
|
||||
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
|
||||
//
|
||||
// Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path
|
||||
// never match any split entry: split strings are validated ASCII-only and
|
||||
// lower-cased in Provision(), so any Unicode equivalence (e.g. fullwidth or
|
||||
// mathematical letters folding to ASCII) would let an attacker upload a file
|
||||
// whose name contains such code points and have it served as PHP. See
|
||||
// FrankenPHP advisories GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8.
|
||||
//
|
||||
// Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
|
||||
func (t Transport) splitPos(path string) int {
|
||||
// TODO: from v1...
|
||||
|
|
@ -438,31 +441,18 @@ func (t Transport) splitPos(path string) int {
|
|||
|
||||
pathLen := len(path)
|
||||
|
||||
// We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in Provision().
|
||||
for _, split := range t.SplitPath {
|
||||
splitLen := len(split)
|
||||
if splitLen == 0 || splitLen > pathLen {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range pathLen {
|
||||
if path[i] >= utf8.RuneSelf {
|
||||
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
|
||||
return end
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if i+splitLen > pathLen {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i <= pathLen-splitLen; i++ {
|
||||
match := true
|
||||
for j := range splitLen {
|
||||
c := path[i+j]
|
||||
|
||||
if c >= utf8.RuneSelf {
|
||||
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
|
||||
return end
|
||||
}
|
||||
match = false
|
||||
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,65 @@ func TestSplitPos(t *testing.T) {
|
|||
splitPath: []string{".php"},
|
||||
wantPos: 9,
|
||||
},
|
||||
// Regression tests adapted from FrankenPHP advisories
|
||||
// GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8: search.IgnoreCase
|
||||
// matched Unicode equivalents of ASCII letters as ".php", and an
|
||||
// inner non-ASCII byte path could leave the match flag stale.
|
||||
{
|
||||
name: "non-ascii byte after dot must not match",
|
||||
path: "/PoC-match-unset.¡.txt",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "non-ascii byte mid-extension must not match",
|
||||
path: "/script.p\xc2\xa1p",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "small full stop ﹒ in extension must not match",
|
||||
path: "/shell﹒php",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "fullwidth full stop . in extension must not match",
|
||||
path: "/shell.php",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "fullwidth p in extension must not match",
|
||||
path: "/shell.php",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "circled php must not match",
|
||||
path: "/shell.ⓟⓗⓟ",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "mathematical sans-serif bold php must not match",
|
||||
path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "mathematical script php must not match",
|
||||
path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
|
||||
splitPath: []string{".php"},
|
||||
wantPos: -1,
|
||||
},
|
||||
{
|
||||
name: "circled php with later real php still picks the real one",
|
||||
path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
|
||||
splitPath: []string{".php"},
|
||||
// "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
|
||||
wantPos: 43,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -244,3 +303,31 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
|
|||
assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
|
||||
// advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
|
||||
// byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
|
||||
// folding fullwidth/mathematical/circled letters onto ASCII). Every payload
|
||||
// below produced a false positive in the vulnerable implementation; none
|
||||
// must match here.
|
||||
func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := Transport{SplitPath: []string{".php"}}
|
||||
payloads := []string{
|
||||
"/PoC-match-unset.¡.txt", // GHSA-3g8v: stale match=true on IndexString fallback
|
||||
"/shell﹒php", // U+FE52 small full stop
|
||||
"/shell.php", // U+FF0E fullwidth full stop
|
||||
"/shell.php", // U+FF50 fullwidth p
|
||||
"/shell.php", // U+FF48 fullwidth h
|
||||
"/shell.php", // U+FF50 fullwidth p (trailing)
|
||||
"/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
|
||||
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
|
||||
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
|
||||
"/shell.ⓟⓗⓟ", // circled latin small
|
||||
}
|
||||
|
||||
for _, p := range payloads {
|
||||
assert.Equalf(t, -1, tr.splitPos(p), "payload %q must not be detected as .php", p)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -730,3 +730,58 @@ func TestRetryMatchAllowsExpressionMixedWithOtherMatchers(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubrouteErrorFallbackWithBody is similar to TestDialErrorBodyRetry but
|
||||
// mimics Subroute's Error handler rather than testing retries specifically
|
||||
func TestSubrouteErrorFallbackWithBody(t *testing.T) {
|
||||
// Good upstream: echoes the request body with 200 OK.
|
||||
goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = w.Write(body)
|
||||
if err != nil {
|
||||
t.Errorf("error writing in good server: %v", err)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(goodServer.Close)
|
||||
|
||||
// Handler which will dial error
|
||||
badProxy := minimalHandler(0, &Upstream{Host: new(Host), Dial: deadUpstreamAddr(t)})
|
||||
|
||||
bodyReader := newCloseOnCloseReader("hello world")
|
||||
req := httptest.NewRequest("POST", "http://localhost/", bodyReader)
|
||||
// httptest.NewRequest wraps the reader in NopCloser; replace
|
||||
// it with our close-aware reader so Close() is propagated.
|
||||
req.Body = bodyReader
|
||||
|
||||
req = prepareTestRequest(req)
|
||||
rec := httptest.NewRecorder()
|
||||
err := badProxy.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error from badProxy.ServeHTTP")
|
||||
}
|
||||
|
||||
// Simulate the Subroute's Error handler by calling another handler with the
|
||||
// same request and recorder
|
||||
goodProxy := minimalHandler(0, &Upstream{Host: new(Host), Dial: goodServer.Listener.Addr().String()})
|
||||
err = goodProxy.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error from goodProxy.ServeHTTP, got: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
expectedBody := "hello world"
|
||||
if rec.Body.String() != expectedBody {
|
||||
t.Errorf("body: got %q, want %q", rec.Body.String(), expectedBody)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -593,20 +593,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
|
|||
reqHost := clonedReq.Host
|
||||
reqHeader := clonedReq.Header
|
||||
|
||||
// When retries are configured and there is a body, wrap it in
|
||||
// io.NopCloser to prevent Go's transport from closing it on dial
|
||||
// errors. cloneRequest does a shallow copy, so clonedReq.Body and
|
||||
// If the request contained a body, wrap it in io.NopCloser
|
||||
// to prevent Go's transport from closing it on dial errors.
|
||||
// cloneRequest does a shallow copy, so clonedReq.Body and
|
||||
// r.Body share the same io.ReadCloser — a dial-failure Close()
|
||||
// would kill the original body for all subsequent retry attempts.
|
||||
// The real body is closed by the HTTP server when the handler
|
||||
// returns.
|
||||
// would kill the original body for all subsequent retry
|
||||
// attempts or subsequent handlers. The real body is closed by
|
||||
// the HTTP server when the handler returns.
|
||||
//
|
||||
// If the body was already fully buffered (via request_buffers),
|
||||
// we also extract the buffer so the retry loop can replay it
|
||||
// from the beginning on each attempt. (see #6259, #7546)
|
||||
// from the beginning on each attempt. (see #6259, #7546, #7713)
|
||||
var bufferedReqBody *bytes.Buffer
|
||||
if clonedReq.Body != nil && h.LoadBalancing != nil &&
|
||||
(h.LoadBalancing.Retries > 0 || h.LoadBalancing.TryDuration > 0) {
|
||||
if clonedReq.Body != nil {
|
||||
if reqBodyBuf, ok := clonedReq.Body.(bodyReadCloser); ok && reqBodyBuf.body == nil && reqBodyBuf.buf != nil {
|
||||
bufferedReqBody = reqBodyBuf.buf
|
||||
reqBodyBuf.buf = nil
|
||||
|
|
@ -679,6 +678,17 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
|
|||
// get the updated list of upstreams
|
||||
upstreams := h.Upstreams
|
||||
if h.DynamicUpstreams != nil {
|
||||
if retries > 0 {
|
||||
// after a failure (and thus during a retry), give dynamic upstream modules an opportunity
|
||||
// to purge their relevant cache entries so we don't keep retrying bad upstreams
|
||||
if cachingDynamicUpstreams, ok := h.DynamicUpstreams.(CachingUpstreamSource); ok {
|
||||
if err := cachingDynamicUpstreams.ResetCache(r); err != nil {
|
||||
if c := h.logger.Check(zapcore.ErrorLevel, "failed clearing dynamic upstream source's cache"); c != nil {
|
||||
c.Write(zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dUpstreams, err := h.DynamicUpstreams.GetUpstreams(r)
|
||||
if err != nil {
|
||||
if c := h.logger.Check(zapcore.ErrorLevel, "failed getting dynamic upstreams; falling back to static upstreams"); c != nil {
|
||||
|
|
@ -1745,10 +1755,28 @@ type Selector interface {
|
|||
// may be called during each retry, multiple times per request, and as
|
||||
// such, needs to be instantaneous. The returned slice will not be
|
||||
// modified.
|
||||
//
|
||||
// For upstream sources that cache results, implement the
|
||||
// [CachingUpstreamSource] interface for optimal performance.
|
||||
type UpstreamSource interface {
|
||||
GetUpstreams(*http.Request) ([]*Upstream, error)
|
||||
}
|
||||
|
||||
// CachingUpstreamSource is an upstream source that caches its upstreams.
|
||||
// The relevant cache entry can be cleared/reset for a given request during
|
||||
// retries if a request fails. This can help ensure that failing backends
|
||||
// are not retried.
|
||||
//
|
||||
// EXPERIMENTAL: Subject to change.
|
||||
type CachingUpstreamSource interface {
|
||||
UpstreamSource
|
||||
|
||||
// ResetCache clears any cache entry related to the given request.
|
||||
// The next time GetUpstreams is called, it should have new upstream
|
||||
// information for the given request.
|
||||
ResetCache(*http.Request) error
|
||||
}
|
||||
|
||||
// Hop-by-hop headers. These are removed when sent to the backend.
|
||||
// As of RFC 7230, hop-by-hop headers are required to appear in the
|
||||
// Connection header field. These are the headers defined by the
|
||||
|
|
|
|||
|
|
@ -664,10 +664,12 @@ func (s CookieHashSelection) Select(pool UpstreamPool, req *http.Request, w http
|
|||
return upstream
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: s.Name,
|
||||
Value: sha,
|
||||
Path: "/",
|
||||
Secure: false,
|
||||
Name: s.Name,
|
||||
Value: sha,
|
||||
Path: "/",
|
||||
Secure: false,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
isProxyHttps := false
|
||||
if trusted, ok := caddyhttp.GetVar(req.Context(), caddyhttp.TrustedProxyVarKey).(bool); ok && trusted {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,18 @@ func (su *SRVUpstreams) Provision(ctx caddy.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (su *SRVUpstreams) ResetCache(r *http.Request) error {
|
||||
srvsMu.Lock()
|
||||
if r == nil {
|
||||
srvs = make(map[string]srvLookup)
|
||||
} else {
|
||||
suAddr, _, _, _ := su.expandedAddr(r)
|
||||
delete(srvs, suAddr)
|
||||
}
|
||||
srvsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (su SRVUpstreams) GetUpstreams(r *http.Request) ([]*Upstream, error) {
|
||||
suAddr, service, proto, name := su.expandedAddr(r)
|
||||
|
||||
|
|
@ -554,8 +566,9 @@ var (
|
|||
|
||||
// Interface guards
|
||||
var (
|
||||
_ caddy.Provisioner = (*SRVUpstreams)(nil)
|
||||
_ UpstreamSource = (*SRVUpstreams)(nil)
|
||||
_ caddy.Provisioner = (*AUpstreams)(nil)
|
||||
_ UpstreamSource = (*AUpstreams)(nil)
|
||||
_ caddy.Provisioner = (*SRVUpstreams)(nil)
|
||||
_ UpstreamSource = (*SRVUpstreams)(nil)
|
||||
_ CachingUpstreamSource = (*SRVUpstreams)(nil)
|
||||
_ caddy.Provisioner = (*AUpstreams)(nil)
|
||||
_ UpstreamSource = (*AUpstreams)(nil)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -211,12 +211,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
|
|||
var newPath, newQuery, newFrag string
|
||||
|
||||
if path != "" {
|
||||
// replace the `path` placeholder to escaped path
|
||||
pathPlaceholder := "{http.request.uri.path}"
|
||||
if strings.Contains(path, pathPlaceholder) {
|
||||
path = strings.ReplaceAll(path, pathPlaceholder, r.URL.EscapedPath())
|
||||
}
|
||||
|
||||
path = escapePathPlaceholders(path, r, repl)
|
||||
newPath = repl.ReplaceAll(path, "")
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +295,31 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
|
|||
return r.Method != oldMethod || r.RequestURI != oldURI
|
||||
}
|
||||
|
||||
func escapePathPlaceholders(path string, r *http.Request, repl *caddy.Replacer) string {
|
||||
// Replace path-valued placeholders in escaped form before the URI is parsed,
|
||||
// otherwise literal '?' and '%' bytes from the path can be interpreted as URI
|
||||
// delimiters or percent-escape sequences during the rewrite.
|
||||
pathPlaceholder := "{http.request.uri.path}"
|
||||
if strings.Contains(path, pathPlaceholder) {
|
||||
path = strings.ReplaceAll(path, pathPlaceholder, r.URL.EscapedPath())
|
||||
}
|
||||
|
||||
fileMatchRelativePlaceholder := "{http.matchers.file.relative}"
|
||||
if strings.Contains(path, fileMatchRelativePlaceholder) {
|
||||
if val, ok := repl.Get("http.matchers.file.relative"); ok {
|
||||
if relativePath, ok := val.(string); ok {
|
||||
path = strings.ReplaceAll(path, fileMatchRelativePlaceholder, escapePathPreservingSlashes(relativePath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func escapePathPreservingSlashes(path string) string {
|
||||
return strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
|
||||
}
|
||||
|
||||
// buildQueryString takes an input query string and
|
||||
// performs replacements on each component, returning
|
||||
// the resulting query string. This function appends
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
|
|
@ -241,8 +242,8 @@ func (routes RouteList) Compile(next Handler) Handler {
|
|||
mid = append(mid, wrapRoute(route))
|
||||
}
|
||||
stack := next
|
||||
for i := len(mid) - 1; i >= 0; i-- {
|
||||
stack = mid[i](stack)
|
||||
for _, middleware := range slices.Backward(mid) {
|
||||
stack = middleware(stack)
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
|
@ -305,8 +306,8 @@ func wrapRoute(route Route) Middleware {
|
|||
}
|
||||
|
||||
// compile this route's handler stack
|
||||
for i := len(route.middleware) - 1; i >= 0; i-- {
|
||||
nextCopy = route.middleware[i](nextCopy)
|
||||
for _, middleware := range slices.Backward(route.middleware) {
|
||||
nextCopy = middleware(nextCopy)
|
||||
}
|
||||
|
||||
// Apply metrics instrumentation once for the entire route,
|
||||
|
|
|
|||
|
|
@ -300,6 +300,8 @@ type Server struct {
|
|||
onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023)
|
||||
}
|
||||
|
||||
var defaultProtocols = []string{"h1", "h2", "h3"}
|
||||
|
||||
var (
|
||||
ServerHeader = "Caddy"
|
||||
serverHeader = []string{ServerHeader}
|
||||
|
|
@ -899,22 +901,58 @@ func (s *Server) logRequest(
|
|||
// protocol returns true if the protocol proto is configured/enabled.
|
||||
func (s *Server) protocol(proto string) bool {
|
||||
if s.ListenProtocols == nil {
|
||||
if slices.Contains(s.Protocols, proto) {
|
||||
return slices.Contains(s.protocolsWithDefaults(), proto)
|
||||
}
|
||||
|
||||
for _, lnProtocols := range s.ListenProtocols {
|
||||
if slices.Contains(s.listenerProtocolsWithDefaults(lnProtocols), proto) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
for _, lnProtocols := range s.ListenProtocols {
|
||||
for _, lnProtocol := range lnProtocols {
|
||||
if lnProtocol == "" && slices.Contains(s.Protocols, proto) || lnProtocol == proto {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) protocolsWithDefaults() []string {
|
||||
if len(s.Protocols) == 0 {
|
||||
return defaultProtocols
|
||||
}
|
||||
return s.Protocols
|
||||
}
|
||||
|
||||
func (s *Server) listenerProtocolsWithDefaults(lnProtocols []string) []string {
|
||||
serverProtocols := s.protocolsWithDefaults()
|
||||
if len(lnProtocols) == 0 {
|
||||
return serverProtocols
|
||||
}
|
||||
|
||||
lnProtocolsDefault := false
|
||||
lnProtocolsInclude := make([]string, 0, len(lnProtocols)+len(serverProtocols))
|
||||
srvProtocolsInclude := make(map[string]struct{}, len(serverProtocols))
|
||||
for _, srvProtocol := range serverProtocols {
|
||||
srvProtocolsInclude[srvProtocol] = struct{}{}
|
||||
}
|
||||
|
||||
for _, lnProtocol := range lnProtocols {
|
||||
if lnProtocol == "" {
|
||||
lnProtocolsDefault = true
|
||||
continue
|
||||
}
|
||||
lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol)
|
||||
delete(srvProtocolsInclude, lnProtocol)
|
||||
}
|
||||
|
||||
if lnProtocolsDefault {
|
||||
for _, srvProtocol := range serverProtocols {
|
||||
if _, ok := srvProtocolsInclude[srvProtocol]; ok {
|
||||
lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lnProtocolsInclude
|
||||
}
|
||||
|
||||
// Listeners returns the server's listeners. These are active listeners,
|
||||
// so calling Accept() or Close() on them will probably break things.
|
||||
// They are made available here for read-only purposes (e.g. Addr())
|
||||
|
|
@ -1085,11 +1123,11 @@ func strictUntrustedClientIp(r *http.Request, headers []string, trusted []netip.
|
|||
for _, headerName := range headers {
|
||||
parts := strings.Split(strings.Join(r.Header.Values(headerName), ","), ",")
|
||||
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
for _, part := range slices.Backward(parts) {
|
||||
// Some proxies may retain the port number, so split if possible
|
||||
host, _, err := net.SplitHostPort(parts[i])
|
||||
host, _, err := net.SplitHostPort(part)
|
||||
if err != nil {
|
||||
host = parts[i]
|
||||
host = part
|
||||
}
|
||||
|
||||
// Remove any zone identifier from the IP address
|
||||
|
|
|
|||
|
|
@ -36,13 +36,22 @@ func init() {
|
|||
// Templates is a middleware which executes response bodies as Go templates.
|
||||
// The syntax is documented in the Go standard library's
|
||||
// [text/template package](https://golang.org/pkg/text/template/).
|
||||
// Note that ANY response body that matches and qualifies may be evaluated,
|
||||
// even if it comes from a proxied backend.
|
||||
//
|
||||
// ⚠️ Template functions/actions are still experimental, so they are subject to change.
|
||||
// ⚠️ Template functions/actions can access the environment, files on disk,
|
||||
// and make HTTP requests. This is extremely useful, but you need to make
|
||||
// sure templates are only evaluated on content that you trust, control, or
|
||||
// at least sanitize properly.
|
||||
//
|
||||
// Custom template functions can be registered by creating a plugin module under the `http.handlers.templates.functions.*` namespace that implements the `CustomFunctions` interface.
|
||||
// ⚠️ Templates are still experimental, so they are subject to change.
|
||||
//
|
||||
// [All Sprig functions](https://masterminds.github.io/sprig/) are supported.
|
||||
//
|
||||
// Custom template functions can be registered by creating a plugin module
|
||||
// under the `http.handlers.templates.functions.*` namespace that implements
|
||||
// the `CustomFunctions` interface.
|
||||
//
|
||||
// In addition to the standard functions and the Sprig library, Caddy adds
|
||||
// extra functions and data that are available to a template:
|
||||
//
|
||||
|
|
@ -162,6 +171,25 @@ func init() {
|
|||
// {{listFiles "/mydir"}}
|
||||
// ```
|
||||
//
|
||||
// ##### `fileExists`
|
||||
//
|
||||
// Returns true if the given file name, relative to the template context's file root,
|
||||
// can be opened successfully.
|
||||
//
|
||||
// ```
|
||||
// {{fileExists "path/to/file.html"}}
|
||||
// ```
|
||||
//
|
||||
// ##### `fileStat`
|
||||
//
|
||||
// Returns [FileInfo](https://pkg.go.dev/io/fs#FileInfo) using [Stat](https://pkg.go.dev/io/fs#Stat)
|
||||
// on the given file name, relative to the template context's file root.
|
||||
//
|
||||
// ```
|
||||
// {{$css := fileStat "css/style.css" -}}
|
||||
// <link rel="stylesheet" href="/css/style.css?v={{ $css.ModTime.Unix }}">
|
||||
// ```
|
||||
//
|
||||
// ##### `markdown`
|
||||
//
|
||||
// Renders the given Markdown text as HTML and returns it. This uses the
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
webEngineName = "Caddy"
|
||||
defaultSpanName = "handler"
|
||||
nextCallCtxKey caddy.CtxKey = "nextCall"
|
||||
)
|
||||
|
|
@ -58,7 +57,7 @@ func newOpenTelemetryWrapper(
|
|||
}
|
||||
|
||||
version, _ := caddy.Version()
|
||||
res, err := ot.newResource(webEngineName, version)
|
||||
res, err := ot.newResource(caddyhttp.ServerHeader, version)
|
||||
if err != nil {
|
||||
return ot, fmt.Errorf("creating resource error: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,42 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error {
|
|||
iss.Email = email
|
||||
}
|
||||
|
||||
// expand CA endpoint, if non-empty
|
||||
if iss.CA != "" {
|
||||
ca, err := repl.ReplaceOrErr(iss.CA, true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expanding CA endpoint '%s': %v", iss.CA, err)
|
||||
}
|
||||
iss.CA = ca
|
||||
}
|
||||
|
||||
// expand TestCA endpoint, if non-empty
|
||||
if iss.TestCA != "" {
|
||||
testca, err := repl.ReplaceOrErr(iss.TestCA, true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expanding TestCA endpoint '%s': %v", iss.TestCA, err)
|
||||
}
|
||||
iss.TestCA = testca
|
||||
}
|
||||
|
||||
// expand EAB credentials, if non-empty
|
||||
if iss.ExternalAccount != nil {
|
||||
if iss.ExternalAccount.KeyID != "" {
|
||||
keyID, err := repl.ReplaceOrErr(iss.ExternalAccount.KeyID, true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expanding EAB key ID '%s': %v", iss.ExternalAccount.KeyID, err)
|
||||
}
|
||||
iss.ExternalAccount.KeyID = keyID
|
||||
}
|
||||
if iss.ExternalAccount.MACKey != "" {
|
||||
macKey, err := repl.ReplaceOrErr(iss.ExternalAccount.MACKey, true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expanding EAB MAC key (redacted): %v", err)
|
||||
}
|
||||
iss.ExternalAccount.MACKey = macKey
|
||||
}
|
||||
}
|
||||
|
||||
// expand account key, if non-empty
|
||||
if iss.AccountKey != "" {
|
||||
accountKey, err := repl.ReplaceOrErr(iss.AccountKey, true, true)
|
||||
|
|
|
|||
43
modules/caddytls/acmeissuer_test.go
Normal file
43
modules/caddytls/acmeissuer_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package caddytls
|
||||
|
||||
import (
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestACMEIssuerExpandPlaceholders(t *testing.T) {
|
||||
t.Setenv("CADDY_TEST_CA_URL", "https://acme.example.com/directory")
|
||||
t.Setenv("CADDY_TEST_TEST_CA_URL", "https://acme2.example.com/directory")
|
||||
t.Setenv("CADDY_TEST_EAB_KEY_ID", "example-key-id")
|
||||
t.Setenv("CADDY_TEST_EAB_MAC_KEY", "example-mac-key")
|
||||
|
||||
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
|
||||
defer cancel()
|
||||
|
||||
iss := &ACMEIssuer{
|
||||
CA: "{env.CADDY_TEST_CA_URL}",
|
||||
TestCA: "{env.CADDY_TEST_TEST_CA_URL}",
|
||||
ExternalAccount: &acme.EAB{
|
||||
KeyID: "{env.CADDY_TEST_EAB_KEY_ID}",
|
||||
MACKey: "{env.CADDY_TEST_EAB_MAC_KEY}",
|
||||
},
|
||||
}
|
||||
|
||||
if err := iss.Provision(caddyCtx); err != nil {
|
||||
t.Fatalf("Provision() returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if want := "https://acme.example.com/directory"; iss.CA != want {
|
||||
t.Errorf("CA: got %q, want %q", iss.CA, want)
|
||||
}
|
||||
if want := "https://acme2.example.com/directory"; iss.TestCA != want {
|
||||
t.Errorf("TestCA: got %q, want %q", iss.TestCA, want)
|
||||
}
|
||||
if want := "example-key-id"; iss.ExternalAccount.KeyID != want {
|
||||
t.Errorf("ExternalAccount.KeyID: got %q, want %q", iss.ExternalAccount.KeyID, want)
|
||||
}
|
||||
if want := "example-mac-key"; iss.ExternalAccount.MACKey != want {
|
||||
t.Errorf("ExternalAccount.MACKey: got %q, want %q", iss.ExternalAccount.MACKey, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -153,9 +153,9 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
|
|||
// in its config (remember, TLS connection policies are used by *other* apps to
|
||||
// run TLS servers) -- we skip names with placeholders
|
||||
if tlsApp.EncryptedClientHello.Publication == nil {
|
||||
var echNames []string
|
||||
repl := caddy.NewReplacer()
|
||||
for _, p := range cp {
|
||||
var echNames []string
|
||||
for _, m := range p.matchers {
|
||||
if sni, ok := m.(MatchServerName); ok {
|
||||
for _, name := range sni {
|
||||
|
|
@ -164,8 +164,8 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
tlsApp.RegisterServerNames(echNames, p.ALPN)
|
||||
}
|
||||
tlsApp.RegisterServerNames(echNames)
|
||||
}
|
||||
|
||||
tlsCfg.GetEncryptedClientHelloKeys = func(chi *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) {
|
||||
|
|
|
|||
|
|
@ -440,6 +440,10 @@ func (t *TLS) publishECHConfigs(logger *zap.Logger) error {
|
|||
zap.Strings("domains", dnsNamesToPublish),
|
||||
zap.Uint8s("config_ids", configIDs))
|
||||
|
||||
if dnsPublisher, ok := publisher.(*ECHDNSPublisher); ok {
|
||||
dnsPublisher.alpnByDomain = t.alpnValuesForServerNames(dnsNamesToPublish)
|
||||
}
|
||||
|
||||
// publish this ECH config list with this publisher
|
||||
pubTime := time.Now()
|
||||
err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin)
|
||||
|
|
@ -776,7 +780,8 @@ type ECHDNSPublisher struct {
|
|||
ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"`
|
||||
provider ECHDNSProvider
|
||||
|
||||
logger *zap.Logger
|
||||
alpnByDomain map[string][]string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
|
|
@ -872,12 +877,7 @@ nextName:
|
|||
continue
|
||||
}
|
||||
params := httpsRec.Params
|
||||
if params == nil {
|
||||
params = make(libdns.SvcParams)
|
||||
}
|
||||
|
||||
// overwrite only the "ech" SvcParamKey
|
||||
params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
|
||||
params = dnsPub.publishedSvcParams(domain, params, configListBin)
|
||||
|
||||
// publish record
|
||||
_, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{
|
||||
|
|
@ -903,6 +903,25 @@ nextName:
|
|||
return nil
|
||||
}
|
||||
|
||||
func (dnsPub *ECHDNSPublisher) publishedSvcParams(domain string, existing libdns.SvcParams, configListBin []byte) libdns.SvcParams {
|
||||
params := make(libdns.SvcParams, len(existing)+2)
|
||||
for key, values := range existing {
|
||||
params[key] = append([]string(nil), values...)
|
||||
}
|
||||
|
||||
params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
|
||||
|
||||
if len(dnsPub.alpnByDomain) == 0 {
|
||||
return params
|
||||
}
|
||||
|
||||
if alpn := dnsPub.alpnByDomain[strings.ToLower(domain)]; len(alpn) > 0 {
|
||||
params["alpn"] = append([]string(nil), alpn...)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// echConfig represents an ECHConfig from the specification,
|
||||
// [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html).
|
||||
type echConfig struct {
|
||||
|
|
|
|||
65
modules/caddytls/ech_dns_test.go
Normal file
65
modules/caddytls/ech_dns_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package caddytls
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/libdns/libdns"
|
||||
)
|
||||
|
||||
func TestRegisterServerNamesWithALPN(t *testing.T) {
|
||||
tlsApp := &TLS{
|
||||
serverNames: make(map[string]serverNameRegistration),
|
||||
serverNamesMu: new(sync.Mutex),
|
||||
}
|
||||
|
||||
tlsApp.RegisterServerNames([]string{
|
||||
"Example.com:443",
|
||||
"example.com",
|
||||
"127.0.0.1:443",
|
||||
}, []string{"h2", "http/1.1"})
|
||||
tlsApp.RegisterServerNames([]string{"EXAMPLE.COM"}, []string{"h3"})
|
||||
|
||||
got := tlsApp.alpnValuesForServerNames([]string{"example.com:443", "127.0.0.1:443"})
|
||||
want := map[string][]string{
|
||||
"example.com": {"h3", "h2", "http/1.1"},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected ALPN values: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestECHDNSPublisherPublishedSvcParams(t *testing.T) {
|
||||
dnsPub := &ECHDNSPublisher{
|
||||
alpnByDomain: map[string][]string{
|
||||
"example.com": {"h3", "h2", "http/1.1"},
|
||||
},
|
||||
}
|
||||
|
||||
existing := libdns.SvcParams{
|
||||
"alpn": {"h2"},
|
||||
"ipv4hint": {"203.0.113.10"},
|
||||
}
|
||||
|
||||
got := dnsPub.publishedSvcParams("Example.com", existing, []byte{0x01, 0x02, 0x03})
|
||||
|
||||
if !reflect.DeepEqual(existing["alpn"], []string{"h2"}) {
|
||||
t.Fatalf("existing params mutated: got %v", existing["alpn"])
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got["alpn"], []string{"h3", "h2", "http/1.1"}) {
|
||||
t.Fatalf("unexpected ALPN params: got %v", got["alpn"])
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got["ipv4hint"], []string{"203.0.113.10"}) {
|
||||
t.Fatalf("unexpected preserved params: got %v", got["ipv4hint"])
|
||||
}
|
||||
|
||||
wantECH := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
|
||||
if !reflect.DeepEqual(got["ech"], []string{wantECH}) {
|
||||
t.Fatalf("unexpected ECH params: got %v want %v", got["ech"], wantECH)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -140,7 +141,7 @@ type TLS struct {
|
|||
logger *zap.Logger
|
||||
events *caddyevents.App
|
||||
|
||||
serverNames map[string]struct{}
|
||||
serverNames map[string]serverNameRegistration
|
||||
serverNamesMu *sync.Mutex
|
||||
|
||||
// set of subjects with managed certificates,
|
||||
|
|
@ -168,7 +169,7 @@ func (t *TLS) Provision(ctx caddy.Context) error {
|
|||
t.logger = ctx.Logger()
|
||||
repl := caddy.NewReplacer()
|
||||
t.managing, t.loaded = make(map[string]string), make(map[string]string)
|
||||
t.serverNames = make(map[string]struct{})
|
||||
t.serverNames = make(map[string]serverNameRegistration)
|
||||
t.serverNamesMu = new(sync.Mutex)
|
||||
|
||||
// set up default DNS module, if any, and make sure it implements all the
|
||||
|
|
@ -613,8 +614,8 @@ func (t *TLS) Manage(subjects map[string]struct{}) error {
|
|||
|
||||
// managingWildcardFor returns true if the app is managing a certificate that covers that
|
||||
// subject name (including consideration of wildcards), either from its internal list of
|
||||
// names that it IS managing certs for, or from the otherSubjsToManage which includes names
|
||||
// that WILL be managed.
|
||||
// names that it IS managing certs for, from the otherSubjsToManage which includes names
|
||||
// that WILL be managed, or from names configured in the 'automate' loader.
|
||||
func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]struct{}) bool {
|
||||
// TODO: we could also consider manually-loaded certs using t.HasCertificateForSubject(),
|
||||
// but that does not account for how manually-loaded certs may be restricted as to which
|
||||
|
|
@ -629,7 +630,9 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str
|
|||
return managing
|
||||
}
|
||||
|
||||
// replace labels of the domain with wildcards until we get a match
|
||||
// replace labels of the domain with wildcards until we get a match from names
|
||||
// already being managed, those about to be managed in this batch, or those
|
||||
// configured for automation
|
||||
labels := strings.Split(subj, ".")
|
||||
for i := range labels {
|
||||
if labels[i] == "*" {
|
||||
|
|
@ -643,32 +646,117 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str
|
|||
if _, ok := otherSubjsToManage[candidate]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := t.automateNames[candidate]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RegisterServerNames registers the provided DNS names with the TLS app.
|
||||
// This is currently used to auto-publish Encrypted ClientHello (ECH)
|
||||
// configurations, if enabled. Use of this function by apps using the TLS
|
||||
// app removes the need for the user to redundantly specify domain names
|
||||
// in their configuration. This function separates hostname and port
|
||||
// (keeping only the hotsname) and filters IP addresses, which can't be
|
||||
// used with ECH.
|
||||
// RegisterServerNames registers the provided DNS names with the TLS app and
|
||||
// associates them with the given HTTPS RR ALPN values, if any. This is
|
||||
// currently used to auto-publish Encrypted ClientHello (ECH) configurations,
|
||||
// if enabled. Use of this function by apps using the TLS app removes the need
|
||||
// for the user to redundantly specify domain names in their configuration.
|
||||
// This function separates hostname and port, keeping only the hostname, and
|
||||
// filters IP addresses which can't be used with ECH.
|
||||
//
|
||||
// EXPERIMENTAL: This function and its semantics/behavior are subject to change.
|
||||
func (t *TLS) RegisterServerNames(dnsNames []string) {
|
||||
func (t *TLS) RegisterServerNames(dnsNames, alpnValues []string) {
|
||||
t.serverNamesMu.Lock()
|
||||
defer t.serverNamesMu.Unlock()
|
||||
|
||||
for _, name := range dnsNames {
|
||||
host, _, err := net.SplitHostPort(name)
|
||||
if err != nil {
|
||||
host = name
|
||||
}
|
||||
if strings.TrimSpace(host) != "" && !certmagic.SubjectIsIP(host) {
|
||||
t.serverNames[strings.ToLower(host)] = struct{}{}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" || certmagic.SubjectIsIP(host) {
|
||||
continue
|
||||
}
|
||||
|
||||
registration := t.serverNames[host]
|
||||
|
||||
if len(alpnValues) == 0 {
|
||||
t.serverNames[host] = registration
|
||||
continue
|
||||
}
|
||||
|
||||
if registration.alpnValues == nil {
|
||||
registration.alpnValues = make(map[string]struct{}, len(alpnValues))
|
||||
}
|
||||
for _, alpn := range alpnValues {
|
||||
if alpn == "" {
|
||||
continue
|
||||
}
|
||||
registration.alpnValues[alpn] = struct{}{}
|
||||
}
|
||||
t.serverNames[host] = registration
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TLS) alpnValuesForServerNames(dnsNames []string) map[string][]string {
|
||||
t.serverNamesMu.Lock()
|
||||
defer t.serverNamesMu.Unlock()
|
||||
|
||||
result := make(map[string][]string, len(dnsNames))
|
||||
for _, name := range dnsNames {
|
||||
host, _, err := net.SplitHostPort(name)
|
||||
if err != nil {
|
||||
host = name
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
registration, ok := t.serverNames[host]
|
||||
if !ok || len(registration.alpnValues) == 0 {
|
||||
continue
|
||||
}
|
||||
result[host] = OrderedHTTPSRRALPN(registration.alpnValues)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// OrderedHTTPSRRALPN returns the HTTPS RR ALPN values in preferred order.
|
||||
func OrderedHTTPSRRALPN(alpnSet map[string]struct{}) []string {
|
||||
if len(alpnSet) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
knownOrder := append([]string{"h3"}, defaultALPN...)
|
||||
ordered := make([]string, 0, len(alpnSet))
|
||||
seen := make(map[string]struct{}, len(alpnSet))
|
||||
|
||||
for _, alpn := range knownOrder {
|
||||
if _, ok := alpnSet[alpn]; ok {
|
||||
ordered = append(ordered, alpn)
|
||||
seen[alpn] = struct{}{}
|
||||
}
|
||||
}
|
||||
t.serverNamesMu.Unlock()
|
||||
|
||||
if len(ordered) == len(alpnSet) {
|
||||
return ordered
|
||||
}
|
||||
|
||||
var remaining []string
|
||||
for alpn := range alpnSet {
|
||||
if _, ok := seen[alpn]; ok {
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, alpn)
|
||||
}
|
||||
slices.Sort(remaining)
|
||||
|
||||
return append(ordered, remaining...)
|
||||
}
|
||||
|
||||
type serverNameRegistration struct {
|
||||
alpnValues map[string]struct{}
|
||||
}
|
||||
|
||||
// HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP
|
||||
|
|
|
|||
96
modules/caddytls/tls_wildcard_test.go
Normal file
96
modules/caddytls/tls_wildcard_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// 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 caddytls
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
func TestAvoidDuplicateAutomation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
automateNames []string
|
||||
expectedToManage bool
|
||||
}{
|
||||
{
|
||||
name: "do not manage if wildcard is automated",
|
||||
automateNames: []string{"*.example.com"},
|
||||
expectedToManage: false,
|
||||
},
|
||||
{
|
||||
name: "manage if no automation configured",
|
||||
automateNames: []string{},
|
||||
expectedToManage: true,
|
||||
},
|
||||
{
|
||||
name: "manage if explicitly requested even when wildcard automated",
|
||||
automateNames: []string{"*.example.com", "sub.example.com"},
|
||||
expectedToManage: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
automateJSON, err := json.Marshal(tc.automateNames)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tlsApp := &TLS{
|
||||
Automation: &AutomationConfig{
|
||||
Policies: []*AutomationPolicy{
|
||||
{
|
||||
IssuersRaw: []json.RawMessage{
|
||||
[]byte(`{"module": "internal"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CertificatesRaw: map[string]json.RawMessage{
|
||||
"automate": automateJSON,
|
||||
},
|
||||
}
|
||||
|
||||
var cfg caddy.Config
|
||||
ctx, err := caddy.ProvisionContext(&cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := tlsApp.Provision(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// simulate a case wherein the HTTP app starts first and
|
||||
// tells the TLS app about the following auto-HTTPS domains
|
||||
httpDomains := map[string]struct{}{"sub.example.com": {}}
|
||||
if err := tlsApp.Manage(httpDomains); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, actuallyManaged := tlsApp.managing["sub.example.com"]
|
||||
if actuallyManaged != tc.expectedToManage {
|
||||
t.Errorf(
|
||||
"expected sub.example.com individually managed: %v, got: %v",
|
||||
tc.expectedToManage,
|
||||
actuallyManaged,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -174,6 +174,47 @@ func TestFileRotationPreserveMode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFileRotationPreserveModeWithUmask(t *testing.T) {
|
||||
m := syscall.Umask(0o022)
|
||||
defer syscall.Umask(m)
|
||||
|
||||
dir, err := os.MkdirTemp("", "caddytest")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create tempdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
fpath := path.Join(dir, "test.log")
|
||||
|
||||
roll := true
|
||||
mode := fileMode(0o660)
|
||||
fw := FileWriter{
|
||||
Filename: fpath,
|
||||
Mode: mode,
|
||||
Roll: &roll,
|
||||
RollSizeMB: 1,
|
||||
}
|
||||
|
||||
logger, err := fw.OpenWriter()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b := make([]byte, 1024*1024-1000)
|
||||
logger.Write(b)
|
||||
logger.Write(b[0:2000])
|
||||
|
||||
st, err := os.Stat(fpath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check file permissions: %v", err)
|
||||
}
|
||||
|
||||
if got := st.Mode().Perm(); got != os.FileMode(mode) {
|
||||
t.Errorf("file mode after rotation is %v, want %v", got, mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileModeConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue