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