@marcel91200 / caddy Mirror · read-only / commits / 03306e64
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 14, 05:15 UTC

admin: /config and /id endpoints

This integrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The /config and /id endpoints make granular config changes possible as well as the exporting of the current configuration. The /load endpoint has been modified to wrap the /config handler so that the currently-running config can always be available for export. The difference is that /load allows configs of varying formats and converts them using config adapters. The adapted config is then processed with /config as JSON. The /config and /id endpoints accept only JSON.

Matthew Holt committed Oct 9, 2019 at 19:10 UTC 03306e646e3ee421f582ef69a2158724bcf2614b
4 files changed +493 -16
admin.go
+8 -12
@@ -87,7 +87,7 @@ func StartAdmin(initialConfigJSON []byte) error {
87 return fmt.Errorf("parsing admin listener address: %v", err)
88 }
89 if len(listenAddrs) != 1 {
90 - return fmt.Errorf("admin endpoint must have exactly one listener; cannot listen on %v", listenAddrs)
90 + return fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs)
91 }
92 ln, err := net.Listen(netw, listenAddrs[0])
93 if err != nil {
@@ -120,7 +120,7 @@ func StartAdmin(initialConfigJSON []byte) error {
120 ReadTimeout: 5 * time.Second,
121 ReadHeaderTimeout: 5 * time.Second,
122 IdleTimeout: 5 * time.Second,
123 - MaxHeaderBytes: 1024 * 256,
123 + MaxHeaderBytes: 1024 * 64,
124 }
125
126 go cfgEndptSrv.Serve(ln)
@@ -169,14 +169,11 @@ type AdminRoute struct {
169 }
170
171 func handleLoadConfig(w http.ResponseWriter, r *http.Request) {
172 - r.Close = true
172 if r.Method != http.MethodPost {
173 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
174 return
175 }
176
178 - var payload io.Reader = r.Body
179 -
177 // if the config is formatted other than Caddy's native
178 // JSON, we need to adapt it before loading it
179 if ctHeader := r.Header.Get("Content-Type"); ctHeader != "" {
@@ -215,16 +212,15 @@ func handleLoadConfig(w http.ResponseWriter, r *http.Request) {
212 }
213 w.Write(respBody)
214 }
218 - payload = bytes.NewReader(result)
215 + // replace original request body with adapted JSON
216 + r.Body.Close()
217 + r.Body = ioutil.NopCloser(bytes.NewReader(result))
218 }
219 }
220
222 - err := Load(payload)
223 - if err != nil {
224 - log.Printf("[ADMIN][ERROR] loading config: %v", err)
225 - http.Error(w, err.Error(), http.StatusBadRequest)
226 - return
227 - }
221 + // pass this off to the /config/ endpoint
222 + r.URL.Path = "/" + rawConfigKey + "/"
223 + handleConfig(w, r)
224 }
225
226 func handleStop(w http.ResponseWriter, r *http.Request) {
caddyconfig/configadapters.go
+4 -4
@@ -27,10 +27,10 @@ type Adapter interface {
27
28 // Warning represents a warning or notice related to conversion.
29 type Warning struct {
30 - File string
31 - Line int
32 - Directive string
33 - Message string
30 + File string `json:"file,omitempty"`
31 + Line int `json:"line,omitempty"`
32 + Directive string `json:"directive,omitempty"`
33 + Message string `json:"message,omitempty"`
34 }
35
36 // JSON encodes val as JSON, returning it as a json.RawMessage. Any
dynamicconfig.go new
+358
@@ -0,0 +1,358 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package caddy
16 +
17 +import (
18 + "bytes"
19 + "encoding/json"
20 + "fmt"
21 + "log"
22 + "net/http"
23 + "path"
24 + "strconv"
25 + "strings"
26 + "sync"
27 +)
28 +
29 +func init() {
30 + RegisterModule(router{})
31 +}
32 +
33 +type router []AdminRoute
34 +
35 +// CaddyModule returns the Caddy module information.
36 +func (router) CaddyModule() ModuleInfo {
37 + return ModuleInfo{
38 + Name: "admin.routers.dynamic_config",
39 + New: func() Module {
40 + return router{
41 + {
42 + Pattern: "/" + rawConfigKey + "/",
43 + Handler: http.HandlerFunc(handleConfig),
44 + },
45 + {
46 + Pattern: "/id/",
47 + Handler: http.HandlerFunc(handleConfigID),
48 + },
49 + }
50 + },
51 + }
52 +}
53 +
54 +func (r router) Routes() []AdminRoute { return r }
55 +
56 +// handleConfig handles config changes or exports according to r.
57 +// This function is safe for concurrent use.
58 +func handleConfig(w http.ResponseWriter, r *http.Request) {
59 + rawCfgMu.Lock()
60 + defer rawCfgMu.Unlock()
61 + unsyncedHandleConfig(w, r)
62 +}
63 +
64 +// handleConfigID accesses the config through a user-assigned ID
65 +// that is mapped to its full/expanded path in the JSON structure.
66 +// It is the same as handleConfig except it replaces the ID in
67 +// the request path with the full, expanded URL path.
68 +// This function is safe for concurrent use.
69 +func handleConfigID(w http.ResponseWriter, r *http.Request) {
70 + parts := strings.Split(r.URL.Path, "/")
71 + if len(parts) < 3 || parts[2] == "" {
72 + http.Error(w, "request path is missing object ID", http.StatusBadRequest)
73 + return
74 + }
75 + id := parts[2]
76 +
77 + rawCfgMu.Lock()
78 + defer rawCfgMu.Unlock()
79 +
80 + // map the ID to the expanded path
81 + expanded, ok := rawCfgIndex[id]
82 + if !ok {
83 + http.Error(w, "unknown object ID: "+id, http.StatusBadRequest)
84 + return
85 + }
86 +
87 + // piece the full URL path back together
88 + parts = append([]string{expanded}, parts[3:]...)
89 + r.URL.Path = path.Join(parts...)
90 +
91 + unsyncedHandleConfig(w, r)
92 +}
93 +
94 +// configIndex recurisvely searches ptr for object fields named "@id"
95 +// and maps that ID value to the full configPath in the index.
96 +// This function is NOT safe for concurrent access; use rawCfgMu.
97 +func configIndex(ptr interface{}, configPath string, index map[string]string) error {
98 + switch val := ptr.(type) {
99 + case map[string]interface{}:
100 + for k, v := range val {
101 + if k == "@id" {
102 + switch idVal := v.(type) {
103 + case string:
104 + index[idVal] = configPath
105 + case float64: // all JSON numbers decode as float64
106 + index[fmt.Sprintf("%v", idVal)] = configPath
107 + default:
108 + return fmt.Errorf("%s: @id field must be a string or number", configPath)
109 + }
110 + delete(val, "@id") // field is no longer needed, and will break config if not removed
111 + continue
112 + }
113 + // traverse this object property recursively
114 + err := configIndex(val[k], path.Join(configPath, k), index)
115 + if err != nil {
116 + return err
117 + }
118 + }
119 + case []interface{}:
120 + // traverse each element of the array recursively
121 + for i := range val {
122 + err := configIndex(val[i], path.Join(configPath, strconv.Itoa(i)), index)
123 + if err != nil {
124 + return err
125 + }
126 + }
127 + }
128 +
129 + return nil
130 +}
131 +
132 +// unsycnedHandleConfig handles config accesses without a lock
133 +// on rawCfgMu. This is NOT safe for concurrent use, so be sure
134 +// to acquire a lock on rawCfgMu before calling this.
135 +func unsyncedHandleConfig(w http.ResponseWriter, r *http.Request) {
136 + switch r.Method {
137 + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
138 + default:
139 + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
140 + return
141 + }
142 +
143 + // perform the mutation with our decoded representation
144 + // (the map), which may change pointers deep within it
145 + err := mutateConfig(w, r)
146 + if err != nil {
147 + http.Error(w, "mutating config: "+err.Error(), http.StatusBadRequest)
148 + return
149 + }
150 +
151 + if r.Method != http.MethodGet {
152 + // find any IDs in this config and index them
153 + idx := make(map[string]string)
154 + err = configIndex(rawCfg[rawConfigKey], "/config", idx)
155 + if err != nil {
156 + http.Error(w, "indexing config: "+err.Error(), http.StatusInternalServerError)
157 + return
158 + }
159 +
160 + // the mutation is complete, so encode the entire config as JSON
161 + newCfg, err := json.Marshal(rawCfg[rawConfigKey])
162 + if err != nil {
163 + http.Error(w, "encoding new config: "+err.Error(), http.StatusBadRequest)
164 + return
165 + }
166 +
167 + // if nothing changed, no need to do a whole reload unless the client forces it
168 + if r.Header.Get("Cache-Control") != "must-revalidate" && bytes.Equal(rawCfgJSON, newCfg) {
169 + log.Printf("[ADMIN][INFO] Config is unchanged")
170 + return
171 + }
172 +
173 + // load this new config; if it fails, we need to revert to
174 + // our old representation of caddy's actual config
175 + err = Load(bytes.NewReader(newCfg))
176 + if err != nil {
177 + // restore old config state to keep it consistent
178 + // with what caddy is still running; we need to
179 + // unmarshal it again because it's likely that
180 + // pointers deep in our rawCfg map were modified
181 + var oldCfg interface{}
182 + err2 := json.Unmarshal(rawCfgJSON, &oldCfg)
183 + if err2 != nil {
184 + err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2)
185 + }
186 + rawCfg[rawConfigKey] = oldCfg
187 +
188 + // report error
189 + log.Printf("[ADMIN][ERROR] loading config: %v", err)
190 + http.Error(w, err.Error(), http.StatusBadRequest)
191 + return
192 + }
193 +
194 + // success, so update our stored copy of the encoded
195 + // config to keep it consistent with what caddy is now
196 + // running (storing an encoded copy is not strictly
197 + // necessary, but avoids an extra json.Marshal for
198 + // each config change)
199 + rawCfgJSON = newCfg
200 + rawCfgIndex = idx
201 + }
202 +}
203 +
204 +// mutateConfig changes the rawCfg according to r. It is NOT
205 +// safe for concurrent use; use rawCfgMu. If the request's
206 +// method is GET, the config will not be changed.
207 +func mutateConfig(w http.ResponseWriter, r *http.Request) error {
208 + var err error
209 + var val interface{}
210 +
211 + // if there is a request body, make sure we recognize its content-type and decode it
212 + if r.Method != http.MethodGet && r.Method != http.MethodDelete {
213 + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "/json") {
214 + return fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct)
215 + }
216 + err = json.NewDecoder(r.Body).Decode(&val)
217 + if err != nil {
218 + return fmt.Errorf("decoding request body: %v", err)
219 + }
220 + }
221 +
222 + buf := new(bytes.Buffer)
223 + enc := json.NewEncoder(buf)
224 +
225 + cleanPath := strings.Trim(r.URL.Path, "/")
226 + if cleanPath == "" {
227 + return fmt.Errorf("no traversable path")
228 + }
229 +
230 + parts := strings.Split(cleanPath, "/")
231 + if len(parts) == 0 {
232 + return fmt.Errorf("path missing")
233 + }
234 +
235 + var ptr interface{} = rawCfg
236 +
237 +traverseLoop:
238 + for i, part := range parts {
239 + switch v := ptr.(type) {
240 + case map[string]interface{}:
241 + // if the next part enters a slice, and the slice is our destination,
242 + // handle it specially (because appending to the slice copies the slice
243 + // header, which does not replace the original one like we want)
244 + if arr, ok := v[part].([]interface{}); ok && i == len(parts)-2 {
245 + var idx int
246 + if r.Method != http.MethodPost {
247 + idxStr := parts[len(parts)-1]
248 + idx, err = strconv.Atoi(idxStr)
249 + if err != nil {
250 + return fmt.Errorf("[%s] invalid array index '%s': %v",
251 + r.URL.Path, idxStr, err)
252 + }
253 + if idx < 0 || idx >= len(arr) {
254 + return fmt.Errorf("[%s] array index out of bounds: %s", r.URL.Path, idxStr)
255 + }
256 + }
257 +
258 + switch r.Method {
259 + case http.MethodGet:
260 + err = enc.Encode(arr[idx])
261 + if err != nil {
262 + return fmt.Errorf("encoding config: %v", err)
263 + }
264 + case http.MethodPost:
265 + v[part] = append(arr, val)
266 + case http.MethodPut:
267 + // avoid creation of new slice and a second copy (see
268 + // https://github.com/golang/go/wiki/SliceTricks#insert)
269 + arr = append(arr, nil)
270 + copy(arr[idx+1:], arr[idx:])
271 + arr[idx] = val
272 + v[part] = arr
273 + case http.MethodPatch:
274 + arr[idx] = val
275 + case http.MethodDelete:
276 + v[part] = append(arr[:idx], arr[idx+1:]...)
277 + default:
278 + return fmt.Errorf("unrecognized method %s", r.Method)
279 + }
280 + break traverseLoop
281 + }
282 +
283 + if i == len(parts)-1 {
284 + switch r.Method {
285 + case http.MethodGet:
286 + err = enc.Encode(v[part])
287 + if err != nil {
288 + return fmt.Errorf("encoding config: %v", err)
289 + }
290 + case http.MethodPost:
291 + if arr, ok := v[part].([]interface{}); ok {
292 + // if the part is an existing list, POST appends to it
293 + // TODO: Do we ever reach this point, since we handle arrays
294 + // separately above?
295 + v[part] = append(arr, val)
296 + } else {
297 + // otherwise, it simply sets the value
298 + v[part] = val
299 + }
300 + case http.MethodPut:
301 + if _, ok := v[part]; ok {
302 + return fmt.Errorf("[%s] key already exists: %s", r.URL.Path, part)
303 + }
304 + v[part] = val
305 + case http.MethodPatch:
306 + if _, ok := v[part]; !ok {
307 + return fmt.Errorf("[%s] key does not exist: %s", r.URL.Path, part)
308 + }
309 + v[part] = val
310 + case http.MethodDelete:
311 + delete(v, part)
312 + default:
313 + return fmt.Errorf("unrecognized method %s", r.Method)
314 + }
315 + } else {
316 + ptr = v[part]
317 + }
318 +
319 + case []interface{}:
320 + partInt, err := strconv.Atoi(part)
321 + if err != nil {
322 + return fmt.Errorf("[/%s] invalid array index '%s': %v",
323 + strings.Join(parts[:i+1], "/"), part, err)
324 + }
325 + if partInt < 0 || partInt >= len(v) {
326 + return fmt.Errorf("[/%s] array index out of bounds: %s",
327 + strings.Join(parts[:i+1], "/"), part)
328 + }
329 + ptr = v[partInt]
330 +
331 + default:
332 + return fmt.Errorf("invalid path: %s", parts[:i+1])
333 + }
334 + }
335 +
336 + if r.Method == http.MethodGet {
337 + w.Header().Set("Content-Type", "application/json")
338 + w.Write(buf.Bytes())
339 + }
340 +
341 + return nil
342 +}
343 +
344 +var (
345 + // rawCfg is the current, generic-decoded configuration;
346 + // we initialize it as a map with one field ("config")
347 + // to maintain parity with the API endpoint and to avoid
348 + // the special case of having to access/mutate the variable
349 + // directly without traversing into it
350 + rawCfg = map[string]interface{}{
351 + rawConfigKey: nil,
352 + }
353 + rawCfgJSON []byte // keeping the encoded form avoids an extra Marshal on changes
354 + rawCfgIndex map[string]string // map of user-assigned ID to expanded path
355 + rawCfgMu sync.Mutex // protects rawCfg, rawCfgJSON, and rawCfgIndex
356 +)
357 +
358 +const rawConfigKey = "config"
dynamicconfig_test.go new
+123
@@ -0,0 +1,123 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package caddy
16 +
17 +import (
18 + "encoding/json"
19 + "net/http"
20 + "net/http/httptest"
21 + "reflect"
22 + "strings"
23 + "testing"
24 +)
25 +
26 +func TestMutateConfig(t *testing.T) {
27 + // each test is performed in sequence, so
28 + // each change builds on the previous ones;
29 + // the config is not reset between tests
30 + for i, tc := range []struct {
31 + method string
32 + path string // rawConfigKey will be prepended
33 + payload string
34 + expect string // JSON representation of what the whole config is expected to be after the request
35 + shouldErr bool
36 + }{
37 + {
38 + method: "POST",
39 + path: "",
40 + payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value
41 + expect: `{"foo": "bar", "list": ["a", "b", "c"]}`,
42 + },
43 + {
44 + method: "POST",
45 + path: "/foo",
46 + payload: `"jet"`,
47 + expect: `{"foo": "jet", "list": ["a", "b", "c"]}`,
48 + },
49 + {
50 + method: "POST",
51 + path: "/bar",
52 + payload: `{"aa": "bb", "qq": "zz"}`,
53 + expect: `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`,
54 + },
55 + {
56 + method: "DELETE",
57 + path: "/bar/qq",
58 + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
59 + },
60 + {
61 + method: "POST",
62 + path: "/list",
63 + payload: `"e"`,
64 + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
65 + },
66 + {
67 + method: "PUT",
68 + path: "/list/3",
69 + payload: `"d"`,
70 + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`,
71 + },
72 + {
73 + method: "DELETE",
74 + path: "/list/3",
75 + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
76 + },
77 + {
78 + method: "PATCH",
79 + path: "/list/3",
80 + payload: `"d"`,
81 + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`,
82 + },
83 + } {
84 + req, err := http.NewRequest(tc.method, rawConfigKey+tc.path, strings.NewReader(tc.payload))
85 + if err != nil {
86 + t.Fatalf("Test %d: making test request: %v", i, err)
87 + }
88 + req.Header.Set("Content-Type", "application/json")
89 +
90 + w := httptest.NewRecorder()
91 +
92 + err = mutateConfig(w, req)
93 +
94 + if tc.shouldErr && err == nil {
95 + t.Fatalf("Test %d: Expected error return value, but got: %v", i, err)
96 + }
97 + if !tc.shouldErr && err != nil {
98 + t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err)
99 + }
100 +
101 + if tc.shouldErr && w.Code == http.StatusOK {
102 + t.Fatalf("Test %d: Expected error, but got HTTP %d: %s",
103 + i, w.Code, w.Body.String())
104 + }
105 + if !tc.shouldErr && w.Code != http.StatusOK {
106 + t.Fatalf("Test %d: Should not have errored, but got HTTP %d: %s",
107 + i, w.Code, w.Body.String())
108 + }
109 +
110 + // decode the expected config so we can do a convenient DeepEqual
111 + var expectedDecoded interface{}
112 + err = json.Unmarshal([]byte(tc.expect), &expectedDecoded)
113 + if err != nil {
114 + t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err)
115 + }
116 +
117 + // make sure the resulting config is as we expect it
118 + if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) {
119 + t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v",
120 + i, expectedDecoded, rawCfg[rawConfigKey])
121 + }
122 + }
123 +}