Skip to content

feat(tls): serve a certificate for names other than localhost (extra_sans, cert_file, key_file) - #1745

Open
james-crowley wants to merge 4 commits into
smart-mcp-proxy:mainfrom
james-crowley:feat/tls-extra-sans
Open

james-crowley wants to merge 4 commits into
smart-mcp-proxy:mainfrom
james-crowley:feat/tls-extra-sans

Conversation

@james-crowley

Copy link
Copy Markdown
Contributor

Description

Native TLS can only serve a certificate that is valid for localhost, 127.0.0.1 and ::1, so a client that reaches mcpproxy by a Docker service name, a VM host name or a tailnet name cannot verify it. This adds the two settings proposed in #1719 (items 1 and 2; the optional tls.client_ca_file is not included). With none of them set, startup is unchanged.

  • tls.extra_sans: DNS names and IP addresses added to the generated server certificate. When localhost.pem does not carry exactly localhost, 127.0.0.1, ::1 and the listed names, it is regenerated from the existing local CA. ca.pem and ca.key are never regenerated, so mcpproxy trust-cert and NODE_EXTRA_CA_CERTS keep working. A restart with an unchanged list rewrites nothing. Emptying the list gives a plain localhost certificate again; a localhost.pem the local CA did not sign is never touched without extra_sans, as before.
  • tls.cert_file and tls.key_file: serve a certificate the operator provides (real CA, internal CA, certbot, a Docker or Kubernetes secret mount). Both or neither, absolute paths, read once at startup with secret.ReadFile (symlinks followed, regular file, 64 KiB cap). Only one set, a file that cannot be read, or a key that does not match the certificate stops startup with an error that names the setting and never shows file content. Nothing is generated; the local CA is created only when require_client_cert is on, because client certificates are verified against it.
  • Validation (config load, REST config apply and startup all use the same rules): extra_sans entries must be a host name or an IP address; empty entries, wildcards, URLs, ports, brackets and non-ASCII names are refused with an error that names the index and value (tls.extra_sans: extra_sans[1] "*.x": wildcard names are not supported; list each name). extra_sans together with cert_file is refused, since the provided certificate decides the names. cert_file/key_file pairing and absolute paths are checked at load; readability and key match at startup.
  • Env vars, following the existing MCPPROXY_TLS_* pattern and recorded as process-only overrides (never written back to mcp_config.json): MCPPROXY_TLS_EXTRA_SANS (comma-separated), MCPPROXY_TLS_CERT_FILE, MCPPROXY_TLS_KEY_FILE.
  • All three settings are restart-required (DetectConfigChanges), like the rest of the tls block.
  • Leaf and key writes now go through a temporary file and a rename (key first), so a crash during regeneration leaves a pair the next start repairs rather than a half-written file.

Not changed: mcpproxy trust-cert, the tray and CLI clients, the Settings page (the new keys are file/env only), the default certificate lifetime (one year, no renewal).

docs/configuration/environment-variables.md listed MCPPROXY_TLS_CERT and MCPPROXY_TLS_KEY, which no code reads; those two rows are replaced by the real names above.

Changes: internal/tlslocal (localca.go, new sans.go), internal/config (config.go, loader.go, process_overrides.go, new tls_cert_validate.go), internal/runtime/config_hotreload.go, internal/server/server.go, regenerated oas/ (make swagger, clean on main without this change), docs (configuration.md, setup.md, configuration/environment-variables.md), CHANGELOG.

Testing

  • I have tested these changes locally
  • I have added/updated tests that prove my fix is effective or my feature works
  • All existing tests pass

New tests:

  • internal/tlslocal: SAN parsing and every refusal; the leaf gains the names while ca.pem/ca.key stay byte-identical and the leaf verifies against that CA; the same list in another order and spelling rewrites nothing; a changed list regenerates, and so does removing one name; emptying the list gives a plain localhost leaf, and a restart after that rewrites nothing; a leaf from another CA is not overwritten; no temporary files are left in certs_dir; cert_file/key_file served as is with no CA or directory created, CA created only with require_client_cert, CRLF files, and the error cases (one of the two, missing, relative, directory, key of another certificate, not PEM, with extra_sans) with a check that no error contains file content.
  • internal/tlslocal handshakes: a client that trusts ca.pem fails https://mcp.internal/ before extra_sans and succeeds after (with the same CA file), for a DNS name and an IP literal, still for localhost and 127.0.0.1, and a name outside the list is still refused; the same with a provided certificate. The client uses a custom dialer, so no DNS is involved.
  • internal/config: validation (valid, invalid entries, pairing, relative paths, extra_sans with cert_file, not checked while TLS is off), loading from a file with JSON built by json.Marshal (so Windows paths are escaped), env override wins and is not persisted, one-sided env value refused.
  • internal/runtime: each setting needs a restart; nil and empty extra_sans are equal.

