From f783290f40febd3eef2a299911ad95bab4d2b414 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Mon, 1 Aug 2022 13:36:22 -0600 Subject: [PATCH] caddyhttp: Implement `caddy respond` command (#4870) --- admin.go | 6 +- caddy.go | 79 +++---- cmd/main.go | 14 ++ modules/caddyhttp/app.go | 2 + modules/caddyhttp/fileserver/command.go | 8 +- modules/caddyhttp/reverseproxy/command.go | 7 +- modules/caddyhttp/server.go | 1 + modules/caddyhttp/staticresp.go | 241 ++++++++++++++++++++++ 8 files changed, 318 insertions(+), 40 deletions(-) diff --git a/admin.go b/admin.go index 670a27017..125a7b2ef 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 98ee5b39b..20da5de14 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 + // currentCtxMu protects everything in this var block. + currentCtxMu sync.RWMutex - // currentCfg is the currently-running configuration. - currentCfg *Config + // 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 498a8ae6a..348bdbced 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 1894a975c..82e052c9e 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 7b4ab1109..902c5f8ce 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 6153b6ec0..fed1cd91f 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 a4a976f7e..44a58882f 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 c587f5ee6..9e12bd5cc 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 ] [--body ] [--listen ] [--access-log] [--debug] [--header "Field: value"] `, + 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)