From a4bdf249dbfb0f35e0a35dad6a38db371b126800 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 28 Jun 2019 15:39:41 -0600 Subject: Caddy 2 gets a CLI! And admin endpoint is now configurable via JSON --- admin.go | 51 ++++++++++++- caddy.go | 2 + cmd/caddy/main.go | 1 - cmd/commands.go | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/main.go | 88 ++++++++++++++++++++++ cmd/proc_posix.go | 16 ++++ cmd/proc_windows.go | 15 ++++ cmd/run.go | 26 ------- 8 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 cmd/commands.go create mode 100644 cmd/main.go create mode 100644 cmd/proc_posix.go create mode 100644 cmd/proc_windows.go delete mode 100644 cmd/run.go diff --git a/admin.go b/admin.go index cacdfd0..3ed9bf4 100644 --- a/admin.go +++ b/admin.go @@ -22,12 +22,47 @@ var ( cfgEndptSrvMu sync.Mutex ) -// StartAdmin starts Caddy's administration endpoint. -func StartAdmin(addr string) error { +// AdminConfig configures the admin endpoint. +type AdminConfig struct { + Listen string `json:"listen,omitempty"` +} + +// DefaultAdminConfig is the default configuration +// for the administration endpoint. +var DefaultAdminConfig = &AdminConfig{ + Listen: "localhost:2019", +} + +// StartAdmin starts Caddy's administration endpoint, +// bootstrapping it with an optional configuration +// in the format of JSON bytes. It opens a listener +// resource. When no longer needed, StopAdmin should +// be called. +func StartAdmin(initialConfigJSON []byte) error { cfgEndptSrvMu.Lock() defer cfgEndptSrvMu.Unlock() - ln, err := net.Listen("tcp", addr) + adminConfig := DefaultAdminConfig + if len(initialConfigJSON) > 0 { + var config *Config + err := json.Unmarshal(initialConfigJSON, &config) + if err != nil { + return fmt.Errorf("unmarshaling bootstrap config: %v", err) + } + if config != nil && config.Admin != nil { + adminConfig = config.Admin + } + if cfgEndptSrv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := cfgEndptSrv.Shutdown(ctx) + if err != nil { + return fmt.Errorf("shutting down old admin endpoint: %v", err) + } + } + } + + ln, err := net.Listen("tcp", adminConfig.Listen) if err != nil { return err } @@ -60,6 +95,16 @@ func StartAdmin(addr string) error { go cfgEndptSrv.Serve(ln) + log.Println("Caddy 2 admin endpoint listening on", adminConfig.Listen) + + if len(initialConfigJSON) > 0 { + err := Load(bytes.NewReader(initialConfigJSON)) + if err != nil { + return fmt.Errorf("loading initial config: %v", err) + } + log.Println("Caddy 2 serving initial configuration") + } + return nil } diff --git a/caddy.go b/caddy.go index f8f6e45..1655948 100644 --- a/caddy.go +++ b/caddy.go @@ -136,6 +136,8 @@ type App interface { // Config represents a Caddy configuration. type Config struct { + Admin *AdminConfig `json:"admin,omitempty"` + StorageRaw json.RawMessage `json:"storage,omitempty"` storage certmagic.Storage diff --git a/cmd/caddy/main.go b/cmd/caddy/main.go index 463b1b9..bbb4956 100644 --- a/cmd/caddy/main.go +++ b/cmd/caddy/main.go @@ -5,7 +5,6 @@ import ( // this is where modules get plugged in _ "github.com/caddyserver/caddy/modules/caddyhttp" - _ "github.com/caddyserver/caddy/modules/caddyhttp/caddylog" _ "github.com/caddyserver/caddy/modules/caddyhttp/encode" _ "github.com/caddyserver/caddy/modules/caddyhttp/encode/brotli" _ "github.com/caddyserver/caddy/modules/caddyhttp/encode/gzip" diff --git a/cmd/commands.go b/cmd/commands.go new file mode 100644 index 0000000..4745af7 --- /dev/null +++ b/cmd/commands.go @@ -0,0 +1,205 @@ +package caddycmd + +import ( + "crypto/rand" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "path/filepath" + + "github.com/caddyserver/caddy" + "github.com/mitchellh/go-ps" +) + +func cmdStart() (int, error) { + startCmd := flag.NewFlagSet("start", flag.ExitOnError) + startCmdConfigFlag := startCmd.String("config", "", "Configuration file") + startCmd.Parse(os.Args[2:]) + + // 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 1, 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) + } + stdinpipe, err := cmd.StdinPipe() + if err != nil { + return 1, 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 1, 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 1, 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 { + 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.Println("Successfully started Caddy") + case err := <-exit: + return 1, fmt.Errorf("caddy process exited with error: %v", err) + } + + return 0, nil +} + +func cmdRun() (int, error) { + runCmd := flag.NewFlagSet("run", flag.ExitOnError) + runCmdConfigFlag := runCmd.String("config", "", "Configuration file") + runCmdPingbackFlag := runCmd.String("pingback", "", "Echo confirmation bytes to this address on success") + runCmd.Parse(os.Args[2:]) + + // if a config file was specified for bootstrapping + // the server instance, load it now + var config []byte + if *runCmdConfigFlag != "" { + var err error + config, err = ioutil.ReadFile(*runCmdConfigFlag) + if err != nil { + return 1, fmt.Errorf("reading config file: %v", err) + } + } + + // start the admin endpoint along with any initial config + err := caddy.StartAdmin(config) + if err != nil { + return 0, 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 1, fmt.Errorf("reading confirmation bytes from stdin: %v", err) + } + conn, err := net.Dial("tcp", *runCmdPingbackFlag) + if err != nil { + return 1, fmt.Errorf("dialing confirmation address: %v", err) + } + defer conn.Close() + _, err = conn.Write(confirmationBytes) + if err != nil { + return 1, fmt.Errorf("writing confirmation bytes to %s: %v", *runCmdPingbackFlag, err) + } + } + + select {} +} + +func cmdStop() (int, error) { + processList, err := ps.Processes() + if err != nil { + return 1, fmt.Errorf("listing processes: %v", err) + } + thisProcName := filepath.Base(os.Args[0]) + 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()) + fmt.Printf("Graceful stop...") + if err := gracefullyStopProcess(p.Pid()); err != nil { + return 1, err + } + } + } + if !found { + return 1, fmt.Errorf("Caddy is not running") + } + fmt.Println(" success") + return 0, nil +} + +func cmdVersion() (int, error) { + goModule := getGoBuildModule() + 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 0, nil +} + +func cmdListModules() (int, error) { + for _, m := range caddy.Modules() { + fmt.Println(m) + } + return 0, nil +} + +func cmdEnviron() (int, error) { + for _, v := range os.Environ() { + fmt.Println(v) + } + return 0, nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b9baf84 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,88 @@ +package caddycmd + +import ( + "bytes" + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "runtime/debug" +) + +// Main executes the main function of the caddy command. +func Main() { + if len(os.Args) <= 1 { + fmt.Println(usageString()) + return + } + + subcommand, ok := commands[os.Args[1]] + if !ok { + fmt.Printf("%q is not a valid command\n", os.Args[1]) + os.Exit(2) + } + + if exitCode, err := subcommand(); err != nil { + log.Println(err) + os.Exit(exitCode) + } +} + +// 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, + "stop": cmdStop, + "run": cmdRun, + "version": cmdVersion, + "list-modules": cmdListModules, + "environ": cmdEnviron, +} + +func usageString() string { + buf := new(bytes.Buffer) + buf.WriteString("usage: caddy []") + flag.CommandLine.SetOutput(buf) + flag.CommandLine.PrintDefaults() + return buf.String() +} + +// handlePingbackConn reads from conn and ensures it matches +// the bytes in expect, or returns an error if it doesn't. +func handlePingbackConn(conn net.Conn, expect []byte) error { + defer conn.Close() + confirmationBytes, err := ioutil.ReadAll(io.LimitReader(conn, 32)) + if err != nil { + return err + } + if !bytes.Equal(confirmationBytes, expect) { + return fmt.Errorf("wrong confirmation: %x", confirmationBytes) + } + return nil +} + +// getGoBuildModule returns the build info of Caddy +// from debug.BuildInfo (requires Go modules). If +// no version information is available, a non-nil +// value will still be returned, but with an +// unknown version. +func getGoBuildModule() *debug.Module { + bi, ok := debug.ReadBuildInfo() + if ok { + // The recommended way to build Caddy involves + // creating a separate main module, which + // TODO: track related Go issue: https://github.com/golang/go/issues/29228 + for _, mod := range bi.Deps { + if mod.Path == "github.com/mholt/caddy" { + return mod + } + } + } + return &debug.Module{Version: "unknown"} +} diff --git a/cmd/proc_posix.go b/cmd/proc_posix.go new file mode 100644 index 0000000..6b71d1a --- /dev/null +++ b/cmd/proc_posix.go @@ -0,0 +1,16 @@ +// +build !windows + +package caddycmd + +import ( + "fmt" + "syscall" +) + +func gracefullyStopProcess(pid int) error { + err := syscall.Kill(pid, syscall.SIGINT) + if err != nil { + return fmt.Errorf("kill: %v", err) + } + return nil +} diff --git a/cmd/proc_windows.go b/cmd/proc_windows.go new file mode 100644 index 0000000..8b1bf23 --- /dev/null +++ b/cmd/proc_windows.go @@ -0,0 +1,15 @@ +package caddycmd + +import ( + "fmt" + "os/exec" + "strconv" +) + +func gracefullyStopProcess(pid int) error { + cmd := exec.Command("taskkill", "/pid", strconv.Itoa(pid)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("taskkill: %v", err) + } + return nil +} diff --git a/cmd/run.go b/cmd/run.go deleted file mode 100644 index 703e119..0000000 --- a/cmd/run.go +++ /dev/null @@ -1,26 +0,0 @@ -package caddycmd - -import ( - "flag" - "log" - - "github.com/caddyserver/caddy" -) - -// Main executes the main function of the caddy command. -func Main() { - flag.Parse() - - err := caddy.StartAdmin(*listenAddr) - if err != nil { - log.Fatal(err) - } - defer caddy.StopAdmin() - - log.Println("Caddy 2 admin endpoint listening on", *listenAddr) - - select {} -} - -// TODO: for dev only -var listenAddr = flag.String("listen", ":1234", "The admin endpoint listener address") -- cgit v1.2.3