feat: add TLS listener support to aibridgeproxyd (#22411)

## Description

Adds optional TLS support for the AI Bridge Proxy listener. When TLS cert and key files are provided, the proxy serves over HTTPS instead of plain HTTP.

## Changes

* New configuration options to enable TLS on the proxy listener 
* Wraps the TCP listener in `tls.NewListener` when configured
* Tests for validation errors, invalid files, and full integration (tunneled + MITM) through a TLS listener

Note: Documentation for TLS listener setup and client configuration will be handled in a follow-up PR.
Related to: https://github.com/coder/internal/issues/1335
This commit is contained in:
Susana Ferreira
2026-03-05 09:19:34 +00:00
committed by GitHub
parent 7bcd9f6de8
commit 21c91cebaa
13 changed files with 346 additions and 7 deletions
+8
View File
@@ -191,6 +191,14 @@ AI BRIDGE PROXY OPTIONS:
Path to the CA private key file used to intercept (MITM) HTTPS traffic
from AI clients.
--aibridge-proxy-tls-cert-file string, $CODER_AIBRIDGE_PROXY_TLS_CERT_FILE
Path to the TLS certificate file for the AI Bridge Proxy listener.
Must be set together with AI Bridge Proxy TLS Key File.
--aibridge-proxy-tls-key-file string, $CODER_AIBRIDGE_PROXY_TLS_KEY_FILE
Path to the TLS private key file for the AI Bridge Proxy listener.
Must be set together with AI Bridge Proxy TLS Certificate File.
--aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM
URL of an upstream HTTP proxy to chain tunneled (non-allowlisted)
requests through. Format: http://[user:pass@]host:port or
+8
View File
@@ -830,6 +830,14 @@ aibridgeproxy:
# The address the AI Bridge Proxy will listen on.
# (default: :8888, type: string)
listen_addr: :8888
# Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set
# together with AI Bridge Proxy TLS Key File.
# (default: <unset>, type: string)
tls_cert_file: ""
# Path to the TLS private key file for the AI Bridge Proxy listener. Must be set
# together with AI Bridge Proxy TLS Certificate File.
# (default: <unset>, type: string)
tls_key_file: ""
# Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI
# clients. This CA must be trusted by AI clients for the proxy to decrypt their
# requests.
+6
View File
@@ -12576,6 +12576,12 @@ const docTemplate = `{
"listen_addr": {
"type": "string"
},
"tls_cert_file": {
"type": "string"
},
"tls_key_file": {
"type": "string"
},
"upstream_proxy": {
"type": "string"
},
+6
View File
@@ -11184,6 +11184,12 @@
"listen_addr": {
"type": "string"
},
"tls_cert_file": {
"type": "string"
},
"tls_key_file": {
"type": "string"
},
"upstream_proxy": {
"type": "string"
},
+22
View File
@@ -3857,6 +3857,26 @@ Write out the current server config as YAML to stdout.`,
Group: &deploymentGroupAIBridgeProxy,
YAML: "listen_addr",
},
{
Name: "AI Bridge Proxy TLS Certificate File",
Description: "Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Key File.",
Flag: "aibridge-proxy-tls-cert-file",
Env: "CODER_AIBRIDGE_PROXY_TLS_CERT_FILE",
Value: &c.AI.BridgeProxyConfig.TLSCertFile,
Default: "",
Group: &deploymentGroupAIBridgeProxy,
YAML: "tls_cert_file",
},
{
Name: "AI Bridge Proxy TLS Key File",
Description: "Path to the TLS private key file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Certificate File.",
Flag: "aibridge-proxy-tls-key-file",
Env: "CODER_AIBRIDGE_PROXY_TLS_KEY_FILE",
Value: &c.AI.BridgeProxyConfig.TLSKeyFile,
Default: "",
Group: &deploymentGroupAIBridgeProxy,
YAML: "tls_key_file",
},
{
Name: "AI Bridge Proxy MITM CA Certificate File",
Description: "Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI clients. This CA must be trusted by AI clients for the proxy to decrypt their requests.",
@@ -4014,6 +4034,8 @@ type AIBridgeBedrockConfig struct {
type AIBridgeProxyConfig struct {
Enabled serpent.Bool `json:"enabled" typescript:",notnull"`
ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"`
TLSCertFile serpent.String `json:"tls_cert_file" typescript:",notnull"`
TLSKeyFile serpent.String `json:"tls_key_file" typescript:",notnull"`
MITMCertFile serpent.String `json:"cert_file" typescript:",notnull"`
MITMKeyFile serpent.String `json:"key_file" typescript:",notnull"`
DomainAllowlist serpent.StringArray `json:"domain_allowlist" typescript:",notnull"`
+2
View File
@@ -170,6 +170,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"enabled": true,
"key_file": "string",
"listen_addr": "string",
"tls_cert_file": "string",
"tls_key_file": "string",
"upstream_proxy": "string",
"upstream_proxy_ca": "string"
},
+10
View File
@@ -623,6 +623,8 @@
"enabled": true,
"key_file": "string",
"listen_addr": "string",
"tls_cert_file": "string",
"tls_key_file": "string",
"upstream_proxy": "string",
"upstream_proxy_ca": "string"
}
@@ -637,6 +639,8 @@
| `enabled` | boolean | false | | |
| `key_file` | string | false | | |
| `listen_addr` | string | false | | |
| `tls_cert_file` | string | false | | |
| `tls_key_file` | string | false | | |
| `upstream_proxy` | string | false | | |
| `upstream_proxy_ca` | string | false | | |
@@ -746,6 +750,8 @@
"enabled": true,
"key_file": "string",
"listen_addr": "string",
"tls_cert_file": "string",
"tls_key_file": "string",
"upstream_proxy": "string",
"upstream_proxy_ca": "string"
},
@@ -2671,6 +2677,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"enabled": true,
"key_file": "string",
"listen_addr": "string",
"tls_cert_file": "string",
"tls_key_file": "string",
"upstream_proxy": "string",
"upstream_proxy_ca": "string"
},
@@ -3240,6 +3248,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"enabled": true,
"key_file": "string",
"listen_addr": "string",
"tls_cert_file": "string",
"tls_key_file": "string",
"upstream_proxy": "string",
"upstream_proxy_ca": "string"
},
+20
View File
@@ -1912,6 +1912,26 @@ Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider requ
The address the AI Bridge Proxy will listen on.
### --aibridge-proxy-tls-cert-file
| | |
|-------------|--------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_AIBRIDGE_PROXY_TLS_CERT_FILE</code> |
| YAML | <code>aibridgeproxy.tls_cert_file</code> |
Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Key File.
### --aibridge-proxy-tls-key-file
| | |
|-------------|-------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_AIBRIDGE_PROXY_TLS_KEY_FILE</code> |
| YAML | <code>aibridgeproxy.tls_key_file</code> |
Path to the TLS private key file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Certificate File.
### --aibridge-proxy-cert-file
| | |
+34 -2
View File
@@ -66,6 +66,7 @@ type Server struct {
proxy *goproxy.ProxyHttpServer
httpServer *http.Server
listener net.Listener
tlsEnabled bool
coderAccessURL *url.URL
aibridgeProviderFromHost func(host string) string
// caCert is the PEM-encoded MITM CA certificate loaded during initialization.
@@ -99,6 +100,10 @@ type requestContext struct {
type Options struct {
// ListenAddr is the address the proxy server will listen on.
ListenAddr string
// TLSCertFile is the path to the TLS certificate file for the proxy listener.
TLSCertFile string
// TLSKeyFile is the path to the TLS private key file for the proxy listener.
TLSKeyFile string
// CoderAccessURL is the URL of the Coder deployment where aibridged is running.
// Requests to supported AI providers are forwarded here.
CoderAccessURL string
@@ -141,6 +146,12 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
return nil, xerrors.New("listen address is required")
}
// Listener TLS requires both cert and key files. When set, the proxy listener
// is served over HTTPS, otherwise it defaults to HTTP.
if (opts.TLSCertFile != "") != (opts.TLSKeyFile != "") {
return nil, xerrors.New("tls cert file and tls key file must both be set")
}
if strings.TrimSpace(opts.CoderAccessURL) == "" {
return nil, xerrors.New("coder access URL is required")
}
@@ -272,6 +283,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
ctx: ctx,
logger: logger,
proxy: proxy,
tlsEnabled: opts.TLSCertFile != "",
coderAccessURL: coderAccessURL,
aibridgeProviderFromHost: aibridgeProviderFromHost,
caCert: certPEM,
@@ -301,13 +313,27 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
// Handle responses from aibridged.
proxy.OnResponse().DoFunc(srv.handleResponse)
// Create listener first so we can get the actual address.
// This is useful in tests where port 0 is used to avoid conflicts.
// Create a plain HTTP listener by default. Port 0 is accepted and resolves
// to a random available port, which is useful in tests to avoid conflicts.
listener, err := net.Listen("tcp", opts.ListenAddr)
if err != nil {
return nil, xerrors.Errorf("failed to listen on %s: %w", opts.ListenAddr, err)
}
// Upgrade to HTTPS by wrapping the listener in TLS. The plain listener is
// closed explicitly on error to avoid leaking the bound socket.
if opts.TLSCertFile != "" {
tlsCert, err := tls.LoadX509KeyPair(opts.TLSCertFile, opts.TLSKeyFile)
if err != nil {
_ = listener.Close()
return nil, xerrors.Errorf("load listener TLS certificate: %w", err)
}
listener = tls.NewListener(listener, &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{tlsCert},
})
}
srv.listener = listener
// Start HTTP server in background
@@ -318,6 +344,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
logger.Info(ctx, "aibridgeproxyd configured",
slog.F("listen_addr", listener.Addr().String()),
slog.F("tls_listener_enabled", srv.tlsEnabled),
slog.F("coder_access_url", coderAccessURL.String()),
slog.F("domain_allowlist", mitmHosts),
slog.F("upstream_proxy", opts.UpstreamProxy),
@@ -342,6 +369,11 @@ func (s *Server) Addr() string {
return s.listener.Addr().String()
}
// IsTLSListener reports whether the proxy listener is serving TLS.
func (s *Server) IsTLSListener() bool {
return s.tlsEnabled
}
// Close gracefully shuts down the proxy server.
func (s *Server) Close() error {
if s.httpServer == nil {
@@ -105,8 +105,47 @@ func generateSharedTestMITMCert() (certFile, keyFile string, err error) {
return certPath, keyPath, nil
}
// generateListenerCert generates a self-signed certificate and key for use as a
// proxy listener TLS certificate. Files are written to t.TempDir() and cleaned
// up automatically when the test ends.
func generateListenerCert(t *testing.T) (certFile, keyFile string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "generate listener key")
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test Listener"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
// The client connects to the proxy via IP address, so the certificate
// must include 127.0.0.1 as a Subject Alternative Name for validation to succeed.
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err, "create listener certificate")
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "listener.crt")
keyPath := filepath.Join(tmpDir, "listener.key")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
require.NoError(t, os.WriteFile(certPath, certPEM, 0o600), "write listener cert file")
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600), "write listener key file")
return certPath, keyPath
}
type testProxyConfig struct {
listenAddr string
tlsCertFile string
tlsKeyFile string
coderAccessURL string
allowedPorts []string
certStore *aibridgeproxyd.CertCache
@@ -167,6 +206,13 @@ func withMetrics(metrics *aibridgeproxyd.Metrics) testProxyOption {
}
}
func withListenerTLS(certFile, keyFile string) testProxyOption {
return func(cfg *testProxyConfig) {
cfg.tlsCertFile = certFile
cfg.tlsKeyFile = keyFile
}
}
// newTestProxy creates a new AI Bridge Proxy server for testing.
// It uses the shared MITM certificate and registers cleanup automatically.
// It waits for the proxy server to be ready before returning.
@@ -190,6 +236,8 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server
aibridgeOpts := aibridgeproxyd.Options{
ListenAddr: cfg.listenAddr,
TLSCertFile: cfg.tlsCertFile,
TLSKeyFile: cfg.tlsKeyFile,
CoderAccessURL: cfg.coderAccessURL,
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
@@ -241,15 +289,21 @@ func getProxyCertPool(t *testing.T) *x509.CertPool {
return certPool
}
// newProxyClient creates an HTTP client configured to use the proxy.
// newProxyClient creates an HTTP(S) client configured to use the proxy.
// It adds a Proxy-Authorization header with the provided token for authentication.
// The certPool parameter specifies which certificates the client should trust.
// For MITM'd requests, use the proxy's MITM certificate. For tunneled requests, use the target server's cert.
// The certPool parameter specifies which certificates the client should trust:
// - If the proxy listener is TLS, include the listener certificate.
// - For MITM'd requests, include the proxy's MITM certificate.
// - For tunneled requests, include the target server's certificate.
func newProxyClient(t *testing.T, srv *aibridgeproxyd.Server, proxyAuth string, certPool *x509.CertPool) *http.Client {
t.Helper()
// Create an HTTP client configured to use the proxy.
proxyURL, err := url.Parse("http://" + srv.Addr())
// Create an HTTP(S) client configured to use the proxy.
scheme := "http"
if srv.IsTLSListener() {
scheme = "https"
}
proxyURL, err := url.Parse(scheme + "://" + srv.Addr())
require.NoError(t, err)
transport := &http.Transport{
@@ -364,6 +418,61 @@ func TestNew(t *testing.T) {
require.Contains(t, err.Error(), "listen address is required")
})
t.Run("TLSCertWithoutKey", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "cert.pem",
CoderAccessURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI},
})
require.Error(t, err)
require.Contains(t, err.Error(), "tls cert file and tls key file must both be set")
})
t.Run("TLSKeyWithoutCert", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSKeyFile: "key.pem",
CoderAccessURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI},
})
require.Error(t, err)
require.Contains(t, err.Error(), "tls cert file and tls key file must both be set")
})
t.Run("InvalidListenerTLSFiles", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "/nonexistent/cert.pem",
TLSKeyFile: "/nonexistent/key.pem",
CoderAccessURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI},
})
require.Error(t, err)
require.Contains(t, err.Error(), "load listener TLS certificate")
})
t.Run("MissingCoderAccessURL", func(t *testing.T) {
t.Parallel()
@@ -616,6 +725,26 @@ func TestNew(t *testing.T) {
require.NotNil(t, srv)
})
t.Run("SuccessWithListenerTLS", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
listenerCertFile, listenerKeyFile := generateListenerCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: listenerCertFile,
TLSKeyFile: listenerKeyFile,
CoderAccessURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI},
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxy", func(t *testing.T) {
t.Parallel()
@@ -1250,6 +1379,90 @@ func TestProxy_MITM(t *testing.T) {
}
}
// TestListenerTLS verifies that the proxy works correctly when its listener is wrapped in TLS.
// It tests both tunneled and MITM'd requests through an HTTPS proxy listener.
func TestListenerTLS(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tunneled bool
expectedBody string
}{
{
name: "Tunneled",
tunneled: true,
expectedBody: "hello from tunneled",
},
{
name: "MITM",
tunneled: false,
expectedBody: "hello from aibridged",
},
}
// Shared across subtests since all use the same TLS listener certificate.
listenerCertFile, listenerKeyFile := generateListenerCert(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Mock aibridged server that receives MITM'd requests.
aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello from aibridged"))
}))
t.Cleanup(func() { aibridgedServer.Close() })
// Target server: response is returned directly for tunneled, intercepted for MITM.
tunneledServer, targetURL := newTargetServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello from tunneled"))
})
var proxyOpts []testProxyOption
proxyOpts = append(proxyOpts,
withListenerTLS(listenerCertFile, listenerKeyFile),
withCoderAccessURL(aibridgedServer.URL),
withAllowedPorts(targetURL.Port()),
)
if tt.tunneled {
// Use a domain allowlist that excludes the target server so requests are tunneled.
proxyOpts = append(proxyOpts, withDomainAllowlist(aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI))
}
srv := newTestProxy(t, proxyOpts...)
// Cert pool must include two certificates: the listener certificate to connect
// to the proxy over TLS, and the MITM or target certificate for the inner
// TLS handshake.
listenerCertPEM, err := os.ReadFile(listenerCertFile)
require.NoError(t, err)
var certPool *x509.CertPool
if tt.tunneled {
certPool = x509.NewCertPool()
certPool.AddCert(tunneledServer.Certificate())
} else {
certPool = getProxyCertPool(t)
}
certPool.AppendCertsFromPEM(listenerCertPEM)
client := newProxyClient(t, srv, makeProxyAuthHeader("test-token"), certPool)
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, targetURL.String(), nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, tt.expectedBody, string(body))
})
}
}
// TestServeCACert validates that a configured certificate file can be served correctly by the API.
//
// Note: Tests for certificate file errors (missing file, invalid PEM) are
+2
View File
@@ -23,6 +23,8 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error
srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{
ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(),
TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(),
TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(),
CoderAccessURL: coderAPI.AccessURL.String(),
MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(),
MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(),
+8
View File
@@ -192,6 +192,14 @@ AI BRIDGE PROXY OPTIONS:
Path to the CA private key file used to intercept (MITM) HTTPS traffic
from AI clients.
--aibridge-proxy-tls-cert-file string, $CODER_AIBRIDGE_PROXY_TLS_CERT_FILE
Path to the TLS certificate file for the AI Bridge Proxy listener.
Must be set together with AI Bridge Proxy TLS Key File.
--aibridge-proxy-tls-key-file string, $CODER_AIBRIDGE_PROXY_TLS_KEY_FILE
Path to the TLS private key file for the AI Bridge Proxy listener.
Must be set together with AI Bridge Proxy TLS Certificate File.
--aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM
URL of an upstream HTTP proxy to chain tunneled (non-allowlisted)
requests through. Format: http://[user:pass@]host:port or
+2
View File
@@ -82,6 +82,8 @@ export interface AIBridgeOpenAIConfig {
export interface AIBridgeProxyConfig {
readonly enabled: boolean;
readonly listen_addr: string;
readonly tls_cert_file: string;
readonly tls_key_file: string;
readonly cert_file: string;
readonly key_file: string;
readonly domain_allowlist: string;