diff --git a/admin.go b/admin.go index 2eb9c3b0426..5aa905ff008 100644 --- a/admin.go +++ b/admin.go @@ -34,6 +34,7 @@ import ( "os" "path" "regexp" + "runtime" "slices" "strconv" "strings" @@ -265,6 +266,7 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Co addRoute("/"+rawConfigKey+"/", handlerLabel, AdminHandlerFunc(handleConfig)) addRoute("/id/", handlerLabel, AdminHandlerFunc(handleConfigID)) addRoute("/stop", handlerLabel, AdminHandlerFunc(handleStop)) + addRoute("/status", handlerLabel, AdminHandlerFunc(handleStatus)) // register debugging endpoints addRouteWithMetrics("/debug/pprof/", handlerLabel, http.HandlerFunc(pprof.Index)) @@ -1470,3 +1472,57 @@ var ( localAdminServer, remoteAdminServer *http.Server identityCertCache *certmagic.Cache ) + +// handleStatus returns a snapshot of the current state of the Caddy instance. +func handleStatus(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodGet { + return APIError{ + HTTPStatus: http.StatusMethodNotAllowed, + Err: fmt.Errorf("method not allowed"), + } + } + + ctx := ActiveContext() + _, fullVer := Version() + + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + // Build the base status structure + statusObj := map[string]any{ + "version": fullVer, + "uptime_secs": time.Since(ProcessStartTime).Seconds(), + "memory": map[string]any{ + "allocated_bytes": mem.Alloc, + "system_bytes": mem.Sys, + }, + "goroutines": runtime.NumGoroutine(), + "apps": map[string]any{}, + } + + // Iterate through running apps if a config is active + if ctx.cfg != nil { + for appName := range ctx.cfg.AppsRaw { + appIntf, err := ctx.App(appName) + if err != nil { + continue // Ignore if the app instance cannot be retrieved + } + + // Dynamically check if the App implements the StatusReporter interface + if reporter, ok := appIntf.(StatusReporter); ok { + appStatus, err := reporter.Status() + if err == nil { + statusObj["apps"].(map[string]any)[appName] = appStatus + } else { + statusObj["apps"].(map[string]any)[appName] = map[string]any{"error": err.Error()} + } + } else { + // The app exists but does not support advanced status reporting yet + statusObj["apps"].(map[string]any)[appName] = map[string]any{"status": "running"} + } + } + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(statusObj) +} diff --git a/caddy.go b/caddy.go index 7309d447129..34a10985a57 100644 --- a/caddy.go +++ b/caddy.go @@ -100,6 +100,15 @@ type App interface { Stop() error } +// StatusReporter can be implemented by Apps or Modules that wish +// to expose runtime status information via the /status admin endpoint. +type StatusReporter interface { + Status() (any, error) +} + +// ProcessStartTime records when the Caddy process started. +var ProcessStartTime = time.Now() + // Run runs the given config, replacing any existing config. func Run(cfg *Config) error { cfgJSON, err := json.Marshal(cfg) diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 085a9d78949..f3fdd87e63d 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -15,12 +15,12 @@ func TestCommandsAreAvailable(t *testing.T) { t.Fatal("default factory failed to build") } - // check that the default factory has 17 commands; it doesn't + // check that the default factory has 18 commands; it doesn't // include the commands registered through calls to init in // other packages cmds := Commands() - if len(cmds) != 17 { - t.Errorf("expected 17 commands, got %d", len(cmds)) + if len(cmds) != 18 { + t.Errorf("expected 18 commands, got %d", len(cmds)) } commandNames := slices.Collect(maps.Keys(cmds)) @@ -30,7 +30,7 @@ func TestCommandsAreAvailable(t *testing.T) { "adapt", "add-package", "build-info", "completion", "environ", "fmt", "list-modules", "manpage", "reload", "remove-package", "run", "start", - "stop", "storage", "upgrade", "validate", "version", + "status", "stop", "storage", "upgrade", "validate", "version", } if !reflect.DeepEqual(expectedCommandNames, commandNames) { diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 00000000000..e84b41dc7e6 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,103 @@ +package caddycmd + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "strings" + + "github.com/caddyserver/caddy/v2" +) + +func init() { + RegisterCommand(Command{ + Name: "status", + Func: cmdStatus, + Usage: "[--json] [--address ] [--config ] [--adapter ]", + Short: "Prints the status of the running Caddy instance", + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("status", flag.ExitOnError) + fs.Bool("json", false, "Output raw JSON instead of human-readable text") + fs.String("address", "", "The address to use to reach the admin API endpoint, if not the default") + fs.String("config", "", "Configuration file") + fs.String("adapter", "", "Name of config adapter to apply") + return fs + }(), + }) +} + +// cmdStatus implements the 'caddy status' command. +func cmdStatus(fl Flags) (int, error) { + useJSON := fl.Bool("json") + addr := fl.String("address") + cfgFile := fl.String("config") + cfgAdapter := fl.String("adapter") + + // Determine the admin API address based on provided flags or defaults + adminAddr, err := DetermineAdminAPIAddress(cfgFile, nil, cfgAdapter, addr) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("could not determine admin API address: %v", err) + } + + resp, err := http.Get(fmt.Sprintf("http://%s/status", adminAddr)) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("failed to reach admin API: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return caddy.ExitCodeFailedStartup, err + } + + if useJSON { + fmt.Println(string(body)) + return caddy.ExitCodeSuccess, nil + } + + var status map[string]any + if err := json.Unmarshal(body, &status); err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("failed to parse status JSON: %v\nRaw output: %s", err, string(body)) + } + + // Format output to be human-readable + fmt.Printf("Caddy %v\n", status["version"]) + fmt.Println("Status: Running") + + if uptime, ok := status["uptime_secs"].(float64); ok { + u := int64(uptime) + h := u / 3600 + m := (u % 3600) / 60 + s := u % 60 + if h > 0 { + fmt.Printf("Uptime: %dh %dm %ds\n", h, m, s) + } else if m > 0 { + fmt.Printf("Uptime: %dm %ds\n", m, s) + } else { + fmt.Printf("Uptime: %ds\n", s) + } + } + + if apps, ok := status["apps"].(map[string]any); ok && len(apps) > 0 { + var appNames []string + for appName := range apps { + appNames = append(appNames, appName) + } + fmt.Printf("\nRunning apps: %s\n", strings.Join(appNames, ", ")) + } else { + fmt.Printf("\nRunning apps: none\n") + } + + fmt.Println("\nMemory") + if mem, ok := status["memory"].(map[string]any); ok { + allocMB := mem["allocated_bytes"].(float64) / 1024 / 1024 + sysMB := mem["system_bytes"].(float64) / 1024 / 1024 + fmt.Printf("Allocated: %.0f MB\nSystem: %.0f MB\n", allocMB, sysMB) + } + + fmt.Printf("Goroutines: %v\n", status["goroutines"]) + + return caddy.ExitCodeSuccess, nil +}