FEATURE (ssh): Add SSH tunnel type picker

This commit is contained in:
Rostislav Dugin
2026-08-13 21:19:33 +03:00
parent dc50ff7c77
commit 2524e51605
40 changed files with 967 additions and 318 deletions
@@ -897,18 +897,20 @@ func storedSshTunnelConfig() sshtunnel.Config {
Host: "bastion.example.com",
Port: 2222,
Username: "tunneluser",
Password: "tunnelpassword",
AuthType: sshtunnel.AuthTypePrivateKey,
PrivateKey: "tunnelprivatekey",
PrivateKeyPassphrase: "tunnelpassphrase",
}
}
// The host moves as well, so an engine that stopped delegating to sshtunnel.Config.Update would
// keep the stored secrets by doing nothing at all and still look correct.
// keep the stored secrets by doing nothing at all and still look correct. The auth type is left out
// alongside the secrets: blank means unchanged for it too, and overwriting it would take the stored
// key down with it.
func submittedSshTunnelConfigWithoutSecrets() sshtunnel.Config {
sshTunnel := storedSshTunnelConfig()
sshTunnel.Host = updatedSshTunnelHost
sshTunnel.Password = ""
sshTunnel.AuthType = ""
sshTunnel.PrivateKey = ""
sshTunnel.PrivateKeyPassphrase = ""
@@ -920,11 +922,12 @@ func assertSshTunnelUpdateMovedTheHostAndKeptTheSecrets(t *testing.T, sshTunnel
assert.Equal(t, updatedSshTunnelHost, sshTunnel.Host,
"the address is always submitted, so the update must land")
assert.Equal(t, sshtunnel.AuthTypePrivateKey, sshTunnel.AuthType,
"a blank auth type must keep the stored one, or the secrets below are cleared with it")
encryptor := encryption.GetFieldEncryptor()
for submittedSecret, storedSecret := range map[string]string{
"tunnelpassword": sshTunnel.Password,
"tunnelprivatekey": sshTunnel.PrivateKey,
"tunnelpassphrase": sshTunnel.PrivateKeyPassphrase,
} {
@@ -1658,11 +1661,21 @@ func bastionedPostgresConfig(
Host: topology.Bastion.Host,
Port: topology.Bastion.Port,
Username: containers.SshBastionUsername,
AuthType: sshtunnel.AuthTypePassword,
Password: containers.SshBastionPassword,
},
}
}
func readBastionTestKey(t *testing.T) string {
t.Helper()
privateKey, err := os.ReadFile(filepath.Join(containers.GetSshBastionTestdataDir(t), "test_key"))
require.NoError(t, err)
return string(privateKey)
}
// Without this the tunnel tests would stay green if the tunnel silently stopped being used, because
// every other assertion only proves that some route to the database exists.
func Test_BastionedPostgres_WithoutTheTunnel_IsUnreachableFromTheHost(t *testing.T) {
@@ -1729,17 +1742,15 @@ func Test_CreateDatabase_OverSshTunnel_DetectsVersionAndHidesTunnelSecrets(t *te
func Test_CreateDatabase_OverSshTunnelWithAPrivateKey_DatabaseCreated(t *testing.T) {
topology := containers.StartPostgresBehindSshBastion(t, "postgres:16")
privateKey, err := os.ReadFile(filepath.Join(containers.GetSshBastionTestdataDir(t), "test_key"))
require.NoError(t, err)
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("SSH Tunnel Key", owner, router)
t.Cleanup(func() { workspaces_testing.RemoveTestWorkspace(workspace, router) })
postgresConfig := bastionedPostgresConfig(topology)
postgresConfig.SshTunnel.AuthType = sshtunnel.AuthTypePrivateKey
postgresConfig.SshTunnel.Password = ""
postgresConfig.SshTunnel.PrivateKey = string(privateKey)
postgresConfig.SshTunnel.PrivateKey = readBastionTestKey(t)
var createdDatabase Database
test_utils.MakePostRequestAndUnmarshal(
@@ -1758,6 +1769,116 @@ func Test_CreateDatabase_OverSshTunnelWithAPrivateKey_DatabaseCreated(t *testing
assert.Equal(t, "16", string(createdDatabase.PostgresqlLogical.Version))
}
// The stored key would otherwise stay a working way into the bastion after the user replaced it
// with a password.
func Test_UpdateDatabase_WhenSshAuthTypeChangesToPassword_ClearsTheStoredPrivateKey(t *testing.T) {
topology := containers.StartPostgresBehindSshBastion(t, "postgres:16")
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("SSH Tunnel Auth Switch", owner, router)
t.Cleanup(func() { workspaces_testing.RemoveTestWorkspace(workspace, router) })
postgresConfig := bastionedPostgresConfig(topology)
postgresConfig.SshTunnel.AuthType = sshtunnel.AuthTypePrivateKey
postgresConfig.SshTunnel.Password = ""
postgresConfig.SshTunnel.PrivateKey = readBastionTestKey(t)
var createdDatabase Database
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/databases/create", "Bearer "+owner.Token,
Database{
Name: "Bastioned PG switching auth",
WorkspaceID: &workspace.ID,
Type: DatabaseTypePostgresLogical,
PostgresqlLogical: postgresConfig,
},
http.StatusCreated, &createdDatabase,
)
t.Cleanup(func() { RemoveTestDatabase(&createdDatabase) })
createdDatabase.PostgresqlLogical.SshTunnel.AuthType = sshtunnel.AuthTypePassword
createdDatabase.PostgresqlLogical.SshTunnel.Password = containers.SshBastionPassword
var updatedDatabase Database
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/databases/update", "Bearer "+owner.Token,
createdDatabase, http.StatusOK, &updatedDatabase,
)
persistedDatabase, err := databaseRepository.FindByID(createdDatabase.ID)
require.NoError(t, err)
require.NotNil(t, persistedDatabase.PostgresqlLogical)
assert.Empty(t, persistedDatabase.PostgresqlLogical.SshTunnel.PrivateKey)
assert.NotEmpty(t, persistedDatabase.PostgresqlLogical.SshTunnel.Password)
}
func Test_CreateDatabase_WhenSshAuthTypeIsPrivateKeyButOnlyAPasswordIsSet_ReturnsBadRequest(
t *testing.T,
) {
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("SSH Tunnel Auth Mismatch", owner, router)
t.Cleanup(func() { workspaces_testing.RemoveTestWorkspace(workspace, router) })
postgresConfig := getTestPostgresConfig()
postgresConfig.SshTunnel = sshtunnel.Config{
IsEnabled: true,
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePrivateKey,
Password: "tunnelpassword",
}
response := workspaces_testing.MakeAPIRequest(
router, "POST", "/api/v1/databases/create", "Bearer "+owner.Token,
Database{
Name: "Tunnel without a key",
WorkspaceID: &workspace.ID,
Type: DatabaseTypePostgresLogical,
PostgresqlLogical: postgresConfig,
},
)
assert.Equal(t, http.StatusBadRequest, response.Code)
assert.Contains(t, response.Body.String(), "SSH tunnel private key is required")
}
// Storing the unused secret would leave a second, invisible way into the bastion behind: the edit
// form only ever shows the chosen one, so nothing would surface it again.
func Test_CreateDatabase_WhenTheSshTunnelCarriesBothSecrets_ReturnsBadRequest(t *testing.T) {
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("SSH Tunnel Both Secrets", owner, router)
t.Cleanup(func() { workspaces_testing.RemoveTestWorkspace(workspace, router) })
postgresConfig := getTestPostgresConfig()
postgresConfig.SshTunnel = sshtunnel.Config{
IsEnabled: true,
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
}
response := workspaces_testing.MakeAPIRequest(
router, "POST", "/api/v1/databases/create", "Bearer "+owner.Token,
Database{
Name: "Tunnel with both secrets",
WorkspaceID: &workspace.ID,
Type: DatabaseTypePostgresLogical,
PostgresqlLogical: postgresConfig,
},
)
assert.Equal(t, http.StatusBadRequest, response.Code)
assert.Contains(t, response.Body.String(), "must not carry a private key")
}
func Test_CreateDatabase_WhenSshTunnelIsEnabledWithoutAHost_ReturnsBadRequest(t *testing.T) {
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
@@ -1769,6 +1890,7 @@ func Test_CreateDatabase_WhenSshTunnelIsEnabledWithoutAHost_ReturnsBadRequest(t
IsEnabled: true,
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
}
@@ -1846,6 +1968,7 @@ func Test_CreateMongodbDatabase_WhenSrvAndSshTunnelAreEnabled_ReturnsBadRequest(
Host: "bastion.example.com",
Port: 22,
Username: containers.SshBastionUsername,
AuthType: sshtunnel.AuthTypePassword,
Password: containers.SshBastionPassword,
},
},
@@ -31,6 +31,7 @@ func enabledTunnelDatabase() *MariadbDatabase {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
},
}
@@ -859,6 +859,7 @@ func Test_Validate_WhenSrvIsEnabledBehindAnSshTunnel_IsRejected(t *testing.T) {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
}
@@ -29,6 +29,7 @@ func enabledTunnelDatabase() *MongodbDatabase {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
},
}
@@ -31,6 +31,7 @@ func enabledTunnelDatabase() *MysqlDatabase {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
},
}
@@ -2179,6 +2179,7 @@ func Test_Validate_WhenDatabasusOnLoopbackIsBehindARemoteBastion_IsAllowed(t *te
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
}
@@ -2195,6 +2196,7 @@ func Test_Validate_WhenDatabasusOnLoopbackIsBehindALocalBastion_IsRejected(t *te
Host: bastionHost,
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
}
@@ -29,6 +29,7 @@ func enabledTunnelDatabase() *PostgresqlLogicalDatabase {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
},
}
@@ -34,6 +34,7 @@ func enabledTunnelDatabase() *PostgresqlPhysicalDatabase {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
},
}
@@ -209,6 +209,7 @@ func filledSshTunnel() sshtunnel.Config {
Host: "bastion.example.com",
Port: 2222,
Username: "tunneluser",
AuthType: sshtunnel.AuthTypePrivateKey,
Password: "enc:tunnelpassword",
PrivateKey: "enc:privatekey",
PrivateKeyPassphrase: "enc:passphrase",
+6 -14
View File
@@ -1,7 +1,6 @@
package sshtunnel
import (
"errors"
"fmt"
"golang.org/x/crypto/ssh"
@@ -10,31 +9,24 @@ import (
)
func buildAuthMethods(config Config, encryptor encryption.FieldEncryptor) ([]ssh.AuthMethod, error) {
var authMethods []ssh.AuthMethod
if config.Password != "" {
switch config.AuthType {
case AuthTypePassword:
password, err := decryptIfNeeded(config.Password, encryptor)
if err != nil {
return nil, fmt.Errorf("failed to decrypt the SSH tunnel password: %w", err)
}
authMethods = append(authMethods, ssh.Password(password))
}
if config.PrivateKey != "" {
return []ssh.AuthMethod{ssh.Password(password)}, nil
case AuthTypePrivateKey:
signer, err := buildPrivateKeySigner(config, encryptor)
if err != nil {
return nil, err
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
if len(authMethods) == 0 {
return nil, errors.New("SSH tunnel requires either a password or a private key")
}
return authMethods, nil
return nil, fmt.Errorf("invalid SSH tunnel auth type: %s", config.AuthType)
}
func buildPrivateKeySigner(config Config, encryptor encryption.FieldEncryptor) (ssh.Signer, error) {
@@ -0,0 +1,59 @@
package sshtunnel
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func generatePrivateKey(t *testing.T) string {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
pemBlock, err := ssh.MarshalPrivateKey(privateKey, "")
require.NoError(t, err)
return string(pem.EncodeToMemory(pemBlock))
}
// The unusable private key is the point: a builder that still branched on field presence would try
// to parse it and fail, so this pins the auth type as the only thing that decides.
func Test_BuildAuthMethods_WhenAuthTypeIsPassword_ReturnsOnlyThePasswordMethod(t *testing.T) {
config := enabledConfig()
config.PrivateKey = "not a key"
authMethods, err := buildAuthMethods(config, nil)
require.NoError(t, err)
require.Len(t, authMethods, 1)
assert.IsType(t, ssh.Password(""), authMethods[0])
}
func Test_BuildAuthMethods_WhenAuthTypeIsPrivateKey_ReturnsOnlyThePublicKeyMethod(t *testing.T) {
config := enabledPrivateKeyConfig()
config.PrivateKey = generatePrivateKey(t)
config.PrivateKeyPassphrase = ""
config.Password = "tunnelpassword"
authMethods, err := buildAuthMethods(config, nil)
require.NoError(t, err)
require.Len(t, authMethods, 1)
assert.IsType(t, ssh.PublicKeys(), authMethods[0])
}
func Test_BuildAuthMethods_WhenAuthTypeIsUnknown_ReturnsError(t *testing.T) {
config := enabledConfig()
config.AuthType = "CERTIFICATE"
_, err := buildAuthMethods(config, nil)
assert.Error(t, err)
}
@@ -0,0 +1,8 @@
package sshtunnel
type AuthType string
const (
AuthTypePassword AuthType = "PASSWORD"
AuthTypePrivateKey AuthType = "PRIVATE_KEY"
)
@@ -49,6 +49,7 @@ func bastionConfig(bastion containers.Endpoint) Config {
Host: bastion.Host,
Port: bastion.Port,
Username: containers.SshBastionUsername,
AuthType: AuthTypePassword,
Password: containers.SshBastionPassword,
}
}
@@ -128,6 +129,7 @@ func Test_Forwarder_WithPrivateKeyAuth_ForwardsBytesToTarget(t *testing.T) {
bastion := startTestBastion(t)
config := bastionConfig(bastion)
config.AuthType = AuthTypePrivateKey
config.Password = ""
config.PrivateKey = readTestKey(t, "test_key")
@@ -140,6 +142,7 @@ func Test_Forwarder_WithPassphraseProtectedPrivateKey_ForwardsBytesToTarget(t *t
bastion := startTestBastion(t)
config := bastionConfig(bastion)
config.AuthType = AuthTypePrivateKey
config.Password = ""
config.PrivateKey = readTestKey(t, "test_key_passphrase")
config.PrivateKeyPassphrase = "testpassphrase"
@@ -170,6 +173,7 @@ func Test_Open_WithEncryptedPrivateKeyAndPassphrase_DecryptsThemBeforeAuthentica
encryptor := prefixingEncryptor{}
config := bastionConfig(bastion)
config.AuthType = AuthTypePrivateKey
config.Password = ""
config.PrivateKey = readTestKey(t, "test_key_passphrase")
config.PrivateKeyPassphrase = "testpassphrase"
@@ -217,6 +221,7 @@ func Test_Open_WhenPassphraseIsWrong_ReturnsError(t *testing.T) {
bastion := startTestBastion(t)
config := bastionConfig(bastion)
config.AuthType = AuthTypePrivateKey
config.Password = ""
config.PrivateKey = readTestKey(t, "test_key_passphrase")
config.PrivateKeyPassphrase = "wrong-passphrase"
@@ -349,6 +354,7 @@ func Test_Open_WhenBastionAcceptsButNeverHandshakes_FailsInsteadOfHanging(t *tes
Host: "127.0.0.1",
Port: silentAddress.Port,
Username: containers.SshBastionUsername,
AuthType: AuthTypePassword,
Password: containers.SshBastionPassword,
},
Target: Endpoint{Host: "127.0.0.1", Port: bastionInternalPort},
+52 -9
View File
@@ -2,6 +2,7 @@ package sshtunnel
import (
"errors"
"fmt"
"databasus-backend/internal/util/encryption"
)
@@ -9,13 +10,14 @@ import (
// Consumers must embed this with gorm:"embedded;embeddedPrefix:ssh_" so each engine table owns its
// own bastion columns; the column names below assume that prefix.
type Config struct {
IsEnabled bool `json:"isEnabled" gorm:"column:is_enabled;type:boolean;not null;default:false"`
Host string `json:"host" gorm:"column:host;type:text;not null;default:''"`
Port int `json:"port" gorm:"column:port;type:integer;not null;default:22"`
Username string `json:"username" gorm:"column:username;type:text;not null;default:''"`
Password string `json:"password" gorm:"column:password;type:text;not null;default:''"`
PrivateKey string `json:"privateKey" gorm:"column:private_key;type:text;not null;default:''"`
PrivateKeyPassphrase string `json:"privateKeyPassphrase" gorm:"column:private_key_passphrase;type:text;not null;default:''"`
IsEnabled bool `json:"isEnabled" gorm:"column:is_enabled;type:boolean;not null;default:false"`
Host string `json:"host" gorm:"column:host;type:text;not null;default:''"`
Port int `json:"port" gorm:"column:port;type:integer;not null;default:22"`
Username string `json:"username" gorm:"column:username;type:text;not null;default:''"`
AuthType AuthType `json:"authType" gorm:"column:auth_type;type:text;not null;default:'PASSWORD'"`
Password string `json:"password" gorm:"column:password;type:text;not null;default:''"`
PrivateKey string `json:"privateKey" gorm:"column:private_key;type:text;not null;default:''"`
PrivateKeyPassphrase string `json:"privateKeyPassphrase" gorm:"column:private_key_passphrase;type:text;not null;default:''"`
}
func (c *Config) Validate() error {
@@ -35,8 +37,29 @@ func (c *Config) Validate() error {
return errors.New("SSH tunnel username is required")
}
if c.Password == "" && c.PrivateKey == "" {
return errors.New("SSH tunnel requires either a password or a private key")
// Rejecting the other type's secret rather than ignoring it keeps a submission from persisting a
// second, invisible way into the bastion; on update the same invariant is reached by clearing.
switch c.AuthType {
case AuthTypePassword:
if c.Password == "" {
return errors.New("SSH tunnel password is required")
}
if c.PrivateKey != "" || c.PrivateKeyPassphrase != "" {
return errors.New("SSH tunnel password auth must not carry a private key")
}
case AuthTypePrivateKey:
if c.PrivateKey == "" {
return errors.New("SSH tunnel private key is required")
}
if c.Password != "" {
return errors.New("SSH tunnel private key auth must not carry a password")
}
case "":
return errors.New("SSH tunnel auth type is required")
default:
return fmt.Errorf("invalid SSH tunnel auth type: %s", c.AuthType)
}
return nil
@@ -62,6 +85,12 @@ func (c *Config) Update(incomingConfig *Config) {
c.Port = incomingConfig.Port
c.Username = incomingConfig.Username
// A blank auth type keeps the stored one for the same reason the secrets below do, and with a
// sharper consequence: switching it also discards the secret of the type left behind.
if incomingConfig.AuthType != "" {
c.AuthType = incomingConfig.AuthType
}
// A blank secret means "keep the stored one" - the edit form never receives
// them back from the API, so overwriting on blank would wipe them.
if incomingConfig.Password != "" {
@@ -75,6 +104,8 @@ func (c *Config) Update(incomingConfig *Config) {
if incomingConfig.PrivateKeyPassphrase != "" {
c.PrivateKeyPassphrase = incomingConfig.PrivateKeyPassphrase
}
c.clearSecretsOfUnusedAuthType()
}
func (c *Config) EncryptSensitiveFields(encryptor encryption.FieldEncryptor) error {
@@ -101,3 +132,15 @@ func (c *Config) EncryptSensitiveFields(encryptor encryption.FieldEncryptor) err
return nil
}
// Without this the "blank keeps the stored one" rule above would leave the previous way of logging
// in behind forever once the user switches the auth type.
func (c *Config) clearSecretsOfUnusedAuthType() {
switch c.AuthType {
case AuthTypePassword:
c.PrivateKey = ""
c.PrivateKeyPassphrase = ""
case AuthTypePrivateKey:
c.Password = ""
}
}
+126 -20
View File
@@ -27,10 +27,21 @@ func enabledConfig() Config {
Host: "bastion.example.com",
Port: 22,
Username: "tunneluser",
AuthType: AuthTypePassword,
Password: "tunnelpassword",
}
}
func enabledPrivateKeyConfig() Config {
config := enabledConfig()
config.AuthType = AuthTypePrivateKey
config.Password = ""
config.PrivateKey = "stored-private-key"
config.PrivateKeyPassphrase = "stored-passphrase"
return config
}
func Test_Validate_WhenTunnelIsDisabled_IgnoresEmptyFields(t *testing.T) {
config := Config{}
@@ -66,22 +77,57 @@ func Test_Validate_WhenPortIsOutOfRange_ReturnsError(t *testing.T) {
}
}
func Test_Validate_WhenNeitherPasswordNorPrivateKeyIsSet_ReturnsError(t *testing.T) {
config := enabledConfig()
config.Password = ""
config.PrivateKey = ""
assert.Error(t, config.Validate())
}
func Test_Validate_WhenOnlyPrivateKeyIsSet_ReturnsNoError(t *testing.T) {
func Test_Validate_WhenAuthTypeIsPasswordAndOnlyPrivateKeyIsSet_ReturnsError(t *testing.T) {
config := enabledConfig()
config.Password = ""
config.PrivateKey = "-----BEGIN OPENSSH PRIVATE KEY-----"
assert.Error(t, config.Validate())
}
func Test_Validate_WhenAuthTypeIsPrivateKeyAndOnlyPasswordIsSet_ReturnsError(t *testing.T) {
config := enabledConfig()
config.AuthType = AuthTypePrivateKey
assert.Error(t, config.Validate())
}
func Test_Validate_WhenAuthTypeIsPrivateKeyAndTheKeyIsSet_ReturnsNoError(t *testing.T) {
config := enabledPrivateKeyConfig()
assert.NoError(t, config.Validate())
}
func Test_Validate_WhenAuthTypeIsBlank_ReturnsError(t *testing.T) {
config := enabledConfig()
config.AuthType = ""
assert.EqualError(t, config.Validate(), "SSH tunnel auth type is required")
}
func Test_Validate_WhenAuthTypeIsUnknown_ReturnsError(t *testing.T) {
config := enabledConfig()
config.AuthType = "CERTIFICATE"
assert.EqualError(t, config.Validate(), "invalid SSH tunnel auth type: CERTIFICATE")
}
// A second secret alongside the chosen one is a second way into the bastion that nothing in the UI
// would ever show again.
func Test_Validate_WhenAuthTypeIsPasswordAndAPrivateKeyIsAlsoSet_ReturnsError(t *testing.T) {
config := enabledConfig()
config.PrivateKey = "-----BEGIN OPENSSH PRIVATE KEY-----"
assert.Error(t, config.Validate())
}
func Test_Validate_WhenAuthTypeIsPrivateKeyAndAPasswordIsAlsoSet_ReturnsError(t *testing.T) {
config := enabledPrivateKeyConfig()
config.Password = "tunnelpassword"
assert.Error(t, config.Validate())
}
func Test_HideSensitiveData_WhenCalled_ClearsSecretsAndKeepsTheRest(t *testing.T) {
config := enabledConfig()
config.PrivateKey = "private-key"
@@ -105,48 +151,108 @@ func Test_HideSensitiveData_WhenReceiverIsNil_DoesNotPanic(t *testing.T) {
// The edit form never receives the stored secrets back, so it submits them blank. Overwriting on
// blank would wipe the bastion credentials on every unrelated edit.
func Test_Update_WithBlankSecrets_KeepsTheStoredOnes(t *testing.T) {
func Test_Update_WhenAuthTypeStaysPasswordAndSecretsAreBlank_KeepsTheStoredPassword(t *testing.T) {
storedConfig := enabledConfig()
storedConfig.PrivateKey = "stored-private-key"
storedConfig.PrivateKeyPassphrase = "stored-passphrase"
storedConfig.Update(&Config{
IsEnabled: true,
Host: "new-bastion.example.com",
Port: 2222,
Username: "newuser",
AuthType: AuthTypePassword,
})
assert.Equal(t, "tunnelpassword", storedConfig.Password)
assert.Equal(t, "stored-private-key", storedConfig.PrivateKey)
assert.Equal(t, "stored-passphrase", storedConfig.PrivateKeyPassphrase)
assert.Equal(t, "new-bastion.example.com", storedConfig.Host)
assert.Equal(t, 2222, storedConfig.Port)
assert.Equal(t, "newuser", storedConfig.Username)
}
func Test_Update_WithNewSecrets_ReplacesTheStoredOnes(t *testing.T) {
func Test_Update_WhenAuthTypeStaysPrivateKeyAndSecretsAreBlank_KeepsTheStoredKeyAndPassphrase(
t *testing.T,
) {
storedConfig := enabledPrivateKeyConfig()
incomingConfig := enabledPrivateKeyConfig()
incomingConfig.PrivateKey = ""
incomingConfig.PrivateKeyPassphrase = ""
storedConfig.Update(&incomingConfig)
assert.Equal(t, "stored-private-key", storedConfig.PrivateKey)
assert.Equal(t, "stored-passphrase", storedConfig.PrivateKeyPassphrase)
}
func Test_Update_WhenAuthTypeIsPasswordAndANewPasswordArrives_ReplacesTheStoredOne(t *testing.T) {
storedConfig := enabledConfig()
storedConfig.PrivateKey = "stored-private-key"
incomingConfig := enabledConfig()
incomingConfig.Password = "new-password"
storedConfig.Update(&incomingConfig)
assert.Equal(t, "new-password", storedConfig.Password)
}
func Test_Update_WhenAuthTypeIsPrivateKeyAndANewKeyArrives_ReplacesTheStoredOne(t *testing.T) {
storedConfig := enabledPrivateKeyConfig()
incomingConfig := enabledPrivateKeyConfig()
incomingConfig.PrivateKey = "new-private-key"
incomingConfig.PrivateKeyPassphrase = "new-passphrase"
storedConfig.Update(&incomingConfig)
assert.Equal(t, "new-password", storedConfig.Password)
assert.Equal(t, "new-private-key", storedConfig.PrivateKey)
assert.Equal(t, "new-passphrase", storedConfig.PrivateKeyPassphrase)
}
// Leaving the previous secret behind would keep a working way into the bastion that the user
// believes they have replaced.
func Test_Update_WhenAuthTypeChangesToPassword_ClearsThePrivateKeyAndItsPassphrase(t *testing.T) {
storedConfig := enabledPrivateKeyConfig()
incomingConfig := enabledConfig()
incomingConfig.Password = "new-password"
storedConfig.Update(&incomingConfig)
assert.Empty(t, storedConfig.PrivateKey)
assert.Empty(t, storedConfig.PrivateKeyPassphrase)
assert.Equal(t, "new-password", storedConfig.Password)
}
func Test_Update_WhenAuthTypeChangesToPrivateKey_ClearsThePassword(t *testing.T) {
storedConfig := enabledConfig()
incomingConfig := enabledPrivateKeyConfig()
storedConfig.Update(&incomingConfig)
assert.Empty(t, storedConfig.Password)
assert.Equal(t, "stored-private-key", storedConfig.PrivateKey)
}
// A caller that only flips the tunnel off must not silently switch how it logs back in, which
// would take the stored key down with it.
func Test_Update_WhenAuthTypeIsBlank_KeepsTheStoredOneAndItsSecrets(t *testing.T) {
storedConfig := enabledPrivateKeyConfig()
storedConfig.Update(&Config{IsEnabled: false})
assert.Equal(t, AuthTypePrivateKey, storedConfig.AuthType)
assert.Equal(t, "stored-private-key", storedConfig.PrivateKey)
assert.Equal(t, "stored-passphrase", storedConfig.PrivateKeyPassphrase)
}
func Test_Update_WhenTunnelIsDisabledInIncoming_TurnsItOff(t *testing.T) {
storedConfig := enabledConfig()
storedConfig.Update(
&Config{IsEnabled: false, Host: storedConfig.Host, Port: storedConfig.Port, Username: storedConfig.Username},
)
incomingConfig := enabledConfig()
incomingConfig.IsEnabled = false
incomingConfig.Password = ""
storedConfig.Update(&incomingConfig)
assert.False(t, storedConfig.IsEnabled)
assert.Equal(t, "tunnelpassword", storedConfig.Password)
@@ -282,6 +282,8 @@ func databasesForEveryEngine(isSshTunnelEnabled bool) []*databases.Database {
Host: "bastion.internal",
Port: 22,
Username: "backup",
AuthType: sshtunnel.AuthTypePassword,
Password: "tunnelpassword",
}
logicalPostgresDatabase := postgresDatabase("pg-logical", availableStatus())
@@ -16,6 +16,7 @@ func GetTunnelConfig(bastionedDatabase containers.BastionedDatabase) sshtunnel.C
Host: bastionedDatabase.Bastion.Host,
Port: bastionedDatabase.Bastion.Port,
Username: containers.SshBastionUsername,
AuthType: sshtunnel.AuthTypePassword,
Password: containers.SshBastionPassword,
}
}
@@ -0,0 +1,109 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE postgresql_logical_databases
ADD COLUMN IF NOT EXISTS ssh_auth_type TEXT NOT NULL DEFAULT 'PASSWORD';
ALTER TABLE postgresql_physical_databases
ADD COLUMN IF NOT EXISTS ssh_auth_type TEXT NOT NULL DEFAULT 'PASSWORD';
ALTER TABLE mysql_databases
ADD COLUMN IF NOT EXISTS ssh_auth_type TEXT NOT NULL DEFAULT 'PASSWORD';
ALTER TABLE mariadb_databases
ADD COLUMN IF NOT EXISTS ssh_auth_type TEXT NOT NULL DEFAULT 'PASSWORD';
ALTER TABLE mongodb_databases
ADD COLUMN IF NOT EXISTS ssh_auth_type TEXT NOT NULL DEFAULT 'PASSWORD';
-- A row holding both secrets was logging in with the password, which the old auth builder offered
-- first, so only a key-only row is migrated to key auth.
UPDATE postgresql_logical_databases
SET ssh_auth_type = 'PRIVATE_KEY'
WHERE ssh_private_key <> '' AND ssh_password = '';
UPDATE postgresql_physical_databases
SET ssh_auth_type = 'PRIVATE_KEY'
WHERE ssh_private_key <> '' AND ssh_password = '';
UPDATE mysql_databases
SET ssh_auth_type = 'PRIVATE_KEY'
WHERE ssh_private_key <> '' AND ssh_password = '';
UPDATE mariadb_databases
SET ssh_auth_type = 'PRIVATE_KEY'
WHERE ssh_private_key <> '' AND ssh_password = '';
UPDATE mongodb_databases
SET ssh_auth_type = 'PRIVATE_KEY'
WHERE ssh_private_key <> '' AND ssh_password = '';
-- The secret of the type not chosen is a dormant second way into the bastion that no screen shows
-- again, so it is cleared in the same migration that picks the type.
UPDATE postgresql_logical_databases
SET ssh_private_key = '', ssh_private_key_passphrase = ''
WHERE ssh_auth_type = 'PASSWORD'
AND (ssh_private_key <> '' OR ssh_private_key_passphrase <> '');
UPDATE postgresql_physical_databases
SET ssh_private_key = '', ssh_private_key_passphrase = ''
WHERE ssh_auth_type = 'PASSWORD'
AND (ssh_private_key <> '' OR ssh_private_key_passphrase <> '');
UPDATE mysql_databases
SET ssh_private_key = '', ssh_private_key_passphrase = ''
WHERE ssh_auth_type = 'PASSWORD'
AND (ssh_private_key <> '' OR ssh_private_key_passphrase <> '');
UPDATE mariadb_databases
SET ssh_private_key = '', ssh_private_key_passphrase = ''
WHERE ssh_auth_type = 'PASSWORD'
AND (ssh_private_key <> '' OR ssh_private_key_passphrase <> '');
UPDATE mongodb_databases
SET ssh_private_key = '', ssh_private_key_passphrase = ''
WHERE ssh_auth_type = 'PASSWORD'
AND (ssh_private_key <> '' OR ssh_private_key_passphrase <> '');
UPDATE postgresql_logical_databases
SET ssh_password = ''
WHERE ssh_auth_type = 'PRIVATE_KEY'
AND ssh_password <> '';
UPDATE postgresql_physical_databases
SET ssh_password = ''
WHERE ssh_auth_type = 'PRIVATE_KEY'
AND ssh_password <> '';
UPDATE mysql_databases
SET ssh_password = ''
WHERE ssh_auth_type = 'PRIVATE_KEY'
AND ssh_password <> '';
UPDATE mariadb_databases
SET ssh_password = ''
WHERE ssh_auth_type = 'PRIVATE_KEY'
AND ssh_password <> '';
UPDATE mongodb_databases
SET ssh_password = ''
WHERE ssh_auth_type = 'PRIVATE_KEY'
AND ssh_password <> '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE mongodb_databases
DROP COLUMN IF EXISTS ssh_auth_type;
ALTER TABLE mariadb_databases
DROP COLUMN IF EXISTS ssh_auth_type;
ALTER TABLE mysql_databases
DROP COLUMN IF EXISTS ssh_auth_type;
ALTER TABLE postgresql_physical_databases
DROP COLUMN IF EXISTS ssh_auth_type;
ALTER TABLE postgresql_logical_databases
DROP COLUMN IF EXISTS ssh_auth_type;
-- +goose StatementEnd
+4 -1
View File
@@ -19,12 +19,15 @@ export {
} from './model/postgresql/physical/physicalConnectionErrorContent';
export { PostgresqlVersion } from './model/postgresql/PostgresqlVersion';
export { type SshTunnelConfig } from './model/sshtunnel/SshTunnelConfig';
export { SshTunnelAuthType } from './model/sshtunnel/SshTunnelAuthType';
export { SSH_TUNNEL_AUTH_TYPE_LABELS } from './model/sshtunnel/sshTunnelAuthTypeLabels';
export {
DEFAULT_SSH_PORT,
createEmptySshTunnelConfig,
} from './model/sshtunnel/createEmptySshTunnelConfig';
export { setSshTunnelAuthTypeAndClearUnusedSecrets } from './model/sshtunnel/setSshTunnelAuthTypeAndClearUnusedSecrets';
export { isSshTunnelReadyToTest } from './model/sshtunnel/isSshTunnelReadyToTest';
export { hasStoredSshTunnelSecrets } from './model/sshtunnel/hasStoredSshTunnelSecrets';
export { hasStoredSshTunnelSecretsForAuthType } from './model/sshtunnel/hasStoredSshTunnelSecretsForAuthType';
export { type MysqlDatabase } from './model/mysql/MysqlDatabase';
export { MysqlVersion } from './model/mysql/MysqlVersion';
export { type MariadbDatabase } from './model/mariadb/MariadbDatabase';
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { SshTunnelAuthType } from '../sshtunnel/SshTunnelAuthType';
import type { SshTunnelConfig } from '../sshtunnel/SshTunnelConfig';
import type { MongodbDatabase } from './MongodbDatabase';
import { MongodbVersion } from './MongodbVersion';
@@ -7,6 +8,7 @@ import { disableSrvWhenTunneled } from './disableSrvWhenTunneled';
const enabledTunnel = (): SshTunnelConfig => ({
isEnabled: true,
authType: SshTunnelAuthType.PASSWORD,
host: 'bastion.example.com',
port: 22,
username: 'tunneluser',
@@ -17,7 +19,7 @@ const enabledTunnel = (): SshTunnelConfig => ({
const srvDatabase = (sshTunnel?: SshTunnelConfig): MongodbDatabase => ({
id: 'db-1',
version: MongodbVersion.V7,
version: MongodbVersion.MongodbVersion70,
host: 'cluster0.example.mongodb.net',
port: 27017,
username: 'testuser',
@@ -0,0 +1,4 @@
export enum SshTunnelAuthType {
PASSWORD = 'PASSWORD',
PRIVATE_KEY = 'PRIVATE_KEY',
}
@@ -1,8 +1,11 @@
import type { SshTunnelAuthType } from './SshTunnelAuthType';
export interface SshTunnelConfig {
isEnabled: boolean;
host: string;
port: number;
username: string;
authType: SshTunnelAuthType;
password: string;
privateKey: string;
privateKeyPassphrase: string;
@@ -1,3 +1,4 @@
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
export const DEFAULT_SSH_PORT = 22;
@@ -8,6 +9,7 @@ export function createEmptySshTunnelConfig(): SshTunnelConfig {
host: '',
port: DEFAULT_SSH_PORT,
username: '',
authType: SshTunnelAuthType.PASSWORD,
password: '',
privateKey: '',
privateKeyPassphrase: '',
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { SshTunnelConfig } from './SshTunnelConfig';
import { hasStoredSshTunnelSecrets } from './hasStoredSshTunnelSecrets';
const enabledTunnel = (): SshTunnelConfig => ({
isEnabled: true,
host: 'bastion.example.com',
port: 22,
username: 'tunneluser',
password: '',
privateKey: '',
privateKeyPassphrase: '',
});
describe('hasStoredSshTunnelSecrets', () => {
it('is true for a saved database whose tunnel is enabled', () => {
expect(hasStoredSshTunnelSecrets(enabledTunnel(), 'db-1')).toBe(true);
});
it('is false while the database is still being created', () => {
expect(hasStoredSshTunnelSecrets(enabledTunnel(), undefined)).toBe(false);
});
it('is false for a saved database that never had a tunnel', () => {
expect(hasStoredSshTunnelSecrets(undefined, 'db-1')).toBe(false);
expect(hasStoredSshTunnelSecrets({ ...enabledTunnel(), isEnabled: false }, 'db-1')).toBe(false);
});
});
@@ -1,12 +0,0 @@
import type { SshTunnelConfig } from './SshTunnelConfig';
/**
* Answers from the saved database, never from the edited copy: a database that never had a tunnel
* would otherwise show the masked placeholder the moment the checkbox is ticked.
*/
export function hasStoredSshTunnelSecrets(
sshTunnel: SshTunnelConfig | undefined,
databaseId: string | undefined,
): boolean {
return !!databaseId && !!sshTunnel?.isEnabled;
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
import { hasStoredSshTunnelSecretsForAuthType } from './hasStoredSshTunnelSecretsForAuthType';
const enabledTunnel = (): SshTunnelConfig => ({
isEnabled: true,
authType: SshTunnelAuthType.PASSWORD,
host: 'bastion.example.com',
port: 22,
username: 'tunneluser',
password: '',
privateKey: '',
privateKeyPassphrase: '',
});
describe('hasStoredSshTunnelSecretsForAuthType', () => {
it('is true for a saved database whose tunnel is enabled', () => {
expect(
hasStoredSshTunnelSecretsForAuthType(enabledTunnel(), SshTunnelAuthType.PASSWORD, 'db-1'),
).toBe(true);
});
it('is false once the user picks the other auth type', () => {
expect(
hasStoredSshTunnelSecretsForAuthType(enabledTunnel(), SshTunnelAuthType.PRIVATE_KEY, 'db-1'),
).toBe(false);
});
it('is false while the database is still being created', () => {
expect(
hasStoredSshTunnelSecretsForAuthType(enabledTunnel(), SshTunnelAuthType.PASSWORD, undefined),
).toBe(false);
});
it('is false for a saved database that never had a tunnel', () => {
expect(
hasStoredSshTunnelSecretsForAuthType(undefined, SshTunnelAuthType.PASSWORD, 'db-1'),
).toBe(false);
expect(
hasStoredSshTunnelSecretsForAuthType(
{ ...enabledTunnel(), isEnabled: false },
SshTunnelAuthType.PASSWORD,
'db-1',
),
).toBe(false);
});
});
@@ -0,0 +1,15 @@
import type { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
/**
* Answers from the saved database, never from the edited copy: a database that never had a tunnel
* would otherwise show the masked placeholder the moment the checkbox is ticked. The stored secret
* only counts for the auth type it was saved under - switching to the other one needs a new secret.
*/
export function hasStoredSshTunnelSecretsForAuthType(
savedSshTunnel: SshTunnelConfig | undefined,
editedAuthType: SshTunnelAuthType | undefined,
databaseId: string | undefined,
): boolean {
return !!databaseId && !!savedSshTunnel?.isEnabled && savedSshTunnel.authType === editedAuthType;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
import { createEmptySshTunnelConfig } from './createEmptySshTunnelConfig';
import { isSshTunnelReadyToTest } from './isSshTunnelReadyToTest';
@@ -27,10 +28,33 @@ describe('isSshTunnelReadyToTest', () => {
expect(isSshTunnelReadyToTest(enabledTunnel(), false)).toBe(true);
});
it('accepts a private key instead of a password', () => {
it('accepts a private key when that is the chosen auth type', () => {
expect(
isSshTunnelReadyToTest(
{
...enabledTunnel(),
authType: SshTunnelAuthType.PRIVATE_KEY,
password: '',
privateKey: 'key',
},
false,
),
).toBe(true);
});
it('rejects a private key while the chosen auth type is a password', () => {
expect(
isSshTunnelReadyToTest({ ...enabledTunnel(), password: '', privateKey: 'key' }, false),
).toBe(true);
).toBe(false);
});
it('rejects a password while the chosen auth type is a private key', () => {
expect(
isSshTunnelReadyToTest(
{ ...enabledTunnel(), authType: SshTunnelAuthType.PRIVATE_KEY },
false,
),
).toBe(false);
});
it.each(['host', 'username'] as const)('rejects a missing %s', (field) => {
@@ -1,3 +1,4 @@
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
/**
@@ -14,5 +15,9 @@ export function isSshTunnelReadyToTest(
if (!sshTunnel.port) return false;
if (!sshTunnel.username) return false;
return hasStoredSecrets || !!sshTunnel.password || !!sshTunnel.privateKey;
if (hasStoredSecrets) return true;
return sshTunnel.authType === SshTunnelAuthType.PASSWORD
? !!sshTunnel.password
: !!sshTunnel.privateKey;
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
import { createEmptySshTunnelConfig } from './createEmptySshTunnelConfig';
import { setSshTunnelAuthTypeAndClearUnusedSecrets } from './setSshTunnelAuthTypeAndClearUnusedSecrets';
function tunnelWithBothSecrets(): SshTunnelConfig {
return {
...createEmptySshTunnelConfig(),
isEnabled: true,
host: 'bastion.example.com',
username: 'tunneluser',
password: 'tunnelpassword',
privateKey: 'tunnelprivatekey',
privateKeyPassphrase: 'tunnelpassphrase',
};
}
describe('setSshTunnelAuthTypeAndClearUnusedSecrets', () => {
it('clears the private key and its passphrase when switching to a password', () => {
const passwordTunnel = setSshTunnelAuthTypeAndClearUnusedSecrets(
tunnelWithBothSecrets(),
SshTunnelAuthType.PASSWORD,
);
expect(passwordTunnel.authType).toBe(SshTunnelAuthType.PASSWORD);
expect(passwordTunnel.privateKey).toBe('');
expect(passwordTunnel.privateKeyPassphrase).toBe('');
expect(passwordTunnel.password).toBe('tunnelpassword');
});
it('clears the password when switching to a private key', () => {
const privateKeyTunnel = setSshTunnelAuthTypeAndClearUnusedSecrets(
tunnelWithBothSecrets(),
SshTunnelAuthType.PRIVATE_KEY,
);
expect(privateKeyTunnel.authType).toBe(SshTunnelAuthType.PRIVATE_KEY);
expect(privateKeyTunnel.password).toBe('');
expect(privateKeyTunnel.privateKey).toBe('tunnelprivatekey');
expect(privateKeyTunnel.privateKeyPassphrase).toBe('tunnelpassphrase');
});
it('keeps the connection fields untouched', () => {
const originalTunnel = tunnelWithBothSecrets();
const privateKeyTunnel = setSshTunnelAuthTypeAndClearUnusedSecrets(
originalTunnel,
SshTunnelAuthType.PRIVATE_KEY,
);
expect(privateKeyTunnel.host).toBe(originalTunnel.host);
expect(privateKeyTunnel.port).toBe(originalTunnel.port);
expect(privateKeyTunnel.username).toBe(originalTunnel.username);
expect(privateKeyTunnel.isEnabled).toBe(true);
});
});
@@ -0,0 +1,17 @@
import { SshTunnelAuthType } from './SshTunnelAuthType';
import type { SshTunnelConfig } from './SshTunnelConfig';
/**
* Mirrors what the backend does on save, so the form never submits a secret belonging to the way
* of logging in the user just abandoned.
*/
export function setSshTunnelAuthTypeAndClearUnusedSecrets(
sshTunnel: SshTunnelConfig,
authType: SshTunnelAuthType,
): SshTunnelConfig {
if (authType === SshTunnelAuthType.PASSWORD) {
return { ...sshTunnel, authType, privateKey: '', privateKeyPassphrase: '' };
}
return { ...sshTunnel, authType, password: '' };
}
@@ -0,0 +1,6 @@
import { SshTunnelAuthType } from './SshTunnelAuthType';
export const SSH_TUNNEL_AUTH_TYPE_LABELS: Record<SshTunnelAuthType, string> = {
[SshTunnelAuthType.PASSWORD]: 'Password',
[SshTunnelAuthType.PRIVATE_KEY]: 'Private key',
};
@@ -0,0 +1,23 @@
import { DownOutlined, UpOutlined } from '@ant-design/icons';
interface Props {
isShowAdvanced: boolean;
onToggle: () => void;
}
export const AdvancedSettingsToggleComponent = ({ isShowAdvanced, onToggle }: Props) => (
<div className="mt-4 mb-1 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={onToggle}
>
<span className="mr-2">Advanced settings</span>
{isShowAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
);
@@ -1,11 +1,11 @@
import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { CopyOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { App, Button, Checkbox, Input, InputNumber, Select, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import {
type Database,
databaseApi,
hasStoredSshTunnelSecrets,
hasStoredSshTunnelSecretsForAuthType,
isSshTunnelReadyToTest,
} from '../../../../entity/databases';
import { MariadbConnectionStringParser } from '../../../../entity/databases/model/mariadb/MariadbConnectionStringParser';
@@ -13,6 +13,7 @@ import { NAME_LIST_TOKEN_SEPARATORS, normalizeNameList } from '../../../../share
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
import { AdvancedSettingsToggleComponent } from './AdvancedSettingsToggleComponent';
import { EditSshTunnelComponent } from './EditSshTunnelComponent';
interface Props {
@@ -57,7 +58,8 @@ export const EditMariaDbSpecificDataComponent = ({
const hasAdvancedValues =
!!database.mariadb?.isExcludeEvents ||
!!database.mariadb?.isSkipGaleraDisable ||
!!database.mariadb?.excludeTables?.length;
!!database.mariadb?.excludeTables?.length ||
!!database.mariadb?.sshTunnel?.isEnabled;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
@@ -176,7 +178,11 @@ export const EditMariaDbSpecificDataComponent = ({
if (!editingDatabase) return null;
const hasStoredSshSecrets = hasStoredSshTunnelSecrets(database.mariadb?.sshTunnel, database.id);
const hasStoredSshSecrets = hasStoredSshTunnelSecretsForAuthType(
database.mariadb?.sshTunnel,
editingDatabase.mariadb?.sshTunnel?.authType,
database.id,
);
let isAllFieldsFilled = true;
if (!editingDatabase.mariadb?.host) isAllFieldsFilled = false;
@@ -332,20 +338,6 @@ export const EditMariaDbSpecificDataComponent = ({
</div>
)}
<EditSshTunnelComponent
sshTunnel={editingDatabase.mariadb?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mariadb) return;
setEditingDatabase({
...editingDatabase,
mariadb: { ...editingDatabase.mariadb, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Use HTTPS</div>
<Switch
@@ -363,23 +355,27 @@ export const EditMariaDbSpecificDataComponent = ({
/>
</div>
<div className="mt-4 mb-1 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!isShowAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{isShowAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
<AdvancedSettingsToggleComponent
isShowAdvanced={isShowAdvanced}
onToggle={() => setShowAdvanced(!isShowAdvanced)}
/>
{isShowAdvanced && (
<>
<EditSshTunnelComponent
sshTunnel={editingDatabase.mariadb?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mariadb) return;
setEditingDatabase({
...editingDatabase,
mariadb: { ...editingDatabase.mariadb, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Exclude events</div>
<div className="flex items-center">
@@ -1,4 +1,4 @@
import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { CopyOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { App, Button, Input, InputNumber, Select, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
@@ -6,7 +6,7 @@ import {
type Database,
databaseApi,
disableSrvWhenTunneled,
hasStoredSshTunnelSecrets,
hasStoredSshTunnelSecretsForAuthType,
isSshTunnelReadyToTest,
} from '../../../../entity/databases';
import { MongodbConnectionStringParser } from '../../../../entity/databases/model/mongodb/MongodbConnectionStringParser';
@@ -14,6 +14,7 @@ import { NAME_LIST_TOKEN_SEPARATORS, normalizeNameList } from '../../../../share
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
import { AdvancedSettingsToggleComponent } from './AdvancedSettingsToggleComponent';
import { EditSshTunnelComponent } from './EditSshTunnelComponent';
interface Props {
@@ -59,7 +60,8 @@ export const EditMongoDbSpecificDataComponent = ({
!!database.mongodb?.authDatabase ||
!!database.mongodb?.isSrv ||
!!database.mongodb?.isDirectConnection ||
!!database.mongodb?.excludeCollections?.length;
!!database.mongodb?.excludeCollections?.length ||
!!database.mongodb?.sshTunnel?.isEnabled;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
@@ -193,7 +195,11 @@ export const EditMongoDbSpecificDataComponent = ({
const isSrvConnection = editingDatabase.mongodb?.isSrv || false;
const hasStoredSshSecrets = hasStoredSshTunnelSecrets(database.mongodb?.sshTunnel, database.id);
const hasStoredSshSecrets = hasStoredSshTunnelSecretsForAuthType(
database.mongodb?.sshTunnel,
editingDatabase.mongodb?.sshTunnel?.authType,
database.id,
);
let isAllFieldsFilled = true;
if (!editingDatabase.mongodb?.host) isAllFieldsFilled = false;
@@ -351,21 +357,6 @@ export const EditMongoDbSpecificDataComponent = ({
</div>
)}
<EditSshTunnelComponent
sshTunnel={editingDatabase.mongodb?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mongodb) return;
setEditingDatabase({
...editingDatabase,
mongodb: disableSrvWhenTunneled({ ...editingDatabase.mongodb, sshTunnel }),
});
setShowAdvanced(true);
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Use HTTPS</div>
<Switch
@@ -412,23 +403,27 @@ export const EditMongoDbSpecificDataComponent = ({
</div>
</div>
<div className="mt-4 mb-1 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!isShowAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{isShowAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
<AdvancedSettingsToggleComponent
isShowAdvanced={isShowAdvanced}
onToggle={() => setShowAdvanced(!isShowAdvanced)}
/>
{isShowAdvanced && (
<>
<EditSshTunnelComponent
sshTunnel={editingDatabase.mongodb?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mongodb) return;
setEditingDatabase({
...editingDatabase,
mongodb: disableSrvWhenTunneled({ ...editingDatabase.mongodb, sshTunnel }),
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Use SRV connection</div>
<div className="flex items-center">
@@ -1,11 +1,11 @@
import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { CopyOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { App, Button, Input, InputNumber, Select, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import {
type Database,
databaseApi,
hasStoredSshTunnelSecrets,
hasStoredSshTunnelSecretsForAuthType,
isSshTunnelReadyToTest,
} from '../../../../entity/databases';
import { MySqlConnectionStringParser } from '../../../../entity/databases/model/mysql/MySqlConnectionStringParser';
@@ -13,6 +13,7 @@ import { NAME_LIST_TOKEN_SEPARATORS, normalizeNameList } from '../../../../share
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
import { AdvancedSettingsToggleComponent } from './AdvancedSettingsToggleComponent';
import { EditSshTunnelComponent } from './EditSshTunnelComponent';
interface Props {
@@ -54,7 +55,8 @@ export const EditMySqlSpecificDataComponent = ({
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const hasAdvancedValues = !!database.mysql?.excludeTables?.length;
const hasAdvancedValues =
!!database.mysql?.excludeTables?.length || !!database.mysql?.sshTunnel?.isEnabled;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
@@ -173,7 +175,11 @@ export const EditMySqlSpecificDataComponent = ({
if (!editingDatabase) return null;
const hasStoredSshSecrets = hasStoredSshTunnelSecrets(database.mysql?.sshTunnel, database.id);
const hasStoredSshSecrets = hasStoredSshTunnelSecretsForAuthType(
database.mysql?.sshTunnel,
editingDatabase.mysql?.sshTunnel?.authType,
database.id,
);
let isAllFieldsFilled = true;
if (!editingDatabase.mysql?.host) isAllFieldsFilled = false;
@@ -328,20 +334,6 @@ export const EditMySqlSpecificDataComponent = ({
</div>
)}
<EditSshTunnelComponent
sshTunnel={editingDatabase.mysql?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mysql) return;
setEditingDatabase({
...editingDatabase,
mysql: { ...editingDatabase.mysql, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-3 flex w-full items-center">
<div className="min-w-[150px]">Use HTTPS</div>
<Switch
@@ -359,23 +351,27 @@ export const EditMySqlSpecificDataComponent = ({
/>
</div>
<div className="mt-4 mb-1 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!isShowAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{isShowAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
<AdvancedSettingsToggleComponent
isShowAdvanced={isShowAdvanced}
onToggle={() => setShowAdvanced(!isShowAdvanced)}
/>
{isShowAdvanced && (
<>
<EditSshTunnelComponent
sshTunnel={editingDatabase.mysql?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.mysql) return;
setEditingDatabase({
...editingDatabase,
mysql: { ...editingDatabase.mysql, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Exclude tables</div>
<Select
@@ -1,4 +1,4 @@
import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { CopyOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { App, Button, Checkbox, Input, InputNumber, Select, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
@@ -7,7 +7,7 @@ import {
PostgresSslMode,
type PostgresqlLogicalDatabase,
databaseApi,
hasStoredSshTunnelSecrets,
hasStoredSshTunnelSecretsForAuthType,
isSshTunnelReadyToTest,
} from '../../../../entity/databases';
import { ConnectionStringParser } from '../../../../entity/databases/model/postgresql/ConnectionStringParser';
@@ -15,6 +15,7 @@ import { NAME_LIST_TOKEN_SEPARATORS, normalizeNameList } from '../../../../share
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
import { AdvancedSettingsToggleComponent } from './AdvancedSettingsToggleComponent';
import { EditSshTunnelComponent } from './EditSshTunnelComponent';
interface Props {
@@ -88,6 +89,7 @@ export const EditPostgreSqlLogicalSpecificDataComponent = ({
const hasAdvancedValues =
!!database.postgresqlLogical?.sslClientCert ||
!!database.postgresqlLogical?.sslRootCert ||
!!database.postgresqlLogical?.sshTunnel?.isEnabled ||
(isRestoreMode
? !!database.postgresqlLogical?.isExcludeExtensions ||
!!database.postgresqlLogical?.isRestoreOwnership ||
@@ -567,20 +569,6 @@ export const EditPostgreSqlLogicalSpecificDataComponent = ({
</div>
)}
<EditSshTunnelComponent
sshTunnel={editingDatabase.postgresqlLogical?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.postgresqlLogical) return;
setEditingDatabase({
...editingDatabase,
postgresqlLogical: { ...editingDatabase.postgresqlLogical, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSL mode</div>
<Select
@@ -640,23 +628,27 @@ export const EditPostgreSqlLogicalSpecificDataComponent = ({
</div>
)}
<div className="mt-4 mb-1 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!isShowAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{isShowAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
<AdvancedSettingsToggleComponent
isShowAdvanced={isShowAdvanced}
onToggle={() => setShowAdvanced(!isShowAdvanced)}
/>
{isShowAdvanced && (
<>
<EditSshTunnelComponent
sshTunnel={editingDatabase.postgresqlLogical?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.postgresqlLogical) return;
setEditingDatabase({
...editingDatabase,
postgresqlLogical: { ...editingDatabase.postgresqlLogical, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
{!isRestoreMode && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Include schemas</div>
@@ -868,8 +860,9 @@ export const EditPostgreSqlLogicalSpecificDataComponent = ({
);
};
const hasStoredSshSecrets = hasStoredSshTunnelSecrets(
const hasStoredSshSecrets = hasStoredSshTunnelSecretsForAuthType(
database.postgresqlLogical?.sshTunnel,
editingDatabase.postgresqlLogical?.sshTunnel?.authType,
database.id,
);
@@ -10,7 +10,7 @@ import {
PostgresSslMode,
type PostgresqlPhysicalDatabase,
databaseApi,
hasStoredSshTunnelSecrets,
hasStoredSshTunnelSecretsForAuthType,
isSshTunnelReadyToTest,
physicalConnectionErrorContent,
} from '../../../../entity/databases';
@@ -19,6 +19,7 @@ import { ApiError } from '../../../../shared/api';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
import { AdvancedSettingsToggleComponent } from './AdvancedSettingsToggleComponent';
import { EditSshTunnelComponent } from './EditSshTunnelComponent';
interface Props {
@@ -98,6 +99,10 @@ export const EditPostgreSqlPhysicalSpecificDataComponent = ({
const [hasUserChosenSslMode, setHasUserChosenSslMode] = useState(!!database.id);
const [isReplacingCerts, setIsReplacingCerts] = useState(false);
const [isShowAdvanced, setShowAdvanced] = useState(
!!database.postgresqlPhysical?.sshTunnel?.isEnabled,
);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const [connectionErrorCode, setConnectionErrorCode] = useState<ConnectionErrorCode | null>(null);
@@ -595,20 +600,6 @@ export const EditPostgreSqlPhysicalSpecificDataComponent = ({
/>
</div>
<EditSshTunnelComponent
sshTunnel={editingDatabase.postgresqlPhysical?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.postgresqlPhysical) return;
setEditingDatabase({
...editingDatabase,
postgresqlPhysical: { ...editingDatabase.postgresqlPhysical, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSL mode</div>
<Select
@@ -636,6 +627,27 @@ export const EditPostgreSqlPhysicalSpecificDataComponent = ({
{renderSslCertSection()}
<AdvancedSettingsToggleComponent
isShowAdvanced={isShowAdvanced}
onToggle={() => setShowAdvanced(!isShowAdvanced)}
/>
{isShowAdvanced && (
<EditSshTunnelComponent
sshTunnel={editingDatabase.postgresqlPhysical?.sshTunnel}
hasStoredSecrets={hasStoredSshSecrets}
onChange={(sshTunnel) => {
if (!editingDatabase.postgresqlPhysical) return;
setEditingDatabase({
...editingDatabase,
postgresqlPhysical: { ...editingDatabase.postgresqlPhysical, sshTunnel },
});
setIsConnectionTested(false);
}}
/>
)}
{renderConnectionError()}
{renderFooter(
@@ -669,8 +681,9 @@ export const EditPostgreSqlPhysicalSpecificDataComponent = ({
);
};
const hasStoredSshSecrets = hasStoredSshTunnelSecrets(
const hasStoredSshSecrets = hasStoredSshTunnelSecretsForAuthType(
database.postgresqlPhysical?.sshTunnel,
editingDatabase.postgresqlPhysical?.sshTunnel?.authType,
database.id,
);
@@ -1,11 +1,14 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Checkbox, Input, InputNumber, Tooltip } from 'antd';
import { Button, Checkbox, Input, InputNumber, Select, Tooltip } from 'antd';
import { useState } from 'react';
import {
DEFAULT_SSH_PORT,
SSH_TUNNEL_AUTH_TYPE_LABELS,
SshTunnelAuthType,
type SshTunnelConfig,
createEmptySshTunnelConfig,
setSshTunnelAuthTypeAndClearUnusedSecrets,
} from '../../../../entity/databases';
interface Props {
@@ -29,66 +32,76 @@ export const EditSshTunnelComponent = ({ sshTunnel, hasStoredSecrets, onChange }
onChange({ ...currentTunnel, password: '', privateKey: '', privateKeyPassphrase: '' });
};
const changeAuthType = (authType: SshTunnelAuthType) => {
onChange(setSshTunnelAuthTypeAndClearUnusedSecrets(currentTunnel, authType));
};
const renderStoredSecrets = () => (
<div className="mb-3 flex w-full items-center">
<div className="min-w-[150px]">SSH credentials</div>
<div className="flex items-center">
<span className="mr-3">*************</span>
<Button size="small" onClick={startReplacingSecrets}>
Replace
</Button>
</div>
</div>
);
const renderPassword = () => (
<div className="mb-3 flex w-full items-center">
<div className="min-w-[150px]">SSH password</div>
<Input.Password
value={currentTunnel.password}
onChange={(e) => updateField('password', e.target.value)}
size="small"
className="max-w-[200px] grow"
placeholder="Enter SSH password"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
);
const renderPrivateKey = () => (
<>
<div className="mb-1 flex w-full items-start">
<div className="min-w-[150px] leading-6">SSH private key</div>
<Input.TextArea
value={currentTunnel.privateKey}
onChange={(e) => updateField('privateKey', e.target.value)}
size="small"
className="max-w-[200px] grow"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
autoSize={{ minRows: 2, maxRows: 5 }}
/>
</div>
<div className="mb-3 flex w-full items-center">
<div className="min-w-[150px]">Key passphrase</div>
<Input.Password
value={currentTunnel.privateKeyPassphrase}
onChange={(e) => updateField('privateKeyPassphrase', e.target.value)}
size="small"
className="max-w-[200px] grow"
placeholder="Only for an encrypted key"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
</>
);
const renderCredentials = () => {
if (hasStoredSecrets && !isReplacingSecrets) {
return (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSH credentials</div>
<div className="flex items-center">
<span className="mr-3">*************</span>
<Button size="small" onClick={startReplacingSecrets}>
Replace
</Button>
</div>
</div>
);
}
if (hasStoredSecrets && !isReplacingSecrets) return renderStoredSecrets();
return (
<>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSH password</div>
<Input.Password
value={currentTunnel.password}
onChange={(e) => updateField('password', e.target.value)}
size="small"
className="max-w-[200px] grow"
placeholder="Leave empty when using a key"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
<div className="mb-1 flex w-full items-start">
<div className="min-w-[150px]">SSH private key</div>
<Input.TextArea
value={currentTunnel.privateKey}
onChange={(e) => updateField('privateKey', e.target.value)}
size="small"
className="max-w-[300px] grow"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
autoSize={{ minRows: 2, maxRows: 5 }}
/>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Key passphrase</div>
<Input.Password
value={currentTunnel.privateKeyPassphrase}
onChange={(e) => updateField('privateKeyPassphrase', e.target.value)}
size="small"
className="max-w-[200px] grow"
placeholder="Only for an encrypted key"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
</>
);
return currentTunnel.authType === SshTunnelAuthType.PRIVATE_KEY
? renderPrivateKey()
: renderPassword();
};
const currentTunnel = sshTunnel ?? createEmptySshTunnelConfig();
@@ -96,20 +109,17 @@ export const EditSshTunnelComponent = ({ sshTunnel, hasStoredSecrets, onChange }
return (
<>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]" />
<div className="min-w-[150px]">SSH tunnel</div>
<Checkbox
checked={currentTunnel.isEnabled}
onChange={(e) => updateField('isEnabled', e.target.checked)}
>
<div className="flex items-center">
<span>Connect through an SSH tunnel</span>
<Tooltip
className="cursor-pointer"
title="For a database inside a closed network. Databasus connects to the SSH host below, and that host reaches the database using the host and port above."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<Tooltip
className="cursor-pointer"
title="For a database inside a closed network. Databasus connects to the SSH host below, and that host reaches the database using the host and port above."
>
<InfoCircleOutlined style={{ color: 'gray' }} />
</Tooltip>
</Checkbox>
</div>
@@ -124,14 +134,13 @@ export const EditSshTunnelComponent = ({ sshTunnel, hasStoredSecrets, onChange }
className="max-w-[200px] grow"
placeholder="bastion.example.com"
/>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]" />
<div className="text-xs text-gray-500">
The database host above is resolved by the SSH host, not by Databasus. Use 127.0.0.1
when the database runs on the SSH host itself.
</div>
<Tooltip
className="cursor-pointer"
title="The database host above is resolved by the SSH host, not by Databasus. Use 127.0.0.1 when the database runs on the SSH host itself."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex w-full items-center">
@@ -157,6 +166,20 @@ export const EditSshTunnelComponent = ({ sshTunnel, hasStoredSecrets, onChange }
/>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSH auth</div>
<Select
value={currentTunnel.authType}
onChange={changeAuthType}
options={Object.entries(SSH_TUNNEL_AUTH_TYPE_LABELS).map(([authType, label]) => ({
label,
value: authType as SshTunnelAuthType,
}))}
size="small"
className="max-w-[200px] grow"
/>
</div>
{renderCredentials()}
</>
)}
@@ -1,4 +1,4 @@
import type { SshTunnelConfig } from '../../../../entity/databases';
import { SSH_TUNNEL_AUTH_TYPE_LABELS, type SshTunnelConfig } from '../../../../entity/databases';
interface Props {
sshTunnel: SshTunnelConfig | undefined;
@@ -24,6 +24,11 @@ export const ShowSshTunnelComponent = ({ sshTunnel }: Props) => {
<div>{sshTunnel.username}</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSH auth</div>
<div>{SSH_TUNNEL_AUTH_TYPE_LABELS[sshTunnel.authType]}</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">SSH credentials</div>
<div>*************</div>