@marcel91200 / caddy Mirror · read-only / commits / 9dd286c5
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 06:00 UTC

httpcaddyfile: give each adaptation its own directive order (#7995)

Faiyaz Rahman committed Sep 6, 2026 at 13:20 UTC 9dd286c5e49e2173406e7b2828820b29262a754c
5 files changed +291 -30
caddyconfig/httpcaddyfile/builtins.go
+1 -1
@@ -818,7 +818,7 @@ func parseRoute(h Helper) (caddyhttp.MiddlewareHandler, error) {
818 }
819 }
820
821 - return buildSubroute(allResults, h.groupCounter, false)
821 + return buildSubroute(allResults, h.groupCounter, nil)
822 }
823
824 func parseHandle(h Helper) (caddyhttp.MiddlewareHandler, error) {
caddyconfig/httpcaddyfile/directives.go
+26 -18
@@ -99,10 +99,24 @@ var defaultDirectiveOrder = []string{
99 }
100
101 // directiveOrder specifies the order to apply directives
102 -// in HTTP routes, after being modified by either the
103 -// plugins or by the user via the "order" global option.
102 +// in HTTP routes, after being modified by the plugins.
103 +// It is only written during plugin registration, i.e.
104 +// before any adaptation runs; adaptations only read it,
105 +// since the user's "order" global option is kept per
106 +// adaptation in options["order"] instead.
107 var directiveOrder = defaultDirectiveOrder
108
109 +// directiveOrderFor returns the directive order to use for the
110 +// adaptation these options belong to. That's whatever the "order"
111 +// global option built up, or the plugin-registered order if the
112 +// Caddyfile didn't use it.
113 +func directiveOrderFor(options map[string]any) []string {
114 + if order, ok := options["order"].([]string); ok {
115 + return order
116 + }
117 + return directiveOrder
118 +}
119 +
120 // RegisterDirective registers a unique directive dir with an
121 // associated unmarshaling (setup) function. When directive dir
122 // is encountered in a Caddyfile, setupFunc will be called to
@@ -169,20 +183,14 @@ func RegisterDirectiveOrder(dir string, position Positional, standardDir string)
183 panic("the 3rd argument '" + standardDir + "' must be a directive that exists in the standard distribution of Caddy")
184 }
185
172 - // insert directive into proper position
173 - newOrder := directiveOrder
174 - for i, d := range newOrder {
175 - if d != standardDir {
176 - continue
177 - }
178 - switch position {
179 - case Before:
180 - newOrder = append(newOrder[:i], append([]string{dir}, newOrder[i:]...)...)
181 - case After:
182 - newOrder = append(newOrder[:i+1], append([]string{dir}, newOrder[i+1:]...)...)
183 - case First, Last:
186 + // insert directive into proper position, into a copy so that we
187 + // never write into the array backing defaultDirectiveOrder
188 + newOrder := slices.Clone(directiveOrder)
189 + if i := slices.Index(newOrder, standardDir); i >= 0 {
190 + if position == After {
191 + i++
192 }
185 - break
193 + newOrder = slices.Insert(newOrder, i, dir)
194 }
195 directiveOrder = newOrder
196 }
@@ -348,7 +356,7 @@ func ParseSegmentAsSubroute(h Helper) (caddyhttp.MiddlewareHandler, error) {
356 return nil, err
357 }
358
351 - return buildSubroute(allResults, h.groupCounter, true)
359 + return buildSubroute(allResults, h.groupCounter, directiveOrderFor(h.options))
360 }
361
362 // parseSegmentAsConfig parses the segment such that its subdirectives
@@ -444,9 +452,9 @@ type ConfigValue struct {
452 directive string
453 }
454
447 -func sortRoutes(routes []ConfigValue) {
455 +func sortRoutes(routes []ConfigValue, order []string) {
456 dirPositions := make(map[string]int)
449 - for i, dir := range directiveOrder {
457 + for i, dir := range order {
458 dirPositions[dir] = i
459 }
460
caddyconfig/httpcaddyfile/directives_test.go
+234
@@ -2,8 +2,14 @@ package httpcaddyfile
2
3 import (
4 "reflect"
5 + "slices"
6 "sort"
7 + "strings"
8 + "sync"
9 "testing"
10 +
11 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
12 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
13 )
14
15 func TestHostsFromKeys(t *testing.T) {
@@ -95,3 +101,231 @@ func TestHostsFromKeys(t *testing.T) {
101 }
102 }
103 }
104 +
105 +// test_ordered_plugin stands in for a plugin directive in the tests below.
106 +// it registers from init() like a real plugin would, which also keeps it to
107 +// once per process, since registering twice panics.
108 +func init() {
109 + RegisterHandlerDirective("test_ordered_plugin", func(h Helper) (caddyhttp.MiddlewareHandler, error) {
110 + h.Next()
111 + return caddyhttp.StaticError{StatusCode: "500"}, nil
112 + })
113 + RegisterDirectiveOrder("test_ordered_plugin", Before, "respond")
114 +}
115 +
116 +// caddyfiles shared by the directive order tests below. the handle block in
117 +// orderTestPlain is there so that the subroute path gets covered too, not
118 +// just the top level of the site block, and orderTestReordered moves respond
119 +// ahead of root so it has to adapt to something different.
120 +const (
121 + orderTestPlain = `:8080 {
122 + root * /srv
123 + handle /foo {
124 + respond 200
125 + root * /other
126 + }
127 +}
128 +`
129 + orderTestReordered = "{\n\torder respond first\n}\n" + orderTestPlain
130 +
131 + // covers every positional, since first and last build the new order in
132 + // their own way and last can append in place
133 + orderTestConcurrent = `{
134 + order redir before respond
135 + order vars after root
136 + order root first
137 + order respond last
138 +}
139 +:8080 {
140 + respond 200
141 +}
142 +`
143 +)
144 +
145 +// adaptCaddyfile adapts the given Caddyfile with a fresh options map.
146 +func adaptCaddyfile(t *testing.T, input string) string {
147 + t.Helper()
148 + return adaptWithOptions(t, input, nil)
149 +}
150 +
151 +// adaptWithOptions adapts the given Caddyfile with the caller's options map.
152 +func adaptWithOptions(t *testing.T, input string, options map[string]any) string {
153 + t.Helper()
154 + adapter := caddyfile.Adapter{ServerType: ServerType{}}
155 + out, _, err := adapter.Adapt([]byte(input), options)
156 + if err != nil {
157 + t.Fatalf("adapting: %v", err)
158 + }
159 + return string(out)
160 +}
161 +
162 +// adaptConcurrently adapts a Caddyfile using the order global option from
163 +// several goroutines at once. A non-nil options map is shared by all of
164 +// them; nil gives each adaptation its own. Run with -race.
165 +func adaptConcurrently(t *testing.T, options map[string]any) {
166 + t.Helper()
167 +
168 + var wg sync.WaitGroup
169 + for range 8 {
170 + wg.Add(1)
171 + go func() {
172 + defer wg.Done()
173 + for range 200 {
174 + adapter := caddyfile.Adapter{ServerType: ServerType{}}
175 + if _, _, err := adapter.Adapt([]byte(orderTestConcurrent), options); err != nil {
176 + t.Errorf("adapting concurrently: %v", err)
177 + return
178 + }
179 + }
180 + }()
181 + }
182 + wg.Wait()
183 +}
184 +
185 +// handlerComesFirst reports whether handler a appears before handler b in
186 +// the adapted JSON.
187 +func handlerComesFirst(adapted, a, b string) bool {
188 + ai := strings.Index(adapted, `"handler":"`+a+`"`)
189 + bi := strings.Index(adapted, `"handler":"`+b+`"`)
190 + return ai >= 0 && bi >= 0 && ai < bi
191 +}
192 +
193 +// TestAdaptDirectiveOrderIsolation makes sure the order global option only
194 +// applies to the adaptation it was written in.
195 +func TestAdaptDirectiveOrderIsolation(t *testing.T) {
196 + baseline := adaptCaddyfile(t, orderTestPlain)
197 +
198 + // this one moves respond to the front, so it has to produce something
199 + // different than the default order does
200 + if out := adaptCaddyfile(t, orderTestReordered); out == baseline {
201 + t.Fatal("the order option had no effect, so this test proves nothing")
202 + }
203 +
204 + if out := adaptCaddyfile(t, orderTestPlain); out != baseline {
205 + t.Errorf("order option leaked into the next adaptation:\nExpected: %s\nActual: %s", baseline, out)
206 + }
207 +}
208 +
209 +// TestAdaptDirectiveOrderAfterError makes sure a failed adaptation leaves
210 +// nothing behind for the next one.
211 +func TestAdaptDirectiveOrderAfterError(t *testing.T) {
212 + baseline := adaptCaddyfile(t, orderTestPlain)
213 +
214 + const broken = `{
215 + order respond before not_a_directive
216 +}
217 +:8080 {
218 + respond 200
219 +}
220 +`
221 + adapter := caddyfile.Adapter{ServerType: ServerType{}}
222 + if _, _, err := adapter.Adapt([]byte(broken), nil); err == nil {
223 + t.Fatal("expected an error for an order option pointing at an unknown directive")
224 + }
225 +
226 + if out := adaptCaddyfile(t, orderTestPlain); out != baseline {
227 + t.Errorf("failed adaptation left the order broken:\nExpected: %s\nActual: %s", baseline, out)
228 + }
229 +}
230 +
231 +// TestAdaptDirectiveOrderWithReusedOptions makes sure that handing the same
232 +// options map to one adaptation after another doesn't carry the first one's
233 +// order into the second, and that the caller's map is left alone.
234 +func TestAdaptDirectiveOrderWithReusedOptions(t *testing.T) {
235 + baseline := adaptCaddyfile(t, orderTestPlain)
236 +
237 + options := map[string]any{}
238 +
239 + if out := adaptWithOptions(t, orderTestReordered, options); out == baseline {
240 + t.Fatal("the order option had no effect, so this test proves nothing")
241 + }
242 + if out := adaptWithOptions(t, orderTestPlain, options); out != baseline {
243 + t.Errorf("reused options map carried the order into the next adaptation:\nExpected: %s\nActual: %s", baseline, out)
244 + }
245 + if len(options) > 0 {
246 + t.Errorf("adapting wrote into the caller's options map: %v", options)
247 + }
248 +
249 + // an order the caller put in the map itself is ignored as well, and the
250 + // slice behind it is never written to; if it were used here, root would
251 + // not be an ordered handler and adapting would fail
252 + callerOrder := []string{"respond"}
253 + options["order"] = callerOrder
254 + if out := adaptWithOptions(t, orderTestPlain, options); out != baseline {
255 + t.Errorf("an order from the caller's map was used:\nExpected: %s\nActual: %s", baseline, out)
256 + }
257 + if !slices.Equal(callerOrder, []string{"respond"}) {
258 + t.Errorf("adapting modified the caller's order: %v", callerOrder)
259 + }
260 +}
261 +
262 +// TestConcurrentAdaptDirectiveOrder makes sure that adapting several
263 +// Caddyfiles at once, all of them using the order global option, doesn't
264 +// race or corrupt the order shared by the whole process.
265 +// See https://github.com/caddyserver/caddy/issues/7994
266 +func TestConcurrentAdaptDirectiveOrder(t *testing.T) {
267 + baseline := slices.Clone(directiveOrder)
268 + defaultBaseline := slices.Clone(defaultDirectiveOrder)
269 +
270 + adaptConcurrently(t, nil)
271 +
272 + if !slices.Equal(directiveOrder, baseline) {
273 + t.Errorf("adaptation changed the process-wide order:\nExpected: %v\nActual: %v", baseline, directiveOrder)
274 + }
275 + if !slices.Equal(defaultDirectiveOrder, defaultBaseline) {
276 + t.Errorf("adaptation changed the default order:\nExpected: %v\nActual: %v", defaultBaseline, defaultDirectiveOrder)
277 + }
278 +}
279 +
280 +// TestConcurrentAdaptDirectiveOrderWithReusedOptions is the same, except
281 +// every goroutine is handed the same options map.
282 +func TestConcurrentAdaptDirectiveOrderWithReusedOptions(t *testing.T) {
283 + baseline := slices.Clone(directiveOrder)
284 + defaultBaseline := slices.Clone(defaultDirectiveOrder)
285 + options := map[string]any{}
286 +
287 + adaptConcurrently(t, options)
288 +
289 + if !slices.Equal(directiveOrder, baseline) {
290 + t.Errorf("adaptation changed the process-wide order:\nExpected: %v\nActual: %v", baseline, directiveOrder)
291 + }
292 + if !slices.Equal(defaultDirectiveOrder, defaultBaseline) {
293 + t.Errorf("adaptation changed the default order:\nExpected: %v\nActual: %v", defaultBaseline, defaultDirectiveOrder)
294 + }
295 + if len(options) > 0 {
296 + t.Errorf("adapting wrote into the caller's options map: %v", options)
297 + }
298 +}
299 +
300 +// TestRegisterDirectiveOrderWithOrderOption makes sure an order registered
301 +// by a plugin is still honored, and can still be overridden by the order
302 +// global option without the override sticking around afterwards.
303 +func TestRegisterDirectiveOrderWithOrderOption(t *testing.T) {
304 + if !slices.Contains(directiveOrder, "test_ordered_plugin") {
305 + t.Fatal("plugin directive was not added to the order")
306 + }
307 + if !slices.Contains(defaultDirectiveOrder, "respond") || slices.Contains(defaultDirectiveOrder, "test_ordered_plugin") {
308 + t.Errorf("registering a plugin order corrupted the default order: %v", defaultDirectiveOrder)
309 + }
310 +
311 + const site = `:8080 {
312 + respond 200
313 + test_ordered_plugin
314 +}
315 +`
316 + // with no order option, the registered position wins
317 + if out := adaptCaddyfile(t, site); !handlerComesFirst(out, "error", "static_response") {
318 + t.Errorf("expected the plugin directive before respond, got: %s", out)
319 + }
320 +
321 + // the order option overrides it
322 + overridden := adaptCaddyfile(t, "{\n\torder test_ordered_plugin after respond\n}\n"+site)
323 + if !handlerComesFirst(overridden, "static_response", "error") {
324 + t.Errorf("expected respond before the plugin directive, got: %s", overridden)
325 + }
326 +
327 + // and the override is gone again
328 + if out := adaptCaddyfile(t, site); !handlerComesFirst(out, "error", "static_response") {
329 + t.Errorf("the order option leaked past its own adaptation, got: %s", out)
330 + }
331 +}
caddyconfig/httpcaddyfile/httptype.go
+20 -5
@@ -18,6 +18,7 @@ import (
18 "cmp"
19 "encoding/json"
20 "fmt"
21 + "maps"
22 "net"
23 "reflect"
24 "slices"
@@ -63,6 +64,18 @@ func (st ServerType) Setup(
64 gc := counter{new(int)}
65 state := make(map[string]any)
66
67 + // adaptation fills the options in as it goes, so work on our own copy;
68 + // the caller may reuse or share the map they gave us
69 + options = maps.Clone(options)
70 + if options == nil {
71 + options = make(map[string]any)
72 + }
73 +
74 + // an order accumulated by a previous adaptation must not carry into
75 + // this one, so drop it and let the first "order" option start over
76 + // from the order the plugins registered
77 + delete(options, "order")
78 +
79 // load all the server blocks and associate them with a "pile" of config values
80 originalServerBlocks := make([]serverBlock, 0, len(inputServerBlocks))
81 for _, sblock := range inputServerBlocks {
@@ -918,7 +931,7 @@ func (st *ServerType) serversFromPairings(
931
932 // set up each handler directive, making sure to honor directive order
933 dirRoutes := sblock.pile["route"]
921 - siteSubroute, err := buildSubroute(dirRoutes, groupCounter, true)
934 + siteSubroute, err := buildSubroute(dirRoutes, groupCounter, directiveOrderFor(options))
935 if err != nil {
936 return nil, err
937 }
@@ -1421,15 +1434,17 @@ func appendSubrouteToRouteList(routeList caddyhttp.RouteList,
1434
1435 // buildSubroute turns the config values, which are expected to be routes
1436 // into a clean and orderly subroute that has all the routes within it.
1424 -func buildSubroute(routes []ConfigValue, groupCounter counter, needsSorting bool) (*caddyhttp.Subroute, error) {
1425 - if needsSorting {
1437 +// The order is the one belonging to the current adaptation; pass nil to
1438 +// keep the routes in the order they were written in.
1439 +func buildSubroute(routes []ConfigValue, groupCounter counter, order []string) (*caddyhttp.Subroute, error) {
1440 + if order != nil {
1441 for _, val := range routes {
1427 - if !slices.Contains(directiveOrder, val.directive) {
1442 + if !slices.Contains(order, val.directive) {
1443 return nil, fmt.Errorf("directive '%s' is not an ordered HTTP handler, so it cannot be used here - try placing within a route block or using the order global option", val.directive)
1444 }
1445 }
1446
1432 - sortRoutes(routes)
1447 + sortRoutes(routes, order)
1448 }
1449
1450 subroute := new(caddyhttp.Subroute)
caddyconfig/httpcaddyfile/options.go
+10 -6
@@ -101,7 +101,7 @@ func parseOptHTTPSPort(d *caddyfile.Dispenser, _ any) (any, error) {
101 return httpsPort, nil
102 }
103
104 -func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
104 +func parseOptOrder(d *caddyfile.Dispenser, existing any) (any, error) {
105 d.Next() // consume option name
106
107 // get directive name
@@ -119,8 +119,16 @@ func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
119 }
120 pos := Positional(d.Val())
121
122 + // build on what an earlier order option in this same Caddyfile produced;
123 + // the first one starts from a clone of the order the plugins registered,
124 + // so from here on we're only ever editing a slice this adaptation owns
125 + order, ok := existing.([]string)
126 + if !ok {
127 + order = slices.Clone(directiveOrder)
128 + }
129 +
130 // if directive already had an order, drop it
123 - newOrder := slices.DeleteFunc(directiveOrder, func(d string) bool {
131 + newOrder := slices.DeleteFunc(order, func(d string) bool {
132 return d == dirName
133 })
134
@@ -131,7 +139,6 @@ func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
139 if d.NextArg() {
140 return nil, d.ArgErr()
141 }
134 - directiveOrder = newOrder
142 return newOrder, nil
143
144 case Last:
@@ -139,7 +146,6 @@ func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
146 if d.NextArg() {
147 return nil, d.ArgErr()
148 }
142 - directiveOrder = newOrder
149 return newOrder, nil
150
151 // if it's Before or After, continue
@@ -171,8 +177,6 @@ func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
177 // insert the directive into the new order
178 newOrder = slices.Insert(newOrder, targetIndex, dirName)
179
174 - directiveOrder = newOrder
175 -
180 return newOrder, nil
181 }
182