mcp connection
This commit is contained in:
@@ -12,6 +12,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -58,8 +59,44 @@ type Config struct {
|
||||
Agents AgentsConfig
|
||||
Model ModelConfig
|
||||
Knowledge KnowledgeConfig
|
||||
OAuth OAuthConfig
|
||||
}
|
||||
|
||||
// OAuthConfig is the MCP surface's OAuth 2.1 identity.
|
||||
//
|
||||
// EMPTY IS THE DEFAULT AND IT MEANS "OFF". A deployment that sets neither
|
||||
// OAUTH_ISSUER nor MCP_RESOURCE does not serve OAuth or MCP at all, and that is
|
||||
// the correct default for every deployment that exists today — the routes are
|
||||
// simply not registered, exactly as routeRuns is skipped without a model
|
||||
// credential.
|
||||
//
|
||||
// NO PRODUCTION DOMAIN IS HARDCODED. Both values are URLs the operator supplies,
|
||||
// because the issuer identifies the deployment and a default would be one
|
||||
// deployment's identity baked into every other one.
|
||||
//
|
||||
// Issuer and Resource look similar and are not the same thing: the ISSUER
|
||||
// identifies the authorization server ("who minted this token"), the RESOURCE
|
||||
// identifies what the token is good for ("which MCP server may spend it"). A
|
||||
// token's audience is checked against Resource. Conflating them is how a token
|
||||
// for one service becomes spendable at another.
|
||||
type OAuthConfig struct {
|
||||
// Issuer is the authorization server's base URL, e.g.
|
||||
// https://api.example.com. No trailing slash.
|
||||
Issuer string
|
||||
|
||||
// Resource is the canonical MCP endpoint URI, e.g.
|
||||
// https://api.example.com/mcp. This becomes an issued token's audience.
|
||||
Resource string
|
||||
|
||||
// LoginPath is where the authorization endpoint sends somebody who is not
|
||||
// signed in. A same-origin path, never an absolute URL — an absolute one
|
||||
// would be an open redirect waiting for a misconfiguration.
|
||||
LoginPath string
|
||||
}
|
||||
|
||||
// Enabled reports whether this deployment serves OAuth and MCP.
|
||||
func (c OAuthConfig) Enabled() bool { return c.Issuer != "" && c.Resource != "" }
|
||||
|
||||
// KnowledgeConfig routes the retrieval layer's embedding provider.
|
||||
//
|
||||
// The chat provider does not serve embeddings, so the dense half of hybrid
|
||||
@@ -213,6 +250,42 @@ type HTTPConfig struct {
|
||||
// this API, and once authentication exists that becomes a real hole rather
|
||||
// than a theoretical one.
|
||||
CORSOrigins []string
|
||||
|
||||
// TrustedProxies are the networks a forwarded client address may be
|
||||
// believed from. Empty by default, and empty means "believe nothing".
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
//
|
||||
// Several limits on this API are keyed by the caller's network address:
|
||||
// failed logins, OAuth registration, and OAuth authorization before the
|
||||
// caller has signed in. Behind a reverse proxy every request arrives from
|
||||
// the proxy, so RemoteAddr is one constant value and those per-address
|
||||
// budgets silently become one budget for the entire deployment. The
|
||||
// symptom is users rate-limiting each other — one person retrying a
|
||||
// connector exhausts everybody's allowance.
|
||||
//
|
||||
// WHY IT IS NOT SIMPLY "READ X-FORWARDED-FOR"
|
||||
//
|
||||
// That header is client-supplied. A caller reaching the API directly can
|
||||
// invent one and mint a fresh budget per request, which is strictly worse
|
||||
// than sharing a bucket: it removes the limit entirely. The header is
|
||||
// meaningful only when the immediate peer is a proxy that is known to
|
||||
// rewrite it, which is what this list names.
|
||||
//
|
||||
// WHY THE DEFAULT IS EMPTY
|
||||
//
|
||||
// So that a missing or misspelt setting cannot open the spoofing hole. An
|
||||
// unconfigured deployment behaves exactly as it did before this setting
|
||||
// existed: RemoteAddr, and X-Forwarded-For ignored. The failure mode of
|
||||
// forgetting to set it is the old shared bucket, which is an availability
|
||||
// problem an operator will notice, rather than an unmetered endpoint which
|
||||
// they will not.
|
||||
//
|
||||
// Entries are CIDR blocks or bare addresses (a bare address is treated as
|
||||
// a single-host block). Both families are accepted. Set it to the network
|
||||
// the load balancer or ingress talks to the API from — see
|
||||
// .env.example and infrastructure/.env.docker.example.
|
||||
TrustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
@@ -278,6 +351,12 @@ func Load() (*Config, error) {
|
||||
return v
|
||||
}
|
||||
|
||||
// Parsed before the literal below because it can fail, and a malformed
|
||||
// entry has to stop startup rather than be dropped: an operator who
|
||||
// mistypes the proxy network gets the shared-bucket behaviour back, and
|
||||
// silently is the one way they will not find out.
|
||||
trustedProxies, trustedProxiesErr := parseTrustedProxies(os.Getenv("HTTP_TRUSTED_PROXIES"))
|
||||
|
||||
cfg := &Config{
|
||||
AppEnv: withDefault("APP_ENV", "development"),
|
||||
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
||||
@@ -293,6 +372,7 @@ func Load() (*Config, error) {
|
||||
// the server derive it from the CORS posture", and an explicit value
|
||||
// overrides that derivation. See Server.sessionSameSite.
|
||||
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
|
||||
TrustedProxies: trustedProxies,
|
||||
},
|
||||
Seed: SeedConfig{
|
||||
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
||||
@@ -300,6 +380,14 @@ func Load() (*Config, error) {
|
||||
Agents: AgentsConfig{
|
||||
CuratedPath: withDefault("CURATED_AGENTS_PATH", "./agents"),
|
||||
},
|
||||
OAuth: OAuthConfig{
|
||||
// Trailing slashes trimmed here rather than at every use: the
|
||||
// canonical form of a resource URI has none, and a token minted
|
||||
// against ".../mcp/" would fail to validate against ".../mcp".
|
||||
Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OAUTH_ISSUER")), "/"),
|
||||
Resource: strings.TrimRight(strings.TrimSpace(os.Getenv("MCP_RESOURCE")), "/"),
|
||||
LoginPath: withDefault("OAUTH_LOGIN_PATH", "/login"),
|
||||
},
|
||||
Knowledge: KnowledgeConfig{
|
||||
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
|
||||
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
|
||||
@@ -347,6 +435,9 @@ func Load() (*Config, error) {
|
||||
},
|
||||
}
|
||||
|
||||
if trustedProxiesErr != nil {
|
||||
return nil, trustedProxiesErr
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("missing required environment variables: %s "+
|
||||
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
|
||||
@@ -531,6 +622,9 @@ func (c *Config) validate() error {
|
||||
if c.Knowledge.EmbedDims < 0 {
|
||||
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
|
||||
}
|
||||
if err := c.validateOAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, model := range map[string]string{
|
||||
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
|
||||
} {
|
||||
@@ -621,6 +715,49 @@ func corsOrigins(appEnv string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTrustedProxies reads HTTP_TRUSTED_PROXIES, a comma-separated list of
|
||||
// CIDR blocks or bare addresses.
|
||||
//
|
||||
// Unset or empty yields nil, which means no proxy is trusted and forwarded
|
||||
// client addresses are ignored entirely. That is the safe default and the
|
||||
// behaviour this API had before the setting existed.
|
||||
//
|
||||
// A bare address is accepted and widened to a single-host prefix, because
|
||||
// "10.0.0.7" is what an operator reaches for when there is exactly one ingress
|
||||
// and requiring them to write "10.0.0.7/32" only invites a mistake.
|
||||
//
|
||||
// Malformed entries are an error rather than a skip. Skipping one would leave
|
||||
// the deployment quietly trusting a shorter list than the operator wrote, and
|
||||
// the consequence — a proxy that is not believed, so every user shares one
|
||||
// rate-limit bucket again — is precisely the fault this setting exists to fix.
|
||||
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
||||
// Masked so that a block written with host bits set — 10.0.0.7/8,
|
||||
// which is easy to write and easy to misread — still contains what
|
||||
// its author meant. Unmasked, Prefix.Contains always reports false.
|
||||
out = append(out, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP_TRUSTED_PROXIES entry %q is not an IP address "+
|
||||
"or CIDR block (for example 10.0.0.0/8, 172.17.0.1 or fd00::/8)", entry)
|
||||
}
|
||||
// Unmap first: ::ffff:10.0.0.1 and 10.0.0.1 are the same host, and a
|
||||
// /128 around the mapped form would not match the peer address Go
|
||||
// reports for an IPv4 connection.
|
||||
addr = addr.Unmap()
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func withDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
@@ -764,3 +901,45 @@ func applyDotEnv(content string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateOAuth checks the MCP surface's OAuth identity.
|
||||
//
|
||||
// Both values empty is the ordinary case and means the surface is off. Setting
|
||||
// exactly one is always a mistake — a deployment that named an issuer but no
|
||||
// resource would serve discovery documents pointing at a resource that does not
|
||||
// exist — so it is refused at boot rather than at the first client connection.
|
||||
func (c *Config) validateOAuth() error {
|
||||
issuer, resource := c.OAuth.Issuer, c.OAuth.Resource
|
||||
if issuer == "" && resource == "" {
|
||||
return nil
|
||||
}
|
||||
if issuer == "" || resource == "" {
|
||||
return fmt.Errorf("OAUTH_ISSUER and MCP_RESOURCE must be set together; " +
|
||||
"one without the other serves discovery documents that point nowhere")
|
||||
}
|
||||
|
||||
for name, raw := range map[string]string{"OAUTH_ISSUER": issuer, "MCP_RESOURCE": resource} {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s must be an absolute URL, got %q", name, raw)
|
||||
}
|
||||
// HTTPS everywhere except a loopback development host. OAuth 2.1
|
||||
// requires every authorization server endpoint to be served over
|
||||
// HTTPS; a token or code sent over plain http is a token on the wire.
|
||||
if parsed.Scheme != "https" && !isLoopback(raw) {
|
||||
return fmt.Errorf("%s must use https (http is permitted only on loopback), got %q", name, raw)
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return fmt.Errorf("%s must not contain a fragment, got %q", name, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A same-origin path, never an absolute URL: the authorization endpoint
|
||||
// redirects here, and an operator-supplied absolute URL would be an open
|
||||
// redirect one config mistake away.
|
||||
if !strings.HasPrefix(c.OAuth.LoginPath, "/") || strings.HasPrefix(c.OAuth.LoginPath, "//") {
|
||||
return fmt.Errorf("OAUTH_LOGIN_PATH must be a same-origin path beginning with a single '/', got %q",
|
||||
c.OAuth.LoginPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
134
go-api/internal/config/trustedproxies_test.go
Normal file
134
go-api/internal/config/trustedproxies_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package config
|
||||
|
||||
// HTTP_TRUSTED_PROXIES parsing.
|
||||
//
|
||||
// The setting decides whether a client-supplied header is believed, so the
|
||||
// tests worth having are about what happens when it is WRONG: unset, empty,
|
||||
// mistyped. Every one of those must end in "trust nothing", because the
|
||||
// alternative — trusting something the operator did not write — is the whole
|
||||
// risk this setting carries.
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrustedProxiesUnsetTrustsNothing(t *testing.T) {
|
||||
for _, raw := range []string{"", " ", ",", " , , "} {
|
||||
got, err := parseTrustedProxies(raw)
|
||||
if err != nil {
|
||||
t.Errorf("parseTrustedProxies(%q): unexpected error %v", raw, err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("parseTrustedProxies(%q) = %v, want empty — an unset value must trust nothing", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesParsesCIDRsAndBareAddresses(t *testing.T) {
|
||||
got, err := parseTrustedProxies(" 10.0.0.0/8 , 172.17.0.1 , fd00::/8 , ::1 ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"10.0.0.0/8", "172.17.0.1/32", "fd00::/8", "::1/128"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parsed %d entries (%v), want %d", len(got), got, len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].String() != w {
|
||||
t.Errorf("entry %d = %q, want %q", i, got[i].String(), w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bare address must become a single-host block that contains that host and
|
||||
// nothing else — the operator wrote one proxy, not a network.
|
||||
func TestTrustedProxyBareAddressIsOneHost(t *testing.T) {
|
||||
got, err := parseTrustedProxies("172.17.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got[0].Contains(netip.MustParseAddr("172.17.0.1")) {
|
||||
t.Error("the host itself is not in its own single-host block")
|
||||
}
|
||||
if got[0].Contains(netip.MustParseAddr("172.17.0.2")) {
|
||||
t.Error("a bare address was widened beyond one host")
|
||||
}
|
||||
}
|
||||
|
||||
// A block written with host bits set is common and easy to misread. Masking it
|
||||
// at parse time makes it mean what its author meant; unmasked, netip.Prefix
|
||||
// .Contains reports false for everything.
|
||||
func TestTrustedProxyCIDRWithHostBitsIsMasked(t *testing.T) {
|
||||
got, err := parseTrustedProxies("10.1.2.3/8")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := "10.0.0.0/8"; got[0].String() != want {
|
||||
t.Fatalf("got %q, want %q", got[0].String(), want)
|
||||
}
|
||||
if !got[0].Contains(netip.MustParseAddr("10.9.9.9")) {
|
||||
t.Error("the masked block does not contain an address inside it")
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv4-mapped address names an IPv4 host, and must match the peer address
|
||||
// Go reports for an IPv4 connection.
|
||||
func TestTrustedProxyIPv4MappedIsUnmapped(t *testing.T) {
|
||||
got, err := parseTrustedProxies("::ffff:10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got[0].Contains(netip.MustParseAddr("10.0.0.1")) {
|
||||
t.Errorf("%q does not contain 10.0.0.1", got[0].String())
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed entries stop startup. Skipping one would leave the deployment
|
||||
// trusting a shorter list than the operator wrote, and the consequence — every
|
||||
// user sharing one rate-limit bucket — is silent.
|
||||
func TestTrustedProxiesRejectMalformedEntries(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"banana",
|
||||
"10.0.0.0/33",
|
||||
"10.0.0.0/8, banana",
|
||||
"300.1.2.3",
|
||||
"10.0.0.1:8080",
|
||||
"*",
|
||||
"https://proxy.internal",
|
||||
"fd00::/200",
|
||||
} {
|
||||
if _, err := parseTrustedProxies(raw); err == nil {
|
||||
t.Errorf("parseTrustedProxies(%q) was accepted; it must refuse and stop startup", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The error has to name the entry and show the shape expected, because it is
|
||||
// read by an operator at 3am with a container that will not boot.
|
||||
func TestTrustedProxiesErrorNamesTheEntry(t *testing.T) {
|
||||
_, err := parseTrustedProxies("10.0.0.0/8, banana")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, want := range []string{"HTTP_TRUSTED_PROXIES", "banana"} {
|
||||
if !contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && (haystack == needle ||
|
||||
len(needle) == 0 || indexOf(haystack, needle) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(haystack, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user