diff --git a/.gitignore b/.gitignore index 951b143..5010f95 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ dblab assets/.DS_Store .DS_Store + +# sshdb pkg testing +pkg/sshdb/testdata/known_hosts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b1fa9a..d0ca7a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,19 +75,61 @@ Run the integration test with make too, but make sure the database containers ar make int-test ``` -This runs the tests. You can check all the options with `help` command. +## SSH Tunnel + +There is an special compose file that spins up an ssh server, to test the ssh tunnel and work with it. The compose file also provides postgres and mysql containers but they are not exposed to the localhost. The sshd server is the intermediary between the client and those containers. + +Run the command below to spin up the ssh server and the databases containers behind it. + +```bash +make up-ssh +``` + +To connect to the databases, the make file provides a new series of targets addding the ssh related parameters: + +```bash +make run-ssh +``` + +The command above, is the equivalent of this command: + +```bash +dblab --host postgres --user postgres --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-pass root +``` + +You can check all the options with `help` command. ```bash Usage: -test Runs the tests -unit-test Runs the tests with the short flag -int-test Runs the integration tests -linter Runs the colangci-lint command -test-all Runs the integration testing bash script with different database docker image versions -docker-build Builds de Docker image -build Builds the Go program -run Runs the application -up Runs all the containers listed in the docker-compose.yml file -down Shut down all the containers listed in the docker-compose.yml file -help Prints this help message + test Runs the tests + unit-test Runs the tests with the short flag + int-test Runs the integration tests + linter Runs the golangci-lint command + test-all Runs the integration testing bash script with different database docker image versions + docker-build Builds de Docker image + build Builds the Go program + run Runs the application + run-ssh Runs the application through a ssh tunnel + run-ssh-key Runs the application through a ssh tunnel using a private key file + run-mysql Runs the application with a connection to mysql + run-mysql-ssh Runs the application through a ssh tunnel + run-mysql-socket Runs the application with a connection to mysql through a socket file. In this example the socke file is located in /var/lib/mysql/mysql.sock. + run-postgres-socket Runs the application with a connection to mysql through a socket file. In this example the socke file is located in /var/lib/mysql/mysql.sock. + run-oracle Runs the application making a connection to the Oracle database + run-sql-server Runs the application making a connection to the SQL Server database + run-mysql-socket-url Runs the application with a connection to mysql through a socket file. In this example the socke file is located in /var/lib/mysql/mysql.sock. + run-sqlite3 Runs the application with a connection to sqlite3 + run-sqlite3-url Runs the application with a connection string to sqlite3 + run-url Runs the app passing the url as parameter + run-url-ssh Runs the application through a ssh tunnel providing the url as parameter + run-mysql-url Runs the app passing the url as parameter + run-mysql-url-ssh Runs the app passing the url as parameter through a ssh tunnel providing the url as parameter + run-config Runs the client using the config file. + up Runs all the containers listed in the docker-compose.yml file + up-ssh Runs all the containers listed in the docker-compose.ssh.yml file to test the ssh tunnel + down Shut down all the containers listed in the docker-compose.yml file + stop-ssh Shut down all the containers listed in the docker-compose.ssh.yml file + form Runs the application with no arguments + create Creates golang-migrate migration files + help Prints this help message ``` diff --git a/Makefile b/Makefile index 268e63c..ff7cb47 100644 --- a/Makefile +++ b/Makefile @@ -41,11 +41,25 @@ build: run: build ./dblab --host localhost --user postgres --db users --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 +.PHONY: run-ssh +## run-ssh: Runs the application through a ssh tunnel +run-ssh: build + ./dblab --host postgres --user postgres --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-pass root + +.PHONY: run-ssh-key +## run-ssh-key: Runs the application through a ssh tunnel using a private key file +run-ssh-key: build + ./dblab --host postgres --user postgres --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-key my_ssh_key + .PHONY: run-mysql ## run-mysql: Runs the application with a connection to mysql run-mysql: build ./dblab --host localhost --user myuser --db mydb --pass 5@klkbN#ABC --ssl enable --port 3306 --driver mysql +.PHONY: run-mysql-ssh +## run-mysql-ssh: Runs the application through a ssh tunnel +run-mysql-ssh: build + ./dblab --host mysql --user myuser --db mydb --pass 5@klkbN#ABC --ssl enable --port 3306 --driver mysql --limit 50 --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-pass root .PHONY: run-mysql-socket ## run-mysql-socket: Runs the application with a connection to mysql through a socket file. In this example the socke file is located in /var/lib/mysql/mysql.sock. @@ -87,10 +101,20 @@ run-sqlite3-url: build run-url: build ./dblab --url postgres://postgres:password@localhost:5432/users?sslmode=disable +.PHONY: run-url-ssh +## run-url-ssh: Runs the application through a ssh tunnel providing the url as parameter +run-url-ssh: build + ./dblab --url postgres://postgres:password@postgres:5432/users?sslmode=disable --schema public --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-pass root + .PHONY: run-mysql-url ## run-mysql-url: Runs the app passing the url as parameter run-mysql-url: build - ./dblab --url "mysql://myuser:5@klkbN#ABC@tcp(localhost:3306)/mydb" + ./dblab --url "mysql://myuser:5@klkbN#ABC@tcp(localhost:3306)/mydb" + +.PHONY: run-mysql-url-ssh +## run-mysql-url-ssh: Runs the app passing the url as parameter through a ssh tunnel providing the url as parameter +run-mysql-url-ssh: build + ./dblab --url "mysql://myuser:5@klkbN#ABC@mysql+tcp(mysql:3306)/mydb" --driver mysql --ssh-host localhost --ssh-port 2222 --ssh-user root --ssh-pass root .PHONY: run-config ## run-config: Runs the client using the config file. @@ -102,11 +126,21 @@ run-config: build up: docker compose up --build -d +.PHONY: up-ssh +## up-ssh: Runs all the containers listed in the docker-compose.ssh.yml file to test the ssh tunnel +up-ssh: + docker compose -f docker-compose.ssh.yml up -d + .PHONY: down ## down: Shut down all the containers listed in the docker-compose.yml file down: docker compose down +.PHONY: stop-ssh +## stop-ssh: Shut down all the containers listed in the docker-compose.ssh.yml file +stop-ssh: + docker compose -f docker-compose.ssh.yml down + .PHONY: form ## form: Runs the application with no arguments form: build diff --git a/README.md b/README.md index 5ff4e9e..396b6a9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ __Interactive client for PostgreSQL, MySQL, SQLite3, Oracle and SQL Server.__ - [Automated installation/update](#automated-installationupdate) - [Help Command](#help) - [Usage](#usage) + - [SSH Tunnel](#ssh-tunnel) - [Configuration](#configuration) - [Navigation](#navigation) - [Key Bindings](#key-bindings) @@ -103,6 +104,12 @@ Flags: --port string Server port --schema string Database schema (postgres only) --socket string Path to a Unix socket file + --ssh-host string SSH Server Hostname/IP + --ssh-key string File with private key for SSH authentication + --ssh-key-pass string Supports connections with protected private keys with passphrase + --ssh-pass string SSH Password (Empty string for no password) + --ssh-port string SSH Port + --ssh-user string SSH User --ssl string SSL mode --ssl-verify string [enable|disable] or [true|false] enable ssl verify for the server --sslcert string This parameter specifies the file name of the client SSL certificate, replacing the default ~/.postgresql/postgresql.crt @@ -170,6 +177,59 @@ Now, it is possible to ensure SSL connections with `PostgreSQL` databases. SSL r dblab --host db-postgresql-nyc3-56456-do-user-foo-0.fake.db.ondigitalocean.com --user myuser --db users --pass password --schema myschema --port 5432 --driver postgres --limit 50 --ssl require --sslrootcert ~/Downloads/foo.crt ``` +### SSH Tunnel + +Now, it's possible to connect to Postgres or MySQL (more to come later) databases on a server via SSH using password or a ssh key files. + +To do so, 6 new flags has been added to the dblab command: + +| Flag | Description | +|----------------------|-------------------------------------------------------------------| +| --ssh-host | SSH Server Hostname/IP | +| --ssh-port | SSH Port | +| --ssh-user | SSH User | +| --ssh-pass | SSH Password (Empty string for no password) | +| --ssh-key | File with private key for SSH authentication | +| --ssh-key-pass | Passphrase for protected private key files | + +#### Examples + +Postgres connection via ssh tunnel using password: + +```{ .sh .copy } +dblab --host localhost --user postgres --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-pass root +``` + +Postgres connection via ssh tunnel using ssh private key file: + +```{ .sh .copy } +dblab --host localhost --user postgres --pass password --schema public --ssl disable --port 5432 --driver postgres --limit 50 --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-key my_ssh_key --ssh-key-pass password +``` + +Postgres connection using the url parameter via ssh tunnel using password: + +```{ .sh .copy } +dblab --url postgres://postgres:password@localhost:5432/users?sslmode=disable --schema public --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-pass root +``` + +MySQL connection via ssh tunnel using password: + +```{ .sh .copy } +dblab --host localhost --user myuser --db mydb --pass 5@klkbN#ABC --ssl enable --port 3306 --driver mysql --limit 50 --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-pass root +``` + +MySQL connection via ssh tunnel using ssh private key file: + +```{ .sh .copy } +dblab --host localhost --user postgres --pass password --ssl enable --port 3306 --driver mysql --limit 50 --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-key my_ssh_key --ssh-key-pass passphrase +``` + +MySQL connection using the url parameter via ssh tunnel using password: + +```{ .sh .copy } +dblab --url "mysql://myuser:5@klkbN#ABC@mysql+tcp(localhost:3306)/mydb" --driver mysql --ssh-host example.com --ssh-port 22 --ssh-user root --ssh-pass root +``` + ### Configuration Enter previous flags every time is tedious, so `dblab` provides a couple of flags to help with it: `--config` and `--cfg-name`. @@ -236,6 +296,18 @@ database: db: "msdb" password: "5@klkbN#ABC" user: "SA" + - name: "ssh-tunnel" + host: "localhost" + port: 5432 + db: "users" + password: "password" + user: "postgres" + schema: "public" + driver: "postgres" + ssh-host: "example.com" + ssh-port: 22 + ssh-user: "ssh-user" + ssh-pass: "password" # should be greater than 0, otherwise the app will error out limit: 50 ``` diff --git a/cmd/root.go b/cmd/root.go index eca44d9..9ec7140 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,13 @@ var ( sslkey string sslpassword string sslrootcert string + // SSH Tunnel. + sshHost string + sshPort string + sshUser string + sshPass string + sshKey string + sshKeyPassphrase string // oracle specific. traceFile string sslVerify string @@ -77,6 +84,12 @@ func NewRootCmd() *cobra.Command { Encrypt: encrypt, TrustServerCertificate: trustServerCertificate, ConnectionTimeout: connectionTimeout, + SSHHost: sshHost, + SSHPort: sshPort, + SSHUser: sshUser, + SSHPass: sshPass, + SSHKeyFile: sshKey, + SSHKeyPassphrase: sshKeyPassphrase, } if form.IsEmpty(opts) { @@ -174,4 +187,16 @@ func init() { StringVarP(&trustServerCertificate, "trust-server-certificate", "", "", "[false|true] server certificate is checked or not") rootCmd.Flags(). StringVarP(&connectionTimeout, "timeout", "", "", "in seconds (default is 0 for no timeout), set to 0 for no timeout. Recommended to set to 0 and use context to manage query and connection timeouts") + rootCmd.Flags().StringVarP(&sshHost, "ssh-host", "", "", "SSH Server Hostname/IP") + rootCmd.Flags().StringVarP(&sshPort, "ssh-port", "", "", "SSH Port") + rootCmd.Flags().StringVarP(&sshUser, "ssh-user", "", "", "SSH User") + rootCmd.Flags(). + StringVarP(&sshPass, "ssh-pass", "", "", "SSH Password (Empty string for no password)") + rootCmd.Flags(). + StringVarP(&sshKey, "ssh-key", "", "", "File with private key for SSH authentication") + rootCmd.Flags(). + StringVarP(&sshKeyPassphrase, "ssh-key-pass", "", "", "Supports connections with protected private keys with passphrase") + + // rootCmd.Flags(). + // StringVarP(&sshKeyAlgo, "ssh-key-algo", "", "", "Publick Key Algorithm") } diff --git a/docker-compose.ssh.yml b/docker-compose.ssh.yml new file mode 100644 index 0000000..0eb7b2b --- /dev/null +++ b/docker-compose.ssh.yml @@ -0,0 +1,75 @@ +services: + ssh: + image: rastasheep/ubuntu-sshd:latest + container_name: test-ssh + ports: + - "2222:22" + environment: + - ROOT_PASSWORD=root + networks: + - private-network + - public-network + + postgres: + image: postgres:15 + container_name: test-postgres + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=users + networks: + - private-network + + mysql: + image: mysql:8.0 + environment: + MYSQL_USER: myuser + MYSQL_PASSWORD: 5@klkbN#ABC + MYSQL_ROOT_PASSWORD: myuser + MYSQL_DATABASE: mydb + networks: + - private-network + + dblab: + build: + context: . + target: builder + volumes: + - ./:/src/app:z + depends_on: + - postgres + environment: + - DB_HOST=postgres + - DB_USER=postgres + - DB_PASSWORD=password + - DB_NAME=users + - DB_PORT=5432 + - DB_DRIVER=postgres + - DB_SCHEMA=public + entrypoint: ["/bin/bash", "./scripts/entrypoint.dev.sh"] + networks: + - private-network + + dblab-mysql: + build: + context: . + target: builder + volumes: + - ./:/src/app:z + depends_on: + - mysql + environment: + - DB_HOST=mysql + - DB_USER=myuser + - DB_PASSWORD=5@klkbN#ABC + - DB_NAME=mydb + - DB_PORT=3306 + - DB_DRIVER=mysql + entrypoint: ["/bin/bash", "./scripts/entrypoint-mysql.dev.sh"] + networks: + - private-network + +networks: + private-network: + internal: true # Makes this network inaccessible from outside Docker + public-network: diff --git a/docker-compose.yml b/docker-compose.yml index db7c1aa..48547f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: postgres: image: postgres:12.1-alpine diff --git a/pkg/app/app.go b/pkg/app/app.go index cc75324..ffabb18 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -3,6 +3,7 @@ package app import ( "github.com/danvergara/dblab/pkg/client" "github.com/danvergara/dblab/pkg/command" + "github.com/danvergara/dblab/pkg/sshdb" "github.com/danvergara/dblab/pkg/tui" ) @@ -14,6 +15,27 @@ type App struct { // New bootstrap a new application. func New(opts command.Options) (*App, error) { + var sshConfig *sshdb.SSHConfig + + if opts.SSHHost != "" { + sshConfig = sshdb.New( + sshdb.WithDBDriver(opts.Driver), + sshdb.WithSShHost(opts.SSHHost), + sshdb.WithSShPort(opts.SSHPort), + sshdb.WithSSHUser(opts.SSHUser), + sshdb.WithPass(opts.SSHPass), + sshdb.WithSSHKeyFile(opts.SSHKeyFile), + sshdb.WithSSHKeyPass(opts.SSHKeyPassphrase), + sshdb.WithDBDURL(opts.URL), + ) + + if err := sshConfig.SSHTunnel(); err != nil { + return nil, err + } + + defer sshConfig.Close() + } + c, err := client.New(opts) if err != nil { return nil, err diff --git a/pkg/client/client.go b/pkg/client/client.go index c37803f..ce6415f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -71,7 +71,7 @@ func New(opts command.Options) (*Client, error) { // This is where an implementation of databaseQuerier is getting picked up. switch c.driver { - case drivers.Postgres, drivers.PostgreSQL: + case drivers.Postgres, drivers.PostgreSQL, drivers.PostgresSSH: c.databaseQuerier = newPostgres(c.schema) case drivers.MySQL: c.databaseQuerier = newMySQL() @@ -87,7 +87,7 @@ func New(opts command.Options) (*Client, error) { if opts.DBName == "" { switch c.driver { - case drivers.PostgreSQL, drivers.Postgres, drivers.MySQL: + case drivers.PostgreSQL, drivers.Postgres, drivers.PostgresSSH, drivers.MySQL: c.showDataCatalog = true dbs, err := c.ShowDatabases() if err != nil { @@ -106,7 +106,7 @@ func New(opts command.Options) (*Client, error) { } switch c.driver { - case drivers.PostgreSQL, drivers.Postgres: + case drivers.PostgreSQL, drivers.Postgres, drivers.PostgresSSH: if _, err = db.Exec(fmt.Sprintf("set search_path='%s'", c.schema)); err != nil { return nil, err } @@ -156,7 +156,7 @@ func (c *Client) Query(q string, args ...interface{}) ([][]string, []string, err if c.activeDatabase != "" { switch c.driver { - case drivers.Postgres, drivers.PostgreSQL, drivers.MySQL: + case drivers.Postgres, drivers.PostgreSQL, drivers.PostgresSSH, drivers.MySQL: db, ok = c.dbs[c.activeDatabase] if !ok { return nil, nil, fmt.Errorf( @@ -393,7 +393,7 @@ func (c *Client) tableContent(tableName string) ([][]string, []string, error) { var query string switch c.driver { - case drivers.Postgres, drivers.PostgreSQL: + case drivers.Postgres, drivers.PostgreSQL, drivers.PostgresSSH: query = fmt.Sprintf( "SELECT * FROM %q LIMIT %d OFFSET %d;", tableName, diff --git a/pkg/command/command.go b/pkg/command/command.go index 9a9d819..c04be6b 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -16,6 +16,13 @@ type Options struct { Limit uint Socket string SSL string + // SSH. + SSHHost string + SSHPort string + SSHUser string + SSHPass string + SSHKeyFile string + SSHKeyPassphrase string // SSL connection params. SSLCert string SSLKey string diff --git a/pkg/config/.dblab.yaml b/pkg/config/.dblab.yaml index 757c553..7323e2a 100644 --- a/pkg/config/.dblab.yaml +++ b/pkg/config/.dblab.yaml @@ -36,4 +36,16 @@ database: db: "msdb" password: "5@klkbN#ABC" user: "SA" + - name: "ssh-tunnel" + host: "localhost" + port: 5432 + db: "users" + password: "password" + user: "postgres" + schema: "public" + driver: "postgres" + ssh-host: "example.com" + ssh-port: 22 + ssh-user: "ssh-user" + ssh-pass: "password" limit: 50 diff --git a/pkg/config/config.go b/pkg/config/config.go index 8974e4d..37fa57a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,6 +40,14 @@ type Database struct { Driver string `validate:"required"` Schema string + // SSH Tunnel. + SSHHost string + SSHPort string + SSHUser string + SSHPass string + SSHKeyFile string + SSHKeyPassphrase string + // SSL connection params. SSL string `default:"disable"` @@ -128,6 +136,12 @@ func Init(configName string) (command.Options, error) { Encrypt: db.Encrypt, TrustServerCertificate: db.TrustServerCertificate, ConnectionTimeout: db.ConnectionTimeout, + SSHHost: db.SSHHost, + SSHPort: db.SSHPort, + SSHUser: db.SSHUser, + SSHPass: db.SSHPass, + SSHKeyFile: db.SSHKeyFile, + SSHKeyPassphrase: db.SSHKeyPassphrase, } return opts, nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1bf1148..df87edb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -26,6 +26,10 @@ func TestInit(t *testing.T) { traceFile string sslVerify string wallet string + sshHost string + sshPort string + sshUser string + sshPass string } var tests = []struct { name string @@ -78,6 +82,25 @@ func TestInit(t *testing.T) { limit: 50, }, }, + { + name: "ssh tunnel", + input: "ssh-tunnel", + want: want{ + host: "localhost", + port: "5432", + dbname: "users", + user: "postgres", + pass: "password", + driver: "postgres", + schema: "public", + ssl: "disable", + sshHost: "example.com", + sshPort: "22", + sshUser: "ssh-user", + sshPass: "password", + limit: 50, + }, + }, { name: "oracle", input: "oracle", diff --git a/pkg/connection/connection.go b/pkg/connection/connection.go index 23aead4..93534a2 100644 --- a/pkg/connection/connection.go +++ b/pkg/connection/connection.go @@ -30,6 +30,7 @@ var ( ErrInvalidMySQLURLFormat = errors.New( "invalid url - valid format: mysql://user:password@tcp(host:port)/db", ) + // ErrInvalidOracleURLFormat is the error used to notify the user that the oracle url is invalid. ErrInvalidOracleURLFormat = errors.New( "invalid url - valid format: oracle://user:pass@server/service_name", ) @@ -57,7 +58,12 @@ func init() { func BuildConnectionFromOpts(opts command.Options) (string, command.Options, error) { if opts.URL != "" { if strings.HasPrefix(opts.URL, drivers.Postgres) { - opts.Driver = drivers.Postgres + if opts.SSHHost != "" { + opts.Driver = drivers.PostgresSSH + } else { + + opts.Driver = drivers.Postgres + } conn, err := formatPostgresURL(opts) @@ -190,8 +196,14 @@ func BuildConnectionFromOpts(opts command.Options) (string, command.Options, err RawQuery: query.Encode(), } + if opts.SSHHost != "" { + opts.Driver = drivers.PostgresSSH + } + return connDB.String(), opts, nil case drivers.MySQL: + var netParam = "tcp" + if opts.Socket != "" { if !validSocketFile(opts.Socket) { return "", opts, ErrInvalidSocketFile @@ -210,10 +222,15 @@ func BuildConnectionFromOpts(opts command.Options) (string, command.Options, err ), opts, nil } + if opts.SSHHost != "" { + netParam = "mysql+tcp" + } + return fmt.Sprintf( - "%s:%s@tcp(%s:%s)/%s", + "%s:%s@%s(%s:%s)/%s", opts.User, opts.Pass, + netParam, opts.Host, opts.Port, opts.DBName, diff --git a/pkg/connection/connection_test.go b/pkg/connection/connection_test.go index e5b0c85..eb5f8b0 100644 --- a/pkg/connection/connection_test.go +++ b/pkg/connection/connection_test.go @@ -91,6 +91,21 @@ func TestBuildConnectionFromOptsFromURL(t *testing.T) { ), }, }, + { + name: "valid postgres localhost via ssh", + given: given{ + opts: command.Options{ + SSHHost: "example.com", + SSHPort: "22", + SSHUser: "ssh-user", + SSHPass: "ssh-pass", + URL: "postgres://user:password@localhost:5432/db?sslmode=disable", + }, + }, + want: want{ + uri: "postgres://user:password@localhost:5432/db?sslmode=disable", + }, + }, { name: "valid postgres localhost", given: given{ @@ -150,6 +165,21 @@ func TestBuildConnectionFromOptsFromURL(t *testing.T) { }, }, // mysql + { + name: "valid mysql localhost via ssh", + given: given{ + opts: command.Options{ + SSHHost: "example.com", + SSHPort: "22", + SSHUser: "ssh-user", + SSHPass: "ssh-pass", + URL: "mysql://user:password@mysql+tcp(localhost:3306)/db", + }, + }, + want: want{ + uri: "user:password@mysql+tcp(localhost:3306)/db", + }, + }, { name: "valid mysql localhost", given: given{ @@ -363,6 +393,27 @@ func TestBuildConnectionFromOptsUserData(t *testing.T) { ), }, }, + + { + name: "success - localhost - postgres - via ssh", + given: given{ + opts: command.Options{ + Driver: drivers.Postgres, + SSHHost: "example.com", + SSHPort: "22", + SSHUser: "ssh-user", + SSHPass: "ssh-pass", + User: "user", + Pass: "password", + Host: "localhost", + Port: "5432", + DBName: "db", + }, + }, + want: want{ + uri: "postgres://user:password@localhost:5432/db?sslmode=disable", + }, + }, { name: "success - localhost with no explicit ssl mode - postgres", given: given{ @@ -451,7 +502,47 @@ func TestBuildConnectionFromOptsUserData(t *testing.T) { uri: "postgres://user:password@your-amazonaws-uri.com:5432/db", }, }, + { + name: "success - postgres - via ssh", + given: given{ + opts: command.Options{ + Driver: drivers.Postgres, + SSHHost: "example.com", + SSHPort: "22", + SSHUser: "ssh-user", + SSHPass: "ssh-pass", + User: "user", + Pass: "password", + Host: "localhost", + Port: "5432", + DBName: "db", + }, + }, + want: want{ + uri: "postgres://user:password@localhost:5432/db?sslmode=disable", + }, + }, // mysql + { + name: "success - localhost - mysql - via ssh", + given: given{ + opts: command.Options{ + Driver: drivers.MySQL, + SSHHost: "example.com", + SSHPort: "22", + SSHUser: "ssh-user", + SSHPass: "ssh-pass", + User: "user", + Pass: "password", + Host: "localhost", + Port: "3306", + DBName: "db", + }, + }, + want: want{ + uri: "user:password@mysql+tcp(localhost:3306)/db", + }, + }, { name: "success - localhost - mysql", given: given{ @@ -742,6 +833,17 @@ func TestFormatMySQLURL(t *testing.T) { given given want want }{ + { + name: "valid mysql localhost", + given: given{ + opts: command.Options{ + URL: "mysql://user:password@mysql+tcp(localhost:3306)/db", + }, + }, + want: want{ + uri: "user:password@mysql+tcp(localhost:3306)/db", + }, + }, { name: "valid mysql localhost", given: given{ diff --git a/pkg/drivers/drivers.go b/pkg/drivers/drivers.go index 811d3a9..0568516 100644 --- a/pkg/drivers/drivers.go +++ b/pkg/drivers/drivers.go @@ -1,10 +1,11 @@ package drivers const ( - Postgres string = "postgres" - PostgreSQL string = "postgresql" - MySQL string = "mysql" - SQLite string = "sqlite" - Oracle string = "oracle" - SQLServer string = "sqlserver" + Postgres string = "postgres" + PostgresSSH string = "postgres+ssh" + PostgreSQL string = "postgresql" + MySQL string = "mysql" + SQLite string = "sqlite" + Oracle string = "oracle" + SQLServer string = "sqlserver" ) diff --git a/pkg/sshdb/sshdb.go b/pkg/sshdb/sshdb.go new file mode 100644 index 0000000..5081417 --- /dev/null +++ b/pkg/sshdb/sshdb.go @@ -0,0 +1,322 @@ +package sshdb + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + "log" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/lib/pq" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + "github.com/danvergara/dblab/pkg/drivers" +) + +// default path to the known_hosts file. +var defaultKnownHostsPath = filepath.Join(os.Getenv("HOME"), ".ssh") + +// createKnownHosts function creates known_hosts if does not exist. +// It uses the os package which has an OpenFile function, this function accepts 3 arguments: +// 1. the file path +// 2. the flag (e.g. os.O_CREATE|os.O_APPEND creates the file if not exists, if exists, appends to the file) +// 3. the last argument is the permission. +func createKnownHosts(knownHostsPath string) (err error) { + f, err := os.OpenFile( + filepath.Join(knownHostsPath, "known_hosts"), + os.O_CREATE, + 0600, + ) + defer func() { + err = errors.Join(err, f.Close()) + }() + + if err != nil { + return err + } + + return nil +} + +// checkKnownHosts fucntion creates a know_hosts callback function with the New function. +// This callback function can be used to check if the host exists in the known_hosts file. +func checkKnownHosts(knownHostsPath string) (ssh.HostKeyCallback, error) { + if knownHostsPath == "" { + knownHostsPath = defaultKnownHostsPath + } + + if err := createKnownHosts(knownHostsPath); err != nil { + return nil, err + } + + kh, err := knownhosts.New(filepath.Join(knownHostsPath, "known_hosts")) + if err != nil { + return nil, err + } + + return kh, nil +} + +// keyString create human-readable SSH-key strings. +func keyString(k ssh.PublicKey) string { + return k.Type() + " " + base64.StdEncoding.EncodeToString( + k.Marshal(), + ) // e.g. "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY...." +} + +// addHostKey adds the host key to known_hosts file by using Normalize and Line functions of knownhosts package. +// This functions implements the ssh.HostKeyCallback type wiich is a function type which signature goes like this: +// type HostKeyCallback func(hostname string, remote net.Addr, key PublicKey) error. +func addHostKey(_ string, remote net.Addr, pubKey ssh.PublicKey, knownHostsPath string) error { + if knownHostsPath == "" { + knownHostsPath = defaultKnownHostsPath + } + + khFilePath := filepath.Join(knownHostsPath, "known_hosts") + + f, err := os.OpenFile(khFilePath, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + + knownHosts := knownhosts.Normalize(remote.String()) + _, err = f.WriteString(knownhosts.Line([]string{knownHosts}, pubKey)) + return err +} + +// PostgresViaSSHDialer implements the driver.Driver interface to register the connection to the database via the ssh tunnel. +type PostgresViaSSHDialer struct { + client *ssh.Client +} + +func (self *PostgresViaSSHDialer) Open(s string) (_ driver.Conn, err error) { + return pq.DialOpen(self, s) +} + +func (self *PostgresViaSSHDialer) Dial(network, address string) (net.Conn, error) { + return self.client.Dial(network, address) +} + +func (self *PostgresViaSSHDialer) DialTimeout( + network, address string, + timeout time.Duration, +) (net.Conn, error) { + return self.client.Dial(network, address) +} + +// MySQLViaSSHDialer used to register the database connection via the ssh tunnel. +type MySQLViaSSHDialer struct { + client *ssh.Client +} + +func (m *MySQLViaSSHDialer) Dial(addr string) (net.Conn, error) { + return m.client.Dial("tcp", addr) +} + +// SSHConfig struct setup the ssh tunnel to connect with a given database. +type SSHConfig struct { + sshUser string + sshPass string + sshKeyFile string + sshKeyPass string + sshHost string + sshPort string + sshClient *ssh.Client + dbDriver string + dbURL string + knownHostsPath string +} + +type Option func(*SSHConfig) + +func New(opts ...Option) *SSHConfig { + c := &SSHConfig{} + + for _, o := range opts { + o(c) + } + + return c +} + +func WithSSHUser(sshUser string) Option { + return func(c *SSHConfig) { + c.sshUser = sshUser + } +} + +func WithPass(sshPass string) Option { + return func(c *SSHConfig) { + c.sshPass = sshPass + } +} + +func WithSSHKeyFile(sshKeyFile string) Option { + return func(c *SSHConfig) { + c.sshKeyFile = sshKeyFile + } +} + +func WithSSHKeyPass(sshKeyPass string) Option { + return func(c *SSHConfig) { + c.sshKeyPass = sshKeyPass + } +} + +func WithSShHost(sshHost string) Option { + return func(c *SSHConfig) { + c.sshHost = sshHost + } +} + +func WithSShPort(sshPort string) Option { + return func(c *SSHConfig) { + c.sshPort = sshPort + } +} + +func WithDBDriver(driver string) Option { + return func(c *SSHConfig) { + c.dbDriver = driver + } +} + +func WithDBDURL(url string) Option { + return func(c *SSHConfig) { + c.dbURL = url + } +} + +func WithKnownHostsPath(knownHostsPath string) Option { + return func(c *SSHConfig) { + c.knownHostsPath = knownHostsPath + } +} + +// SSHTunnel method sets up the ssh tunnel and does a number of things: +// Create a ssh client config object that witht he user. +// Define a HostKeyCallback to ensures known ssh server is the actual server. +// If host key checking is ignore then any server that has the same FQDN or IP address can impersonate the actual ssh server. +// Define the authentication method to perform the ssh tunnel (passsword or private key). +// Register the ViaSSHDialer with the ssh connection as a parameter. +func (c *SSHConfig) SSHTunnel() error { + // Reference: https://github.com/melbahja/goph/blob/master/client.go + // Reference: https://github.com/melbahja/goph/blob/master/hosts.go + // Study the client.go and hosts.go to understand how to write host key call back. + var ( + keyErr *knownhosts.KeyError + signer ssh.Signer + parseKeyErr error + ) + config := &ssh.ClientConfig{ + User: c.sshUser, + HostKeyCallback: ssh.HostKeyCallback( + func(host string, remote net.Addr, pubKey ssh.PublicKey) error { + kh, err := checkKnownHosts(c.knownHostsPath) + if err != nil { + return err + } + + hErr := kh(host, remote, pubKey) + if errors.As(hErr, &keyErr) && len(keyErr.Want) > 0 { + // Reference: https://www.godoc.org/golang.org/x/crypto/ssh/knownhosts#KeyError + // if keyErr.Want slice is empty then host is unknown, if keyErr.Want is not empty + // and if host is known then there is key mismatch the connection is then rejected. + log.Printf( + "%v is not a key of %s, either a MiTM attack or %s has reconfigured the host pub key.", + keyString(pubKey), + host, + host, + ) + return keyErr + } else if errors.As(hErr, &keyErr) && len(keyErr.Want) == 0 { + // host key not found in known_hosts then give a warning and continue to connect. + log.Printf("%s is not trusted, adding this key: %q to known_hosts file.", host, keyString(pubKey)) + return addHostKey(host, remote, pubKey, c.knownHostsPath) + } + + log.Printf("pubkey exists for %s.", host) + return nil + }, + ), + } + + if c.sshPass != "" { + config.Auth = []ssh.AuthMethod{ssh.Password(c.sshPass)} + } else if c.sshKeyFile != "" { + // Load the private key for SSH authentication. + key, err := os.ReadFile(c.sshKeyFile) + if err != nil { + return fmt.Errorf("error reading private key: %w", err) + } + + // Parse the private using a passphrase if required. + if c.sshKeyPass != "" { + signer, parseKeyErr = ssh.ParsePrivateKeyWithPassphrase(key, []byte(c.sshKeyPass)) + } else { + signer, parseKeyErr = ssh.ParsePrivateKey(key) + } + if parseKeyErr != nil { + return fmt.Errorf("error parsing private key: %w", parseKeyErr) + } + + config.Auth = []ssh.AuthMethod{ + ssh.PublicKeys(signer), + } + } + + client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", c.sshHost, c.sshPort), config) + if err != nil { + return fmt.Errorf("failed to connect to the ssh server: %w", err) + } + + c.sshClient = client + + switch c.dbDriver { + case drivers.PostgreSQL, drivers.Postgres: + sql.Register("postgres+ssh", &PostgresViaSSHDialer{c.sshClient}) + case drivers.MySQL: + mysql.RegisterDialContext( + "mysql+tcp", + func(_ context.Context, addr string) (net.Conn, error) { + dialer := &MySQLViaSSHDialer{c.sshClient} + return dialer.Dial(addr) + }, + ) + } + + if c.dbURL != "" { + switch { + case strings.Contains(c.dbURL, drivers.Postgres): + fallthrough + case strings.Contains(c.dbURL, drivers.PostgreSQL): + sql.Register("postgres+ssh", &PostgresViaSSHDialer{c.sshClient}) + case strings.Contains(c.dbURL, drivers.MySQL): + mysql.RegisterDialContext( + "mysql+tcp", + func(_ context.Context, addr string) (net.Conn, error) { + dialer := &MySQLViaSSHDialer{c.sshClient} + return dialer.Dial(addr) + }, + ) + } + + } + + return nil +} + +// Close method closes the tcp connection. +func (c *SSHConfig) Close() error { + return c.sshClient.Close() +} diff --git a/pkg/sshdb/sshdb_test.go b/pkg/sshdb/sshdb_test.go new file mode 100644 index 0000000..7f92c37 --- /dev/null +++ b/pkg/sshdb/sshdb_test.go @@ -0,0 +1,98 @@ +package sshdb_test + +import ( + "fmt" + "log" + "net" + "os" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/danvergara/dblab/pkg/drivers" + "github.com/danvergara/dblab/pkg/sshdb" +) + +func mockSSHServer(t *testing.T, privateKeyPath, authorizedKeyPath string) (net.Listener, error) { + t.Helper() + + // Load the server's private key + privateBytes, err := os.ReadFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read private key: %w", err) + } + privateKey, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + // Load the client's public key + authorizedKeyBytes, err := os.ReadFile(authorizedKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read authorized keys: %w", err) + } + authorizedKey, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKeyBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse authorized key: %w", err) + } + + // Configure the server to require the correct public key for authentication + config := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) == string(authorizedKey.Marshal()) { + return nil, nil // Authentication successful + } + return nil, fmt.Errorf("unauthorized key") + }, + } + config.AddHostKey(privateKey) + + // Start the server + listener, err := net.Listen("tcp", "127.0.0.1:0") // Bind to a random port + if err != nil { + return nil, fmt.Errorf("failed to start listener: %w", err) + } + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + continue + } + go func() { + _, _, _, err := ssh.NewServerConn(conn, config) + if err != nil { + log.Printf("failed to establish server connection: %v", err) + } + conn.Close() + }() + } + }() + + return listener, nil +} + +func TestSSHKeyFileAuthentication(t *testing.T) { + privateKeyPath := "testdata/test_host_key" + authorizedKeyPath := "testdata/test_client_key.pub" + + // Start the mock server + listener, err := mockSSHServer(t, privateKeyPath, authorizedKeyPath) + if err != nil { + t.Fatalf("Failed to start mock SSH server: %v", err) + } + defer listener.Close() + host, port, _ := net.SplitHostPort(listener.Addr().String()) + + sc := sshdb.New( + sshdb.WithDBDriver(drivers.Postgres), + sshdb.WithSShHost(host), + sshdb.WithSShPort(port), + sshdb.WithSSHUser("testuser"), + sshdb.WithSSHKeyFile("testdata/test_client_key"), + sshdb.WithKnownHostsPath("testdata"), + ) + if err := sc.SSHTunnel(); err != nil { + t.Fatalf("failed to connect to the ssh server %v", err) + } +} diff --git a/pkg/sshdb/testdata/test_client_key b/pkg/sshdb/testdata/test_client_key new file mode 100644 index 0000000..6426451 --- /dev/null +++ b/pkg/sshdb/testdata/test_client_key @@ -0,0 +1,28 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAtEKZcr3wIjag7tjR9oPWezVq3V+YL5OfG7zdwydmsG8dvwpDpT/8 +SFo9zxDvjLDJhlbxDJf7vjVQNLIUg+ZCqirHc+M0bE153ze1tcxyHGDOAgxDSbhHFM8TaO +CQgNAZauM2M8IHANXDWbmb5VJCoJek/sclayi0wQdK8QQi9+gtXHodKnOsOKxJDOibxA2N +jsAxw5eDDL84ET8CsffBkC/hd4h9DEh70LPerXxvnl32c1HG//8ykbeBS+CI/MAq0wCWvX +Niqeldrb/To1rEpAvI6nIrB7d79Mt2I8QGFDxfR6cxpS5a0QiM9Hl0GJ1fFETfnemUjfCh +uzd4HXzZvwAABADHyNK5x8jSuQAAAAdzc2gtcnNhAAABAQC0QplyvfAiNqDu2NH2g9Z7NW +rdX5gvk58bvN3DJ2awbx2/CkOlP/xIWj3PEO+MsMmGVvEMl/u+NVA0shSD5kKqKsdz4zRs +TXnfN7W1zHIcYM4CDENJuEcUzxNo4JCA0Blq4zYzwgcA1cNZuZvlUkKgl6T+xyVrKLTBB0 +rxBCL36C1ceh0qc6w4rEkM6JvEDY2OwDHDl4MMvzgRPwKx98GQL+F3iH0MSHvQs96tfG+e +XfZzUcb//zKRt4FL4Ij8wCrTAJa9c2Kp6V2tv9OjWsSkC8jqcisHt3v0y3YjxAYUPF9Hpz +GlLlrRCIz0eXQYnV8URN+d6ZSN8KG7N3gdfNm/AAAAAwEAAQAAAQBIwPST9gs8k9XicMpV +d6KSed3W2WVgFnHKTTEoOffdUAuudmMVCD03qox1zX0RyKydtut1TMZDX9suWY2kKsRPUB +LOOC6JY7/DkwWZCZooz/11oCNsVp8BzA4mbzSDePo5RNk0jKQs9xnwVdSQ+uF/VZU3a4Mz +u+swWVQq8KN4cKJB7kyG8OKML9IKQmQWv/2DpXYc7B89SbrGo++1hmWCKlDI7C2raWoqIu +X9x2gAABeCB94236tUsj9oRvtSWXpb/mSJsfGgWa9S1GZcClIh0zo7eDXrZhIVaE319MzV +UwTxqMkJ64d2wFzfS/UBTyrW/fddH89Hy4G4nyfN834JAAAAgCxFhINkJAvrxTgeSSo02Z +4PsXDzNgimopGLmwUYc81UY8HwjZbv2hlFvSKSdw0NyrJXWYPPx4YaEubXw/SHaGr1tuvb +1curMrnXFkYOMEYk5cecyIL0w04CLnki4xNrWdJc9bUUfvi8KMMGWrVVhw77+XrtNUhtDs +H4+oJB1mW1AAAAgQD93gv4Iae+UdP4uakFnuyWNU7eo6zeBXO5M2QsixPR8vuParUckOzy +l/KEOFfxhbL65KAgBWmCiIA6BFEidVZM6rJLhbXAAu0QNHBZTb+CUkvjqZSM4TUC6L6B9p +eCIODriUFQa86NcV73O3nFtFpIL35Yfk58JU9DXMGe1rfPvQAAAIEAtcZBy1zjGqJPP3SH +HYEF8SE4yVxEnL/mCaFPclhYZ2z4NZCMHqUAmmK4PDTYjxjeyRf8ffIKOMRg4mNcsLM1rL +pCTpW9vRweYgt+Z+6GutJ1HGcyca67QCdPf4CaWLT2fFxKvDKpZGGNJ1rcHs1dz0hWeCZ5 +ydqfg9yC6QgZmSsAAABIZGFudmVyZ2FyYUAyODA2LTEwYTYtMDAyNC05ZWJjLTM2YzUtOT +RhYi04MzFhLTY2MmEuaXB2Ni5pbmZpbml0dW0ubmV0Lm14AQID +-----END OPENSSH PRIVATE KEY----- diff --git a/pkg/sshdb/testdata/test_client_key.pub b/pkg/sshdb/testdata/test_client_key.pub new file mode 100644 index 0000000..6a59816 --- /dev/null +++ b/pkg/sshdb/testdata/test_client_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0QplyvfAiNqDu2NH2g9Z7NWrdX5gvk58bvN3DJ2awbx2/CkOlP/xIWj3PEO+MsMmGVvEMl/u+NVA0shSD5kKqKsdz4zRsTXnfN7W1zHIcYM4CDENJuEcUzxNo4JCA0Blq4zYzwgcA1cNZuZvlUkKgl6T+xyVrKLTBB0rxBCL36C1ceh0qc6w4rEkM6JvEDY2OwDHDl4MMvzgRPwKx98GQL+F3iH0MSHvQs96tfG+eXfZzUcb//zKRt4FL4Ij8wCrTAJa9c2Kp6V2tv9OjWsSkC8jqcisHt3v0y3YjxAYUPF9HpzGlLlrRCIz0eXQYnV8URN+d6ZSN8KG7N3gdfNm/ danvergara@2806-10a6-0024-9ebc-36c5-94ab-831a-662a.ipv6.infinitum.net.mx diff --git a/pkg/sshdb/testdata/test_host_key b/pkg/sshdb/testdata/test_host_key new file mode 100644 index 0000000..414ca39 --- /dev/null +++ b/pkg/sshdb/testdata/test_host_key @@ -0,0 +1,28 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAzo73lUonQ/PKhRtkRsqHLKe7zD6mYDXcF4/kHSBs1kQwHB/G+t8P +QuNag8H5XtKjXs7Q+VlveabCqPN7AbUeFr7UO9/0po0wpem/aSiqI/FhC+K/cIr8zXAbqp +W/hHZEJi753+pAd5ryscjNl3mORTJOg70ZJKTmW8y2CVpTjQCiXLxtl5qRNpx/8Ckn0JsO +Qr97V5EagWKZCYQgbxQG0mIOkteWPMLaMkXk0TpO10pjelAxz/TBdNPGYJq9fSS+cCgi2V +v5TLSUZGGpk4zFMLwGn9CqMmr9/bNy0SLvt0i2pztsEtN9mi/qEWD90LBPL4DNF04BS+8E +V5Ci+Gq3FwAABABMW9/ATFvfwAAAAAdzc2gtcnNhAAABAQDOjveVSidD88qFG2RGyocsp7 +vMPqZgNdwXj+QdIGzWRDAcH8b63w9C41qDwfle0qNeztD5WW95psKo83sBtR4WvtQ73/Sm +jTCl6b9pKKoj8WEL4r9wivzNcBuqlb+EdkQmLvnf6kB3mvKxyM2XeY5FMk6DvRkkpOZbzL +YJWlONAKJcvG2XmpE2nH/wKSfQmw5Cv3tXkRqBYpkJhCBvFAbSYg6S15Y8wtoyReTROk7X +SmN6UDHP9MF008Zgmr19JL5wKCLZW/lMtJRkYamTjMUwvAaf0Koyav39s3LRIu+3SLanO2 +wS032aL+oRYP3QsE8vgM0XTgFL7wRXkKL4arcXAAAAAwEAAQAAAQA/X973h3pXn3dt8nMI +Q0BJA6ebaUdrsmq2Mfg3tYifDunB30ASHZkVmSLe1QdZQABO6N51+qo4pWEJLDb71aGHMg +J04mgyJ5Sa+wY20fqtr3PqjSXWdlZNA84BPxO1JQIQww34VOt1pu06fdUSWgG8Gky7n6uU +siFZXgwl/3guBMwZR4RVpki81fp+gpnO5ane+uTrF+hWS8XwVY5r+eDiLfQGCn53JElayq +A0mB5N4lQ+++i6/AVAnZzpdOtC36OqN1fiM+pG5GgnGsBu2uy+kvmpgw6fTf6tebH/PPG8 +rU+OzboUOj4LOeg8ixDPc5F2H/XbhJ/YPugS0IBvy1qZAAAAgCeL/Ke2i8t4u+5YTGjTG0 +Zz6RUAmQj2rexVUIVw73MLfMORwkaqTNjpp5MEe33VW97srDcRALQpOEkYtbOG94Uq7UBW +2Hkz+Lm2bTyed51om2OSw6s+T2OPGR2wqAwCMyx0Rze2elj923hMtk1PFkYCtvMH+TS7+T +5amOUvTlukAAAAgQDuVv0cA5e87VqSkt1J51+yefuGnaB/9hwzrZndDxdsfSkWQdaYKN9U +8NsaVjk/4Uhu9TBJj1EJFfq/FvmcnyDlbZA/muBxTInKSuSl3ONKusC/CRnreowevuqI0U +abUnMQObjlSxGrxc7WJJrRrjKVKTv4ITm8FGqowpIcE0TRrwAAAIEA3d0gNFaHX2cIro9M +YoEGO48OZhNcu9jkPsszqEw0VKo++6o2uDlmLAM65RLtAex+1mMDyW5EKrEe8YqMwU6GcP +aWW84Y4YZukl3ye1nH2sgUCh7x+HUpfFJEB1cXfLnXt0uvUVCNwB0xEUvk6IRkXdHsjSlu +pAsRYzsaIDLR0xkAAABIZGFudmVyZ2FyYUAyODA2LTEwYTYtMDAyNC05ZWJjLTM2YzUtOT +RhYi04MzFhLTY2MmEuaXB2Ni5pbmZpbml0dW0ubmV0Lm14AQID +-----END OPENSSH PRIVATE KEY----- diff --git a/pkg/sshdb/testdata/test_host_key.pub b/pkg/sshdb/testdata/test_host_key.pub new file mode 100644 index 0000000..04eb814 --- /dev/null +++ b/pkg/sshdb/testdata/test_host_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDOjveVSidD88qFG2RGyocsp7vMPqZgNdwXj+QdIGzWRDAcH8b63w9C41qDwfle0qNeztD5WW95psKo83sBtR4WvtQ73/SmjTCl6b9pKKoj8WEL4r9wivzNcBuqlb+EdkQmLvnf6kB3mvKxyM2XeY5FMk6DvRkkpOZbzLYJWlONAKJcvG2XmpE2nH/wKSfQmw5Cv3tXkRqBYpkJhCBvFAbSYg6S15Y8wtoyReTROk7XSmN6UDHP9MF008Zgmr19JL5wKCLZW/lMtJRkYamTjMUwvAaf0Koyav39s3LRIu+3SLanO2wS032aL+oRYP3QsE8vgM0XTgFL7wRXkKL4arcX danvergara@2806-10a6-0024-9ebc-36c5-94ab-831a-662a.ipv6.infinitum.net.mx