From 0006df60264e7fdc55fc1a0962109e8198fa9d71 Mon Sep 17 00:00:00 2001 From: aca Date: Tue, 1 Oct 2019 12:23:58 +0900 Subject: cmd: Refactor subcommands, add help, make them pluggable * cli: Change command structure, add help subcommand (#328) * cli: improve subcommand structure - make help command as normal subcommand - add flag usage message for each command * cmd: Refactor subcommands and command line help; make commands pluggable --- cmd/commandfuncs.go | 391 +++++++++++++++++++++++++++++++++++++++++ cmd/commands.go | 497 +++++++++++++++++++--------------------------------- cmd/main.go | 130 +++++++++++--- 3 files changed, 671 insertions(+), 347 deletions(-) create mode 100644 cmd/commandfuncs.go (limited to 'cmd') diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go new file mode 100644 index 0000000..be9da93 --- /dev/null +++ b/cmd/commandfuncs.go @@ -0,0 +1,391 @@ +// 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 caddycmd + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "os/exec" + "strings" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/keybase/go-ps" + "github.com/mholt/certmagic" +) + +func cmdStart(fl Flags) (int, error) { + startCmdConfigFlag := fl.String("config") + startCmdConfigAdapterFlag := fl.String("config-adapter") + + // open a listener to which the child process will connect when + // it is ready to confirm that it has successfully started + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("opening listener for success confirmation: %v", err) + } + defer ln.Close() + + // craft the command with a pingback address and with a + // pipe for its stdin, so we can tell it our confirmation + // code that we expect so that some random port scan at + // the most unfortunate time won't fool us into thinking + // the child succeeded (i.e. the alternative is to just + // wait for any connection on our listener, but better to + // ensure it's the process we're expecting - we can be + // sure by giving it some random bytes and having it echo + // them back to us) + cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) + if startCmdConfigFlag != "" { + cmd.Args = append(cmd.Args, "--config", startCmdConfigFlag) + } + if startCmdConfigAdapterFlag != "" { + cmd.Args = append(cmd.Args, "--config-adapter", startCmdConfigAdapterFlag) + } + stdinpipe, err := cmd.StdinPipe() + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("creating stdin pipe: %v", err) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // generate the random bytes we'll send to the child process + expect := make([]byte, 32) + _, err = rand.Read(expect) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("generating random confirmation bytes: %v", err) + } + + // begin writing the confirmation bytes to the child's + // stdin; use a goroutine since the child hasn't been + // started yet, and writing sychronously would result + // in a deadlock + go func() { + stdinpipe.Write(expect) + stdinpipe.Close() + }() + + // start the process + err = cmd.Start() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("starting caddy process: %v", err) + } + + // there are two ways we know we're done: either + // the process will connect to our listener, or + // it will exit with an error + success, exit := make(chan struct{}), make(chan error) + + // in one goroutine, we await the success of the child process + go func() { + for { + conn, err := ln.Accept() + if err != nil { + if !strings.Contains(err.Error(), "use of closed network connection") { + log.Println(err) + } + break + } + err = handlePingbackConn(conn, expect) + if err == nil { + close(success) + break + } + log.Println(err) + } + }() + + // in another goroutine, we await the failure of the child process + go func() { + err := cmd.Wait() // don't send on this line! Wait blocks, but send starts before it unblocks + exit <- err // sending on separate line ensures select won't trigger until after Wait unblocks + }() + + // when one of the goroutines unblocks, we're done and can exit + select { + case <-success: + fmt.Printf("Successfully started Caddy (pid=%d)\n", cmd.Process.Pid) + case err := <-exit: + return caddy.ExitCodeFailedStartup, + fmt.Errorf("caddy process exited with error: %v", err) + } + + return caddy.ExitCodeSuccess, nil +} + +func cmdRun(fl Flags) (int, error) { + runCmdConfigFlag := fl.String("config") + runCmdConfigAdapterFlag := fl.String("config-adapter") + runCmdPrintEnvFlag := fl.Bool("print-env") + runCmdPingbackFlag := fl.String("pingback") + + // if we are supposed to print the environment, do that first + if runCmdPrintEnvFlag { + printEnvironment() + } + + // get the config in caddy's native format + config, err := loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, err + } + + // set a fitting User-Agent for ACME requests + goModule := caddy.GoModule() + cleanModVersion := strings.TrimPrefix(goModule.Version, "v") + certmagic.UserAgent = "Caddy/" + cleanModVersion + + // start the admin endpoint along with any initial config + err = caddy.StartAdmin(config) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("starting caddy administration endpoint: %v", err) + } + defer caddy.StopAdmin() + + // if we are to report to another process the successful start + // of the server, do so now by echoing back contents of stdin + if runCmdPingbackFlag != "" { + confirmationBytes, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("reading confirmation bytes from stdin: %v", err) + } + conn, err := net.Dial("tcp", runCmdPingbackFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("dialing confirmation address: %v", err) + } + defer conn.Close() + _, err = conn.Write(confirmationBytes) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("writing confirmation bytes to %s: %v", runCmdPingbackFlag, err) + } + } + + select {} +} + +func cmdStop(_ Flags) (int, error) { + processList, err := ps.Processes() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("listing processes: %v", err) + } + thisProcName := getProcessName() + var found bool + for _, p := range processList { + // the process we're looking for should have the same name but different PID + if p.Executable() == thisProcName && p.Pid() != os.Getpid() { + found = true + fmt.Printf("pid=%d\n", p.Pid()) + + if err := gracefullyStopProcess(p.Pid()); err != nil { + return caddy.ExitCodeFailedStartup, err + } + } + } + if !found { + return caddy.ExitCodeFailedStartup, fmt.Errorf("Caddy is not running") + } + fmt.Println(" success") + return caddy.ExitCodeSuccess, nil +} + +func cmdReload(fl Flags) (int, error) { + reloadCmdConfigFlag := fl.String("config") + reloadCmdConfigAdapterFlag := fl.String("config-adapter") + reloadCmdAddrFlag := fl.String("address") + + // a configuration is required + if reloadCmdConfigFlag == "" { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("no configuration to load (use --config)") + } + + // get the config in caddy's native format + config, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, err + } + + // get the address of the admin listener and craft endpoint URL + adminAddr := reloadCmdAddrFlag + if adminAddr == "" { + var tmpStruct struct { + Admin caddy.AdminConfig `json:"admin"` + } + err = json.Unmarshal(config, &tmpStruct) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("unmarshaling admin listener address from config: %v", err) + } + adminAddr = tmpStruct.Admin.Listen + } + if adminAddr == "" { + adminAddr = caddy.DefaultAdminListen + } + adminEndpoint := fmt.Sprintf("http://%s/load", adminAddr) + + // send the configuration to the instance + resp, err := http.Post(adminEndpoint, "application/json", bytes.NewReader(config)) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("sending configuration to instance: %v", err) + } + defer resp.Body.Close() + + // if it didn't work, let the user know + if resp.StatusCode >= 400 { + respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*10)) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err) + } + return caddy.ExitCodeFailedStartup, + fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody) + } + + return caddy.ExitCodeSuccess, nil +} + +func cmdVersion(_ Flags) (int, error) { + goModule := caddy.GoModule() + if goModule.Sum != "" { + // a build with a known version will also have a checksum + fmt.Printf("%s %s\n", goModule.Version, goModule.Sum) + } else { + fmt.Println(goModule.Version) + } + return caddy.ExitCodeSuccess, nil +} + +func cmdListModules(_ Flags) (int, error) { + for _, m := range caddy.Modules() { + fmt.Println(m) + } + return caddy.ExitCodeSuccess, nil +} + +func cmdEnviron(_ Flags) (int, error) { + printEnvironment() + return caddy.ExitCodeSuccess, nil +} + +func cmdAdaptConfig(fl Flags) (int, error) { + adaptCmdAdapterFlag := fl.String("adapter") + adaptCmdInputFlag := fl.String("input") + adaptCmdPrettyFlag := fl.Bool("pretty") + + if adaptCmdAdapterFlag == "" || adaptCmdInputFlag == "" { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("usage: caddy adapt-config --adapter --input ") + } + + cfgAdapter := caddyconfig.GetAdapter(adaptCmdAdapterFlag) + if cfgAdapter == nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("unrecognized config adapter: %s", adaptCmdAdapterFlag) + } + + input, err := ioutil.ReadFile(adaptCmdInputFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, + fmt.Errorf("reading input file: %v", err) + } + + opts := make(map[string]interface{}) + if adaptCmdPrettyFlag { + opts["pretty"] = "true" + } + + adaptedConfig, warnings, err := cfgAdapter.Adapt(input, opts) + if err != nil { + return caddy.ExitCodeFailedStartup, err + } + + // print warnings to stderr + for _, warn := range warnings { + msg := warn.Message + if warn.Directive != "" { + msg = fmt.Sprintf("%s: %s", warn.Directive, warn.Message) + } + log.Printf("[WARNING][%s] %s:%d: %s", adaptCmdAdapterFlag, warn.File, warn.Line, msg) + } + + // print result to stdout + fmt.Println(string(adaptedConfig)) + + return caddy.ExitCodeSuccess, nil +} + +func cmdHelp(fl Flags) (int, error) { + const fullDocs = `Full documentation is available at: +https://github.com/caddyserver/caddy/wiki/v2:-Documentation` + + args := fl.Args() + if len(args) == 0 { + s := `Caddy is an extensible server platform. + +usage: + caddy [] + +commands: +` + for _, cmd := range commands { + s += fmt.Sprintf(" %-15s %s\n", cmd.Name, cmd.Short) + } + + s += "\nUse 'caddy help ' for more information about a command.\n" + s += "\n" + fullDocs + "\n" + + fmt.Print(s) + + return caddy.ExitCodeSuccess, nil + } else if len(args) > 1 { + return caddy.ExitCodeFailedStartup, fmt.Errorf("can only give help with one command") + } + + subcommand, ok := commands[args[0]] + if !ok { + return caddy.ExitCodeFailedStartup, fmt.Errorf("unknown command: %s", args[0]) + } + + result := fmt.Sprintf("%s\n\nusage:\n caddy %s %s\n", + strings.TrimSpace(subcommand.Long), + subcommand.Name, + strings.TrimSpace(subcommand.Usage), + ) + + if help := flagHelp(subcommand.Flags); help != "" { + result += fmt.Sprintf("\nflags:\n%s", help) + } + + result += "\n" + fullDocs + "\n" + + fmt.Print(result) + + return caddy.ExitCodeSuccess, nil +} diff --git a/cmd/commands.go b/cmd/commands.go index 2526e45..8a9d482 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -15,343 +15,204 @@ package caddycmd import ( - "bytes" - "crypto/rand" - "encoding/json" "flag" - "fmt" - "io" - "io/ioutil" - "log" - "net" - "net/http" - "os" - "os/exec" - - "strings" - - "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/caddyconfig" - "github.com/keybase/go-ps" - "github.com/mholt/certmagic" + "regexp" ) -func cmdStart() (int, error) { - startCmd := flag.NewFlagSet("start", flag.ExitOnError) - startCmdConfigFlag := startCmd.String("config", "", "Configuration file") - startCmdConfigAdapterFlag := startCmd.String("config-adapter", "", "Name of config adapter to apply") - startCmd.Parse(os.Args[2:]) +// Command represents a subcommand. All fields +// are required to be set except for Flags if +// there are no flags and Usage if there are +// no flags or arguments. +type Command struct { + Name string - // open a listener to which the child process will connect when - // it is ready to confirm that it has successfully started - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("opening listener for success confirmation: %v", err) - } - defer ln.Close() + // Run is a function that executes a subcommand. + // It returns an exit code and any associated error. + // Takes non-flag commandline arguments as args. + // Flag must be parsed before Run is executed. + Func CommandFunc - // craft the command with a pingback address and with a - // pipe for its stdin, so we can tell it our confirmation - // code that we expect so that some random port scan at - // the most unfortunate time won't fool us into thinking - // the child succeeded (i.e. the alternative is to just - // wait for any connection on our listener, but better to - // ensure it's the process we're expecting - we can be - // sure by giving it some random bytes and having it echo - // them back to us) - cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) - if *startCmdConfigFlag != "" { - cmd.Args = append(cmd.Args, "--config", *startCmdConfigFlag) - } - if *startCmdConfigAdapterFlag != "" { - cmd.Args = append(cmd.Args, "--config-adapter", *startCmdConfigAdapterFlag) - } - stdinpipe, err := cmd.StdinPipe() - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("creating stdin pipe: %v", err) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + // Usage is the one-line message explaining args, flags. + Usage string - // generate the random bytes we'll send to the child process - expect := make([]byte, 32) - _, err = rand.Read(expect) - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("generating random confirmation bytes: %v", err) - } + // Short is the short description for command. + Short string - // begin writing the confirmation bytes to the child's - // stdin; use a goroutine since the child hasn't been - // started yet, and writing sychronously would result - // in a deadlock - go func() { - stdinpipe.Write(expect) - stdinpipe.Close() - }() + // Long is the message for 'caddy help ' + Long string - // start the process - err = cmd.Start() - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("starting caddy process: %v", err) - } - - // there are two ways we know we're done: either - // the process will connect to our listener, or - // it will exit with an error - success, exit := make(chan struct{}), make(chan error) - - // in one goroutine, we await the success of the child process - go func() { - for { - conn, err := ln.Accept() - if err != nil { - if !strings.Contains(err.Error(), "use of closed network connection") { - log.Println(err) - } - break - } - err = handlePingbackConn(conn, expect) - if err == nil { - close(success) - break - } - log.Println(err) - } - }() - - // in another goroutine, we await the failure of the child process - go func() { - err := cmd.Wait() // don't send on this line! Wait blocks, but send starts before it unblocks - exit <- err // sending on separate line ensures select won't trigger until after Wait unblocks - }() - - // when one of the goroutines unblocks, we're done and can exit - select { - case <-success: - fmt.Printf("Successfully started Caddy (pid=%d)\n", cmd.Process.Pid) - case err := <-exit: - return caddy.ExitCodeFailedStartup, - fmt.Errorf("caddy process exited with error: %v", err) - } - - return caddy.ExitCodeSuccess, nil + // Flags is flagset for command. + Flags *flag.FlagSet } -func cmdRun() (int, error) { - runCmd := flag.NewFlagSet("run", flag.ExitOnError) - runCmdConfigFlag := runCmd.String("config", "", "Configuration file") - runCmdConfigAdapterFlag := runCmd.String("config-adapter", "", "Name of config adapter to apply") - runCmdPrintEnvFlag := runCmd.Bool("print-env", false, "Print environment") - runCmdPingbackFlag := runCmd.String("pingback", "", "Echo confirmation bytes to this address on success") - runCmd.Parse(os.Args[2:]) - - // if we are supposed to print the environment, do that first - if *runCmdPrintEnvFlag { - exitCode, err := cmdEnviron() - if err != nil { - return exitCode, err - } - } - - // get the config in caddy's native format - config, err := loadConfig(*runCmdConfigFlag, *runCmdConfigAdapterFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // set a fitting User-Agent for ACME requests - goModule := caddy.GoModule() - cleanModVersion := strings.TrimPrefix(goModule.Version, "v") - certmagic.UserAgent = "Caddy/" + cleanModVersion - - // start the admin endpoint along with any initial config - err = caddy.StartAdmin(config) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("starting caddy administration endpoint: %v", err) - } - defer caddy.StopAdmin() - - // if we are to report to another process the successful start - // of the server, do so now by echoing back contents of stdin - if *runCmdPingbackFlag != "" { - confirmationBytes, err := ioutil.ReadAll(os.Stdin) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("reading confirmation bytes from stdin: %v", err) - } - conn, err := net.Dial("tcp", *runCmdPingbackFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("dialing confirmation address: %v", err) - } - defer conn.Close() - _, err = conn.Write(confirmationBytes) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("writing confirmation bytes to %s: %v", *runCmdPingbackFlag, err) - } - } - - select {} +// CommandFunc is a command's function. It runs the +// command and returns the proper exit code along with +// any error that occurred. +type CommandFunc func(Flags) (int, error) + +var commands = map[string]Command{ + "start": { + Name: "start", + Func: cmdStart, + Usage: "[--config ] [--config-adapter ]", + Short: "Starts the Caddy process and returns after server has started.", + Long: ` +Starts the Caddy process, optionally bootstrapped with an initial +config file. Blocks until server is successfully running (or fails to run), +then returns. On Windows, the child process will remain attached to the +terminal, so closing the window will forcefully stop Caddy. See run for more +details.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("start", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + return fs + }(), + }, + + "run": { + Name: "run", + Func: cmdRun, + Usage: "[--config ] [--config-adapter ] [--print-env]", + Short: `Starts the Caddy process and blocks indefinitely.`, + Long: ` +Same as start, but blocks indefinitely; i.e. runs Caddy in "daemon" mode. On +Windows, this is recommended over caddy start when running Caddy manually since +it will be more obvious that Caddy is still running and bound to the terminal +window. + +If a config file is specified, it will be applied immediately after the process +is running. If the config file is not in Caddy's native JSON format, you can +specify an adapter with --config-adapter to adapt the given config file to +Caddy's native format. The config adapter must be a registered module. Any +warnings will be printed to the log, but beware that any adaptation without +errors will immediately be used. If you want to review the results of the +adaptation first, use adapt-config. + +As a special case, if the current working directory has a file called +"Caddyfile" and the caddyfile config adapter is plugged in (default), then that +file will be loaded and used to configure Caddy, even without any command line +flags. + +If --print-env is specified, the environment as seen by the Caddy process will +be printed before starting. This is the same as the environ command but does +not quit after printing.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("run", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + fs.Bool("print-env", false, "Print environment") + fs.String("pingback", "", "Echo confirmation bytes to this address on success") + return fs + }(), + }, + + "stop": { + Name: "stop", + Func: cmdStop, + Short: "Gracefully stops the running Caddy process", + Long: `Gracefully stops the running Caddy process. (Note: this will stop any process +named the same as the executable.) On Windows, this stop is forceful and Caddy +will not have an opportunity to clean up any active locks; for a graceful +shutdown on Windows, use Ctrl+C or the /stop endpoint.`, + }, + + "reload": { + Name: "reload", + Func: cmdReload, + Usage: "--config [--config-adapter ] [--address ]", + Short: "Gives the running Caddy instance a new configuration", + Long: `Gives the running Caddy instance a new configuration. This has the same effect +as POSTing a document to the /load endpoint, but is convenient for simple +workflows revolving around config files. Since the admin endpoint is +configurable, the endpoint configuration is loaded from the --address flag if +specified; otherwise it is loaded from the given config file; otherwise the +default is assumed.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("load", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + fs.String("address", "", "Address of the administration listener, if different from config") + return fs + }(), + }, + + "version": { + Name: "version", + Func: cmdVersion, + Short: "Prints the version.", + Long: `Prints the version.`, + }, + + "list-modules": { + Name: "list-modules", + Func: cmdListModules, + Short: "List installed Caddy modules.", + Long: `List installed Caddy modules.`, + }, + + "environ": { + Name: "environ", + Func: cmdEnviron, + Short: "Prints the environment as seen by Caddy.", + Long: `Prints the environment as seen by Caddy.`, + }, + + "adapt-config": { + Name: "adapt-config", + Func: cmdAdaptConfig, + Usage: "--input --adapter [--pretty]", + Short: "Adapts a configuration to Caddy's native JSON config structure", + Long: ` +Adapts a configuration to Caddy's native JSON config structure and writes the +output to stdout, along with any warnings to stderr. If --pretty is specified, +the output will be formatted with indentation for human readability.`, + }, } -func cmdStop() (int, error) { - processList, err := ps.Processes() - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("listing processes: %v", err) - } - thisProcName := getProcessName() - var found bool - for _, p := range processList { - // the process we're looking for should have the same name but different PID - if p.Executable() == thisProcName && p.Pid() != os.Getpid() { - found = true - fmt.Printf("pid=%d\n", p.Pid()) - - if err := gracefullyStopProcess(p.Pid()); err != nil { - return caddy.ExitCodeFailedStartup, err - } - } - } - if !found { - return caddy.ExitCodeFailedStartup, fmt.Errorf("Caddy is not running") +func init() { + // the help command is special in that its func + // refers to the commands map; thus, defining it + // inline with the commands map's initialization + // yields a compile-time error, so we have to + // define this command separately + commands["help"] = Command{ + Name: "help", + Func: cmdHelp, + Usage: "", + Short: "Shows help for a Caddy subcommand.", } - fmt.Println(" success") - return caddy.ExitCodeSuccess, nil } -func cmdReload() (int, error) { - reloadCmd := flag.NewFlagSet("load", flag.ExitOnError) - reloadCmdConfigFlag := reloadCmd.String("config", "", "Configuration file") - reloadCmdConfigAdapterFlag := reloadCmd.String("config-adapter", "", "Name of config adapter to apply") - reloadCmdAddrFlag := reloadCmd.String("address", "", "Address of the administration listener, if different from config") - reloadCmd.Parse(os.Args[2:]) - - // a configuration is required - if *reloadCmdConfigFlag == "" { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("no configuration to load (use --config)") - } - - // get the config in caddy's native format - config, err := loadConfig(*reloadCmdConfigFlag, *reloadCmdConfigAdapterFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // get the address of the admin listener and craft endpoint URL - adminAddr := *reloadCmdAddrFlag - if adminAddr == "" { - var tmpStruct struct { - Admin caddy.AdminConfig `json:"admin"` - } - err = json.Unmarshal(config, &tmpStruct) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("unmarshaling admin listener address from config: %v", err) - } - adminAddr = tmpStruct.Admin.Listen +// RegisterCommand registers the command cmd. +// cmd.Name must be unique and conform to the +// following format: +// +// - lowercase +// - alphanumeric and hyphen characters only +// - cannot start or end with a hyphen +// - hyphen cannot be adjacent to another hyphen +// +// This function panics if the name is already registered, +// if the name does not meet the described format, or if +// any of the fields are missing from cmd. +func RegisterCommand(cmd Command) { + if cmd.Name == "" { + panic("command name is required") } - if adminAddr == "" { - adminAddr = caddy.DefaultAdminListen + if cmd.Func == nil { + panic("command function missing") } - adminEndpoint := fmt.Sprintf("http://%s/load", adminAddr) - - // send the configuration to the instance - resp, err := http.Post(adminEndpoint, "application/json", bytes.NewReader(config)) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("sending configuration to instance: %v", err) + if cmd.Short == "" { + panic("command short string is required") } - defer resp.Body.Close() - - // if it didn't work, let the user know - if resp.StatusCode >= 400 { - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*10)) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err) - } - return caddy.ExitCodeFailedStartup, - fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody) + if _, exists := commands[cmd.Name]; exists { + panic("command already registered: " + cmd.Name) } - - return caddy.ExitCodeSuccess, nil -} - -func cmdVersion() (int, error) { - goModule := caddy.GoModule() - if goModule.Sum != "" { - // a build with a known version will also have a checksum - fmt.Printf("%s %s\n", goModule.Version, goModule.Sum) - } else { - fmt.Println(goModule.Version) - } - return caddy.ExitCodeSuccess, nil -} - -func cmdListModules() (int, error) { - for _, m := range caddy.Modules() { - fmt.Println(m) - } - return caddy.ExitCodeSuccess, nil -} - -func cmdEnviron() (int, error) { - for _, v := range os.Environ() { - fmt.Println(v) + if !commandNameRegex.MatchString(cmd.Name) { + panic("invalid command name") } - return caddy.ExitCodeSuccess, nil + commands[cmd.Name] = cmd } -func cmdAdaptConfig() (int, error) { - adaptCmd := flag.NewFlagSet("adapt", flag.ExitOnError) - adaptCmdAdapterFlag := adaptCmd.String("adapter", "", "Name of config adapter") - adaptCmdInputFlag := adaptCmd.String("input", "", "Configuration file to adapt") - adaptCmdPrettyFlag := adaptCmd.Bool("pretty", false, "Format the output for human readability") - adaptCmd.Parse(os.Args[2:]) - - if *adaptCmdAdapterFlag == "" || *adaptCmdInputFlag == "" { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("usage: caddy adapt-config --adapter --input ") - } - - cfgAdapter := caddyconfig.GetAdapter(*adaptCmdAdapterFlag) - if cfgAdapter == nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("unrecognized config adapter: %s", *adaptCmdAdapterFlag) - } - - input, err := ioutil.ReadFile(*adaptCmdInputFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("reading input file: %v", err) - } - - opts := make(map[string]interface{}) - if *adaptCmdPrettyFlag { - opts["pretty"] = "true" - } - - adaptedConfig, warnings, err := cfgAdapter.Adapt(input, opts) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // print warnings to stderr - for _, warn := range warnings { - msg := warn.Message - if warn.Directive != "" { - msg = fmt.Sprintf("%s: %s", warn.Directive, warn.Message) - } - log.Printf("[WARNING][%s] %s:%d: %s", *adaptCmdAdapterFlag, warn.File, warn.Line, msg) - } - - // print result to stdout - fmt.Println(string(adaptedConfig)) - - return caddy.ExitCodeSuccess, nil -} +var commandNameRegex = regexp.MustCompile(`^[a-z0-9]$|^([a-z0-9]+-?[a-z0-9]*)+[a-z0-9]$`) diff --git a/cmd/main.go b/cmd/main.go index 5b97992..aea020f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,9 @@ import ( "log" "net" "os" + "strconv" + "strings" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" @@ -33,45 +36,43 @@ import ( func Main() { caddy.TrapSignals() - if len(os.Args) < 2 { - fmt.Println(usageString()) - return + switch len(os.Args) { + case 0: + log.Printf("[FATAL] no arguments provided by OS; args[0] must be command") + os.Exit(caddy.ExitCodeFailedStartup) + case 1: + os.Args = append(os.Args, "help") } - subcommand, ok := commands[os.Args[1]] + subcommandName := os.Args[1] + subcommand, ok := commands[subcommandName] if !ok { - fmt.Printf("%q is not a valid command\n", os.Args[1]) + if strings.HasPrefix(os.Args[1], "-") { + // user probably forgot to type the subcommand + log.Println("[ERROR] first argument must be a subcommand; see 'caddy help'") + } else { + log.Printf("[ERROR] '%s' is not a recognized subcommand; see 'caddy help'", os.Args[1]) + } os.Exit(caddy.ExitCodeFailedStartup) } - if exitCode, err := subcommand(); err != nil { + fs := subcommand.Flags + if fs == nil { + fs = flag.NewFlagSet(subcommand.Name, flag.ExitOnError) + } + + err := fs.Parse(os.Args[2:]) + if err != nil { log.Println(err) - os.Exit(exitCode) + os.Exit(caddy.ExitCodeFailedStartup) } -} -// commandFunc is a function that executes -// a subcommand. It returns an exit code and -// any associated error. -type commandFunc func() (int, error) - -var commands = map[string]commandFunc{ - "start": cmdStart, - "run": cmdRun, - "stop": cmdStop, - "reload": cmdReload, - "version": cmdVersion, - "list-modules": cmdListModules, - "environ": cmdEnviron, - "adapt-config": cmdAdaptConfig, -} + exitCode, err := subcommand.Func(Flags{fs}) + if err != nil { + log.Printf("%s: %v", subcommand.Name, err) + } -func usageString() string { - buf := new(bytes.Buffer) - buf.WriteString("usage: caddy []") - flag.CommandLine.SetOutput(buf) - flag.CommandLine.PrintDefaults() - return buf.String() + os.Exit(exitCode) } // handlePingbackConn reads from conn and ensures it matches @@ -156,3 +157,74 @@ func loadConfig(configFile, adapterName string) ([]byte, error) { return config, nil } + +// Flags wraps a FlagSet so that typed values +// from flags can be easily retrieved. +type Flags struct { + *flag.FlagSet +} + +// String returns the string representation of the +// flag given by name. It panics if the flag is not +// in the flag set. +func (f Flags) String(name string) string { + return f.FlagSet.Lookup(name).Value.String() +} + +// Bool returns the boolean representation of the +// flag given by name. It returns false if the flag +// is not a boolean type. It panics if the flag is +// not in the flag set. +func (f Flags) Bool(name string) bool { + val, _ := strconv.ParseBool(f.String(name)) + return val +} + +// Int returns the integer representation of the +// flag given by name. It returns 0 if the flag +// is not an integer type. It panics if the flag is +// not in the flag set. +func (f Flags) Int(name string) int { + val, _ := strconv.ParseInt(f.String(name), 0, strconv.IntSize) + return int(val) +} + +// Float64 returns the float64 representation of the +// flag given by name. It returns false if the flag +// is not a float63 type. It panics if the flag is +// not in the flag set. +func (f Flags) Float64(name string) float64 { + val, _ := strconv.ParseFloat(f.String(name), 64) + return val +} + +// Duration returns the duration representation of the +// flag given by name. It returns false if the flag +// is not a duration type. It panics if the flag is +// not in the flag set. +func (f Flags) Duration(name string) time.Duration { + val, _ := time.ParseDuration(f.String(name)) + return val +} + +// flagHelp returns the help text for fs. +func flagHelp(fs *flag.FlagSet) string { + if fs == nil { + return "" + } + + // temporarily redirect output + out := fs.Output() + defer fs.SetOutput(out) + + buf := new(bytes.Buffer) + fs.SetOutput(buf) + fs.PrintDefaults() + return buf.String() +} + +func printEnvironment() { + for _, v := range os.Environ() { + fmt.Println(v) + } +} -- cgit v1.2.3