Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/vmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,40 @@ vmcp uses a YAML configuration file to define:

See [examples/vmcp-config.yaml](../../examples/vmcp-config.yaml) for a complete example.

### Redis Session Storage

Add `sessionStorage` to the vMCP YAML configuration to share session data
through Redis. Set `tls: {}` to enable TLS with certificate and hostname
verification using system root CAs:

```yaml
sessionStorage:
provider: redis
address: redis.example.com:6379
tls: {}
```

For a private CA, replace the empty `tls` object with a local PEM bundle path:

```yaml
sessionStorage:
provider: redis
address: redis.example.com:6379
tls:
caCertFile: /etc/redis/ca.crt
```

The CA bundle replaces system roots and is read at startup. Missing, empty,
or invalid CA files fail startup; TLS errors never fall back to plaintext.
Restart vMCP after updating the file. Redis-backed rate limiting uses these
same TLS settings. TLS requires `provider: redis`; omitting `tls` selects
plaintext. Set `THV_SESSION_REDIS_PASSWORD` in the process environment when
Redis requires a password.

Start with `vmcp serve --config vmcp-config.yaml` or
`thv vmcp serve --config vmcp-config.yaml`. TLS is configured in YAML and
requires no additional CLI flag.

## Authentication Model

Virtual MCP implements **two independent authentication boundaries**:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4137,6 +4137,21 @@ spec:
- memory
- redis
type: string
tls:
description: |-
TLS enables TLS for session storage and rate limiting. An empty
configuration verifies the Redis server using system roots.
properties:
caCertFile:
description: |-
CACertFile is the path to a PEM-encoded CA bundle. When configured, this
bundle replaces the system roots used to verify the Redis server.
type: string
insecureSkipVerify:
description: InsecureSkipVerify skips server certificate
and hostname verification.
type: boolean
type: object
required:
- provider
type: object
Expand Down Expand Up @@ -9187,6 +9202,21 @@ spec:
- memory
- redis
type: string
tls:
description: |-
TLS enables TLS for session storage and rate limiting. An empty
configuration verifies the Redis server using system roots.
properties:
caCertFile:
description: |-
CACertFile is the path to a PEM-encoded CA bundle. When configured, this
bundle replaces the system roots used to verify the Redis server.
type: string
insecureSkipVerify:
description: InsecureSkipVerify skips server certificate
and hostname verification.
type: boolean
type: object
required:
- provider
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4140,6 +4140,21 @@ spec:
- memory
- redis
type: string
tls:
description: |-
TLS enables TLS for session storage and rate limiting. An empty
configuration verifies the Redis server using system roots.
properties:
caCertFile:
description: |-
CACertFile is the path to a PEM-encoded CA bundle. When configured, this
bundle replaces the system roots used to verify the Redis server.
type: string
insecureSkipVerify:
description: InsecureSkipVerify skips server certificate
and hostname verification.
type: boolean
type: object
required:
- provider
type: object
Expand Down Expand Up @@ -9190,6 +9205,21 @@ spec:
- memory
- redis
type: string
tls:
description: |-
TLS enables TLS for session storage and rate limiting. An empty
configuration verifies the Redis server using system roots.
properties:
caCertFile:
description: |-
CACertFile is the path to a PEM-encoded CA bundle. When configured, this
bundle replaces the system roots used to verify the Redis server.
type: string
insecureSkipVerify:
description: InsecureSkipVerify skips server certificate
and hostname verification.
type: boolean
type: object
required:
- provider
type: object
Expand Down
24 changes: 24 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 8 additions & 15 deletions pkg/authserver/runner/embeddedauthserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/stacklok/toolhive/pkg/authserver/upstream"
"github.com/stacklok/toolhive/pkg/bodylimit"
"github.com/stacklok/toolhive/pkg/oauthproto"
"github.com/stacklok/toolhive/pkg/redisconfig"
)

// Redis ACL credential environment variable names.
Expand Down Expand Up @@ -1147,26 +1148,18 @@ func applyRedisTimeouts(rc *storage.RedisRunConfig, cfg *redisconn.Config) error
}

