Skip to content

Commit f888826

Browse files
authored
feat(tls): add support for manual TLS certificate management and auto-reloading (#90)
1 parent a832cfc commit f888826

File tree

6 files changed

+223
-16
lines changed

6 files changed

+223
-16
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ That's it! Your HTTP endpoint is now available at `https://{your-domain}/mcp`.
3737
- stdio (when a command is specified): MCP endpoint is https://{your-domain}/mcp.
3838
- SSE/HTTP (when a URL is specified): MCP endpoint uses the backend’s original path (no conversion).
3939

40-
> Don't want the proxy to manage TLS? Add `--no-auto-tls` so you can terminate TLS elsewhere or keep the backend on plain HTTP.
40+
> Already have certificates? Pass `--tls-cert-file` and `--tls-key-file` instead of `--tls-accept-tos`.
4141
4242
## Why not MCP Gateway?
4343

docs/docs/configuration.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ Complete reference for all MCP Auth Proxy configuration options.
1616

1717
### TLS Options
1818

19-
| Option | Environment Variable | Default | Description |
20-
| --------------------- | -------------------- | ------------------------------------------------ | ----------------------------------------------------- |
21-
| `--no-auto-tls` | `NO_AUTO_TLS` | `false` | Disable automatic TLS host detection from externalURL |
22-
| `--tls-accept-tos` | `TLS_ACCEPT_TOS` | `false` | Accept TLS terms of service |
23-
| `--tls-directory-url` | `TLS_DIRECTORY_URL` | `https://acme-v02.api.letsencrypt.org/directory` | ACME directory URL for TLS certificates |
24-
| `--tls-host` | `TLS_HOST` | - | Host name for TLS |
19+
| Option | Environment Variable | Default | Description |
20+
| --------------------- | -------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
21+
| `--no-auto-tls` | `NO_AUTO_TLS` | `false` | Disable automatic TLS host detection from externalURL (ignored when `--tls-cert-file` is provided) |
22+
| `--tls-accept-tos` | `TLS_ACCEPT_TOS` | `false` | Accept TLS terms of service |
23+
| `--tls-directory-url` | `TLS_DIRECTORY_URL` | `https://acme-v02.api.letsencrypt.org/directory` | ACME directory URL for TLS certificates |
24+
| `--tls-host` | `TLS_HOST` | - | Host name used for automatic TLS certificate provisioning |
25+
| `--tls-cert-file` | `TLS_CERT_FILE` | - | Path to PEM-encoded TLS certificate served directly by the proxy (auto-reloads on file changes) |
26+
| `--tls-key-file` | `TLS_KEY_FILE` | - | Path to PEM-encoded TLS private key (requires `--tls-cert-file`, auto-reloads on file changes) |
2527

2628
### Authentication Options
2729

docs/docs/quickstart.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ For URL-based MCP servers:
7979

8080
### TLS Configuration
8181

82-
MCP Auth Proxy automatically handles HTTPS certificates:
82+
MCP Auth Proxy can automatically issue certificates or serve an existing pair:
8383

8484
- `--tls-accept-tos`: Accept Let's Encrypt terms of service
85-
- `--no-auto-tls`: Disable automatic TLS (use with TLS reverse proxy)
85+
- `--tls-cert-file` / `--tls-key-file`: Serve the provided PEM certificate and key with automatic reload when files change (overrides `--no-auto-tls`)
86+
- `--no-auto-tls`: Disable automatic TLS (use with TLS reverse proxy or custom certificate)
8687

8788
## Accessing Your Server
8889

main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ func main() {
7171
var tlsHost string
7272
var tlsDirectoryURL string
7373
var tlsAcceptTOS bool
74+
var tlsCertFile string
75+
var tlsKeyFile string
7476
var dataPath string
7577
var repositoryBackend string
7678
var repositoryDSN string
@@ -176,10 +178,12 @@ func main() {
176178
if err := mcpproxy.Run(
177179
listen,
178180
tlsListen,
179-
!noAutoTLS,
181+
(!noAutoTLS) || tlsCertFile != "" || tlsKeyFile != "",
180182
tlsHost,
181183
tlsDirectoryURL,
182184
tlsAcceptTOS,
185+
tlsCertFile,
186+
tlsKeyFile,
183187
dataPath,
184188
repositoryBackend,
185189
repositoryDSN,
@@ -216,9 +220,11 @@ func main() {
216220
rootCmd.Flags().StringVar(&listen, "listen", getEnvWithDefault("LISTEN", ":80"), "Address to listen on")
217221
rootCmd.Flags().StringVar(&tlsListen, "tls-listen", getEnvWithDefault("TLS_LISTEN", ":443"), "Address to listen on for TLS")
218222
rootCmd.Flags().BoolVar(&noAutoTLS, "no-auto-tls", getEnvBoolWithDefault("NO_AUTO_TLS", false), "Disable automatic TLS host detection from externalURL")
219-
rootCmd.Flags().StringVarP(&tlsHost, "tls-host", "H", getEnvWithDefault("TLS_HOST", ""), "Host name for TLS")
223+
rootCmd.Flags().StringVarP(&tlsHost, "tls-host", "H", getEnvWithDefault("TLS_HOST", ""), "Host name for automatic TLS certificate provisioning")
220224
rootCmd.Flags().StringVar(&tlsDirectoryURL, "tls-directory-url", getEnvWithDefault("TLS_DIRECTORY_URL", "https://acme-v02.api.letsencrypt.org/directory"), "ACME directory URL for TLS certificates")
221225
rootCmd.Flags().BoolVar(&tlsAcceptTOS, "tls-accept-tos", getEnvBoolWithDefault("TLS_ACCEPT_TOS", false), "Accept TLS terms of service")
226+
rootCmd.Flags().StringVar(&tlsCertFile, "tls-cert-file", getEnvWithDefault("TLS_CERT_FILE", ""), "Path to TLS certificate file (PEM). Requires --tls-key-file")
227+
rootCmd.Flags().StringVar(&tlsKeyFile, "tls-key-file", getEnvWithDefault("TLS_KEY_FILE", ""), "Path to TLS private key file (PEM). Requires --tls-cert-file")
222228
rootCmd.Flags().StringVarP(&dataPath, "data-path", "d", getEnvWithDefault("DATA_PATH", "./data"), "Path to the data directory")
223229
rootCmd.Flags().StringVar(&repositoryBackend, "repository-backend", getEnvWithDefault("REPOSITORY_BACKEND", "local"), "Repository backend to use: local, sqlite, postgres, or mysql")
224230
rootCmd.Flags().StringVar(&repositoryDSN, "repository-dsn", getEnvWithDefault("REPOSITORY_DSN", ""), "DSN passed directly to the SQL driver (required when repository-backend is sqlite/postgres/mysql)")

pkg/mcp-proxy/main.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcpproxy
22

33
import (
44
"context"
5+
"crypto/tls"
56
"errors"
67
"fmt"
78
"net/http"
@@ -23,6 +24,7 @@ import (
2324
"github.com/sigbit/mcp-auth-proxy/pkg/idp"
2425
"github.com/sigbit/mcp-auth-proxy/pkg/proxy"
2526
"github.com/sigbit/mcp-auth-proxy/pkg/repository"
27+
"github.com/sigbit/mcp-auth-proxy/pkg/tlsreload"
2628
"github.com/sigbit/mcp-auth-proxy/pkg/utils"
2729
"go.uber.org/zap"
2830
"golang.org/x/crypto/acme"
@@ -39,6 +41,8 @@ func Run(
3941
tlsHost string,
4042
tlsDirectoryURL string,
4143
tlsAcceptTOS bool,
44+
tlsCertFile string,
45+
tlsKeyFile string,
4246
dataPath string,
4347
repositoryBackend string,
4448
repositoryDSN string,
@@ -78,6 +82,20 @@ func Run(
7882
return fmt.Errorf("external URL must not have a path, got: %s", parsedExternalURL.Path)
7983
}
8084

85+
if (tlsCertFile == "") != (tlsKeyFile == "") {
86+
return fmt.Errorf("both TLS certificate and key files must be provided together")
87+
}
88+
var manualTLS bool
89+
if tlsCertFile != "" && tlsKeyFile != "" {
90+
manualTLS = true
91+
}
92+
if manualTLS && tlsHost != "" {
93+
return fmt.Errorf("tlsHost cannot be used when TLS certificate and key files are provided")
94+
}
95+
if !manualTLS && !autoTLS && tlsHost != "" {
96+
return fmt.Errorf("tlsHost requires automatic TLS; remove noAutoTLS or provide certificate files instead")
97+
}
98+
8199
secret, err := utils.LoadOrGenerateSecret(path.Join(dataPath, "secret"))
82100
if err != nil {
83101
return fmt.Errorf("failed to load or generate secret: %w", err)
@@ -271,7 +289,7 @@ func Run(
271289
proxyRouter.SetupRoutes(router)
272290

273291
var tlsHostDetected bool
274-
if autoTLS &&
292+
if autoTLS && !manualTLS &&
275293
tlsHost == "" &&
276294
parsedExternalURL.Scheme == "https" &&
277295
parsedExternalURL.Host != "localhost" {
@@ -284,13 +302,75 @@ func Run(
284302
errs := []error{}
285303
lock := sync.Mutex{}
286304

287-
if tlsHost != "" {
305+
if manualTLS {
306+
certReloader, err := tlsreload.NewFileReloader(tlsCertFile, tlsKeyFile, logger)
307+
if err != nil {
308+
return fmt.Errorf("failed to prepare TLS certificate reloader: %w", err)
309+
}
310+
311+
logger.Info("Starting server with provided TLS certificate")
312+
httpServer := &http.Server{
313+
Addr: listen,
314+
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
315+
host := r.Host
316+
if host == "" {
317+
host = r.URL.Host
318+
}
319+
target := "https://" + host + r.RequestURI
320+
http.Redirect(w, r, target, http.StatusMovedPermanently)
321+
}),
322+
}
323+
httpsServer := &http.Server{
324+
Addr: tlsListen,
325+
Handler: router,
326+
TLSConfig: &tls.Config{GetCertificate: certReloader.GetCertificate},
327+
}
328+
wg.Add(1)
329+
go func() {
330+
defer wg.Done()
331+
err := httpServer.ListenAndServe()
332+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
333+
lock.Lock()
334+
errs = append(errs, err)
335+
lock.Unlock()
336+
}
337+
logger.Debug("HTTP server closed")
338+
exit <- struct{}{}
339+
}()
340+
go func() {
341+
<-ctx.Done()
342+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), ServerShutdownTimeout)
343+
defer shutdownCancel()
344+
if shutdownErr := httpServer.Shutdown(shutdownCtx); shutdownErr != nil {
345+
logger.Warn("HTTP server shutdown error", zap.Error(shutdownErr))
346+
}
347+
}()
348+
wg.Add(1)
349+
go func() {
350+
defer wg.Done()
351+
err := httpsServer.ListenAndServeTLS("", "")
352+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
353+
lock.Lock()
354+
errs = append(errs, err)
355+
lock.Unlock()
356+
}
357+
logger.Debug("HTTPS server closed")
358+
exit <- struct{}{}
359+
}()
360+
go func() {
361+
<-ctx.Done()
362+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), ServerShutdownTimeout)
363+
defer shutdownCancel()
364+
if shutdownErr := httpsServer.Shutdown(shutdownCtx); shutdownErr != nil {
365+
logger.Warn("HTTPS server shutdown error", zap.Error(shutdownErr))
366+
}
367+
}()
368+
} else if tlsHost != "" {
288369
if !tlsAcceptTOS {
289370
if tlsHostDetected {
290371
return errors.New("TLS host is auto-detected, but tlsAcceptTOS is not set to true. Please agree to the TOS or set noAutoTLS to true")
291-
} else {
292-
return errors.New("TLS is enabled, but tlsAcceptTOS is not set to true. Please explicitly agree to the TOS")
293372
}
373+
return errors.New("TLS is enabled, but tlsAcceptTOS is not set to true. Please explicitly agree to the TOS")
294374
}
295375

296376
m := autocert.Manager{
@@ -401,7 +481,7 @@ func Run(
401481
}()
402482
}
403483

404-
if tlsHost != "" {
484+
if manualTLS || tlsHost != "" {
405485
logger.Info("Starting server", zap.Strings("listen", []string{listen, tlsListen}))
406486
} else {
407487
logger.Info("Starting server", zap.Strings("listen", []string{listen}))

pkg/tlsreload/file_reloader.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package tlsreload
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
"os"
7+
"sync"
8+
"sync/atomic"
9+
"time"
10+
11+
"go.uber.org/zap"
12+
)
13+
14+
type fileState struct {
15+
modTime time.Time
16+
size int64
17+
}
18+
19+
// FileReloader watches certificate and key files and reloads them when they change.
20+
type FileReloader struct {
21+
certPath string
22+
keyPath string
23+
logger *zap.Logger
24+
25+
cert atomic.Value // *tls.Certificate
26+
mu sync.Mutex
27+
certState fileState
28+
keyState fileState
29+
}
30+
31+
// NewFileReloader loads the initial certificate/key pair and prepares for reloads.
32+
func NewFileReloader(certPath, keyPath string, logger *zap.Logger) (*FileReloader, error) {
33+
certInfo, err := os.Stat(certPath)
34+
if err != nil {
35+
return nil, fmt.Errorf("stat cert file: %w", err)
36+
}
37+
keyInfo, err := os.Stat(keyPath)
38+
if err != nil {
39+
return nil, fmt.Errorf("stat key file: %w", err)
40+
}
41+
42+
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
43+
if err != nil {
44+
return nil, fmt.Errorf("load key pair: %w", err)
45+
}
46+
47+
reloader := &FileReloader{
48+
certPath: certPath,
49+
keyPath: keyPath,
50+
logger: logger,
51+
certState: fileState{
52+
modTime: certInfo.ModTime(),
53+
size: certInfo.Size(),
54+
},
55+
keyState: fileState{
56+
modTime: keyInfo.ModTime(),
57+
size: keyInfo.Size(),
58+
},
59+
}
60+
reloader.cert.Store(&cert)
61+
return reloader, nil
62+
}
63+
64+
func (r *FileReloader) maybeReload() error {
65+
r.mu.Lock()
66+
defer r.mu.Unlock()
67+
68+
certInfo, err := os.Stat(r.certPath)
69+
if err != nil {
70+
return fmt.Errorf("stat cert file: %w", err)
71+
}
72+
keyInfo, err := os.Stat(r.keyPath)
73+
if err != nil {
74+
return fmt.Errorf("stat key file: %w", err)
75+
}
76+
77+
newCertState := fileState{modTime: certInfo.ModTime(), size: certInfo.Size()}
78+
newKeyState := fileState{modTime: keyInfo.ModTime(), size: keyInfo.Size()}
79+
80+
if newCertState.modTime.Equal(r.certState.modTime) && newCertState.size == r.certState.size &&
81+
newKeyState.modTime.Equal(r.keyState.modTime) && newKeyState.size == r.keyState.size {
82+
return nil
83+
}
84+
85+
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
86+
if err != nil {
87+
return fmt.Errorf("load key pair: %w", err)
88+
}
89+
90+
r.cert.Store(&cert)
91+
r.certState = newCertState
92+
r.keyState = newKeyState
93+
94+
if r.logger != nil {
95+
r.logger.Info("Reloaded TLS certificate files",
96+
zap.String("certFile", r.certPath),
97+
zap.String("keyFile", r.keyPath),
98+
zap.Time("certModTime", newCertState.modTime),
99+
zap.Time("keyModTime", newKeyState.modTime),
100+
)
101+
}
102+
return nil
103+
}
104+
105+
// GetCertificate reloads certificate/key as needed and returns the current pair.
106+
func (r *FileReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
107+
if err := r.maybeReload(); err != nil {
108+
if r.logger != nil {
109+
r.logger.Warn("Failed to reload TLS certificate", zap.Error(err))
110+
}
111+
}
112+
113+
value := r.cert.Load()
114+
if value == nil {
115+
return nil, fmt.Errorf("no TLS certificate loaded")
116+
}
117+
return value.(*tls.Certificate), nil
118+
}

0 commit comments

Comments
 (0)