├── README.md └── autoload └── db └── adapter └── ssh.vim /README.md: -------------------------------------------------------------------------------- 1 | # vim-dadbod-ssh 2 | 3 | [![Project Status: Active - The project has reached a stable, usable state and is being actively developed.](http://www.repostatus.org/badges/latest/active.svg)](http://www.repostatus.org/#active) 4 | 5 | **NeoVim** plugin that allows [vim-dadbod](https://github.com/tpope/vim-dadbod) 6 | connections to remote servers through ssh. 7 | 8 | It's actually a wrapper for existing adapters. It creates ssh tunnel using 9 | `ssh -L ...` and then passing changed connection url to proper adapter. 10 | 11 | It was tested with `mysql`, I'm not sure how well it works with other 12 | connections, if you are using it with other db please let me know. 13 | 14 | 15 | ## Requirements 16 | 17 | - **NeoVim** - Plugin is using nvim's `jobstart()` API to create and keep 18 | tunnel, I'm sure it can be done for Vim 8+ as well. I would be grateful 19 | for PR :heart: 20 | - **Linux** or **MacOS** - adapter is using `ssh` command to connect to 21 | the remote server. It is also using following commands: 22 | `netstat` (Linux), `lsof` (MacOS), `grep`, `awk` and `sed`. To be more specific this command is 23 | used: 24 | `netstat -tuplen 2>/dev/null | grep {localhost} | awk '{print $4}' | sed 's/.*://g'` (Linux) 25 | `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep {localhost} | awk '{print $9}' | sed 's/.*://g'` (MacOS) 26 | 27 | ## Installation 28 | 29 | Using [vim-plug](https://github.com/junegunn/vim-plug): 30 | 31 | ```vim 32 | Plug 'tpope/vim-dadbod' 33 | 34 | Plug 'pbogut/vim-dadbod-ssh' 35 | ``` 36 | 37 | Or use your favourite method / package manager. 38 | 39 | ## Configuration 40 | 41 | Adapter format is quite simple, here is example for `mysql` connection: 42 | 43 | ```vim 44 | let g:my_db = "ssh://sshremotehost:mysql://user:password@databasehost/db_name" 45 | ``` 46 | 47 | Do not use `DB g:my_db = "ssh://.....` especially in your start-up scripts, as 48 | this command will create tunnel (which may take couple seconds) and assign 49 | modified URL to the `g:my_db` variable (see how it works section), which will 50 | work for some time but will fail if tunnel breaks and new one will have to be 51 | established. 52 | 53 | As you can see normal connection URL is prepended with `ssh://sshremotehost:` 54 | 55 | How to set up SSH password? Please, use public key. 56 | How to set up user and port? You can do this in your `$HOME/.ssh/config`: 57 | 58 | ``` 59 | Host mydbhost-name 60 | User username 61 | HostName 123.123.123.123 62 | Port 22222 63 | ``` 64 | 65 | With that you can use `ssh://mydbhost-name:` in your connection string. 66 | 67 | 68 | To work adapter don't need any additional configuration, but there are few 69 | things one may want to adjust. 70 | 71 | 72 | - `g:db_adapter_ssh_localhost` - defaults to `127.0.0.1` 73 | Why IP and not just `localhost`? It is used to replace your connection host 74 | and `localhost` is causing issues with `mysql` (maybe others too?). When 75 | host is `localhost` `mysql` is trying to connect with socket instead of 76 | network. 77 | - `g:db_adapter_ssh_timeout` - defaults to `10000` (10 seconds) 78 | It's how long adapter will wait for tunnel to be established. 79 | - `g:db_adapter_ssh_port_range` - defaults to `range(7000, 7100)` 80 | It's range of local ports that will be used to create tunnels, you can 81 | specific different range. Script is checking if port is available before 82 | trying to create tunnel, so if some IPs in range are taken that should be 83 | fine. 84 | 85 | ## So how it works? 86 | 87 | On first connection `ssh` is used to create tunnel to the remote server. Then 88 | in connection URL port and host are changed to use `localhost` and port that was 89 | used to create tunnel. URL modified like that is then passed to the adapter. 90 | 91 | With this URL `ssh://sshremotehost:mysql://user:password@databasehost/db_name` 92 | adapter will run: `ssh -L 7000:databasehost:3306 -t echo ssh_connected; read`. 93 | URL is modified to `mysql://user:password@127.0.0.1:7000/db_name` and that is 94 | passed to the `mysql` adapter. 95 | 96 | `read` is used to keep connection alive and `echo` to confirm when connection is 97 | established. `-N` could be used instead `read` but then would have to find 98 | another method to confirm connection was established. If you have better ideas 99 | I accept PR's. 100 | 101 | ## Contributions 102 | 103 | Always welcome. 104 | 105 | ## License 106 | 107 | MIT License; 108 | The software is provided "as is", without warranty of any kind. 109 | -------------------------------------------------------------------------------- /autoload/db/adapter/ssh.vim: -------------------------------------------------------------------------------- 1 | if exists('g:autoloaded_db_ssh') 2 | finish 3 | endif 4 | 5 | let g:autoloaded_db_ssh = 1 6 | let s:localhost = get(g:, 'db_adapter_ssh_localhost', '127.0.0.1') 7 | let s:timeout = get(g:, 'db_adapter_ssh_timeout', 10000) 8 | let s:port_range = get(g:, 'db_adapter_ssh_port_range', range(7000, 7100)) 9 | 10 | let s:tunnels = {} 11 | let s:wait_list = {} 12 | let s:default_ports = { 13 | \ 'mariadb': 3306, 14 | \ 'mysql': 3306, 15 | \ 'postgresql': 5432, 16 | \ 'sqlserver': 1433, 17 | \ 'presto': 8080, 18 | \ 'oracle': 1521, 19 | \ 'mongodb': 27017, 20 | \ } 21 | 22 | " experimental, when true play nicer with DBUI (but worse with DB) 23 | let s:default_async = get(g:, 'db_ssh_default_async', v:false) 24 | 25 | function s:get_free_port() 26 | let env = toupper(substitute(system('uname'), '\n', '', '')) 27 | 28 | let detect_open_port_cmd = env =~ 'DARWIN' 29 | \ ? ("lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep " . s:localhost . " | awk '{print $9}' | sed 's/.*://g'") 30 | \ : ("netstat -tuplen 2>/dev/null | grep " . s:localhost . " | awk '{print $4}' | sed 's/.*://g'") 31 | 32 | let ports = systemlist(detect_open_port_cmd) 33 | 34 | for port in s:port_range 35 | call add(s:port_range, port) 36 | call remove(s:port_range, 0) 37 | if index(ports, "" . port) == -1 38 | return port 39 | endif 40 | endfor 41 | 42 | return 0 43 | endfunction 44 | 45 | call s:get_free_port() 46 | 47 | function! s:prefix(url) abort 48 | let scheme = tolower(matchstr(a:url, '^[^:]\+')) 49 | let adapter = tr(scheme, '-+.', '_##') 50 | if empty(adapter) 51 | throw 'DB: no URL' 52 | endif 53 | if exists('g:db_adapter_' . adapter) 54 | let prefix = g:db_adapter_{adapter} 55 | else 56 | let prefix = 'db#adapter#'.adapter.'#' 57 | endif 58 | return prefix 59 | endfunction 60 | 61 | function! s:fn_name(url, fn) abort 62 | let url = substitute(a:url, '^[^:]\+', '\=get(g:db_adapters, submatch(0), submatch(0))', '') 63 | let prefix = s:prefix(url) 64 | return prefix . a:fn 65 | endfunction 66 | 67 | function! s:drop_ssh_part(url) abort 68 | return substitute(a:url, '^ssh://[^:]*:', '', '') 69 | endfunction 70 | 71 | function! s:get_ssh_host(url) abort 72 | return substitute(a:url, '^ssh://\([^:]*\):.*', '\1', '') 73 | endfunction 74 | 75 | function! s:on_event(job_id, data, event) dict 76 | if a:event == 'stdout' 77 | let str = self.tunnel_id . ' stdout: '.join(a:data) 78 | if str =~ 'ssh_connected' 79 | let s:tunnels[self.tunnel_id] = self.redirect_port 80 | let s:wait_list[self.tunnel_id] = v:false 81 | if self.show_connection_info 82 | echo "Tunnel " . self.tunnel_id . " has been created" 83 | end 84 | return 85 | endif 86 | elseif a:event == 'stderr' 87 | let str = self.tunnel_id . ' stderr: '.join(a:data) 88 | if str =~ 'Pseudo-terminal will not be allocated' 89 | return 90 | endif 91 | if str =~ 'Address already in use' 92 | call jobstop(a:job_id) 93 | return 94 | endif 95 | else 96 | let s:wait_list[self.tunnel_id] = v:false 97 | if (!empty(get(s:tunnels, self.tunnel_id))) 98 | call remove(s:tunnels, self.tunnel_id) 99 | endif 100 | return 101 | endif 102 | if len(get(l:, 'str')) > len(self.tunnel_id . ' stdout: ') 103 | echom str 104 | endif 105 | endfunction 106 | 107 | let s:callbacks = { 108 | \ 'on_stdout': function('s:on_event'), 109 | \ 'on_stderr': function('s:on_event'), 110 | \ 'on_exit': function('s:on_event') 111 | \ } 112 | 113 | function! s:get_tunneled_url(url, ...) 114 | if a:url !~? '^ssh:' 115 | return a:url 116 | endif 117 | let async = get(a:, 1, s:default_async) 118 | 119 | let ssh_host = s:get_ssh_host(a:url) 120 | let url = s:drop_ssh_part(a:url) 121 | 122 | let url_parts = db#url#parse(url) 123 | let scheme = get(url_parts, 'scheme') 124 | let port = get(url_parts, 'port', get(s:default_ports, scheme)) 125 | let host = get(url_parts, 'host', 'localhost') 126 | let tunnel_id = ssh_host . ':' . host . ':' . port 127 | 128 | let url_parts['host'] = s:localhost 129 | 130 | let current_port = get(s:tunnels, tunnel_id) 131 | if (!empty(current_port)) 132 | let url_parts['port'] = current_port 133 | if async 134 | echo "Tunnel " . tunnel_id . " already exists" 135 | endif 136 | return db#url#format(url_parts) 137 | endif 138 | 139 | let redirect_port = s:get_free_port() 140 | if redirect_port == 0 141 | throw "DB SSH: Can't find free port to use" 142 | endif 143 | 144 | let url_parts['port'] = redirect_port 145 | let new_url = db#url#format(url_parts) 146 | let ssh_redirect = redirect_port . ':' . host . ':' .port 147 | 148 | " check if connection is not currently being made 149 | if get(s:wait_list, l:tunnel_id, v:false) != v:true 150 | let s:wait_list[l:tunnel_id] = v:true 151 | let job = jobstart(['ssh', '-L', ssh_redirect, ssh_host, '-t', 'echo ssh_connected; read'], extend({ 152 | \ 'tunnel_id': tunnel_id, 153 | \ 'redirect_port': redirect_port, 154 | \ 'show_connection_info': async, 155 | \ }, s:callbacks)) 156 | endif 157 | 158 | if async 159 | echo "Creating tunnel " . tunnel_id . "..." 160 | return new_url 161 | end 162 | 163 | " wait for tunnel to be established 164 | let connection_status = wait(s:timeout, { -> s:wait_list[l:tunnel_id] == v:false }, 100) 165 | if connection_status == -1 166 | echom "DB SSH: Timeout while creating tunnel" 167 | elseif connection_status == -2 168 | echom "DB SSH: Connection canceled by user" 169 | elseif connection_status == -3 170 | echom "DB SSH: Unknown error occured while creating tunnel" 171 | endif 172 | 173 | return new_url 174 | endfunction 175 | 176 | function! db#adapter#ssh#create_tunnel(url, ...) abort 177 | let async = get(a:, 1, v:true) 178 | call s:get_tunneled_url(a:url, async) 179 | endfunction 180 | 181 | function! db#adapter#ssh#canonicalize(url) abort 182 | let url = s:get_tunneled_url(a:url) 183 | return call(s:fn_name(l:url, 'canonicalize'), [l:url]) 184 | endfunction 185 | 186 | function! db#adapter#ssh#interactive(url) abort 187 | let url = s:get_tunneled_url(a:url) 188 | return call(s:fn_name(l:url, 'interactive'), [l:url]) 189 | endfunction 190 | 191 | function! db#adapter#ssh#filter(url) abort 192 | let url = s:get_tunneled_url(a:url) 193 | return call(s:fn_name(l:url, 'filter'), [l:url]) 194 | endfunction 195 | 196 | function! db#adapter#ssh#auth_pattern() abort 197 | return '^ERROR 104[45]\|denied\|login\|auth\|not permitted\|ORA-01017' 198 | endfunction 199 | 200 | function! db#adapter#ssh#tables(url) abort 201 | let url = s:get_tunneled_url(a:url) 202 | return call(s:fn_name(l:url, 'tables'), [l:url]) 203 | endfunction 204 | 205 | function! db#adapter#ssh#complete_opaque(url) abort 206 | return [] 207 | endfunction 208 | 209 | function! db#adapter#ssh#complete_database(url) abort 210 | return [] 211 | endfunction 212 | --------------------------------------------------------------------------------