Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"os"
"path"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions cmd/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
@@ -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 <admin-api-address>] [--config <path>] [--adapter <name>]",
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
}
Loading