From 871678ccec35c9009ddd24241a3ed0245d3b3f05 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Wed, 11 Nov 2026 15:21:44 +0700 Subject: [PATCH 1/2] feat: serve the MCP servers over TLS when given a cert alb-mcp and network-mcp answered plain HTTP only, and the assistant now refuses to send a caller's bearer token to an http endpoint, so it drops both tool sets. The servers take --tls-cert-file and --tls-key-file (or ALB_MCP_TLS_* and NETWORK_MCP_TLS_*) and serve HTTPS with a watched certificate, so a renewal is picked up without a restart. With neither set they serve plain HTTP as before, and with only one they refuse to start. --- cmd/alb-mcp/main.go | 4 +- cmd/network-mcp/main.go | 4 +- internal/mcpserver/server.go | 68 +++++++++-- internal/mcpserver/tls.go | 75 ++++++++++++ internal/mcpserver/tls_test.go | 204 +++++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 internal/mcpserver/tls.go create mode 100644 internal/mcpserver/tls_test.go diff --git a/cmd/alb-mcp/main.go b/cmd/alb-mcp/main.go index 19967c9a..4bcd6e1b 100644 --- a/cmd/alb-mcp/main.go +++ b/cmd/alb-mcp/main.go @@ -55,9 +55,11 @@ func main() { buildinfo.SetVersion(version) var addr string + var tlsOpts mcpserver.TLSOptions flag.StringVar(&addr, "addr", mcpserver.EnvOr("ALB_MCP_ADDR", ":8080"), "address to serve MCP on") + tlsOpts.BindFlags(flag.CommandLine, "ALB_MCP") opts := zap.Options{} opts.BindFlags(flag.CommandLine) @@ -86,7 +88,7 @@ func main() { } setupLog.Info("listening", "addr", addr, "mcp", "/mcp", "docs", docs.Paths()) - err = mcpserver.Serve(addr, + err = mcpserver.Serve(ctrl.SetupSignalHandler(), addr, tlsOpts, &mcp.Implementation{Name: serverName, Version: serverVersion}, docs, func(s *mcp.Server, r *http.Request) { agent.RegisterTools(s, depsFromRequest(r, baseConfig)) }) if err != nil { diff --git a/cmd/network-mcp/main.go b/cmd/network-mcp/main.go index 8ef407ec..d6495e7c 100644 --- a/cmd/network-mcp/main.go +++ b/cmd/network-mcp/main.go @@ -47,9 +47,11 @@ func main() { buildinfo.SetVersion(version) var addr string + var tlsOpts mcpserver.TLSOptions flag.StringVar(&addr, "addr", mcpserver.EnvOr("NETWORK_MCP_ADDR", ":8080"), "address to serve MCP on") + tlsOpts.BindFlags(flag.CommandLine, "NETWORK_MCP") opts := zap.Options{} opts.BindFlags(flag.CommandLine) @@ -75,7 +77,7 @@ func main() { } setupLog.Info("listening", "addr", addr, "mcp", "/mcp", "docs", docs.Paths()) - err = mcpserver.Serve(addr, + err = mcpserver.Serve(ctrl.SetupSignalHandler(), addr, tlsOpts, &mcp.Implementation{Name: serverName, Version: serverVersion}, docs, func(s *mcp.Server, r *http.Request) { networkagent.RegisterTools(s, depsFromRequest(r, baseConfig)) }) if err != nil { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index fd00412e..37117e62 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -7,6 +7,7 @@ package mcpserver import ( + "context" "errors" "fmt" "net" @@ -17,10 +18,12 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/sync/errgroup" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/validation" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "go.datum.net/network-services-operator/internal/cmd/alb/util" @@ -32,6 +35,7 @@ const ( ProjectHeader = "X-Datum-Project" readHeaderTimeout = 10 * time.Second + shutdownTimeout = 20 * time.Second // misconfiguredClientNote closes every error a request can fail with before // a read is attempted. All of them are the calling client's configuration, @@ -41,6 +45,8 @@ const ( "help: this is a configuration problem for whoever operates that client" ) +var serveLog = ctrl.Log.WithName("mcpserver") + // CheckControlPlaneEndpoint refuses to start when the configuration resolved to // the API server of the cluster this process runs in. // @@ -166,12 +172,27 @@ func BearerToken(r *http.Request) string { } // Serve answers every URL a capability document names from one process: MCP at -// /mcp, the knowledge and skills from docs, and liveness at /healthz. +// /mcp, the knowledge and skills from docs, and liveness at /healthz. It serves +// HTTPS when tlsOpts names a certificate and plain HTTP otherwise, and returns +// once ctx is cancelled and in-flight requests have drained. // // The MCP handler is stateless and builds a server per request, bound to that // caller's identity and project, so nothing is shared between callers. -func Serve(addr string, impl *mcp.Implementation, docs *KnowledgeHandler, - register func(*mcp.Server, *http.Request)) error { +func Serve(ctx context.Context, addr string, tlsOpts TLSOptions, impl *mcp.Implementation, + docs *KnowledgeHandler, register func(*mcp.Server, *http.Request)) error { + if err := tlsOpts.Validate(); err != nil { + return err + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listening on %s: %w", addr, err) + } + return serve(ctx, listener, tlsOpts, newHandler(impl, docs, register)) +} + +func newHandler(impl *mcp.Implementation, docs *KnowledgeHandler, + register func(*mcp.Server, *http.Request)) http.Handler { handler := mcp.NewStreamableHTTPHandler( func(r *http.Request) *mcp.Server { s := mcp.NewServer(impl, nil) @@ -189,17 +210,48 @@ func Serve(addr string, impl *mcp.Implementation, docs *KnowledgeHandler, w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintln(w, "ok") }) + return mux +} +func serve(ctx context.Context, listener net.Listener, tlsOpts TLSOptions, handler http.Handler) error { server := &http.Server{ - Addr: addr, - Handler: mux, + Handler: handler, ReadHeaderTimeout: readHeaderTimeout, } + listen := func() error { return server.Serve(listener) } - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("serving on %s: %w", addr, err) + group, ctx := errgroup.WithContext(ctx) + + if tlsOpts.Enabled() { + tlsConfig, watcher, err := tlsOpts.watchedConfig() + if err != nil { + _ = listener.Close() + return err + } + server.TLSConfig = tlsConfig + listen = func() error { return server.ServeTLS(listener, "", "") } + group.Go(func() error { return watcher.Start(ctx) }) } - return nil + + serveLog.Info("serving", "scheme", tlsOpts.Scheme(), "addr", listener.Addr().String()) + + group.Go(func() error { + if err := listen(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serving on %s: %w", listener.Addr(), err) + } + return nil + }) + group.Go(func() error { + <-ctx.Done() + drainCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(drainCtx); err != nil { + return server.Close() + } + return nil + }) + + return group.Wait() } // EnvOr returns the environment variable key, or fallback when it is unset. diff --git a/internal/mcpserver/tls.go b/internal/mcpserver/tls.go new file mode 100644 index 00000000..cf9a976f --- /dev/null +++ b/internal/mcpserver/tls.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package mcpserver + +import ( + "crypto/tls" + "flag" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/certwatcher" +) + +const ( + tlsCertFileFlag = "tls-cert-file" + tlsKeyFileFlag = "tls-key-file" +) + +// TLSOptions names the serving certificate. Both paths set serves HTTPS, both +// empty serves plain HTTP, and one without the other is refused: a deployment +// that lost half its certificate must not quietly fall back to plaintext, which +// the assistant will not send a caller's token over. +type TLSOptions struct { + CertFile string + KeyFile string +} + +// BindFlags registers --tls-cert-file and --tls-key-file, defaulting each to +// _TLS_CERT_FILE and _TLS_KEY_FILE. +func (o *TLSOptions) BindFlags(fs *flag.FlagSet, envPrefix string) { + fs.StringVar(&o.CertFile, tlsCertFileFlag, EnvOr(envPrefix+"_TLS_CERT_FILE", ""), + "path to the PEM serving certificate; set together with --"+tlsKeyFileFlag+" to serve HTTPS") + fs.StringVar(&o.KeyFile, tlsKeyFileFlag, EnvOr(envPrefix+"_TLS_KEY_FILE", ""), + "path to the PEM private key; set together with --"+tlsCertFileFlag+" to serve HTTPS") +} + +// Enabled reports whether the server serves HTTPS. +func (o TLSOptions) Enabled() bool { + return o.CertFile != "" && o.KeyFile != "" +} + +// Scheme is the URL scheme the server answers on. +func (o TLSOptions) Scheme() string { + if o.Enabled() { + return "https" + } + return "http" +} + +// Validate refuses a certificate without its key, or a key without its +// certificate. +func (o TLSOptions) Validate() error { + if (o.CertFile == "") == (o.KeyFile == "") { + return nil + } + set, missing := tlsCertFileFlag, tlsKeyFileFlag + if o.CertFile == "" { + set, missing = tlsKeyFileFlag, tlsCertFileFlag + } + return fmt.Errorf("--%s is set without --%s: set both to serve HTTPS, or neither to serve plain HTTP", + set, missing) +} + +// watchedConfig serves whatever certificate is on disk now, not the one read at +// startup: cert-manager's CSI driver rewrites the files in place on renewal, +// and a server that kept the first one would expire with it. +func (o TLSOptions) watchedConfig() (*tls.Config, *certwatcher.CertWatcher, error) { + watcher, err := certwatcher.New(o.CertFile, o.KeyFile) + if err != nil { + return nil, nil, fmt.Errorf("loading serving certificate: %w", err) + } + return &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: watcher.GetCertificate, + }, watcher, nil +} diff --git a/internal/mcpserver/tls_test.go b/internal/mcpserver/tls_test.go new file mode 100644 index 00000000..4d2f93ff --- /dev/null +++ b/internal/mcpserver/tls_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package mcpserver + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serveReturnTimeout = 10 * time.Second + // Longer than the certificate watcher's poll interval, which is the + // fallback when a file event is missed. + rotationTimeout = 20 * time.Second +) + +// TestServesTLSWhenGivenACertificate is the reason the option exists: the +// assistant refuses to send a caller's token over plain HTTP, so a server given +// a certificate has to answer HTTPS and nothing else. +func TestServesTLSWhenGivenACertificate(t *testing.T) { + opts, roots := writeServingCertificate(t, t.TempDir(), 1) + addr, stop := startServing(t, opts) + defer stop() + + resp, err := httpsClient(roots).Get("https://" + addr + "/healthz") + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, resp.TLS) + assert.GreaterOrEqual(t, resp.TLS.Version, uint16(tls.VersionTLS12)) + + plain, err := http.Get("http://" + addr + "/healthz") + require.NoError(t, err, "Go answers plaintext on a TLS port with a 400 rather than a reset") + defer func() { _ = plain.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, plain.StatusCode, "plain HTTP must not be served on a TLS listener") +} + +// TestPicksUpARotatedCertificateWithoutARestart pins why the certificate is +// watched rather than loaded once: the CSI driver renews it in place. +func TestPicksUpARotatedCertificateWithoutARestart(t *testing.T) { + dir := t.TempDir() + opts, _ := writeServingCertificate(t, dir, 1) + addr, stop := startServing(t, opts) + defer stop() + + _, rotatedRoots := writeServingCertificate(t, dir, 2) + + require.Eventually(t, func() bool { + resp, err := httpsClient(rotatedRoots).Get("https://" + addr + "/healthz") + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, rotationTimeout, 100*time.Millisecond, "the rotated certificate was never served") +} + +// TestServesPlainHTTPWithoutACertificate keeps local runs and e2e working. +func TestServesPlainHTTPWithoutACertificate(t *testing.T) { + addr, stop := startServing(t, TLSOptions{}) + defer stop() + + resp, err := http.Get("http://" + addr + "/healthz") + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Nil(t, resp.TLS) +} + +// TestHalfACertificateFailsAtStartup: falling back to plaintext here would +// start a server the assistant silently drops every tool of. +func TestHalfACertificateFailsAtStartup(t *testing.T) { + for _, tc := range []struct { + name string + opts TLSOptions + missing string + }{ + {"certificate without key", TLSOptions{CertFile: "/tls/tls.crt"}, "--tls-key-file"}, + {"key without certificate", TLSOptions{KeyFile: "/tls/tls.key"}, "--tls-cert-file"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := Serve(context.Background(), "127.0.0.1:0", tc.opts, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "without "+tc.missing) + }) + } +} + +func TestAnUnreadableCertificateFailsAtStartup(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + missing := filepath.Join(t.TempDir(), "absent") + err = serve(context.Background(), listener, TLSOptions{CertFile: missing, KeyFile: missing}, http.NotFoundHandler()) + require.Error(t, err) + assert.Contains(t, err.Error(), "loading serving certificate") +} + +func TestTLSFlagsDefaultFromTheBinarysEnvironment(t *testing.T) { + t.Setenv("TEST_MCP_TLS_CERT_FILE", "/env/tls.crt") + t.Setenv("TEST_MCP_TLS_KEY_FILE", "/env/tls.key") + + var fromEnv TLSOptions + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fromEnv.BindFlags(fs, "TEST_MCP") + require.NoError(t, fs.Parse(nil)) + assert.Equal(t, TLSOptions{CertFile: "/env/tls.crt", KeyFile: "/env/tls.key"}, fromEnv) + assert.Equal(t, "https", fromEnv.Scheme()) + + var fromFlags TLSOptions + fs = flag.NewFlagSet("test", flag.ContinueOnError) + fromFlags.BindFlags(fs, "TEST_MCP") + require.NoError(t, fs.Parse([]string{"--tls-cert-file=/flag/tls.crt", "--tls-key-file=/flag/tls.key"})) + assert.Equal(t, TLSOptions{CertFile: "/flag/tls.crt", KeyFile: "/flag/tls.key"}, fromFlags) + + assert.Equal(t, "http", TLSOptions{}.Scheme()) +} + +func startServing(t *testing.T, opts TLSOptions) (addr string, stop func()) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- serve(ctx, listener, opts, mux) }() + + return listener.Addr().String(), func() { + cancel() + select { + case err := <-done: + assert.NoError(t, err, "a cancelled context is a clean shutdown") + case <-time.After(serveReturnTimeout): + t.Error("serve did not return after its context was cancelled") + } + } +} + +func httpsClient(roots *x509.CertPool) *http.Client { + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, + DisableKeepAlives: true, + }, + } +} + +// writeServingCertificate writes a throwaway self-signed certificate for +// 127.0.0.1 into dir, replacing any already there, and returns a pool trusting +// only it. +func writeServingCertificate(t *testing.T, dir string, serial int64) (TLSOptions, *x509.CertPool) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: "mcpserver-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + opts := TLSOptions{CertFile: filepath.Join(dir, "tls.crt"), KeyFile: filepath.Join(dir, "tls.key")} + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + require.NoError(t, os.WriteFile(opts.CertFile, certPEM, 0o600)) + require.NoError(t, os.WriteFile(opts.KeyFile, + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600)) + + roots := x509.NewCertPool() + require.True(t, roots.AppendCertsFromPEM(certPEM)) + return opts, roots +} From 8ed4314404faa1b9cb23494a3782b57d73c684fa Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Wed, 11 Nov 2026 15:21:44 +0700 Subject: [PATCH 2/2] fix: deploy alb-mcp and network-mcp with https The two MCP deployments served plain HTTP and their capability documents advertised http URLs, which the assistant no longer forwards a caller's token to. Each deployment now mounts a datum-control-plane certificate through the cert-manager CSI driver, passes it to the server and probes over HTTPS, and every endpoint, knowledge and runbook URL is https. --- config/components/alb-mcp/README.md | 21 ++++++++++++-- config/components/alb-mcp/deployment.yaml | 28 +++++++++++++++++-- config/components/alb-mcp/service.yaml | 5 ++-- .../network-service-agent-configuration.yaml | 18 ++++++------ .../service-agent-configuration.yaml | 24 ++++++++-------- config/components/network-mcp/README.md | 9 ++++-- config/components/network-mcp/deployment.yaml | 28 +++++++++++++++++-- config/components/network-mcp/service.yaml | 5 ++-- 8 files changed, 105 insertions(+), 33 deletions(-) diff --git a/config/components/alb-mcp/README.md b/config/components/alb-mcp/README.md index 915506dc..4864d0c2 100644 --- a/config/components/alb-mcp/README.md +++ b/config/components/alb-mcp/README.md @@ -10,7 +10,7 @@ and why. | File | What it is | | --- | --- | | `service_account.yaml` | An intentionally unbound `ServiceAccount`. No `Role` or binding is ever attached to it — see the comments in that file for why. | -| `deployment.yaml` | Runs the `alb-mcp` binary from the operator's own image (`command: [/alb-mcp]`), exposing `/mcp`, `/llms-full.txt`, `/runbooks/*` and `/healthz` on port 8080. | +| `deployment.yaml` | Runs the `alb-mcp` binary from the operator's own image (`command: [/alb-mcp]`), exposing `/mcp`, `/llms-full.txt`, `/runbooks/*` and `/healthz` over HTTPS on port 8080. | | `service.yaml` | A `ClusterIP` Service in front of the Deployment. Cluster-internal only; no Gateway or HTTPRoute fronts it. | ## Deployment @@ -24,7 +24,24 @@ It is **not** applied by the operator's own deployment overlays. It is deployed from the infra repo, alongside the assistant platform's capability document, which names this Service's `/mcp`, `/llms-full.txt` and `/runbooks/` URLs. -Two things there have to move with it, and both fail silently if they do not: +## Transport + +The server answers HTTPS only. The assistant refuses to send a caller's bearer +token to a plain `http://` endpoint, so the Deployment mounts a certificate from +the `datum-control-plane` ClusterIssuer through the cert-manager CSI driver and +passes it as `--tls-cert-file` and `--tls-key-file`. The binary re-reads the +files when the driver renews them, so a rotation needs no restart. Run without +both flags, as in local development, it serves plain HTTP. + +The certificate names the Service as Datum deploys it: +`network-services-operator-alb-mcp` in `datum-system`. Kustomize rewrites the +Service's own name and namespace but not the volume attributes that name it, so +a deployment using a different `namePrefix` or namespace has to patch +`csi.cert-manager.io/common-name` and `csi.cert-manager.io/dns-names` to match, +along with the URLs in `../assistant-capability/service-agent-configuration.yaml`. + +Two more things there have to move with it, and both fail silently if they do +not: - The assistant forwards the calling user's bearer token only to hosts on an allow-list. This Service's host has to be on it, without a port, or every tool diff --git a/config/components/alb-mcp/deployment.yaml b/config/components/alb-mcp/deployment.yaml index e39ca2a0..9fdf968a 100644 --- a/config/components/alb-mcp/deployment.yaml +++ b/config/components/alb-mcp/deployment.yaml @@ -52,6 +52,8 @@ spec: - /alb-mcp args: - --addr=:8080 + - --tls-cert-file=/etc/alb-mcp/tls/tls.crt + - --tls-key-file=/etc/alb-mcp/tls/tls.key ports: - containerPort: 8080 name: http @@ -73,18 +75,40 @@ spec: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 + volumeMounts: + - name: alb-mcp-certs + mountPath: /etc/alb-mcp/tls + readOnly: true # No writable volume: the binary is static, the documents it serves are # compiled in, and nothing here writes to disk. + volumes: + # The serving certificate. The assistant sends a caller's token only + # over HTTPS and already trusts this issuer. + # + # The names are the Service as deployed, prefix and namespace included: + # kustomize does not rewrite volume attributes, so a deployer using a + # different namePrefix or namespace has to patch them to match. + - name: alb-mcp-certs + csi: + driver: csi.cert-manager.io + readOnly: true + volumeAttributes: + csi.cert-manager.io/issuer-name: datum-control-plane + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/common-name: network-services-operator-alb-mcp + csi.cert-manager.io/dns-names: network-services-operator-alb-mcp,network-services-operator-alb-mcp.datum-system.svc,network-services-operator-alb-mcp.datum-system.svc.cluster.local + csi.cert-manager.io/key-usages: digital signature,key encipherment,server auth + csi.cert-manager.io/fs-group: '65532' dnsPolicy: ClusterFirst restartPolicy: Always terminationGracePeriodSeconds: 30 diff --git a/config/components/alb-mcp/service.yaml b/config/components/alb-mcp/service.yaml index 37f706bc..87f298fb 100644 --- a/config/components/alb-mcp/service.yaml +++ b/config/components/alb-mcp/service.yaml @@ -1,6 +1,7 @@ # Cluster-internal only: the assistant reaches /mcp over this Service's DNS -# name, and no Gateway or HTTPRoute fronts it. The pod serves plain HTTP and the -# network layer owns transport security. +# name, and no Gateway or HTTPRoute fronts it. The pod serves HTTPS with a +# certificate issued for this name; the port keeps the name network policies +# and probes already select it by. apiVersion: v1 kind: Service metadata: diff --git a/config/components/assistant-capability/network-service-agent-configuration.yaml b/config/components/assistant-capability/network-service-agent-configuration.yaml index 720c70d3..df2bf798 100644 --- a/config/components/assistant-capability/network-service-agent-configuration.yaml +++ b/config/components/assistant-capability/network-service-agent-configuration.yaml @@ -25,7 +25,7 @@ spec: sources: - type: LLMDocs title: Networks and network services, and how to read their status - url: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/llms-full.txt + url: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/llms-full.txt concepts: - gvk: group: networking.datumapis.com @@ -46,7 +46,7 @@ spec: tools: mcpServers: - name: network - endpoint: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/mcp + endpoint: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/mcp toolSelector: include: - network_list @@ -60,25 +60,25 @@ spec: skills: - name: network-create description: 'Create a network: the one or two choices there are, why IPv4 is not one, and render, plan, show, confirm, then apply' - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-create.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-create.md - name: network-service-create description: 'Create a network service for a workload: selecting its instances, naming ports, and render, plan, show, confirm, then apply' - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-service-create.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-service-create.md - name: network-not-ready description: A network that is not ready, from IPv6Required (the customer's) to address allocation failures (Datum's) - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-not-ready.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-not-ready.md - name: network-location-triage description: A network not reaching a location, or one location's instances waiting on it while others are fine - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-location-triage.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-location-triage.md - name: network-interface-triage description: An instance's network interface not addressed, an IPv4 request on an IPv6-only network, or a kept interface refusing a workload - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-interface-triage.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-interface-triage.md - name: network-service-not-serving description: A network service sending traffic nowhere, matching nothing, spanning two networks, or with no ready instances - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-service-not-serving.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-service-not-serving.md - name: network-deletion-stuck description: Deleting a network that does not finish because instances or kept interfaces still hold addresses in it - source: http://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-deletion-stuck.md + source: https://network-services-operator-network-mcp.datum-system.svc.cluster.local:8080/runbooks/network-deletion-stuck.md authority: reads: - gvk: diff --git a/config/components/assistant-capability/service-agent-configuration.yaml b/config/components/assistant-capability/service-agent-configuration.yaml index d173556c..4cf4f2d3 100644 --- a/config/components/assistant-capability/service-agent-configuration.yaml +++ b/config/components/assistant-capability/service-agent-configuration.yaml @@ -32,7 +32,7 @@ spec: sources: - type: LLMDocs title: Application Load Balancer model and how to read its status - url: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/llms-full.txt + url: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/llms-full.txt concepts: - gvk: group: networking.datumapis.com @@ -53,7 +53,7 @@ spec: tools: mcpServers: - name: alb - endpoint: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/mcp + endpoint: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/mcp toolSelector: include: - alb_list @@ -75,34 +75,34 @@ spec: skills: - name: alb-not-serving description: Triage an Application Load Balancer that is not working, from symptom to root cause and who has to act - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/alb-not-serving.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/alb-not-serving.md - name: hostname-not-working description: Work a hostname that does not reach a load balancer through claim, ownership, DNS record and certificate, in the order each gates the next - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/hostname-not-working.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/hostname-not-working.md - name: domain-verification description: 'Prove ownership of a domain: the exact record to create, and why RecordNotFound is usually not a typo' - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/domain-verification.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/domain-verification.md - name: dns-delegation description: Decide whether Datum answers DNS for a domain or the customer does, and handle DNSAuthorityMissing and nameserver mismatches - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/dns-delegation.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/dns-delegation.md - name: certificate-not-issued description: Tell a certificate that is the cause from one that is a symptom of DNS that has not landed - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/certificate-not-issued.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/certificate-not-issued.md - name: backend-not-reachable description: 'Diagnose origins that cannot be reached: a missing network service or port name, a selector matching nothing, locations out of rotation' - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/backend-not-reachable.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/backend-not-reachable.md - name: edge-propagation description: Handle a load balancer that reports ready but is not serving, and know when that stops being propagation - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/edge-propagation.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/edge-propagation.md - name: traffic-protection-triage description: Traffic protection that is off, not attached, or blocking real traffic, and how to turn it on without breaking a live site - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/traffic-protection-triage.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/traffic-protection-triage.md - name: access-log-triage description: 'Read what actually arrived: response codes, response flags, and why no traffic is not a fault' - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/access-log-triage.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/access-log-triage.md - name: alb-create description: 'Create a load balancer: prerequisites, what is settled at create, and render, plan, show, confirm, then apply' - source: http://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/alb-create.md + source: https://network-services-operator-alb-mcp.datum-system.svc.cluster.local:8080/runbooks/alb-create.md authority: reads: - gvk: diff --git a/config/components/network-mcp/README.md b/config/components/network-mcp/README.md index 633a89d4..3a3fea0d 100644 --- a/config/components/network-mcp/README.md +++ b/config/components/network-mcp/README.md @@ -10,7 +10,7 @@ it publishes and why. | File | What it is | | --- | --- | | `service_account.yaml` | An intentionally unbound `ServiceAccount`. No `Role` or binding is ever attached to it. | -| `deployment.yaml` | Runs the `network-mcp` binary from the operator's own image (`command: [/network-mcp]`), exposing `/mcp`, `/llms-full.txt`, `/runbooks/*` and `/healthz` on port 8080. | +| `deployment.yaml` | Runs the `network-mcp` binary from the operator's own image (`command: [/network-mcp]`), exposing `/mcp`, `/llms-full.txt`, `/runbooks/*` and `/healthz` over HTTPS on port 8080. | | `service.yaml` | A `ClusterIP` Service in front of the Deployment. Cluster-internal only. | ## Deployment @@ -24,8 +24,13 @@ Deployed the same way as `../alb-mcp`, and with the same three requirements: forwarding allow-list, or every tool call arrives unauthenticated. - Permit the assistant's namespace, and nothing else, to reach this pod. +It serves HTTPS only, for the reason and in the way `../alb-mcp` does: a +certificate from the `datum-control-plane` ClusterIssuer, mounted through the +cert-manager CSI driver and re-read on renewal. + It is not applied by the operator's own overlays. It is deployed from the infra repo, alongside the capability registration in `../assistant-capability/network-service-agent-configuration.yaml`, whose URLs assume the Service is named `network-services-operator-network-mcp` in -`datum-system`. +`datum-system`. The certificate in `deployment.yaml` assumes the same name, so +a different `namePrefix` or namespace has to patch both. diff --git a/config/components/network-mcp/deployment.yaml b/config/components/network-mcp/deployment.yaml index 29cfc9a2..a9c3648c 100644 --- a/config/components/network-mcp/deployment.yaml +++ b/config/components/network-mcp/deployment.yaml @@ -51,6 +51,8 @@ spec: - /network-mcp args: - --addr=:8080 + - --tls-cert-file=/etc/network-mcp/tls/tls.crt + - --tls-key-file=/etc/network-mcp/tls/tls.key ports: - containerPort: 8080 name: http @@ -72,18 +74,40 @@ spec: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 + volumeMounts: + - name: network-mcp-certs + mountPath: /etc/network-mcp/tls + readOnly: true # No writable volume: the binary is static, the documents it serves are # compiled in, and nothing here writes to disk. + volumes: + # The serving certificate. The assistant sends a caller's token only + # over HTTPS and already trusts this issuer. + # + # The names are the Service as deployed, prefix and namespace included: + # kustomize does not rewrite volume attributes, so a deployer using a + # different namePrefix or namespace has to patch them to match. + - name: network-mcp-certs + csi: + driver: csi.cert-manager.io + readOnly: true + volumeAttributes: + csi.cert-manager.io/issuer-name: datum-control-plane + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/common-name: network-services-operator-network-mcp + csi.cert-manager.io/dns-names: network-services-operator-network-mcp,network-services-operator-network-mcp.datum-system.svc,network-services-operator-network-mcp.datum-system.svc.cluster.local + csi.cert-manager.io/key-usages: digital signature,key encipherment,server auth + csi.cert-manager.io/fs-group: '65532' dnsPolicy: ClusterFirst restartPolicy: Always terminationGracePeriodSeconds: 30 diff --git a/config/components/network-mcp/service.yaml b/config/components/network-mcp/service.yaml index 1d77384c..04f43167 100644 --- a/config/components/network-mcp/service.yaml +++ b/config/components/network-mcp/service.yaml @@ -1,6 +1,7 @@ # Cluster-internal only: the assistant reaches /mcp over this Service's DNS -# name, and no Gateway or HTTPRoute fronts it. The pod serves plain HTTP and the -# network layer owns transport security. +# name, and no Gateway or HTTPRoute fronts it. The pod serves HTTPS with a +# certificate issued for this name; the port keeps the name network policies +# and probes already select it by. apiVersion: v1 kind: Service metadata: