docs/superpowers/plans/2026-04-20-instance-tls-file-path.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add local filesystem TLS material paths for instance data sources while keeping use_ssl as the persisted/API compatibility switch.
Architecture: Add path fields as an additive proto/API change, infer the effective TLS source from use_ssl plus path presence, and resolve file paths to PEM content before database drivers consume TLS settings. Validate and normalize API writes so mixed explicit inline/path submissions are rejected while source switches clear inactive stored material.
Tech Stack: Go, protobuf/buf, Connect RPC, Bytebase store JSONB proto metadata, React, TypeScript, Vitest.
Files:
Modify: proto/store/store/instance.proto
Modify: proto/v1/v1/instance_service.proto
Generated by cd proto && buf generate: backend/generated-go/store/instance.pb.go
Generated by cd proto && buf generate: backend/generated-go/store/instance_equal.pb.go
Generated by cd proto && buf generate: backend/generated-go/v1/instance_service.pb.go
Generated by cd proto && buf generate: backend/generated-go/v1/instance_service_equal.pb.go
Generated by cd proto && buf generate: frontend/src/types/proto-es/store/instance_pb.ts
Generated by cd proto && buf generate: frontend/src/types/proto-es/v1/instance_service_pb.ts
Generated by cd proto && buf generate: frontend/src/types/proto-es/v1/instance_service_pb.d.ts
Step 1: Add store path fields
In proto/store/store/instance.proto, insert these fields immediately after obfuscated_ssl_key = 7;:
string ssl_ca_path = 50;
string obfuscated_ssl_ca_path = 51;
string ssl_cert_path = 52;
string obfuscated_ssl_cert_path = 53;
string ssl_key_path = 54;
string obfuscated_ssl_key_path = 55;
In proto/v1/v1/instance_service.proto, insert these fields immediately after ssl_key = 7:
// The local filesystem path to the SSL certificate authority certificate.
string ssl_ca_path = 41 [(google.api.field_behavior) = INPUT_ONLY];
// The local filesystem path to the SSL client certificate.
string ssl_cert_path = 42 [(google.api.field_behavior) = INPUT_ONLY];
// The local filesystem path to the SSL client private key.
string ssl_key_path = 43 [(google.api.field_behavior) = INPUT_ONLY];
// Whether an SSL certificate authority path has been configured.
bool has_ssl_ca_path = 44 [(google.api.field_behavior) = OUTPUT_ONLY];
// Whether an SSL client certificate path has been configured.
bool has_ssl_cert_path = 45 [(google.api.field_behavior) = OUTPUT_ONLY];
// Whether an SSL client private key path has been configured.
bool has_ssl_key_path = 46 [(google.api.field_behavior) = OUTPUT_ONLY];
Run:
buf format -w proto
buf lint proto
Expected: both commands exit 0.
Run:
cd proto && buf generate
Expected: command exits 0 and generated Go/TypeScript files include SslCaPath, SslCertPath, SslKeyPath, HasSslCaPath, HasSslCertPath, and HasSslKeyPath.
Run:
git add proto/store/store/instance.proto proto/v1/v1/instance_service.proto backend/generated-go/store/instance.pb.go backend/generated-go/store/instance_equal.pb.go backend/generated-go/v1/instance_service.pb.go backend/generated-go/v1/instance_service_equal.pb.go frontend/src/types/proto-es/store/instance_pb.ts frontend/src/types/proto-es/v1/instance_service_pb.ts frontend/src/types/proto-es/v1/instance_service_pb.d.ts
git commit -m "feat(instance): add TLS path fields"
Files:
Modify: backend/store/instance.go
Modify: backend/api/v1/instance_service_converter.go
Modify: backend/api/v1/instance_service.go
Modify: backend/api/v1/audit.go
Step 1: Obfuscate path fields before persistence
In backend/store/instance.go, update obfuscateInstance() after the existing SSL key obfuscation:
ds.ObfuscatedSslCaPath = common.Obfuscate(ds.GetSslCaPath(), secret)
ds.SslCaPath = ""
ds.ObfuscatedSslCertPath = common.Obfuscate(ds.GetSslCertPath(), secret)
ds.SslCertPath = ""
ds.ObfuscatedSslKeyPath = common.Obfuscate(ds.GetSslKeyPath(), secret)
ds.SslKeyPath = ""
In backend/store/instance.go, update deobfuscateInstances() after existing SSL key deobfuscation:
sslCaPath, err := common.Unobfuscate(ds.GetObfuscatedSslCaPath(), secret)
if err != nil {
return err
}
ds.SslCaPath = sslCaPath
sslCertPath, err := common.Unobfuscate(ds.GetObfuscatedSslCertPath(), secret)
if err != nil {
return err
}
ds.SslCertPath = sslCertPath
sslKeyPath, err := common.Unobfuscate(ds.GetObfuscatedSslKeyPath(), secret)
if err != nil {
return err
}
ds.SslKeyPath = sslKeyPath
In backend/api/v1/instance_service_converter.go, add these assignments to convertV1DataSource() next to the existing inline SSL assignments:
SslCaPath: dataSource.SslCaPath,
SslCertPath: dataSource.SslCertPath,
SslKeyPath: dataSource.SslKeyPath,
In backend/api/v1/instance_service_converter.go, update convertDataSources() to populate presence flags and keep path strings empty:
HasSslCaPath: ds.GetSslCaPath() != "",
HasSslCertPath: ds.GetSslCertPath() != "",
HasSslKeyPath: ds.GetSslKeyPath() != "",
Keep SslCaPath, SslCertPath, and SslKeyPath unset in responses because they are input-only.
In backend/api/v1/instance_service.go, add these cases in UpdateDataSource() next to the existing SSL mask cases:
case "ssl_ca_path":
dataSource.SslCaPath = req.Msg.DataSource.SslCaPath
case "ssl_cert_path":
dataSource.SslCertPath = req.Msg.DataSource.SslCertPath
case "ssl_key_path":
dataSource.SslKeyPath = req.Msg.DataSource.SslKeyPath
In backend/api/v1/audit.go, update redactDataSource() after inline SSL redaction:
if cloned.SslCaPath != "" {
cloned.SslCaPath = maskedString
}
if cloned.SslCertPath != "" {
cloned.SslCertPath = maskedString
}
if cloned.SslKeyPath != "" {
cloned.SslKeyPath = maskedString
}
Run:
gofmt -w backend/store/instance.go backend/api/v1/instance_service_converter.go backend/api/v1/instance_service.go backend/api/v1/audit.go
go test ./backend/store ./backend/api/v1 -run 'TestAudit|TestObfuscate|TestCommon' -count=1
Expected: gofmt changes are applied and tests exit 0. If the targeted -run expression matches no tests in a package, rerun that package without -run before committing.
Run:
git add backend/store/instance.go backend/api/v1/instance_service_converter.go backend/api/v1/instance_service.go backend/api/v1/audit.go
git commit -m "feat(instance): persist TLS path fields"
Files:
Create: backend/plugin/db/util/ssl_test.go
Modify: backend/plugin/db/util/ssl.go
Modify: backend/component/dbfactory/dbfactory.go
Step 1: Write failing tests for path resolution
Create backend/plugin/db/util/ssl_test.go with these tests:
package util
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)
func TestResolveTLSMaterialReadsPathFields(t *testing.T) {
dir := t.TempDir()
caPath := filepath.Join(dir, "ca.pem")
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(caPath, []byte("ca-pem"), 0600))
require.NoError(t, os.WriteFile(certPath, []byte("cert-pem"), 0600))
require.NoError(t, os.WriteFile(keyPath, []byte("key-pem"), 0600))
ds := &storepb.DataSource{
UseSsl: true,
SslCa: "stale-ca",
SslCert: "stale-cert",
SslKey: "stale-key",
SslCaPath: caPath,
SslCertPath: certPath,
SslKeyPath: keyPath,
}
resolved, err := ResolveTLSMaterial(ds)
require.NoError(t, err)
require.Equal(t, "ca-pem", resolved.GetSslCa())
require.Equal(t, "cert-pem", resolved.GetSslCert())
require.Equal(t, "key-pem", resolved.GetSslKey())
require.Equal(t, "stale-ca", ds.GetSslCa())
}
func TestResolveTLSMaterialRejectsRelativePath(t *testing.T) {
_, err := ResolveTLSMaterial(&storepb.DataSource{
UseSsl: true,
SslCaPath: "relative-ca.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "CA certificate path must be absolute")
require.NotContains(t, err.Error(), "relative-ca.pem")
}
func TestResolveTLSMaterialRedactsReadErrors(t *testing.T) {
missingPath := filepath.Join(t.TempDir(), "missing.pem")
_, err := ResolveTLSMaterial(&storepb.DataSource{
UseSsl: true,
SslCaPath: missingPath,
})
require.Error(t, err)
require.Contains(t, err.Error(), "failed to read CA certificate file")
require.False(t, strings.Contains(err.Error(), missingPath), err.Error())
require.NotContains(t, err.Error(), "no such file")
}
Run:
go test ./backend/plugin/db/util -run 'TestResolveTLSMaterial' -count=1
Expected: FAIL because ResolveTLSMaterial is undefined.
In backend/plugin/db/util/ssl.go, add imports:
"os"
"path/filepath"
"google.golang.org/protobuf/proto"
Add these helpers above GetTLSConfig():
func HasTLSPath(ds *storepb.DataSource) bool {
return ds.GetSslCaPath() != "" || ds.GetSslCertPath() != "" || ds.GetSslKeyPath() != ""
}
func ResolveTLSMaterial(ds *storepb.DataSource) (*storepb.DataSource, error) {
resolved := proto.CloneOf(ds)
if !resolved.GetUseSsl() || !HasTLSPath(resolved) {
return resolved, nil
}
if resolved.GetSslCaPath() != "" {
content, err := readTLSPathFile("CA certificate", resolved.GetSslCaPath())
if err != nil {
return nil, err
}
resolved.SslCa = content
}
if resolved.GetSslCertPath() != "" {
content, err := readTLSPathFile("client certificate", resolved.GetSslCertPath())
if err != nil {
return nil, err
}
resolved.SslCert = content
}
if resolved.GetSslKeyPath() != "" {
content, err := readTLSPathFile("client key", resolved.GetSslKeyPath())
if err != nil {
return nil, err
}
resolved.SslKey = content
}
return resolved, nil
}
func readTLSPathFile(label, path string) (string, error) {
if !filepath.IsAbs(path) {
return "", errors.Errorf("%s path must be absolute", label)
}
content, err := os.ReadFile(path)
if err != nil {
return "", errors.Errorf("failed to read %s file", label)
}
if len(content) == 0 {
return "", errors.Errorf("%s file is empty", label)
}
return string(content), nil
}
At the start of GetTLSConfig() in backend/plugin/db/util/ssl.go, replace direct use of the input data source:
func GetTLSConfig(ds *storepb.DataSource) (*tls.Config, error) {
resolved, err := ResolveTLSMaterial(ds)
if err != nil {
return nil, err
}
ds = resolved
if !ds.GetUseSsl() {
return nil, nil
}
In backend/component/dbfactory/dbfactory.go, add an import alias:
dbutil "github.com/bytebase/bytebase/backend/plugin/db/util"
Before db.Open(...), resolve the data source:
resolvedDataSource, err := dbutil.ResolveTLSMaterial(dataSource)
if err != nil {
return nil, err
}
driver, err := db.Open(
ctx,
instance.Metadata.GetEngine(),
db.ConnectionConfig{
DataSource: resolvedDataSource,
ConnectionContext: connectionContext,
Password: password,
},
)
Run:
gofmt -w backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go
go test ./backend/plugin/db/util ./backend/component/dbfactory -run 'TestResolveTLSMaterial' -count=1
Expected: tests exit 0.
Run:
git add backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go
git commit -m "feat(instance): resolve TLS path material"
Files:
Create: backend/api/v1/instance_service_tls_test.go
Modify: backend/api/v1/instance_service.go
Step 1: Add focused unit tests for TLS write semantics
Create backend/api/v1/instance_service_tls_test.go with helper-level tests in package v1:
package v1
import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/fieldmaskpb"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)
func TestValidateDataSourceTLSWriteRejectsMixedExplicitMaterial(t *testing.T) {
err := validateDataSourceTLSWrite(
&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
[]string{"ssl_ca", "ssl_ca_path"},
)
require.Error(t, err)
require.Contains(t, err.Error(), "cannot set both inline TLS material and TLS file paths")
}
func TestNormalizeDataSourceTLSClearsInlineWhenPathWins(t *testing.T) {
ds := &storepb.DataSource{
UseSsl: true,
SslCa: "inline-ca",
SslCert: "inline-cert",
SslKey: "inline-key",
SslCaPath: "/tmp/ca.pem",
}
normalizeDataSourceTLS(ds, []string{"ssl_ca_path"})
require.Empty(t, ds.GetSslCa())
require.Empty(t, ds.GetSslCert())
require.Empty(t, ds.GetSslKey())
require.Equal(t, "/tmp/ca.pem", ds.GetSslCaPath())
}
func TestNormalizeDataSourceTLSClearsPathsWhenSwitchingToInline(t *testing.T) {
ds := &storepb.DataSource{
UseSsl: true,
SslCa: "inline-ca",
SslCaPath: "",
SslCertPath: "",
SslKeyPath: "",
}
normalizeDataSourceTLS(ds, []string{"ssl_ca", "ssl_ca_path", "ssl_cert_path", "ssl_key_path"})
require.Equal(t, "inline-ca", ds.GetSslCa())
require.Empty(t, ds.GetSslCaPath())
require.Empty(t, ds.GetSslCertPath())
require.Empty(t, ds.GetSslKeyPath())
}
func TestNormalizeDataSourceTLSClearsAllWhenDisabled(t *testing.T) {
ds := &storepb.DataSource{
UseSsl: false,
SslCa: "inline-ca",
SslCert: "inline-cert",
SslKey: "inline-key",
SslCaPath: "/tmp/ca.pem",
SslCertPath: "/tmp/cert.pem",
SslKeyPath: "/tmp/key.pem",
}
normalizeDataSourceTLS(ds, []string{"use_ssl"})
require.Empty(t, ds.GetSslCa())
require.Empty(t, ds.GetSslCert())
require.Empty(t, ds.GetSslKey())
require.Empty(t, ds.GetSslCaPath())
require.Empty(t, ds.GetSslCertPath())
require.Empty(t, ds.GetSslKeyPath())
}
func TestTLSMaskPaths(t *testing.T) {
mask := &fieldmaskpb.FieldMask{Paths: []string{"ssl_ca_path", "host"}}
require.True(t, tlsMaskContains(mask.GetPaths(), "ssl_ca_path"))
require.False(t, tlsMaskContains(mask.GetPaths(), "ssl_cert_path"))
}
Run:
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths' -count=1
Expected: FAIL because helper functions are undefined.
In backend/api/v1/instance_service.go, add imports:
"crypto/tls"
"crypto/x509"
"path/filepath"
Add these helpers near checkInstanceDataSources():
func hasInlineTLSMaterial(ds *storepb.DataSource) bool {
return ds.GetSslCa() != "" || ds.GetSslCert() != "" || ds.GetSslKey() != ""
}
func hasPathTLSMaterial(ds *storepb.DataSource) bool {
return ds.GetSslCaPath() != "" || ds.GetSslCertPath() != "" || ds.GetSslKeyPath() != ""
}
func hasExplicitInlineTLSMaterial(ds *storepb.DataSource, mask []string) bool {
return (tlsMaskContains(mask, "ssl_ca") && ds.GetSslCa() != "") ||
(tlsMaskContains(mask, "ssl_cert") && ds.GetSslCert() != "") ||
(tlsMaskContains(mask, "ssl_key") && ds.GetSslKey() != "")
}
func hasExplicitPathTLSMaterial(ds *storepb.DataSource, mask []string) bool {
return (tlsMaskContains(mask, "ssl_ca_path") && ds.GetSslCaPath() != "") ||
(tlsMaskContains(mask, "ssl_cert_path") && ds.GetSslCertPath() != "") ||
(tlsMaskContains(mask, "ssl_key_path") && ds.GetSslKeyPath() != "")
}
func tlsMaskContains(mask []string, field string) bool {
for _, path := range mask {
if path == field {
return true
}
}
return false
}
func validateDataSourceTLSWrite(requested, merged *storepb.DataSource, mask []string) error {
if hasExplicitInlineTLSMaterial(requested, mask) && hasExplicitPathTLSMaterial(requested, mask) {
return errors.Errorf("cannot set both inline TLS material and TLS file paths")
}
if hasPathTLSMaterial(merged) && hasExplicitInlineTLSMaterial(requested, mask) {
return errors.Errorf("cannot set inline TLS material while TLS file paths are configured")
}
return validateDataSourceTLSConfig(merged)
}
func validateDataSourceTLSConfig(ds *storepb.DataSource) error {
if !ds.GetUseSsl() {
return nil
}
if hasPathTLSMaterial(ds) {
for label, path := range map[string]string{
"ssl_ca_path": ds.GetSslCaPath(),
"ssl_cert_path": ds.GetSslCertPath(),
"ssl_key_path": ds.GetSslKeyPath(),
} {
if path != "" && !filepath.IsAbs(path) {
return errors.Errorf("%s must be an absolute path", label)
}
}
return nil
}
if ds.GetSslCa() != "" {
pool := x509.NewCertPool()
if ok := pool.AppendCertsFromPEM([]byte(ds.GetSslCa())); !ok {
return errors.Errorf("invalid ssl_ca PEM")
}
}
if (ds.GetSslCert() == "") != (ds.GetSslKey() == "") {
return errors.Errorf("ssl_cert and ssl_key must be both set or unset")
}
if ds.GetSslCert() != "" {
if _, err := tls.X509KeyPair([]byte(ds.GetSslCert()), []byte(ds.GetSslKey())); err != nil {
return errors.Wrap(err, "invalid ssl_cert or ssl_key PEM")
}
}
return nil
}
func normalizeDataSourceTLS(ds *storepb.DataSource, _ []string) {
if !ds.GetUseSsl() {
clearInlineTLSMaterial(ds)
clearPathTLSMaterial(ds)
return
}
if hasPathTLSMaterial(ds) {
clearInlineTLSMaterial(ds)
return
}
clearPathTLSMaterial(ds)
}
func clearInlineTLSMaterial(ds *storepb.DataSource) {
ds.SslCa = ""
ds.SslCert = ""
ds.SslKey = ""
}
func clearPathTLSMaterial(ds *storepb.DataSource) {
ds.SslCaPath = ""
ds.SslCertPath = ""
ds.SslKeyPath = ""
}
In AddDataSource(), after checkDataSource() and before appending to metadata, add:
if err := validateDataSourceTLSWrite(dataSource, dataSource, []string{
"use_ssl",
"ssl_ca",
"ssl_cert",
"ssl_key",
"ssl_ca_path",
"ssl_cert_path",
"ssl_key_path",
}); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
normalizeDataSourceTLS(dataSource, nil)
In UpdateDataSource(), after the update-mask loop and clearDataSourceAuthentication(dataSource), add:
if err := validateDataSourceTLSWrite(req.Msg.DataSource, dataSource, req.Msg.UpdateMask.GetPaths()); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
normalizeDataSourceTLS(dataSource, req.Msg.UpdateMask.GetPaths())
Keep this before s.checkInstanceDataSources(...).
Extend backend/api/v1/instance_service_tls_test.go with:
func TestValidateDataSourceTLSWriteRejectsInactiveInlineWithExistingPath(t *testing.T) {
err := validateDataSourceTLSWrite(
&storepb.DataSource{UseSsl: true, SslCa: "inline-ca"},
&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
[]string{"ssl_ca"},
)
require.Error(t, err)
require.Contains(t, err.Error(), "cannot set inline TLS material while TLS file paths are configured")
}
func TestValidateDataSourceTLSConfigRejectsRelativePath(t *testing.T) {
err := validateDataSourceTLSConfig(&storepb.DataSource{UseSsl: true, SslCaPath: "ca.pem"})
require.Error(t, err)
require.Contains(t, err.Error(), "ssl_ca_path must be an absolute path")
}
Run:
gofmt -w backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths|TestValidateDataSourceTLSConfig' -count=1
Expected: tests exit 0.
Run:
git add backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
git commit -m "fix(instance): validate TLS path writes"
Files:
Create: backend/component/ghost/config_test.go
Modify: backend/component/ghost/config.go
Modify: backend/plugin/db/elasticsearch/elasticsearch.go
Modify: backend/plugin/db/elasticsearch/elasticsearch_test.go
Modify: backend/plugin/db/mongodb/mongodb.go
Modify: backend/plugin/db/mongodb/mongodb_test.go
Modify: backend/plugin/db/pg/pg.go
Modify: backend/plugin/db/cockroachdb/cockroachdb.go
Step 1: Add gh-ost temp-file test
Create backend/component/ghost/config_test.go:
package ghost
import (
"os"
"testing"
"github.com/stretchr/testify/require"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
storepkg "github.com/bytebase/bytebase/backend/store"
)
func TestGetMigrationContextWritesTLSMaterialToFiles(t *testing.T) {
caPEM, certPEM, keyPEM := generateTLSPEMForGhostTest(t)
instance := &storepkg.InstanceMessage{
Metadata: &storepb.Instance{
Engine: storepb.Engine_MYSQL,
DataSources: []*storepb.DataSource{{
Type: storepb.DataSourceType_ADMIN,
Host: "127.0.0.1",
Port: "3306",
Username: "root",
UseSsl: true,
VerifyTlsCertificate: true,
SslCa: caPEM,
SslCert: certPEM,
SslKey: keyPEM,
}},
},
}
ctx, err := getMigrationContext(instance, "secret", &storepkg.DatabaseMessage{DatabaseName: "db"}, "tbl", "alter table tbl add column c int", true)
require.NoError(t, err)
require.True(t, ctx.UseTLS)
require.False(t, ctx.TLSAllowInsecure)
require.FileExists(t, ctx.TLSCACertificate)
require.FileExists(t, ctx.TLSCertificate)
require.FileExists(t, ctx.TLSKey)
gotCA, err := os.ReadFile(ctx.TLSCACertificate)
require.NoError(t, err)
require.Equal(t, caPEM, string(gotCA))
}
Add a local generateTLSPEMForGhostTest(t) helper in the same file using crypto/rsa, crypto/x509, and encoding/pem to return a self-signed CA PEM, a leaf cert PEM, and a leaf key PEM.
Use this helper implementation:
func generateTLSPEMForGhostTest(t *testing.T) (string, string, string) {
t.Helper()
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
require.NoError(t, err)
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "client"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, &leafKey.PublicKey, caKey)
require.NoError(t, err)
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)})
return string(caPEM), string(certPEM), string(keyPEM)
}
The file imports must include:
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"
In backend/component/ghost/config.go, import the DB TLS utility:
dbutil "github.com/bytebase/bytebase/backend/plugin/db/util"
Resolve path material before configuring gh-ost:
dataSource, err = dbutil.ResolveTLSMaterial(dataSource)
if err != nil {
return nil, err
}
Replace direct PEM assignment with temp-file assignment:
migrationContext.UseTLS = true
cleanup, err := configureGhostTLSFiles(migrationContext, dataSource)
if err != nil {
return nil, err
}
defer cleanup()
migrationContext.TLSAllowInsecure = !dataSource.GetVerifyTlsCertificate()
Add helper functions in the same file:
func configureGhostTLSFiles(migrationContext *ghostbase.MigrationContext, dataSource *storepb.DataSource) (func(), error) {
var files []string
write := func(pattern, content string) (string, error) {
if content == "" {
return "", nil
}
file, err := os.CreateTemp(os.TempDir(), pattern)
if err != nil {
return "", err
}
defer file.Close()
if _, err := file.WriteString(content); err != nil {
return "", err
}
files = append(files, file.Name())
return file.Name(), nil
}
var err error
if migrationContext.TLSCACertificate, err = write("ghost-tls-ca-*", dataSource.GetSslCa()); err != nil {
return nil, err
}
if migrationContext.TLSCertificate, err = write("ghost-tls-cert-*", dataSource.GetSslCert()); err != nil {
return nil, err
}
if migrationContext.TLSKey, err = write("ghost-tls-key-*", dataSource.GetSslKey()); err != nil {
return nil, err
}
return func() {
for _, name := range files {
_ = os.Remove(name)
}
}, nil
}
Keep cleanup deferred after SetupTLS() succeeds so gh-ost reads the files during setup.
In backend/plugin/db/elasticsearch/elasticsearch.go, change the typed client CA block to use the resolved CA:
if config.DataSource.GetSslCa() != "" {
esConfig.CACert = []byte(config.DataSource.GetSslCa())
}
Apply the same fix in both Elasticsearch and OpenSearch client setup paths if both set CACert.
In backend/plugin/db/mongodb/mongodb.go, replace os.WriteFile(caFileName, ...) with os.CreateTemp(os.TempDir(), "mongodb-tls-ca-*"), write content to that file, close it, append file.Name() to cleanup, and pass file.Name() to --tlsCAFile.
Use the same pattern for the client certificate key file with prefix mongodb-tls-client-cert-*.
In backend/plugin/db/pg/pg.go and backend/plugin/db/cockroachdb/cockroachdb.go, keep util.GetPGSSLMode(config.DataSource) as-is after Task 3 because DBFactory passes resolved data sources to drivers. Do not add a second ResolveTLSMaterial() call in these functions.
Run:
gofmt -w backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
go test ./backend/component/ghost ./backend/plugin/db/elasticsearch ./backend/plugin/db/mongodb ./backend/plugin/db/pg ./backend/plugin/db/cockroachdb -count=1
Expected: tests exit 0.
Run:
git add backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
git commit -m "fix(instance): apply resolved TLS material to drivers"
Files:
Modify: frontend/src/react/components/instance/SslCertificateForm.tsx
Modify: frontend/src/react/components/instance/DataSourceForm.tsx
Modify: frontend/src/react/components/instance/common.ts
Modify: frontend/src/react/components/instance/InstanceFormBody.tsx
Modify: frontend/src/react/components/instance/InstanceFormButtons.tsx
Create: frontend/src/react/components/instance/tls.ts
Create: frontend/src/react/components/instance/SslCertificateForm.test.tsx
Create: frontend/src/react/components/instance/common.test.ts
Modify: frontend/src/locales/en-US.json
Modify: frontend/src/locales/es-ES.json
Modify: frontend/src/locales/ja-JP.json
Modify: frontend/src/locales/vi-VN.json
Modify: frontend/src/locales/zh-CN.json
Step 1: Add local TLS source helpers
Create frontend/src/react/components/instance/tls.ts:
import type { DataSource } from "@/types/proto-es/v1/instance_service_pb";
export type TlsSource = "DISABLED" | "INLINE_PEM" | "FILE_PATH";
export const getTlsSource = (dataSource: DataSource): TlsSource => {
if (!dataSource.useSsl) {
return "DISABLED";
}
if (
dataSource.sslCaPath ||
dataSource.sslCertPath ||
dataSource.sslKeyPath ||
dataSource.hasSslCaPath ||
dataSource.hasSslCertPath ||
dataSource.hasSslKeyPath
) {
return "FILE_PATH";
}
return "INLINE_PEM";
};
export const clearInlineTlsFields = (dataSource: DataSource) => {
dataSource.sslCa = "";
dataSource.sslCert = "";
dataSource.sslKey = "";
};
export const clearPathTlsFields = (dataSource: DataSource) => {
dataSource.sslCaPath = "";
dataSource.sslCertPath = "";
dataSource.sslKeyPath = "";
};
In SslCertificateForm.tsx, add props for path mode and labels:
source?: "INLINE_PEM" | "FILE_PATH";
onSourceChange?: (source: "INLINE_PEM" | "FILE_PATH") => void;
caPath?: string;
onCaPathChange?: (val: string) => void;
certPath?: string;
onCertPathChange?: (val: string) => void;
sslKeyPath?: string;
onKeyPathChange?: (val: string) => void;
Render a two-tab selector for inline PEM vs file paths when useSsl is true. In file-path mode, render <input> fields instead of droppable textareas.
In SslCertificateForm.tsx, render this hint under the CA input in both modes:
<p className="mt-1 text-xs text-control-light">
{t("data-source.ssl.empty-ca-uses-system-trust")}
</p>
In SslCertificateForm.tsx, replace the duplicate booleans with one variable:
const hasSSLKeyAndCertFields =
showKeyAndCert || ![Engine.MSSQL].includes(engineType);
Use hasSSLKeyAndCertFields for both key and cert tab rendering.
In DataSourceForm.tsx, import helpers:
import {
clearInlineTlsFields,
clearPathTlsFields,
getTlsSource,
type TlsSource,
} from "./tls";
Add handler near handleUseSslChanged:
const handleTlsSourceChanged = (source: TlsSource) => {
const next = { ...dataSource, updateSsl: true };
if (source === "DISABLED") {
next.useSsl = false;
clearInlineTlsFields(next);
clearPathTlsFields(next);
} else if (source === "INLINE_PEM") {
next.useSsl = true;
clearPathTlsFields(next);
} else {
next.useSsl = true;
clearInlineTlsFields(next);
}
update(next);
};
Pass source={getTlsSource(dataSource) === "FILE_PATH" ? "FILE_PATH" : "INLINE_PEM"} and path field props to SslCertificateForm.
In frontend/src/react/components/instance/common.ts, update calcDataSourceUpdateMask():
if (updateSsl) {
updateMask.add("use_ssl");
updateMask.add("ssl_ca");
updateMask.add("ssl_key");
updateMask.add("ssl_cert");
updateMask.add("ssl_ca_path");
updateMask.add("ssl_key_path");
updateMask.add("ssl_cert_path");
}
In InstanceFormBody.tsx and InstanceFormButtons.tsx, expand hasSslConfig:
const hasSslConfig = !!(
ds.useSsl ||
ds.sslCa ||
ds.sslCert ||
ds.sslKey ||
ds.sslCaPath ||
ds.sslCertPath ||
ds.sslKeyPath ||
ds.hasSslCaPath ||
ds.hasSslCertPath ||
ds.hasSslKeyPath
);
Add these keys under data-source.ssl in every listed locale file:
"inline-pem": "Inline PEM",
"file-path": "File paths",
"ca-cert-path": "CA certificate path",
"client-cert-path": "Client certificate path",
"client-key-path": "Client key path",
"empty-ca-uses-system-trust": "Leave CA empty to use the system trust store."
Use direct translations for non-English files. If translation quality is uncertain, use English rather than leaving empty strings.
Create frontend/src/react/components/instance/common.test.ts with:
import { describe, expect, it } from "vitest";
import { DataSource } from "@/types/proto-es/v1/instance_service_pb";
import { calcDataSourceUpdateMask, type EditDataSource } from "./common";
describe("calcDataSourceUpdateMask", () => {
it("includes TLS path fields when SSL is updated", () => {
const editing = Object.assign(
new DataSource({ id: "admin", useSsl: true, sslCaPath: "/tmp/ca.pem" }),
{
pendingCreate: false,
updatedPassword: "",
updatedMasterPassword: "",
updateSsl: true,
}
) as EditDataSource;
const original = new DataSource({ id: "admin", useSsl: false });
const mask = calcDataSourceUpdateMask(editing, original, editing);
expect(mask).toContain("ssl_ca_path");
expect(mask).toContain("ssl_cert_path");
expect(mask).toContain("ssl_key_path");
});
});
Create frontend/src/react/components/instance/SslCertificateForm.test.tsx with a render test that asserts the empty CA hint is visible:
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SslCertificateForm } from "./SslCertificateForm";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
key === "data-source.ssl.empty-ca-uses-system-trust"
? "Leave CA empty to use the system trust store."
: key,
}),
}));
describe("SslCertificateForm", () => {
it("shows the system trust hint", () => {
render(<SslCertificateForm source="INLINE_PEM" />);
expect(
screen.getByText("Leave CA empty to use the system trust store.")
).toBeInTheDocument();
});
});
Run:
pnpm --dir frontend fix
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx
pnpm --dir frontend type-check
Expected: commands exit 0.
Run:
git add frontend/src/react/components/instance/SslCertificateForm.tsx frontend/src/react/components/instance/DataSourceForm.tsx frontend/src/react/components/instance/common.ts frontend/src/react/components/instance/InstanceFormBody.tsx frontend/src/react/components/instance/InstanceFormButtons.tsx frontend/src/react/components/instance/tls.ts frontend/src/react/components/instance/SslCertificateForm.test.tsx frontend/src/react/components/instance/common.test.ts frontend/src/locales/en-US.json frontend/src/locales/es-ES.json frontend/src/locales/ja-JP.json frontend/src/locales/vi-VN.json frontend/src/locales/zh-CN.json
git commit -m "feat(frontend): add TLS path source selection"
Files:
Review all files changed by Tasks 1-6.
Step 1: Confirm no TLS mode enum exists
Run:
rg -n "TLSMode|tls_mode|TlsMode" proto backend frontend/src/react/components/instance frontend/src/types/proto-es
Expected: no matches related to instance data source TLS source selection.
Run:
git diff --name-only origin/main...HEAD | rg 'backend/migrator/migration|LATEST.sql|migrator_test.go'
Expected: no output.
Run:
buf format -w proto
buf lint proto
cd proto && buf generate
Expected: commands exit 0 and git diff --exit-code after generation shows no unexpected generated drift beyond committed generated files.
Run:
gofmt -w backend/store/instance.go backend/api/v1/instance_service.go backend/api/v1/instance_service_converter.go backend/api/v1/audit.go backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
golangci-lint run --allow-parallel-runners
go test ./backend/store ./backend/api/v1 ./backend/plugin/db/util ./backend/component/dbfactory ./backend/component/ghost ./backend/plugin/db/elasticsearch ./backend/plugin/db/mongodb ./backend/plugin/db/pg ./backend/plugin/db/cockroachdb -count=1
Expected: commands exit 0. If golangci-lint reports fixable issues, run golangci-lint run --fix --allow-parallel-runners, then rerun golangci-lint run --allow-parallel-runners.
Run:
pnpm --dir frontend check
pnpm --dir frontend type-check
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx
Expected: commands exit 0.
Run:
go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go
Expected: command exits 0.
Run:
git diff --stat origin/main...HEAD
git diff origin/main...HEAD -- proto/store/store/instance.proto proto/v1/v1/instance_service.proto backend/api/v1/instance_service.go backend/plugin/db/util/ssl.go frontend/src/react/components/instance
Confirm:
use_ssl remains in store and v1 API.
Only additive proto fields were introduced.
No persisted/API TLSMode enum exists.
No migration file exists.
API write validation rejects explicit mixed inline/path material.
Runtime file path reads return redacted errors.
Empty CA hint is present in UI strings and form rendering.
Step 8: Commit verification-only fixes if needed
If verification commands changed formatting or generated files, commit them:
git add .
git commit -m "chore: finalize TLS path verification fixes"
If verification produced no changes, do not create an empty commit.