diff --git a/backend/pkg/mongoconnect/connector.go b/backend/pkg/mongoconnect/connector.go index 6b25670371..3d461ea801 100644 --- a/backend/pkg/mongoconnect/connector.go +++ b/backend/pkg/mongoconnect/connector.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "sync" mgmtv1alpha1 "github.com/nucleuscloud/neosync/backend/gen/go/protos/mgmt/v1alpha1" @@ -34,16 +35,15 @@ type WrappedMongoClient struct { client *mongo.Client clientMu sync.Mutex - details *connstring.ConnString - // tunnel *sshtunnel.Sshtunnel + details *ConnectionDetails - // logger *slog.Logger + logger *slog.Logger } var _ DbContainer = &WrappedMongoClient{} -func newWrappedMongoClient(details *connstring.ConnString) *WrappedMongoClient { - return &WrappedMongoClient{details: details} +func newWrappedMongoClient(details *ConnectionDetails, logger *slog.Logger) *WrappedMongoClient { + return &WrappedMongoClient{details: details, logger: logger} } func (w *WrappedMongoClient) Open(ctx context.Context) (*mongo.Client, error) { @@ -52,12 +52,21 @@ func (w *WrappedMongoClient) Open(ctx context.Context) (*mongo.Client, error) { if w.client != nil { return w.client, nil } - // todo: tunneling + + if w.details.Tunnel != nil { + ready, err := w.details.Tunnel.Start(w.logger) + if err != nil { + return nil, err + } + <-ready + w.logger.Info("tunnel is now ready", "isopen", w.details.Tunnel.IsOpen()) + } serverAPI := options.ServerAPI(options.ServerAPIVersion1) + w.logger.Info("connecting to mongo instance", "url", w.details.String()) opts := options.Client().ApplyURI(w.details.String()).SetServerAPIOptions(serverAPI) client, err := mongo.Connect(ctx, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to connect to mongo instance: %w", err) } w.client = client return client, nil @@ -71,7 +80,12 @@ func (w *WrappedMongoClient) Close(ctx context.Context) error { } client := w.client w.client = nil - return client.Disconnect(ctx) + err := client.Disconnect(ctx) + if w.details.Tunnel != nil && w.details.Tunnel.IsOpen() { + w.logger.Debug("closing tunnel...") + w.details.Tunnel.Close() + } + return err } var _ Interface = &Connector{} @@ -94,7 +108,7 @@ func (c *Connector) NewFromConnectionConfig( if err != nil { return nil, err } - wrappedclient := newWrappedMongoClient(details.Details) + wrappedclient := newWrappedMongoClient(details, logger) return wrappedclient, nil } @@ -107,7 +121,16 @@ func (c *ConnectionDetails) GetTunnel() *sshtunnel.Sshtunnel { return c.Tunnel } func (c *ConnectionDetails) String() string { - // todo: add tunnel support + if c.Tunnel != nil && c.Tunnel.IsOpen() { + localhost, port := c.Tunnel.GetLocalHostPort() + parseUrl, err := url.Parse(c.Details.String()) + if err != nil { + return "" // todo + } + parseUrl.Host = fmt.Sprintf("%s:%d", localhost, port) + parseUrl.Scheme = "mongodb" + return parseUrl.String() + } return c.Details.String() } @@ -119,7 +142,6 @@ func GetConnectionDetails( if cc == nil { return nil, errors.New("cc was nil, expected *mgmtv1alpha1.ConnectionConfig") } - mongoConfig := cc.GetMongoConfig() if mongoConfig == nil { return nil, fmt.Errorf("mongo config was nil, expected ConnectionConfig to contain valid MongoConfig") @@ -142,13 +164,16 @@ func GetConnectionDetails( }, nil } - var destination *sshtunnel.Endpoint // todo + destination, err := getEndpointFromMongoConnectionConfig(mongoConfig) + if err != nil { + return nil, err + } authmethod, err := sshtunnel.GetTunnelAuthMethodFromSshConfig(tunnelCfg.GetAuthentication()) if err != nil { return nil, err } var publickey ssh.PublicKey - if tunnelCfg.GetKnownHostPublicKey() == "" { + if tunnelCfg.GetKnownHostPublicKey() != "" { publickey, err = sshtunnel.ParseSshKey(tunnelCfg.GetKnownHostPublicKey()) if err != nil { return nil, err @@ -166,7 +191,6 @@ func GetConnectionDetails( if err != nil { return nil, err } - _ = connDetails return &ConnectionDetails{ Tunnel: tunnel, @@ -181,3 +205,11 @@ func getGeneralDbConnectConfigFromMongo(config *mgmtv1alpha1.MongoConnectionConf } return connstring.ParseAndValidate(dburl) } + +func getEndpointFromMongoConnectionConfig(config *mgmtv1alpha1.MongoConnectionConfig) (*sshtunnel.Endpoint, error) { + details, err := getGeneralDbConnectConfigFromMongo(config) + if err != nil { + return nil, err + } + return sshtunnel.NewEndpointWithUser(details.Hosts[0], -1, details.Username), nil +} diff --git a/backend/pkg/sshtunnel/endpoint.go b/backend/pkg/sshtunnel/endpoint.go index a9c6b7ded9..f58c9eb29b 100644 --- a/backend/pkg/sshtunnel/endpoint.go +++ b/backend/pkg/sshtunnel/endpoint.go @@ -28,5 +28,8 @@ func NewEndpoint(host string, port int) *Endpoint { // Returns the stringified endpoint sans user func (endpoint *Endpoint) String() string { + if endpoint.Port < 0 { + return endpoint.Host + } return fmt.Sprintf("%s:%d", endpoint.Host, endpoint.Port) } diff --git a/backend/pkg/sshtunnel/tunnel.go b/backend/pkg/sshtunnel/tunnel.go index 6788a39537..713625a8cd 100644 --- a/backend/pkg/sshtunnel/tunnel.go +++ b/backend/pkg/sshtunnel/tunnel.go @@ -62,6 +62,10 @@ func New( } } +func (t *Sshtunnel) IsOpen() bool { + return t.isOpen +} + // After a tunnel has started, this will return the auto-generated port (if 0 was passed in) func (t *Sshtunnel) GetLocalHostPort() (host string, port int) { return t.local.Host, t.local.Port @@ -108,6 +112,7 @@ func (t *Sshtunnel) serve(listener net.Listener, ready chan<- any, logger *slog. t.isOpen = false go func() { t.shutdowns.Range(func(key, value any) bool { + logger.Debug("shutting down tunnel session", "key", key) sd, ok := value.(chan any) if ok { sd <- struct{}{} @@ -208,7 +213,7 @@ func (s *Sshtunnel) getSshClient( if err != nil { return nil, err } - logger.Debug(fmt.Sprintf("conntected to %s", addr)) + logger.Debug(fmt.Sprintf("[ssh-client] conntected to %s", addr)) s.sshclient = client return client, nil } diff --git a/backend/pkg/sshtunnel/utils.go b/backend/pkg/sshtunnel/utils.go index 94ccca6583..c2ff51f47f 100644 --- a/backend/pkg/sshtunnel/utils.go +++ b/backend/pkg/sshtunnel/utils.go @@ -32,7 +32,7 @@ func getPlaintextPrivateKeyAuthMethod(keyBytes []byte) (ssh.AuthMethod, error) { func ParseSshKey(keyString string) (ssh.PublicKey, error) { // Parse the key - publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) //nolint + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) //nolint:dogsled if err != nil { return nil, fmt.Errorf("failed to parse public key: %v", err) } diff --git a/frontend/apps/web/app/(mgmt)/[account]/connections/[id]/components/MongoDbForm.tsx b/frontend/apps/web/app/(mgmt)/[account]/connections/[id]/components/MongoDbForm.tsx index c445a420c1..22ac916572 100644 --- a/frontend/apps/web/app/(mgmt)/[account]/connections/[id]/components/MongoDbForm.tsx +++ b/frontend/apps/web/app/(mgmt)/[account]/connections/[id]/components/MongoDbForm.tsx @@ -4,6 +4,12 @@ import Spinner from '@/components/Spinner'; import RequiredLabel from '@/components/labels/RequiredLabel'; import PermissionsDialog from '@/components/permissions/PermissionsDialog'; import { useAccount } from '@/components/providers/account-provider'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { @@ -16,7 +22,11 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { MongoDbFormValues } from '@/yup-validations/connections'; +import { Textarea } from '@/components/ui/textarea'; +import { + EditMongoDbFormContext, + MongoDbFormValues, +} from '@/yup-validations/connections'; import { yupResolver } from '@hookform/resolvers/yup'; import { CheckConnectionConfigResponse, @@ -38,7 +48,7 @@ export default function MongoDbForm(props: Props): ReactElement { const { connectionId, defaultValues, onSaved, onSaveFailed } = props; const { account } = useAccount(); - const form = useForm({ + const form = useForm({ resolver: yupResolver(MongoDbFormValues), mode: 'onChange', values: defaultValues, @@ -137,6 +147,133 @@ export default function MongoDbForm(props: Props): ReactElement { )} /> + + + Bastion Host Configuration + +
+ This section is optional and only necessary if your database is + not publicly accessible to the internet. +
+ ( + + Host + + The hostname of the bastion server that will be used for + SSH tunneling. + + + + + + + )} + /> + ( + + Port + + The port of the bastion host. Typically this is port 22. + + + { + field.onChange(e.target.valueAsNumber); + }} + /> + + + + )} + /> + ( + + User + + The name of the user that will be used to authenticate. + + + + + + + )} + /> + ( + + Private Key + + The private key that will be used to authenticate against + the SSH server. If using passphrase auth, provide that in + the appropriate field below. + + +