Caddy 2 gets a CLI! And admin endpoint is now configurable via JSON

This commit is contained in:
Matthew Holt 2019-06-28 15:39:41 -06:00
parent 006dc1792f
commit a4bdf249db
8 changed files with 374 additions and 30 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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"

205
cmd/commands.go Normal file
View file

@ -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
}

88
cmd/main.go Normal file
View file

@ -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 <command> [<args>]")
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"}
}

16
cmd/proc_posix.go Normal file
View file

@ -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
}

15
cmd/proc_windows.go Normal file
View file

@ -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
}

View file

@ -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")