caddyhttp: Implement caddy respond command (#4870)

This commit is contained in:
Matt Holt 2022-08-01 13:36:22 -06:00 committed by GitHub
parent ebd6abcbd5
commit f783290f40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 318 additions and 40 deletions

View File

@ -993,9 +993,9 @@ func handleConfigID(w http.ResponseWriter, r *http.Request) error {
id := parts[2] id := parts[2]
// map the ID to the expanded path // map the ID to the expanded path
currentCfgMu.RLock() currentCtxMu.RLock()
expanded, ok := rawCfgIndex[id] expanded, ok := rawCfgIndex[id]
defer currentCfgMu.RUnlock() defer currentCtxMu.RUnlock()
if !ok { if !ok {
return APIError{ return APIError{
HTTPStatus: http.StatusNotFound, HTTPStatus: http.StatusNotFound,
@ -1030,7 +1030,7 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
// the operation at path according to method, using body and out as // the operation at path according to method, using body and out as
// needed. This is a low-level, unsynchronized function; most callers // needed. This is a low-level, unsynchronized function; most callers
// will want to use changeConfig or readConfig instead. This requires a // will want to use changeConfig or readConfig instead. This requires a
// read or write lock on currentCfgMu, depending on method (GET needs // read or write lock on currentCtxMu, depending on method (GET needs
// only a read lock; all others need a write lock). // only a read lock; all others need a write lock).
func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error { func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error {
var err error var err error

View File

@ -141,8 +141,8 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
return fmt.Errorf("method not allowed") return fmt.Errorf("method not allowed")
} }
currentCfgMu.Lock() currentCtxMu.Lock()
defer currentCfgMu.Unlock() defer currentCtxMu.Unlock()
if ifMatchHeader != "" { if ifMatchHeader != "" {
// expect the first and last character to be quotes // expect the first and last character to be quotes
@ -242,15 +242,15 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
// readConfig traverses the current config to path // readConfig traverses the current config to path
// and writes its JSON encoding to out. // and writes its JSON encoding to out.
func readConfig(path string, out io.Writer) error { func readConfig(path string, out io.Writer) error {
currentCfgMu.RLock() currentCtxMu.RLock()
defer currentCfgMu.RUnlock() defer currentCtxMu.RUnlock()
return unsyncedConfigAccess(http.MethodGet, path, nil, out) return unsyncedConfigAccess(http.MethodGet, path, nil, out)
} }
// indexConfigObjects recursively searches ptr for object fields named // indexConfigObjects recursively searches ptr for object fields named
// "@id" and maps that ID value to the full configPath in the index. // "@id" and maps that ID value to the full configPath in the index.
// This function is NOT safe for concurrent access; obtain a write lock // This function is NOT safe for concurrent access; obtain a write lock
// on currentCfgMu. // on currentCtxMu.
func indexConfigObjects(ptr interface{}, configPath string, index map[string]string) error { func indexConfigObjects(ptr interface{}, configPath string, index map[string]string) error {
switch val := ptr.(type) { switch val := ptr.(type) {
case map[string]interface{}: case map[string]interface{}:
@ -290,7 +290,7 @@ func indexConfigObjects(ptr interface{}, configPath string, index map[string]str
// it as the new config, replacing any other current config. // it as the new config, replacing any other current config.
// It does NOT update the raw config state, as this is a // It does NOT update the raw config state, as this is a
// lower-level function; most callers will want to use Load // lower-level function; most callers will want to use Load
// instead. A write lock on currentCfgMu is required! If // instead. A write lock on currentCtxMu is required! If
// allowPersist is false, it will not be persisted to disk, // allowPersist is false, it will not be persisted to disk,
// even if it is configured to. // even if it is configured to.
func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error { func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
@ -319,17 +319,17 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
} }
// run the new config and start all its apps // run the new config and start all its apps
err = run(newCfg, true) ctx, err := run(newCfg, true)
if err != nil { if err != nil {
return err return err
} }
// swap old config with the new one // swap old context (including its config) with the new one
oldCfg := currentCfg oldCtx := currentCtx
currentCfg = newCfg currentCtx = ctx
// Stop, Cleanup each old app // Stop, Cleanup each old app
unsyncedStop(oldCfg) unsyncedStop(oldCtx)
// autosave a non-nil config, if not disabled // autosave a non-nil config, if not disabled
if allowPersist && if allowPersist &&
@ -373,7 +373,7 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
// This is a low-level function; most callers // This is a low-level function; most callers
// will want to use Run instead, which also // will want to use Run instead, which also
// updates the config's raw state. // updates the config's raw state.
func run(newCfg *Config, start bool) error { func run(newCfg *Config, start bool) (Context, error) {
// because we will need to roll back any state // because we will need to roll back any state
// modifications if this function errors, we // modifications if this function errors, we
// keep a single error value and scope all // keep a single error value and scope all
@ -404,8 +404,8 @@ func run(newCfg *Config, start bool) error {
cancel() cancel()
// also undo any other state changes we made // also undo any other state changes we made
if currentCfg != nil { if currentCtx.cfg != nil {
certmagic.Default.Storage = currentCfg.storage certmagic.Default.Storage = currentCtx.cfg.storage
} }
} }
}() }()
@ -417,14 +417,14 @@ func run(newCfg *Config, start bool) error {
} }
err = newCfg.Logging.openLogs(ctx) err = newCfg.Logging.openLogs(ctx)
if err != nil { if err != nil {
return err return ctx, err
} }
// start the admin endpoint (and stop any prior one) // start the admin endpoint (and stop any prior one)
if start { if start {
err = replaceLocalAdminServer(newCfg) err = replaceLocalAdminServer(newCfg)
if err != nil { if err != nil {
return fmt.Errorf("starting caddy administration endpoint: %v", err) return ctx, fmt.Errorf("starting caddy administration endpoint: %v", err)
} }
} }
@ -453,7 +453,7 @@ func run(newCfg *Config, start bool) error {
return nil return nil
}() }()
if err != nil { if err != nil {
return err return ctx, err
} }
// Load and Provision each app and their submodules // Load and Provision each app and their submodules
@ -466,18 +466,18 @@ func run(newCfg *Config, start bool) error {
return nil return nil
}() }()
if err != nil { if err != nil {
return err return ctx, err
} }
if !start { if !start {
return nil return ctx, nil
} }
// Provision any admin routers which may need to access // Provision any admin routers which may need to access
// some of the other apps at runtime // some of the other apps at runtime
err = newCfg.Admin.provisionAdminRouters(ctx) err = newCfg.Admin.provisionAdminRouters(ctx)
if err != nil { if err != nil {
return err return ctx, err
} }
// Start // Start
@ -502,12 +502,12 @@ func run(newCfg *Config, start bool) error {
return nil return nil
}() }()
if err != nil { if err != nil {
return err return ctx, err
} }
// now that the user's config is running, finish setting up anything else, // now that the user's config is running, finish setting up anything else,
// such as remote admin endpoint, config loader, etc. // such as remote admin endpoint, config loader, etc.
return finishSettingUp(ctx, newCfg) return ctx, finishSettingUp(ctx, newCfg)
} }
// finishSettingUp should be run after all apps have successfully started. // finishSettingUp should be run after all apps have successfully started.
@ -612,10 +612,10 @@ type ConfigLoader interface {
// stop the others. Stop should only be called // stop the others. Stop should only be called
// if not replacing with a new config. // if not replacing with a new config.
func Stop() error { func Stop() error {
currentCfgMu.Lock() currentCtxMu.Lock()
defer currentCfgMu.Unlock() defer currentCtxMu.Unlock()
unsyncedStop(currentCfg) unsyncedStop(currentCtx)
currentCfg = nil currentCtx = Context{}
rawCfgJSON = nil rawCfgJSON = nil
rawCfgIndex = nil rawCfgIndex = nil
rawCfg[rawConfigKey] = nil rawCfg[rawConfigKey] = nil
@ -628,13 +628,13 @@ func Stop() error {
// it is logged and the function continues stopping // it is logged and the function continues stopping
// the next app. This function assumes all apps in // the next app. This function assumes all apps in
// cfg were successfully started first. // cfg were successfully started first.
func unsyncedStop(cfg *Config) { func unsyncedStop(ctx Context) {
if cfg == nil { if ctx.cfg == nil {
return return
} }
// stop each app // stop each app
for name, a := range cfg.apps { for name, a := range ctx.cfg.apps {
err := a.Stop() err := a.Stop()
if err != nil { if err != nil {
log.Printf("[ERROR] stop %s: %v", name, err) log.Printf("[ERROR] stop %s: %v", name, err)
@ -642,13 +642,13 @@ func unsyncedStop(cfg *Config) {
} }
// clean up all modules // clean up all modules
cfg.cancelFunc() ctx.cfg.cancelFunc()
} }
// Validate loads, provisions, and validates // Validate loads, provisions, and validates
// cfg, but does not start running it. // cfg, but does not start running it.
func Validate(cfg *Config) error { func Validate(cfg *Config) error {
err := run(cfg, false) _, err := run(cfg, false)
if err == nil { if err == nil {
cfg.cancelFunc() // call Cleanup on all modules cfg.cancelFunc() // call Cleanup on all modules
} }
@ -823,16 +823,25 @@ func goModule(mod *debug.Module) *debug.Module {
return mod return mod
} }
func ActiveContext() Context {
currentCtxMu.RLock()
defer currentCtxMu.RUnlock()
return currentCtx
}
// CtxKey is a value type for use with context.WithValue. // CtxKey is a value type for use with context.WithValue.
type CtxKey string type CtxKey string
// This group of variables pertains to the current configuration. // This group of variables pertains to the current configuration.
var ( var (
// currentCfgMu protects everything in this var block. // currentCtxMu protects everything in this var block.
currentCfgMu sync.RWMutex currentCtxMu sync.RWMutex
// currentCfg is the currently-running configuration. // currentCtx is the root context for the currently-running
currentCfg *Config // configuration, which can be accessed through this value.
// If the Config contained in this value is not nil, then
// a config is currently active/running.
currentCtx Context
// rawCfg is the current, generic-decoded configuration; // rawCfg is the current, generic-decoded configuration;
// we initialize it as a map with one field ("config") // we initialize it as a map with one field ("config")

View File

@ -338,6 +338,7 @@ func flagHelp(fs *flag.FlagSet) string {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
fs.SetOutput(buf) fs.SetOutput(buf)
buf.Write([]byte("(NOTE: use -- instead of - for flags)\n\n"))
fs.PrintDefaults() fs.PrintDefaults()
return buf.String() return buf.String()
} }
@ -480,3 +481,16 @@ func CaddyVersion() string {
} }
return ver return ver
} }
// StringSlice is a flag.Value that enables repeated use of a string flag.
type StringSlice []string
func (ss StringSlice) String() string { return "[" + strings.Join(ss, ", ") + "]" }
func (ss *StringSlice) Set(value string) error {
*ss = append(*ss, value)
return nil
}
// Interface guard
var _ flag.Value = (*StringSlice)(nil)

View File

@ -392,6 +392,8 @@ func (app *App) Start() error {
//nolint:errcheck //nolint:errcheck
go s.Serve(ln) go s.Serve(ln)
srv.listeners = append(srv.listeners, ln)
app.servers = append(app.servers, s) app.servers = append(app.servers, s)
} }
} }

View File

@ -117,8 +117,14 @@ func cmdFileServer(fs caddycmd.Flags) (int, error) {
Servers: map[string]*caddyhttp.Server{"static": server}, Servers: map[string]*caddyhttp.Server{"static": server},
} }
var false bool
cfg := &caddy.Config{ cfg := &caddy.Config{
Admin: &caddy.AdminConfig{Disabled: true}, Admin: &caddy.AdminConfig{
Disabled: true,
Config: &caddy.ConfigSettings{
Persist: &false,
},
},
AppsRaw: caddy.ModuleMap{ AppsRaw: caddy.ModuleMap{
"http": caddyconfig.JSON(httpApp, nil), "http": caddyconfig.JSON(httpApp, nil),
}, },

View File

@ -172,8 +172,13 @@ func cmdReverseProxy(fs caddycmd.Flags) (int, error) {
appsRaw["tls"] = caddyconfig.JSON(tlsApp, nil) appsRaw["tls"] = caddyconfig.JSON(tlsApp, nil)
} }
var false bool
cfg := &caddy.Config{ cfg := &caddy.Config{
Admin: &caddy.AdminConfig{Disabled: true}, Admin: &caddy.AdminConfig{Disabled: true,
Config: &caddy.ConfigSettings{
Persist: &false,
},
},
AppsRaw: appsRaw, AppsRaw: appsRaw,
} }

View File

@ -132,6 +132,7 @@ type Server struct {
primaryHandlerChain Handler primaryHandlerChain Handler
errorHandlerChain Handler errorHandlerChain Handler
listenerWrappers []caddy.ListenerWrapper listenerWrappers []caddy.ListenerWrapper
listeners []net.Listener
tlsApp *caddytls.TLS tlsApp *caddytls.TLS
logger *zap.Logger logger *zap.Logger

View File

@ -15,16 +15,71 @@
package caddyhttp package caddyhttp
import ( import (
"bytes"
"encoding/json"
"flag"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings"
"text/template"
"time"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
) )
func init() { func init() {
caddy.RegisterModule(StaticResponse{}) 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. // StaticResponse implements a simple responder for static responses.
@ -165,6 +220,192 @@ func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, _ Hand
return nil 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 := cut(h, ":") // TODO: use strings.Cut() once Go 1.18 is our minimum
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 {}
}
// TODO: delete this and use strings.Cut() once Go 1.18 is our minimum
func cut(s, sep string) (before, after string, found bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
// respondCmdHeaders holds the parsed values from repeated use of the --header flag.
var respondCmdHeaders caddycmd.StringSlice
// Interface guards // Interface guards
var ( var (
_ MiddlewareHandler = (*StaticResponse)(nil) _ MiddlewareHandler = (*StaticResponse)(nil)