caddy/modules/caddyhttp/staticresp.go

442 lines
13 KiB
Go

// 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"
"fmt"
"io"
"net/http"
"net/textproto"
"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"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
func init() {
caddy.RegisterModule(StaticResponse{})
caddycmd.RegisterCommand(caddycmd.Command{
Name: "respond",
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.
`,
CobraFunc: func(cmd *cobra.Command) {
cmd.Flags().StringP("listen", "l", ":0", "The address to which to bind the listener")
cmd.Flags().IntP("status", "s", http.StatusOK, "The response status code")
cmd.Flags().StringP("body", "b", "", "The body of the HTTP response")
cmd.Flags().BoolP("access-log", "", false, "Enable the access log")
cmd.Flags().BoolP("debug", "v", false, "Enable more verbose debug-level logging")
cmd.Flags().StringSliceP("header", "H", []string{}, "Set a header on the response (format: \"Field: value\")")
cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdRespond)
},
})
}
// 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 = textproto.CanonicalMIMEHeaderKey(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
headers, err := fl.GetStringSlice("header")
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("invalid header flag: %v", err)
}
hdr := make(http.Header)
for i, h := range headers {
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": {BaseLog: caddy.BaseLog{Level: zap.DebugLevel.CapitalString()}},
},
}
}
// 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 {}
}
// Interface guards
var (
_ MiddlewareHandler = (*StaticResponse)(nil)
_ caddyfile.Unmarshaler = (*StaticResponse)(nil)
)