-
Notifications
You must be signed in to change notification settings - Fork 0
security: add Docker HEALTHCHECK via binary self-probe #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,13 +3,16 @@ package main | |
|
|
||
| import ( | ||
| "context" | ||
| "crypto/tls" | ||
| stderrors "errors" | ||
| "flag" | ||
| "fmt" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "os" | ||
| "os/signal" | ||
| "strings" | ||
| "syscall" | ||
| "time" | ||
|
|
||
|
|
@@ -27,23 +30,35 @@ import ( | |
| ) | ||
|
|
||
| const ( | ||
| serverName = "cockroachdb-mcp-server" | ||
| shutdownPeriod = 10 * time.Second | ||
| readHeaderTimeout = 10 * time.Second | ||
| readTimeout = 30 * time.Second | ||
| idleTimeout = 120 * time.Second | ||
| maxHeaderBytes = 32 << 10 // 32 KiB | ||
| serverName = "cockroachdb-mcp-server" | ||
| shutdownPeriod = 10 * time.Second | ||
| readHeaderTimeout = 10 * time.Second | ||
| readTimeout = 30 * time.Second | ||
| idleTimeout = 120 * time.Second | ||
| maxHeaderBytes = 32 << 10 // 32 KiB | ||
| healthCheckTimeout = 3 * time.Second | ||
| ) | ||
|
|
||
| var serverVersion = "0.1.0" | ||
|
|
||
| func main() { | ||
| showVersion := flag.Bool("version", false, "print version and exit") | ||
| healthCheck := flag.Bool("healthcheck", false, "probe local /healthz and exit 0/1; intended for Docker HEALTHCHECK in HTTP mode") | ||
| flag.Parse() | ||
| if *showVersion { | ||
| fmt.Printf("%s %s\n", serverName, serverVersion) | ||
| return | ||
| } | ||
| if *healthCheck { | ||
| if !healthCheckEnabled() { | ||
| return | ||
| } | ||
| if err := probeHealthz(healthCheckURL()); err != nil { | ||
| fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Bootstrap logger keeps config-load fatals visible. Stderr-only so a | ||
| // bad CRDB_MCP_LOG_PATH can't swallow its own error. | ||
|
|
@@ -61,6 +76,53 @@ func main() { | |
| } | ||
| } | ||
|
|
||
| // healthCheckEnabled reports whether the self-probe applies: /healthz only | ||
| // exists in HTTP mode, so stdio containers pass trivially. | ||
| func healthCheckEnabled() bool { | ||
| return strings.EqualFold(os.Getenv("CRDB_MCP_TRANSPORT"), config.TransportHTTP) | ||
| } | ||
|
|
||
| // healthCheckURL derives the probe URL from the server's own listen env vars, | ||
| // rewriting wildcard hosts to loopback. | ||
| func healthCheckURL() string { | ||
| addr := os.Getenv("CRDB_MCP_HTTP_LISTEN_ADDR") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Confidence: 12 (Low) Nit (maintenance coupling): |
||
| if addr == "" { | ||
| addr = ":8080" | ||
| } | ||
| if host, port, err := net.SplitHostPort(addr); err == nil { | ||
| switch host { | ||
| case "", "0.0.0.0", "::": | ||
| host = "127.0.0.1" | ||
| } | ||
| addr = net.JoinHostPort(host, port) | ||
| } | ||
| scheme := "http" | ||
| if os.Getenv("CRDB_MCP_TLS_CERT") != "" && os.Getenv("CRDB_MCP_TLS_KEY") != "" { | ||
| scheme = "https" | ||
| } | ||
| return scheme + "://" + addr + "/healthz" | ||
| } | ||
|
|
||
| // probeHealthz issues a single GET and returns nil only on 200. TLS | ||
| // verification is skipped: this is a loopback self-check. | ||
| func probeHealthz(url string) error { | ||
| client := &http.Client{ | ||
| Timeout: healthCheckTimeout, | ||
| Transport: &http.Transport{ | ||
| TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // loopback self-probe | ||
| }, | ||
| } | ||
| resp, err := client.Get(url) | ||
| if err != nil { | ||
| return errors.Wrap(err, "probe healthz") | ||
| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Confidence: 3 (Low) Nit: the response body is closed without being drained ( |
||
| if resp.StatusCode != http.StatusOK { | ||
| return errors.Newf("/healthz returned %d", resp.StatusCode) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func run() error { | ||
| cfg, err := config.Load() | ||
| if err != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Confidence: 8 (Low)
Minor inconsistency:
healthCheckEnabled()usesstrings.EqualFoldfor case-insensitive comparison, whileconfig.Load()does an exactswitchagainst"http". This meansCRDB_MCP_TRANSPORT=HTTPwould make the health check attempt a probe, but the server itself would reject the value and never start. The net result is correct (probe fails → unhealthy container), so no wrong behavior occurs in practice.