Commands run (Linux, Go 1.27.1): go build ./..., go build -tags server ./..., GOOS=windows go vet ./internal/tlslocal ./internal/config, go vet on the touched packages, go test -race ./internal/tlslocal/... ./internal/config/..., go test -race -run 'DetectConfigChanges|HotReload' ./internal/runtime, go test ./internal/runtime (whole package, 270s, passes), go test -run 'TLS|Tls|Config|HotReload|Trust' ./internal/runtime ./cmd/mcpproxy ./internal/server, and golangci-lint run --config .github/.golangci.yml ./internal/tlslocal/... ./internal/config/... ./internal/runtime/... (v2.9.0 built with Go 1.26): 0 issues.

By hand with a built binary: with extra_sans: ["mcp.internal", "10.9.8.7"], curl --cacert certs/ca.pem --resolve mcp.internal:PORT:127.0.0.1 https://mcp.internal:PORT/healthz verifies (200) and a name outside the list fails; openssl x509 -ext subjectAltName shows the five names. With cert_file alone, a key that does not match, and a missing file, startup stops with the setting named (exit 1); with a matching pair the provided certificate is served (curl --cacert other.pem verifies svc.example).

Not run: macOS and Windows (the tests use only filepath/t.TempDir() and no file permissions; go vet for GOOS=windows is clean).

Closes #1719

Add tls.extra_sans, which adds DNS names and IP addresses to the generated
server certificate, and tls.cert_file/tls.key_file, which serve an
operator-provided certificate instead.

When extra_sans is set and localhost.pem does not carry exactly those names
plus localhost, 127.0.0.1 and ::1, the leaf is regenerated from the existing
local CA; the CA is never regenerated, so clients that trust ca.pem keep
working. A leaf the local CA did not issue is not overwritten.

cert_file and key_file (both or neither, absolute paths, read with the secret
file rules) are loaded once at startup and nothing is generated, except the
local CA when require_client_cert needs it. A missing, unreadable or
mismatched file stops startup with an error naming the setting.

Environment variables: MCPPROXY_TLS_EXTRA_SANS, MCPPROXY_TLS_CERT_FILE,
MCPPROXY_TLS_KEY_FILE. All three need a restart. With none set, startup is
unchanged.
@codecov-commenter

codecov-commenter commented Oct 23, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 81.91489% with 34 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/tlslocal/localca.go 68.81% 16 Missing and 13 partials ⚠️
internal/server/server.go 0.00% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

Only oas/docs.go conflicted (generated); regenerated with make swagger.

@Dumbris Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, James. Both settings from #1719 are in, the failure messages name the setting and never show file content, and the tests do real handshakes with a custom dialer instead of checking SAN lists. Approving.

What I changed on your branch

oas/docs.go conflicted with main (it is generated). I merged main into the branch (266edb7) and regenerated it with make swagger. The OAS diff against main is only your three new tls fields. Nothing else changed, so the PR merges cleanly again.

What I verified

  • go build ./... and go build -tags server ./... pass. go test -race ./internal/tlslocal/... ./internal/config/... ./internal/oauth/... passes. The runtime hot-reload tests pass. GOOS=windows go vet ./internal/tlslocal ./internal/config is clean.
  • Both golangci-lint v2 runs (bare and --build-tags server) have no findings in any file this PR touches.
  • A real mcpproxy serve with tls.enabled and extra_sans: ["mcp.internal", "10.9.8.7"]:
    • curl with only ca.pem as the trust root verifies https://mcp.internal:… and https://10.9.8.7:…, still verifies localhost, and refuses a name that is not listed (other.example).
    • A second start with the same list rewrites nothing (localhost.pem mtime unchanged), and ca.pem and ca.key are byte-identical.
  • A real run with a certificate I made with openssl (SAN ext.example):
    • cert_file and key_file are served and verify against that certificate.
    • A key file that is a symlink works, and so does a CRLF key file.
    • Refused at startup with a message that names the setting and no PEM content in any log: key of another certificate, relative path, missing key file, a directory as the key, only one of the two set, and extra_sans together with cert_file.
    • A wildcard in extra_sans is refused with extra_sans[1] "*.x": wildcard names are not supported; list each name.
  • No Gemini or other bot review comments exist on this PR (only Codecov). I did not run a cross-model round on this one.

Not blocking, for @Dumbris

  • Design call: with extra_sans empty, a localhost.pem that the local CA signed but that carries other names is replaced by a plain localhost one. That is what makes "emptying the list" work, and it is documented, but someone who hand-signed a leaf with ca.key before this existed would lose it on upgrade. It is a rare case; if you want it, a marker in the generated certificate (for example a Subject OU) would tell ours from theirs.
  • #1719 had no maintainer reply on the design before this PR, so the shape (cert_file/key_file refused together with extra_sans, client_ca_file left out) is James's. The code and docs are consistent with it.
  • With cert_file set, mcpproxy trust-cert and the tray client's use of ca.pem do not apply, as the PR says. They stay unchanged here.

I have not merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tls: allow a certificate for names other than localhost (extra_sans, cert_file, key_file)

3 participants