summaryrefslogtreecommitdiff
path: root/modules/caddyhttp/staticresp.go
blob: ccc70e2da5c9827e24f5378318b6bb09d0cdab09 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
// 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 caddyhttp

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/caddyconfig"
	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
	caddycmd "github.com/caddyserver/caddy/v2/cmd"
)

func init() {
	caddy.RegisterModule(StaticResponse{})
	caddycmd.RegisterCommand(caddycmd.Command{
		Name:  "respond",
		Func:  cmdRespond,
		Usage: `[--status <code>] [--body <content>] [--listen <addr>] [--access-log] [--debug] [--header "Field: value"] <body|status>`,
		Short: "Simple, hard-coded HTTP responses for development and testing",
		Long: `
Spins up a quick-and-clean HTTP server for development and testing purposes.

With no options specified, this command listens on a random available port
and answers HTTP requests with an empty 200 response. The listen address can
be customized with the --listen flag and will always be printed to stdout.
If the listen address includes a port range, multiple servers will be started.

If a final, unnamed argument is given, it will be treated as a status code
(same as the --status flag) if it is a 3-digit number. Otherwise, it is used
as the response body (same as the --body flag). The --status and --body flags
will always override this argument (for example, to write a body that
literally says "404" but with a status code of 200, do '--status 200 404').

A body may be given in 3 ways: a flag, a final (and unnamed) argument to
the command, or piped to stdin (if flag and argument are unset). Limited
template evaluation is supported on the body, with the following variables:

	{{.N}}        The server number (useful if using a port range)
	{{.Port}}     The listener port
	{{.Address}}  The listener address

(See the docs for the text/template package in the Go standard library for
information about using templates: https://pkg.go.dev/text/template)

Access/request logging and more verbose debug logging can also be enabled.

Response headers may be added using the --header flag for each header field.
`,
		Flags: func() *flag.FlagSet {
			fs := flag.NewFlagSet("respond", flag.ExitOnError)
			fs.String("listen", ":0", "The address to which to bind the listener")
			fs.Int("status", http.StatusOK, "The response status code")
			fs.String("body", "", "The body of the HTTP response")
			fs.Bool("access-log", false, "Enable the access log")
			fs.Bool("debug", false, "Enable more verbose debug-level logging")
			fs.Var(&respondCmdHeaders, "header", "Set a header on the response (format: \"Field: value\"")
			return fs
		}(),
	})
}

// StaticResponse implements a simple responder for static responses.
type StaticResponse struct {
	// The HTTP status code to respond with. Can be an integer or,
	// if needing to use a placeholder, a string.
	//
	// If the status code is 103 (Early Hints), the response headers
	// will be written to the client immediately, the body will be
	// ignored, and the next handler will be invoked. This behavior
	// is EXPERIMENTAL while RFC 8297 is a draft, and may be changed
	// or removed.
	StatusCode WeakString `json:"status_code,omitempty"`

	// Header fields to set on the response; overwrites any existing
	// header fields of the same names after normalization.
	Headers http.Header `json:"headers,omitempty"`

	// The response body. If non-empty, the Content-Type header may
	// be added automatically if it is not explicitly configured nor
	// already set on the response; the default value is
	// "text/plain; charset=utf-8" unless the body is a valid JSON object
	// or array, in which case the value will be "application/json".
	// Other than those common special cases the Content-Type header
	// should be set explicitly if it is desired because MIME sniffing
	// is disabled for safety.
	Body string `json:"body,omitempty"`

	// If true, the server will close the client's connection
	// after writing the response.
	Close bool `json:"close,omitempty"`

	// Immediately and forcefully closes the connection without
	// writing a response. Interrupts any other HTTP streams on
	// the same connection.
	Abort bool `json:"abort,omitempty"`
}

// CaddyModule returns the Caddy module information.
func (StaticResponse) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "http.handlers.static_response",
		New: func() caddy.Module { return new(StaticResponse) },
	}
}

// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax:
//
//	respond [<matcher>] <status>|<body> [<status>] {
//	    body <text>
//	    close
//	}
//
// If there is just one argument (other than the matcher), it is considered
// to be a status code if it's a valid positive integer of 3 digits.
func (s *StaticResponse) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
	for d.Next() {
		args := d.RemainingArgs()
		switch len(args) {
		case 1:
			if len(args[0]) == 3 {
				if num, err := strconv.Atoi(args[0]); err == nil && num > 0 {
					s.StatusCode = WeakString(args[0])
					break
				}
			}
			s.Body = args[0]
		case 2:
			s.Body = args[0]
			s.StatusCode = WeakString(args[1])
		default:
			return d.ArgErr()
		}

		for d.NextBlock(0) {
			switch d.Val() {
			case "body":
				if s.Body != "" {
					return d.Err("body already specified")
				}
				if !d.AllArgs(&s.Body) {
					return d.ArgErr()
				}
			case "close":
				if s.Close {
					return d.Err("close already specified")
				}
				s.Close = true
			default:
				return d.Errf("unrecognized subdirective '%s'", d.Val())
			}
		}
	}
	return nil
}

func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error {
	// close the connection immediately
	if s.Abort {
		panic(http.ErrAbortHandler)
	}

	// close the connection after responding
	if s.Close {
		r.Close = true
		w.Header().Set("Connection", "close")
	}

	repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)

	// set all headers
	for field, vals := range s.Headers {
		field = repl.ReplaceAll(field, "")
		newVals := make([]string, len(vals))
		for i := range vals {
			newVals[i] = repl.ReplaceAll(vals[i], "")
		}
		w.Header()[field] = newVals
	}

	// implicitly set Content-Type header if we can do so safely
	// (this allows templates handler to eval templates successfully
	// or for clients to render JSON properly which is very common)
	body := repl.ReplaceKnown(s.Body, "")
	if body != "" && w.Header().Get("Content-Type") == "" {
		content := strings.TrimSpace(s.Body)
		if len(content) > 2 &&
			(content[0] == '{' && content[len(content)-1] == '}' ||
				(content[0] == '[' && content[len(content)-1] == ']')) &&
			json.Valid([]byte(content)) {
			w.Header().Set("Content-Type", "application/json")
		} else {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		}
	}

	// do not allow Go to sniff the content-type, for safety
	if w.Header().Get("Content-Type") == "" {
		w.Header()["Content-Type"] = nil
	}

	// get the status code; if this handler exists in an error route,
	// use the recommended status code as the default; otherwise 200
	statusCode := http.StatusOK
	if reqErr, ok := r.Context().Value(ErrorCtxKey).(error); ok {
		if handlerErr, ok := reqErr.(HandlerError); ok {
			if handlerErr.StatusCode > 0 {
				statusCode = handlerErr.StatusCode
			}
		}
	}
	if codeStr := s.StatusCode.String(); codeStr != "" {
		intVal, err := strconv.Atoi(repl.ReplaceAll(codeStr, ""))
		if err != nil {
			return Error(http.StatusInternalServerError, err)
		}
		statusCode = intVal
	}

	// write headers
	w.WriteHeader(statusCode)

	// write response body
	if statusCode != http.StatusEarlyHints && body != "" {
		fmt.Fprint(w, body)
	}

	// continue handling after Early Hints as they are not the final response
	if statusCode == http.StatusEarlyHints {
		return next.ServeHTTP(w, r)
	}

	return nil
}