// convertRedisTLSRunConfig converts a RedisTLSRunConfig to a runtime
// redisconn.TLSConfig. Returns an error if a CA cert file is configured but
// cannot be read — this is treated as a hard error because silently falling
// back to system CAs could mask a misconfiguration and cause confusing TLS
// failures downstream.
// redisconn.TLSConfig. A configured CA certificate file must be readable and
// contain valid certificates; errors never fall back to system roots.
func convertRedisTLSRunConfig(rc *storage.RedisTLSRunConfig) (*redisconn.TLSConfig, error) {
if rc == nil {
return nil, nil
}
cfg := &redisconn.TLSConfig{
// Preserve the auth server's existing snake_case serialization while sharing
// CA loading and validation with the session storage configuration.
return (&redisconfig.TLSConfig{
InsecureSkipVerify: rc.InsecureSkipVerify,
}
if rc.CACertFile != "" {
// #nosec G304 - file path is from configuration, not user input
data, err := os.ReadFile(rc.CACertFile)
if err != nil {
return nil, fmt.Errorf("failed to read Redis CA cert file %q: %w", rc.CACertFile, err)
}
cfg.CACert = data
}
return cfg, nil
CACertFile: rc.CACertFile,
}).Load()
}

// resolveCIMDConfig extracts CIMD settings from a CIMDRunConfig.
Expand Down
3 changes: 2 additions & 1 deletion pkg/authserver/runner/redis_tls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive/pkg/authserver/storage"
"github.com/stacklok/toolhive/test/helpers/redistls"
)

func TestConvertRedisTLSRunConfig(t *testing.T) {
Expand Down Expand Up @@ -50,7 +51,7 @@ func TestConvertRedisTLSRunConfig(t *testing.T) {

dir := t.TempDir()
certPath := filepath.Join(dir, "ca.crt")
certData := []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n")
_, certData := redistls.Certificate(t)
require.NoError(t, os.WriteFile(certPath, certData, 0600))

rc := &storage.RedisTLSRunConfig{
Expand Down
23 changes: 12 additions & 11 deletions pkg/ratelimit/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
"os"
"time"

"github.com/redis/go-redis/v9"

"github.com/stacklok/toolhive-core/redisconn"
v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
"github.com/stacklok/toolhive/pkg/auth"
"github.com/stacklok/toolhive/pkg/mcp"
"github.com/stacklok/toolhive/pkg/redisconfig"
"github.com/stacklok/toolhive/pkg/transport/session"
"github.com/stacklok/toolhive/pkg/transport/types"
)
Expand All @@ -40,6 +40,7 @@ type MiddlewareParams struct {
Config *v1beta1.RateLimitConfig `json:"config"`
RedisAddr string `json:"redis_addr,omitempty"`
RedisDB int32 `json:"redis_db,omitempty"`
RedisTLS *redisconfig.TLSConfig `json:"redis_tls,omitempty"`
}

// rateLimitMiddleware wraps rate limiting functionality for the factory pattern.
Expand Down Expand Up @@ -67,19 +68,19 @@ func NewRedisLimiter(params MiddlewareParams) (Limiter, io.Closer, error) {
return nil, nil, fmt.Errorf("rate limit middleware requires a Redis address")
}

// TODO: share a Redis client builder with session storage to get TLS,
// dial/read/write timeouts, and username support. For now, a basic client
// suffices since rate limiting and session storage target the same Redis.
client := redis.NewClient(&redis.Options{
tlsCfg, err := params.RedisTLS.Load()
if err != nil {
return nil, nil, fmt.Errorf("rate limit middleware TLS configuration: %w", err)
}
pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pingCancel()
client, err := redisconn.NewClient(pingCtx, &redisconn.Config{
Addr: params.RedisAddr,
DB: int(params.RedisDB),
Password: os.Getenv(redisPasswordEnvVar),
TLS: tlsCfg,
})

pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pingCancel()
if err := client.Ping(pingCtx).Err(); err != nil {
_ = client.Close()
if err != nil {
return nil, nil, fmt.Errorf("rate limit middleware: failed to connect to Redis at %s: %w", params.RedisAddr, err)
}

Expand Down
Loading
Loading