summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatt Holt <mholt@users.noreply.github.com>2022-08-01 13:36:22 -0600
committerGitHub <noreply@github.com>2022-08-01 13:36:22 -0600
commitf783290f40febd3eef2a299911ad95bab4d2b414 (patch)
tree1baed0987f2ad5ed723dbda671f0eec13a414ca7
parentebd6abcbd5f6b31b7b4cadca0a546d9f0eab9ad3 (diff)
caddyhttp: Implement `caddy respond` command (#4870)
-rw-r--r--admin.go6
-rw-r--r--caddy.go81
-rw-r--r--cmd/main.go14
-rw-r--r--modules/caddyhttp/app.go2
-rw-r--r--modules/caddyhttp/fileserver/command.go8
-rw-r--r--modules/caddyhttp/reverseproxy/command.go7
-rw-r--r--modules/caddyhttp/server.go1
-rw-r--r--modules/caddyhttp/staticresp.go241
8 files changed, 319 insertions, 41 deletions
diff --git a/admin.go b/admin.go
index 670a270..125a7b2 100644
--- a/admin.go
+++ b/admin.go
@@ -993,9 +993,9 @@ func handleConfigID(w http.ResponseWriter, r *http.Request) error {
id := parts[2]
// map the ID to the expanded path
- currentCfgMu.RLock()
+ currentCtxMu.RLock()
expanded, ok := rawCfgIndex[id]
- defer currentCfgMu.RUnlock()
+ defer currentCtxMu.RUnlock()
if !ok {
return APIError{
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
// needed. This is a low-level, unsynchronized function; most callers
// 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).
func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error {
var err error
diff --git a/caddy.go b/caddy.go
index 98ee5b3..20da5de 100644
--- a/caddy.go
+++ b/caddy.go
@@ -141,8 +141,8 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
return fmt.Errorf("method not allowed")
}
- currentCfgMu.Lock()
- defer currentCfgMu.Unlock()
+ currentCtxMu.Lock()
+ defer currentCtxMu.Unlock()
if ifMatchHeader != "" {
// 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
// and writes its JSON encoding to out.
func readConfig(path string, out io.Writer) error {
- currentCfgMu.RLock()
- defer currentCfgMu.RUnlock()
+ currentCtxMu.RLock()
+ defer currentCtxMu.RUnlock()
return unsyncedConfigAccess(http.MethodGet, path, nil, out)
}
// indexConfigObjects recursively searches ptr for object fields named
// "@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
-// on currentCfgMu.
+// on currentCtxMu.
func indexConfigObjects(ptr interface{}, configPath string, index map[string]string) error {
switch val := ptr.(type) {
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 does NOT update the raw config state, as this is a
// 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,
// even if it is configured to.
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
- err = run(newCfg, true)
+ ctx, err := run(newCfg, true)
if err != nil {
return err
}
- // swap old config with the new one
- oldCfg := currentCfg
- currentCfg = newCfg
+ // swap old context (including its config) with the new one
+ oldCtx := currentCtx
+ currentCtx = ctx
// Stop, Cleanup each old app
- unsyncedStop(oldCfg)
+ unsyncedStop(oldCtx)
// autosave a non-nil config, if not disabled
if allowPersist &&
@@ -373,7 +373,7 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
// This is a low-level function; most callers
// will want to use Run instead, which also
// 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
// modifications if this function errors, we
// keep a single error value and scope all
@@ -404,8 +404,8 @@ func run(newCfg *Config, start bool) error {
cancel()
// also undo any other state changes we made
- if currentCfg != nil {
- certmagic.Default.Storage = currentCfg.storage
+ if currentCtx.cfg != nil {
+ certmagic.Default.Storage = currentCtx.cfg.storage
}
}
}()
@@ -417,14 +417,14 @@ func run(newCfg *Config, start bool) error {
}
err = newCfg.Logging.openLogs(ctx)
if err != nil {
- return err
+ return ctx, err
}
// start the admin endpoint (and stop any prior one)
if start {
err = replaceLocalAdminServer(newCfg)
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
}()
if err != nil {
- return err
+ return ctx, err
}
// Load and Provision each app and their submodules
@@ -466,18 +466,18 @@ func run(newCfg *Config, start bool) error {
return nil
}()
if err != nil {
- return err
+ return ctx, err
}
if !start {
- return nil
+ return ctx, nil
}
// Provision any admin routers which may need to access
// some of the other apps at runtime
err = newCfg.Admin.provisionAdminRouters(ctx)
if err != nil {
- return err
+ return ctx, err
}
// Start
@@ -502,12 +502,12 @@ func run(newCfg *Config, start bool) error {
return nil
}()
if err != nil {
- return err
+ return ctx, err
}
// now that the user's config is running, finish setting up anything else,
// 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.
@@ -612,10 +612,10 @@ type ConfigLoader interface {
// stop the others. Stop should only be called
// if not replacing with a new config.
func Stop() error {
- currentCfgMu.Lock()
- defer currentCfgMu.Unlock()
- unsyncedStop(currentCfg)
- currentCfg = nil
+ currentCtxMu.Lock()
+ defer currentCtxMu.Unlock()
+ unsyncedStop(currentCtx)
+ currentCtx = Context{}
rawCfgJSON = nil
rawCfgIndex = nil
rawCfg[rawConfigKey] = nil
@@ -628,13 +628,13 @@ func Stop() error {
// it is logged and the function continues stopping
// the next app. This function assumes all apps in
// cfg were successfully started first.
-func unsyncedStop(cfg *Config) {
- if cfg == nil {
+func unsyncedStop(ctx Context) {
+ if ctx.cfg == nil {
return
}
// stop each app
- for name, a := range cfg.apps {
+ for name, a := range ctx.cfg.apps {
err := a.Stop()
if err != nil {
log.Printf("[ERROR] stop %s: %v", name, err)
@@ -642,13 +642,13 @@ func unsyncedStop(cfg *Config) {
}
// clean up all modules
- cfg.cancelFunc()
+ ctx.cfg.cancelFunc()
}
// Validate loads, provisions, and validates
// cfg, but does not start running it.
func Validate(cfg *Config) error {
- err := run(cfg, false)
+ _, err := run(cfg, false)
if err == nil {
cfg.cancelFunc() // call Cleanup on all modules
}
@@ -823,16 +823,25 @@ func goModule(mod *debug.Module) *debug.Module {
return mod
}
+func ActiveContext() Context {
+ currentCtxMu.RLock()
+ defer currentCtxMu.RUnlock()
+ return currentCtx
+}
+
// CtxKey is a value type for use with context.WithValue.
type CtxKey string
// This group of variables pertains to the current configuration.
var (
- // currentCfgMu protects everything in this var block.
- currentCfgMu sync.RWMutex
-
- // currentCfg is the currently-running configuration.
- currentCfg *Config
+ // currentCtxMu protects everything in this var block.
+ currentCtxMu sync.RWMutex
+
+ // currentCtx is the root context for the currently-running
+ // 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;
// we initialize it as a map with one field ("config")
diff --git a/cmd/main.go b/cmd/main.go
index 498a8ae..348bdbc 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -338,6 +338,7 @@ func flagHelp(fs *flag.FlagSet) string {
buf := new(bytes.Buffer)
fs.SetOutput(buf)
+ buf.Write([]byte("(NOTE: use -- instead of - for flags)\n\n"))
fs.PrintDefaults()
return buf.String()
}
@@ -480,3 +481,16 @@ func CaddyVersion() string {
}
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)
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 1894a97..82e052c 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -392,6 +392,8 @@ func (app *App) Start() error {
//nolint:errcheck
go s.Serve(ln)
+
+ srv.listeners = append(srv.listeners, ln)
app.servers = append(app.servers, s)
}
}
diff --git a/modules/caddyhttp/fileserver/command.go b/modules/caddyhttp/fileserver/command.go
index 7b4ab11..902c5f8 100644
--- a/modules/caddyhttp/fileserver/command.go
+++ b/modules/caddyhttp/fileserver/command.go
@@ -117,8 +117,14 @@ func cmdFileServer(fs caddycmd.Flags) (int, error) {
Servers: map[string]*caddyhttp.Server{"static": server},
}
+ var false bool
cfg := &caddy.Config{
- Admin: &caddy.AdminConfig{Disabled: true},
+ Admin: &caddy.AdminConfig{
+ Disabled: true,
+ Config: &caddy.ConfigSettings{
+ Persist: &false,
+ },
+ },
AppsRaw: caddy.ModuleMap{
"http": caddyconfig.JSON(httpApp, nil),
},
diff --git a/modules/caddyhttp/reverseproxy/command.go b/modules/caddyhttp/reverseproxy/command.go
index 6153b6e..fed1cd9 100644
--- a/modules/caddyhttp/reverseproxy/command.go
+++ b/modules/caddyhttp/reverseproxy/command.go
@@ -172,8 +172,13 @@ func cmdReverseProxy(fs caddycmd.Flags) (int, error) {
appsRaw["tls"] = caddyconfig.JSON(tlsApp, nil)
}
+ var false bool
cfg := &caddy.Config{
- Admin: &caddy.AdminConfig{Disabled: true},
+ Admin: &caddy.AdminConfig{Disabled: true,
+ Config: &caddy.ConfigSettings{
+ Persist: &false,
+ },
+ },
AppsRaw: appsRaw,
}
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index a4a976f..44a5888 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -132,6 +132,7 @@ type Server struct {
primaryHandlerChain Handler
errorHandlerChain Handler
listenerWrappers []caddy.ListenerWrapper
+ listeners []net.Listener
tlsApp *caddytls.TLS
logger *zap.Logger
diff --git a/modules/caddyhttp/staticresp.go b/modules/caddyhttp/staticresp.go
index c587f5e..9e12bd5 100644
--- a/modules/caddyhttp/staticresp.go
+++ b/modules/caddyhttp/staticresp.go
@@ -15,16 +15,71 @@
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.
@@ -165,6 +220,192 @@ func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, _ Hand
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
var (
_ MiddlewareHandler = (*StaticResponse)(nil)