func cmdRespond(fl caddycmd.Flags) (int, error) {
	caddy.TrapSignals()

	// get flag values
	listen := fl.String("listen")
	statusCodeFl := fl.Int("status")
	bodyFl := fl.String("body")
	accessLog := fl.Bool("access-log")
	debug := fl.Bool("debug")
	arg := fl.Arg(0)

	if fl.NArg() > 1 {
		return caddy.ExitCodeFailedStartup, fmt.Errorf("too many unflagged arguments")
	}

	// prefer status and body from explicit flags
	statusCode, body := statusCodeFl, bodyFl

	// figure out if status code was explicitly specified; this lets
	// us set a non-zero value as the default but is a little hacky
	var statusCodeFlagSpecified bool
	for _, fl := range os.Args {
		if fl == "--status" {
			statusCodeFlagSpecified = true
			break
		}
	}

	// try to determine what kind of parameter the unnamed argument is
	if arg != "" {
		// specifying body and status flags makes the argument redundant/unused
		if bodyFl != "" && statusCodeFlagSpecified {
			return caddy.ExitCodeFailedStartup, fmt.Errorf("unflagged argument \"%s\" is overridden by flags", arg)
		}

		// if a valid 3-digit number, treat as status code; otherwise body
		if argInt, err := strconv.Atoi(arg); err == nil && !statusCodeFlagSpecified {
			if argInt >= 100 && argInt <= 999 {
				statusCode = argInt
			}
		} else if body == "" {
			body = arg
		}
	}

	// if we still need a body, see if stdin is being piped
	if body == "" {
		stdinInfo, err := os.Stdin.Stat()
		if err != nil {
			return caddy.ExitCodeFailedStartup, err
		}
		if stdinInfo.Mode()&os.ModeNamedPipe != 0 {
			bodyBytes, err := io.ReadAll(os.Stdin)
			if err != nil {
				return caddy.ExitCodeFailedStartup, err
			}
			body = string(bodyBytes)
		}
	}

	// build headers map
	hdr := make(http.Header)
	for i, h := range respondCmdHeaders {
		key, val, found := strings.Cut(h, ":")
		key, val = strings.TrimSpace(key), strings.TrimSpace(val)
		if !found || key == "" || val == "" {
			return caddy.ExitCodeFailedStartup, fmt.Errorf("header %d: invalid format \"%s\" (expecting \"Field: value\")", i, h)
		}
		hdr.Set(key, val)
	}

	// expand listen address, if more than one port
	listenAddr, err := caddy.ParseNetworkAddress(listen)
	if err != nil {
		return caddy.ExitCodeFailedStartup, err
	}
	listenAddrs := make([]string, 0, listenAddr.PortRangeSize())
	for offset := uint(0); offset < listenAddr.PortRangeSize(); offset++ {
		listenAddrs = append(listenAddrs, listenAddr.JoinHostPort(offset))
	}

	// build each HTTP server
	httpApp := App{Servers: make(map[string]*Server)}

	for i, addr := range listenAddrs {
		var handlers []json.RawMessage

		// response body supports a basic template; evaluate it
		tplCtx := struct {
			N       int    // server number
			Port    uint   // only the port
			Address string // listener address
		}{
			N:       i,
			Port:    listenAddr.StartPort + uint(i),
			Address: addr,
		}
		tpl, err := template.New("body").Parse(body)
		if err != nil {
			return caddy.ExitCodeFailedStartup, err
		}
		buf := new(bytes.Buffer)
		err = tpl.Execute(buf, tplCtx)
		if err != nil {
			return caddy.ExitCodeFailedStartup, err
		}

		// create route with handler
		handler := StaticResponse{
			StatusCode: WeakString(fmt.Sprintf("%d", statusCode)),
			Headers:    hdr,
			Body:       buf.String(),
		}
		handlers = append(handlers, caddyconfig.JSONModuleObject(handler, "handler", "static_response", nil))
		route := Route{HandlersRaw: handlers}

		server := &Server{
			Listen:            []string{addr},
			ReadHeaderTimeout: caddy.Duration(10 * time.Second),
			IdleTimeout:       caddy.Duration(30 * time.Second),
			MaxHeaderBytes:    1024 * 10,
			Routes:            RouteList{route},
			AutoHTTPS:         &AutoHTTPSConfig{DisableRedir: true},
		}
		if accessLog {
			server.Logs = new(ServerLogConfig)
		}

		// save server
		httpApp.Servers[fmt.Sprintf("static%d", i)] = server
	}

	// finish building the config
	var false bool
	cfg := &caddy.Config{
		Admin: &caddy.AdminConfig{
			Disabled: true,
			Config: &caddy.ConfigSettings{
				Persist: &false,
			},
		},
		AppsRaw: caddy.ModuleMap{
			"http": caddyconfig.JSON(httpApp, nil),
		},
	}
	if debug {
		cfg.Logging = &caddy.Logging{
			Logs: map[string]*caddy.CustomLog{
				"default": {Level: "DEBUG"},
			},
		}
	}

	// run it!
	err = caddy.Run(cfg)
	if err != nil {
		return caddy.ExitCodeFailedStartup, err
	}

	// to print listener addresses, get the active HTTP app
	loadedHTTPApp, err := caddy.ActiveContext().App("http")
	if err != nil {
		return caddy.ExitCodeFailedStartup, err
	}

	// print each listener address
	for _, srv := range loadedHTTPApp.(*App).Servers {
		for _, ln := range srv.listeners {
			fmt.Printf("Server address: %s\n", ln.Addr())
		}
	}

	select {}
}

// respondCmdHeaders holds the parsed values from repeated use of the --header flag.
var respondCmdHeaders caddycmd.StringSlice

// Interface guards
var (
	_ MiddlewareHandler     = (*StaticResponse)(nil)
	_ caddyfile.Unmarshaler = (*StaticResponse)(nil)
)