Repository navigation
feat(tls): serve a certificate for names other than localhost (extra_sans, cert_file, key_file) - #1745
feat(tls): serve a certificate for names other than localhost (extra_sans, cert_file, key_file)#1745james-crowley wants to merge 4 commits into
Conversation
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Only oas/docs.go conflicted (generated); regenerated with make swagger.
Dumbris
left a comment
There was a problem hiding this comment.
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 ./...andgo 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/configis clean.- Both golangci-lint v2 runs (bare and
--build-tags server) have no findings in any file this PR touches. - A real
mcpproxy servewithtls.enabledandextra_sans: ["mcp.internal", "10.9.8.7"]:- curl with only
ca.pemas the trust root verifieshttps://mcp.internal:…andhttps://10.9.8.7:…, still verifieslocalhost, and refuses a name that is not listed (other.example). - A second start with the same list rewrites nothing (
localhost.pemmtime unchanged), andca.pemandca.keyare byte-identical.
- curl with only
- A real run with a certificate I made with
openssl(SANext.example):cert_fileandkey_fileare 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_sanstogether withcert_file. - A wildcard in
extra_sansis refused withextra_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_sansempty, alocalhost.pemthat 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 withca.keybefore 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_filerefused together withextra_sans,client_ca_fileleft out) is James's. The code and docs are consistent with it. - With
cert_fileset,mcpproxy trust-certand the tray client's use ofca.pemdo not apply, as the PR says. They stay unchanged here.
I have not merged.
Description
Native TLS can only serve a certificate that is valid for
localhost,127.0.0.1and::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 optionaltls.client_ca_fileis not included). With none of them set, startup is unchanged.tls.extra_sans: DNS names and IP addresses added to the generated server certificate. Whenlocalhost.pemdoes not carry exactlylocalhost,127.0.0.1,::1and the listed names, it is regenerated from the existing local CA.ca.pemandca.keyare never regenerated, somcpproxy trust-certandNODE_EXTRA_CA_CERTSkeep working. A restart with an unchanged list rewrites nothing. Emptying the list gives a plainlocalhostcertificate again; alocalhost.pemthe local CA did not sign is never touched withoutextra_sans, as before.tls.cert_fileandtls.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 withsecret.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 whenrequire_client_certis on, because client certificates are verified against it.extra_sansentries 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_sanstogether withcert_fileis refused, since the provided certificate decides the names.cert_file/key_filepairing and absolute paths are checked at load; readability and key match at startup.MCPPROXY_TLS_*pattern and recorded as process-only overrides (never written back tomcp_config.json):MCPPROXY_TLS_EXTRA_SANS(comma-separated),MCPPROXY_TLS_CERT_FILE,MCPPROXY_TLS_KEY_FILE.DetectConfigChanges), like the rest of thetlsblock.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.mdlistedMCPPROXY_TLS_CERTandMCPPROXY_TLS_KEY, which no code reads; those two rows are replaced by the real names above.Changes:
internal/tlslocal(localca.go, newsans.go),internal/config(config.go,loader.go,process_overrides.go, newtls_cert_validate.go),internal/runtime/config_hotreload.go,internal/server/server.go, regeneratedoas/(make swagger, clean onmainwithout this change), docs (configuration.md,setup.md,configuration/environment-variables.md), CHANGELOG.Testing
New tests:
internal/tlslocal: SAN parsing and every refusal; the leaf gains the names whileca.pem/ca.keystay 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 incerts_dir;cert_file/key_fileserved as is with no CA or directory created, CA created only withrequire_client_cert, CRLF files, and the error cases (one of the two, missing, relative, directory, key of another certificate, not PEM, withextra_sans) with a check that no error contains file content.internal/tlslocalhandshakes: a client that trustsca.pemfailshttps://mcp.internal/beforeextra_sansand succeeds after (with the same CA file), for a DNS name and an IP literal, still forlocalhostand127.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_sanswithcert_file, not checked while TLS is off), loading from a file with JSON built byjson.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 emptyextra_sansare equal.Commands run (Linux, Go 1.27.1):
go build ./...,go build -tags server ./...,GOOS=windows go vet ./internal/tlslocal ./internal/config,go veton 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, andgolangci-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/healthzverifies (200) and a name outside the list fails;openssl x509 -ext subjectAltNameshows the five names. Withcert_filealone, 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.pemverifiessvc.example).Not run: macOS and Windows (the tests use only
filepath/t.TempDir()and no file permissions;go vetforGOOS=windowsis clean).Closes #1719