FEATURE (ssh): Add SSH tunnel type picker
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
|
||||
+58
@@ -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);
|
||||
});
|
||||
});
|
||||
+17
@@ -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
|
||||
|
||||
+24
-31
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+29
-16
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user