Skip to content

Commit 0a3971c

Browse files
feat(tls): serve a certificate for names other than localhost (extra_sans, cert_file, key_file) (#1745)
## 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 - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] 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
1 parent 831f79f commit 0a3971c

17 files changed

Lines changed: 1091 additions & 30 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Releases follow [Semantic Versioning](https://semver.org/).
104104
- **secrets: `${file:/absolute/path}` references (#1706, spec 111 FR-030).** A secret can be read from a file, as Docker/Compose secrets, Kubernetes secret volumes and systemd `LoadCredential=` provide it, wherever `${env:}` and `${keyring:}` are accepted: an upstream server's `env`, `args` and `headers`, `data_dir`, and the proxy's own `api_key`. The path must be absolute, a symlink must lead to a regular file, at most 64 KiB is read, one trailing LF or CRLF is removed, and a missing, unreadable or empty file is an error that names the reference, never the content. A server shows a missing-secret error and the proxy stays up; an `api_key` reference that does not resolve stops startup (no key is generated over it). The stored config keeps the reference: the value is resolved on a copy, and the `api_key` secret is a process-only override that no save, including `PATCH /api/v1/config`, writes back. (refs #1706)
105105

106106
- **config: `MCPPROXY_API_KEY_FILE` reads the API key from a file (#1706).** The `*_FILE` form of Docker Compose/Swarm secrets, Kubernetes secret volumes and systemd `LoadCredential=`: the key is not in the process environment (`docker inspect`, `/proc/<pid>/environ`) and is never written to `mcp_config.json`. The path must be absolute; one trailing LF or CRLF is removed; at most 64 KiB is read. Setting both `MCPPROXY_API_KEY` and `MCPPROXY_API_KEY_FILE` (both non-empty) is an error: mcpproxy refuses to start with a message naming both variables (never a key or file contents), and the CLI commands and the tray stop the same way. An empty value is treated as unset. A file that is named but cannot be used stops startup with an error naming the variable and path: mcpproxy does not fall back to the config file or a generated key. `mcpproxy status`, the other CLI commands and the tray read it too, and a tray-launched core reads the file itself instead of receiving the key in its environment. `Config.EnsureAPIKey` now also returns an error. (refs #1706, spec 111 FR-014/FR-030/US7)
107+
- **tls: serve a certificate for names other than `localhost` (#1719).** `tls.extra_sans` lists DNS names and IP addresses to add to the generated server certificate; when `localhost.pem` does not carry exactly those names plus `localhost`, `127.0.0.1` and `::1`, it is regenerated and signed by the same local CA, so `ca.pem` and `mcpproxy trust-cert` keep working. `tls.cert_file` and `tls.key_file` (absolute paths, both or neither) serve a certificate you provide instead, for a real or internal CA, `certbot` or a Docker/Kubernetes secret mount; nothing is generated, and a missing, unreadable or mismatched file stops startup with an error naming the setting. Environment variables `MCPPROXY_TLS_EXTRA_SANS`, `MCPPROXY_TLS_CERT_FILE` and `MCPPROXY_TLS_KEY_FILE`. Changing any of them needs a restart; with none set, startup is unchanged. Removes `MCPPROXY_TLS_CERT` and `MCPPROXY_TLS_KEY` from the environment-variable reference, which no code ever read.
107108

108109
- **catalog:** a catalog source whose live search times out or fails is now answered from the listing
109110
the daemon last saw from it (at most 24 hours old, kept in memory). Those results are marked

‎docs/configuration.md‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,17 +1144,23 @@ The tokenizer automatically maps model names to encodings:
11441144
"enabled": false,
11451145
"require_client_cert": false,
11461146
"certs_dir": "",
1147-
"hsts": true
1147+
"hsts": true,
1148+
"extra_sans": [],
1149+
"cert_file": "",
1150+
"key_file": ""
11481151
}
11491152
}
11501153
```
11511154

11521155
| Field | Type | Default | Description |
11531156
|-------|------|---------|-------------|
11541157
| `enabled` | boolean | `false` | Enable HTTPS/TLS |
1155-
| `require_client_cert` | boolean | `false` | Enable mutual TLS (mTLS) for client authentication |
1158+
| `require_client_cert` | boolean | `false` | Enable mutual TLS (mTLS) for client authentication. Client certificates are verified against the local CA (`ca.pem` in `certs_dir`), also when `cert_file` is set |
11561159
| `certs_dir` | string | `""` | Custom certificate directory (defaults to `${data_dir}/certs`) |
11571160
| `hsts` | boolean | `true` | Enable HTTP Strict Transport Security headers |
1161+
| `extra_sans` | string array | `[]` | DNS names and IP addresses added to the generated server certificate, next to `localhost`, `127.0.0.1` and `::1`. See [Other host names](#other-host-names-extra_sans-cert_file-key_file) |
1162+
| `cert_file` | string | `""` | Absolute path of a server certificate (PEM) to serve instead of the generated one. Needs `key_file` |
1163+
| `key_file` | string | `""` | Absolute path of the private key (PEM) for `cert_file`. Needs `cert_file` |
11581164

11591165
**Quick Setup:**
11601166
1. Trust certificate: `mcpproxy trust-cert`
@@ -1163,6 +1169,36 @@ The tokenizer automatically maps model names to encodings:
11631169

11641170
See [Setup Guide - HTTPS](setup.md#optional-https-setup) for complete details.
11651171

1172+
### Other host names (`extra_sans`, `cert_file`, `key_file`)
1173+
1174+
The generated server certificate is valid for `localhost`, `127.0.0.1` and `::1` only. A client that reaches mcpproxy by another name (a Docker or Kubernetes service name, a VM host name, a tailnet name) cannot verify it. Either of these fixes that; without either, startup is unchanged.
1175+
1176+
**`extra_sans`**: names for the generated certificate.
1177+
1178+
```json
1179+
{ "tls": { "enabled": true, "extra_sans": ["mcp.internal", "mcpproxy.tail1234.ts.net", "10.0.0.5"] } }
1180+
```
1181+
1182+
- Each entry is a host name or an IP address. Wildcards (`*.example.com`), URLs, ports and empty entries are refused at startup with an error that names the entry. Host names are lower-cased; non-ASCII names must be given in punycode.
1183+
- When `localhost.pem` does not carry exactly `localhost`, `127.0.0.1`, `::1` and the listed names, it is replaced with a new certificate and key signed by the **same** CA. `ca.pem` and `ca.key` are never regenerated, so clients that trust `ca.pem` (`mcpproxy trust-cert`, `NODE_EXTRA_CA_CERTS`) keep working with no new step. A restart with an unchanged list rewrites nothing, and emptying the list replaces it with a plain `localhost` certificate (one the local CA did not sign is left as it is).
1184+
- With an empty list an existing `localhost.pem` is used as it is. If `localhost.pem` was not issued by the local CA and does not carry the listed names (for example one you placed there by hand), startup stops instead of overwriting it; use `cert_file` and `key_file` for such a certificate.
1185+
- The new certificate is valid for one year from the day it is written. There is no automatic renewal.
1186+
- Environment variable: `MCPPROXY_TLS_EXTRA_SANS` (comma-separated).
1187+
1188+
**`cert_file` and `key_file`**: serve a certificate you provide (a public or internal CA, `certbot`, a Docker or Kubernetes secret mount).
1189+
1190+
```json
1191+
{ "tls": { "enabled": true, "cert_file": "/run/secrets/mcpproxy.crt", "key_file": "/run/secrets/mcpproxy.key" } }
1192+
```
1193+
1194+
- Set both or neither, as absolute paths to PEM files. A certificate chain goes in `cert_file`, leaf first. The files are read with the same rules as `MCPPROXY_API_KEY_FILE`: a symlink is followed (Kubernetes secret volumes), the target must be a regular file of at most 64 KiB.
1195+
- If only one is set, a file cannot be read, or the key does not belong to the certificate, startup stops with an error that names the setting and never shows file content.
1196+
- Nothing is generated and `extra_sans` is refused alongside it, since the certificate you provide decides the names. The local CA is created only when `require_client_cert` is on, because client certificates are verified against it.
1197+
- The files are read once at startup. After the certificate is renewed, restart mcpproxy.
1198+
- Environment variables: `MCPPROXY_TLS_CERT_FILE` and `MCPPROXY_TLS_KEY_FILE`.
1199+
1200+
Changing any of the three settings needs a restart.
1201+
11661202
---
11671203

11681204
## Logging Configuration
@@ -1905,6 +1941,9 @@ Many configuration options can be overridden via environment variables:
19051941
| `MCPPROXY_TLS_ENABLED` | `tls.enabled` | Enable HTTPS/TLS |
19061942
| `MCPPROXY_TLS_REQUIRE_CLIENT_CERT` | `tls.require_client_cert` | Enable mTLS |
19071943
| `MCPPROXY_CERTS_DIR` | `tls.certs_dir` | Custom certificates directory |
1944+
| `MCPPROXY_TLS_EXTRA_SANS` | `tls.extra_sans` | Comma-separated host names and IP addresses added to the generated certificate |
1945+
| `MCPPROXY_TLS_CERT_FILE` | `tls.cert_file` | Absolute path of a server certificate to serve instead of the generated one |
1946+
| `MCPPROXY_TLS_KEY_FILE` | `tls.key_file` | Absolute path of the private key for `MCPPROXY_TLS_CERT_FILE` |
19081947
| `MCPPROXY_DATA_DIR` | `data_dir` | Override data directory; also where `serve` looks for `mcp_config.json` (same as `--data-dir`) |
19091948
| `MCPPROXY_DATA` | `data_dir` | Override data directory only (legacy name; `MCPPROXY_DATA_DIR` wins) |
19101949
| `MCPPROXY_TOOL_RESPONSE_MODE` | `tool_response_mode` | `retrieve_tools` serialization: `full` (default) or `compact` |

‎docs/configuration/environment-variables.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,13 @@ secrets:
7979
| `MCPPROXY_TRUSTED_HOSTS` | Comma-separated `Host` header allowlist for loopback listeners behind a reverse proxy (see [Reverse Proxy Deployment](/operations/reverse-proxy)) | - |
8080
| `MCPPROXY_TRUSTED_PROXIES` | Comma-separated CIDRs or IP addresses whose `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers are honoured (overrides `trusted_proxies`). Any other peer's forwarded headers are ignored. An entry that is neither a CIDR nor an IP is refused at boot with `trusted_proxies[N] "value" is not a valid CIDR or IP address`, exactly like a file value. See [trusted_proxies](/operations/reverse-proxy#trusted_proxies-forwarded-headers) | - (trust nobody) |
8181
| `MCPPROXY_TLS_ENABLED` | Enable TLS/HTTPS | `false` |
82-
| `MCPPROXY_TLS_CERT` | Path to TLS certificate | - |
83-
| `MCPPROXY_TLS_KEY` | Path to TLS private key | - |
82+
| `MCPPROXY_TLS_CERT_FILE` | Absolute path of a PEM server certificate to serve instead of the generated one (needs `MCPPROXY_TLS_KEY_FILE`) | - |
83+
| `MCPPROXY_TLS_KEY_FILE` | Absolute path of the PEM private key for `MCPPROXY_TLS_CERT_FILE` | - |
84+
| `MCPPROXY_TLS_EXTRA_SANS` | Comma-separated host names and IP addresses added to the generated certificate (not with `MCPPROXY_TLS_CERT_FILE`) | - |
8485
| `MCPPROXY_TLS_REQUIRE_CLIENT_CERT` | Require client certificates for mTLS | `false` |
8586
| `MCPPROXY_CERTS_DIR` | Custom directory for TLS certificates | - |
8687

87-
**Note:** TLS certificates are managed in `~/.mcpproxy/certs/` or via the `tls.certs_dir` config option. Use `mcpproxy trust-cert` to set up certificates.
88+
**Note:** TLS certificates are managed in `~/.mcpproxy/certs/` or via the `tls.certs_dir` config option. Use `mcpproxy trust-cert` to set up certificates. To serve a name other than `localhost`, see [Other host names](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/configuration.md#other-host-names-extra_sans-cert_file-key_file).
8889

8990
### Audit Log
9091

‎docs/setup.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,35 @@ After enabling HTTPS, update your client configurations to use `https://` URLs:
535535
- `require_client_cert`: Enable mutual TLS (mTLS) for client authentication
536536
- `certs_dir`: Custom directory for certificates (default: `{data_dir}/certs`)
537537
- `hsts`: Enable HTTP Strict Transport Security headers
538+
- `extra_sans`: Host names and IP addresses to add to the generated certificate (see below)
539+
- `cert_file`, `key_file`: Serve your own certificate instead of the generated one (see below)
540+
541+
**Reaching mcpproxy by a name other than `localhost`:**
542+
543+
The generated certificate is valid for `localhost`, `127.0.0.1` and `::1`. For a Docker service name, a VM host name or a tailnet name, add it:
544+
545+
```json
546+
{
547+
"tls": {
548+
"enabled": true,
549+
"extra_sans": ["mcp.internal", "10.0.0.5"]
550+
}
551+
}
552+
```
553+
554+
On the next start `localhost.pem` is replaced with a certificate that carries these names, signed by the same CA, so a client that already trusts `ca.pem` needs no change. Or serve a certificate you provide, for instance from a Docker or Kubernetes secret:
555+
556+
```json
557+
{
558+
"tls": {
559+
"enabled": true,
560+
"cert_file": "/run/secrets/mcpproxy.crt",
561+
"key_file": "/run/secrets/mcpproxy.key"
562+
}
563+
}
564+
```
565+
566+
Both settings are described in [Configuration Documentation - Other host names](configuration.md#other-host-names-extra_sans-cert_file-key_file).
538567

539568
**📚 For complete TLS configuration reference:** See [Configuration Documentation - TLS/HTTPS](configuration.md#tlshttps-configuration).
540569

‎internal/config/config.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,15 @@ type TLSConfig struct {
655655
RequireClientCert bool `json:"require_client_cert" mapstructure:"require_client_cert"` // Enable mTLS
656656
CertsDir string `json:"certs_dir,omitempty" mapstructure:"certs_dir"` // Directory for certificates
657657
HSTS bool `json:"hsts" mapstructure:"hsts"` // Enable HTTP Strict Transport Security
658+
659+
// ExtraSANs are DNS names and IP addresses added to the generated server
660+
// certificate, which is signed by the same local CA. Not used with CertFile.
661+
ExtraSANs []string `json:"extra_sans,omitempty" mapstructure:"extra_sans"`
662+
// CertFile is the absolute path of an operator-provided server certificate
663+
// (PEM, leaf first), served instead of a generated one. Needs KeyFile.
664+
CertFile string `json:"cert_file,omitempty" mapstructure:"cert_file"`
665+
// KeyFile is the absolute path of the private key for CertFile (PEM).
666+
KeyFile string `json:"key_file,omitempty" mapstructure:"key_file"`
658667
}
659668

660669
// TokenizerConfig represents tokenizer configuration for token counting
@@ -3052,6 +3061,7 @@ func (c *Config) validateDetailedCore() []ValidationError {
30523061
})
30533062
}
30543063
}
3064+
errors = append(errors, validateTLSCertSettings(c.TLS)...)
30553065
}
30563066

30573067
// Validate logging configuration

‎internal/config/loader.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,24 @@ func applyTLSEnvOverrides(cfg *Config) {
859859
value = os.Getenv("MCPPROXY_CERTS_DIR")
860860
envOverride(b, cfg, FieldTLSCertsDir, value != "", value)
861861

862+
// Extra names for the generated server certificate: comma-separated DNS
863+
// names and IP addresses. An empty variable leaves the file value; entries
864+
// are validated like file values (Validate runs after the overrides).
865+
var sans []string
866+
value = os.Getenv("MCPPROXY_TLS_EXTRA_SANS")
867+
for _, e := range strings.Split(value, ",") {
868+
if e = strings.TrimSpace(e); e != "" {
869+
sans = append(sans, e)
870+
}
871+
}
872+
envOverride(b, cfg, FieldTLSExtraSANs, len(sans) > 0, sans)
873+
874+
// Operator-provided server certificate and key (absolute paths).
875+
value = os.Getenv("MCPPROXY_TLS_CERT_FILE")
876+
envOverride(b, cfg, FieldTLSCertFile, value != "", value)
877+
value = os.Getenv("MCPPROXY_TLS_KEY_FILE")
878+
envOverride(b, cfg, FieldTLSKeyFile, value != "", value)
879+
862880
// Override data directory from environment. MCPPROXY_DATA_DIR is the
863881
// documented name; MCPPROXY_DATA is kept for backward compatibility and
864882
// loses when both are set.

‎internal/config/process_overrides.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ var (
117117
Get: func(c *Config) string { return tlsOf(c).CertsDir },
118118
Set: func(c *Config, v string) { t := cowTLS(c); t.CertsDir = v },
119119
}
120+
FieldTLSExtraSANs = Field[[]string]{
121+
Name: "tls.extra_sans",
122+
Get: func(c *Config) []string { return tlsOf(c).ExtraSANs },
123+
Set: func(c *Config, v []string) { t := cowTLS(c); t.ExtraSANs = v },
124+
}
125+
FieldTLSCertFile = Field[string]{
126+
Name: "tls.cert_file",
127+
Get: func(c *Config) string { return tlsOf(c).CertFile },
128+
Set: func(c *Config, v string) { t := cowTLS(c); t.CertFile = v },
129+
}
130+
FieldTLSKeyFile = Field[string]{
131+
Name: "tls.key_file",
132+
Get: func(c *Config) string { return tlsOf(c).KeyFile },
133+
Set: func(c *Config, v string) { t := cowTLS(c); t.KeyFile = v },
134+
}
120135
FieldTPABundlePath = Field[string]{
121136
Name: "security.tpa_bundle_path",
122137
Get: func(c *Config) string { return securityOf(c).TPABundlePath },

0 commit comments

Comments
 (0)