Files
neovim/runtime/lua/vim/net/_ssh.lua
T
Justin M. Keyes 62f79bd7e1 feat(remote-ssh): lifecycle, cleanup, slow tests
- SshHost fields: follow ssh's "first wins" rule, so e.g. a later User
  line in the same block no longer overrides an earlier one.
- Local server now exits on its own when the remote session ends.
- Before auto-install, try existing `nvim` found in remote `$PATH`.
- Combine the remote_ssh_x.sh scripts into one remote_ssh.sh script.
- Ignore `RemoteCommand` from ssh_config, which made ssh refuse our command.
- Don't accumulate the remote heartbeat in memory for the whole session:
  `run_wait()` captures output only until it returns.
- Before detaching the UI, check that the remote Nvim answers through the
  forward: sshd may refuse the forward, but still run the script.
- Stop the session (tunnel, remote Nvim) if any connect step fails, or if
  the UI detached meanwhile.
- Run `ssh -G` once per session, instead of for every ssh command.
- Remote script handles signals at once, instead of after the heartbeat's
- Error if :restart is attempted.
  FUTURE: Remote :restart prints a marker, with its [command], on
  stdout. It also sends the TUI a restart event pointing at the LOCAL
  server. The TUI reattaches there. Local server repeats the remote-ssh
  connect steps, and sends the TUI a connect event for the new forwarded
  socket. Then it runs [command] (e.g. session restore) on the new
  remote over RPC.
2026-11-03 19:05:58 +01:00

645 lines
20 KiB
Lua

-- Converted into Lua from https://github.com/cyjake/ssh-config
-- TODO (siddhantdev): deal with include directives
local M = {}
local whitespace_pattern = '%s'
local line_break_pattern = '[\r\n]'
--- Command that runs ssh. Tests replace it with a fake.
M.ssh_cmd = { 'ssh' }
--- Remote-ssh data dir, relative to the remote `$HOME`.
local remote_data_dir = '.local/share/nvim-remote'
local _log --- @type vim.Log?
---@param param string
local function is_multi_value_directive(param)
local multi_value_directives = {
'globalknownhostsfile',
'host',
'ipqos',
'sendenv',
'userknownhostsfile',
'proxycommand',
'match',
'canonicaldomains',
}
return vim.list_contains(multi_value_directives, param:lower())
end
---@class vim.net.SshHost
---@field alias string Host alias from ssh config.
---@field hostname? string Resolved Hostname directive.
---@field user? string User directive.
---@field port? string Port directive.
---@field identity_file? string IdentityFile directive.
---@param text string The ssh configuration which needs to be parsed.
---@return vim.net.SshHost[] The parsed host configurations.
function M.parse_ssh_config(text)
local i = 1
local line = 1
local function consume()
if i <= #text then
local char = text:sub(i, i)
i = i + 1
return char
end
return nil
end
local chr = consume()
local function parse_spaces()
local spaces = ''
while chr and chr:match(whitespace_pattern) do
spaces = spaces .. chr
chr = consume()
end
return spaces
end
local function parse_linebreaks()
local breaks = ''
while chr and chr:match(line_break_pattern) do
line = line + 1
breaks = breaks .. chr
chr = consume()
end
return breaks
end
local function parse_parameter_name()
local param = ''
while chr and not chr:match('[ \t=]') do
param = param .. chr
chr = consume()
end
return param
end
local function parse_separator()
local sep = parse_spaces()
if chr == '=' then
sep = sep .. chr
chr = consume()
end
return sep .. parse_spaces()
end
local function parse_value()
local val = {}
local quoted, escaped = false, false
while chr and not chr:match(line_break_pattern) do
if escaped then
table.insert(val, chr == '"' and chr or '\\' .. chr)
escaped = false
elseif chr == '"' and (#val == 0 or quoted) then
quoted = not quoted
elseif chr == '\\' then
escaped = true
elseif chr == '#' and not quoted then
break
else
table.insert(val, chr)
end
chr = consume()
end
if quoted or escaped then
error(('Unexpected line break at line %d'):format(line))
end
return vim.trim(table.concat(val))
end
local function parse_comment()
while chr and not chr:match(line_break_pattern) do
chr = consume()
end
end
---@return string[]
local function parse_multiple_values()
local results = {}
local val = {}
local quoted = false
local escaped = false
while chr and not chr:match(line_break_pattern) do
if escaped then
table.insert(val, chr == '"' and chr or '\\' .. chr)
escaped = false
elseif chr == '"' then
quoted = not quoted
elseif chr == '\\' then
escaped = true
elseif quoted then
table.insert(val, chr)
elseif chr:match('[ \t=]') then
if #val > 0 then
table.insert(results, vim.trim(table.concat(val)))
val = {}
end
elseif chr == '#' and #results > 0 then
break
else
table.insert(val, chr)
end
chr = consume()
end
if quoted or escaped then
error(('Unexpected line break at line %d'):format(line))
end
if #val > 0 then
table.insert(results, vim.trim(table.concat(val)))
end
return results
end
local function parse_directive()
local param = parse_parameter_name()
local multiple = is_multi_value_directive(param)
local _ = parse_separator()
local value = multiple and parse_multiple_values() or parse_value()
local result = {
param = param,
value = value,
}
return result
end
local function parse_line()
local _ = parse_spaces()
if chr == '#' then
parse_comment()
return nil
end
local node = parse_directive()
local _ = parse_linebreaks()
return node
end
---@type vim.net.SshHost[]
local hosts = {}
local seen = {} ---@type table<string, boolean>
---@type vim.net.SshHost[]
local current_hosts = {}
---@param value string
local function is_valid(value)
return not (value:find('[?*!]') or seen[value])
end
local function flush()
for _, h in ipairs(current_hosts) do
table.insert(hosts, h)
end
current_hosts = {}
end
---@param aliases string[]
local function add_aliases(aliases)
flush()
for _, alias in ipairs(aliases) do
if is_valid(alias) then
seen[alias] = true
table.insert(current_hosts, { alias = alias })
end
end
end
while chr do
local node = parse_line()
if node then
local lp = node.param:lower()
if lp == 'match' and node.value then
local values = node.value --[[@as string[] ]]
local match_aliases = {} ---@type string[]
for ind, val in ipairs(values) do
if val:lower() == 'host' and ind + 1 <= #values and is_valid(assert(values[ind + 1])) then
table.insert(match_aliases, values[ind + 1])
end
end
add_aliases(match_aliases)
elseif lp == 'host' and node.value then
local valid = {} ---@type string[]
for _, value in
ipairs(node.value --[[@as string[] ]])
do
if is_valid(value) then
table.insert(valid, value)
end
end
add_aliases(valid)
else
local field = ({
hostname = 'hostname',
user = 'user',
port = 'port',
identityfile = 'identity_file',
})[lp]
local val = (type(node.value) == 'string' and node.value or nil) --[[@as string?]]
if field and val then
-- As ssh: the first obtained value wins.
for _, h in ipairs(current_hosts) do
h[field] = h[field] or val
end
end
end
end
end
flush()
return hosts
end
---@param filename string
---@return vim.net.SshHost[] The host configurations in the file.
function M.parse_config(filename)
local text = vim.fn.readblob(filename)
return M.parse_ssh_config(text)
end
---@param filename? string Path to the SSH config file. Defaults to `~/.ssh/config`.
---@return string[] The hostnames configured in the SSH config file. Note: This does not process
--- `Include` directives.
function M.get_hosts(filename)
filename = filename or vim.fs.normalize('~/.ssh/config')
local ok, hosts = pcall(M.parse_config, filename)
if not ok then
return {}
end
return vim.tbl_map(
---@param h vim.net.SshHost
function(h)
return h.alias
end,
hosts
)
end
--- Lazy `vim.log` instance for the remote-ssh feature. Writes to `stdpath('log')/remote-ssh.log`.
---@return vim.Log
local function log()
if not _log then
_log = vim.log.new({ name = 'remote-ssh', level = vim.log.levels.INFO })
end
return _log
end
--- Returns a function that shows (and logs) connect status as one |progress-message|, updated in
--- place.
---@return fun(status: 'running'|'success'|'failed', msg: string)
local function new_progress_report()
local progress = { kind = 'progress', source = 'remote-ssh', title = 'remote-ssh' }
return function(status, msg)
if status == 'failed' then
log().error(msg)
else
log().info(msg)
end
progress.status = status
progress.id = vim.api.nvim_echo({ { msg } }, status ~= 'running', progress)
vim.cmd.redraw()
end
end
local function core_system()
-- Keep this lazy so parser-only users can load vim.net._ssh without editor state.
return require('vim._core.system')
end
--- Builds a command for the remote host identified by `ssh_uri`.
---
--- `ssh_args` are inserted before the target; `remote_cmd` is appended after the target.
---
---@param ssh_uri {host:string, user?:string, port?:string, config?:string}
---@param opts? { ssh_args?: string[], remote_cmd?: string[] }
---@return string[]
local function get_ssh_cmd(ssh_uri, opts)
opts = opts or {}
local mux_dir = vim.fs.joinpath(vim.fn.stdpath('cache') --[[@as string]], 'ssh')
vim.fn.mkdir(mux_dir, 'p')
-- `%C` is SSH's hash of (local hostname, remote host, port, user). Its fixed length keeps the
-- ControlPath socket under the 104-byte `sun_path` limit, which the raw `%h_%p_%r` name plus a
-- long base dir can exceed. See also |vim.fs.slug()|.
local mux_path = vim.fs.joinpath(mux_dir, 'mux_%C')
local ssh_cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), {
'-T',
-- Fails instead of prompting for a password or key passphrase.
'-o',
'BatchMode=yes',
'-o',
'ControlMaster=auto',
'-o',
('ControlPath=%s'):format(mux_path),
'-o',
'ControlPersist=10m',
-- Workaround "Cannot execute command-line and remote command" if `RemoteCommand` is in user
-- ssh_config.
'-o',
'RemoteCommand=none',
})
local target = ssh_uri.user and ('%s@%s'):format(ssh_uri.user, ssh_uri.host) or ssh_uri.host
local dest = ssh_uri.port and { '-p', ssh_uri.port } or {}
-- Ends option parsing, so a target like `-oProxyCommand=x` is not taken as an option.
vim.list_extend(dest, { '--', target })
-- Effective ssh_config. Once per `ssh_uri`: `ssh -G` evaluates e.g. `Match exec`, or DNS for
-- `CanonicalizeHostname`.
if not ssh_uri.config then
local cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), { '-G', unpack(dest) })
ssh_uri.config = vim.system(cmd, { text = true }):wait().stdout or ''
end
local config = ('\n%s'):format(ssh_uri.config)
-- At the default `ask`, BatchMode fails on an unknown host key. `accept-new` records it instead,
-- as if the user typed "yes"; a changed key still fails. An explicit ssh_config value is kept.
if config:match('\nstricthostkeychecking (%S+)') == 'ask' then
vim.list_extend(ssh_cmd, { '-o', 'StrictHostKeyChecking=accept-new' })
end
-- At the default 0, a dropped connection (e.g. after sleep) leaves the session frozen until TCP
-- gives up. 15s, times the default `ServerAliveCountMax` 3: ends the session within 45s.
if config:match('\nserveraliveinterval (%S+)') == '0' then
vim.list_extend(ssh_cmd, { '-o', 'ServerAliveInterval=15' })
end
if opts.ssh_args then
vim.list_extend(ssh_cmd, opts.ssh_args)
end
vim.list_extend(ssh_cmd, dest)
if opts.remote_cmd then
vim.list_extend(ssh_cmd, opts.remote_cmd)
end
return ssh_cmd
end
--- Runs the local `$VIMRUNTIME/scripts/remote_ssh.sh` on the remote host. The script goes to
--- `sh -s` over stdin, so the remote login shell (any shell) parses only `args`.
---
---@param uri {host:string, user?:string, port?:string}
---@param args string[] Script args: plain words, which no shell expands, so they need no quoting.
---@param opts? { ssh_args?: string[], wait_until?: fun(stdout: string, stderr: string): boolean }
---@param on_exit? fun(out: vim.SystemCompleted) See `run_wait()`.
---@return vim.SystemRunWaitResult
local function run_script(uri, args, opts, on_exit)
opts = opts or {}
for _, arg in ipairs(args) do
assert(arg:match('^[%w%-%._/:]+$'), ('not a plain word: %s'):format(arg))
end
local script =
vim.fn.readblob(vim.fs.joinpath(assert(vim.env.VIMRUNTIME), 'scripts', 'remote_ssh.sh'))
local remote_cmd = vim.list_extend({ 'sh', '-s', '--' }, args)
local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = remote_cmd })
log().debug('run_script: running', ssh_cmd)
return core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000, on_exit)
end
--- Gets the operating system and architecture from the remote system.
---
---@param uri {host:string, user?:string, port?:string}
---@return string os, string arch
function M.get_system_info(uri)
local ssh_cmd = get_ssh_cmd(uri, { remote_cmd = { 'uname -s && uname -m' } })
log().debug('get_system_info: running', ssh_cmd)
local obj = vim.system(ssh_cmd, { text = true }):wait(300000)
log().debug('get_system_info: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
if obj.code ~= 0 then
local detail = obj.stderr ~= '' and obj.stderr or obj.stdout
error(('Failed to detect remote system info: %s'):format(detail))
end
local lines = vim.split(vim.trim(obj.stdout or ''), '\n', { plain = true })
local valid_lines = {}
for _, line in ipairs(lines) do
if vim.trim(line) ~= '' then
table.insert(valid_lines, vim.trim(line))
end
end
if #valid_lines < 2 then
error(('Unexpected output from system info detection: %s'):format(obj.stdout))
end
---@type string
local os = valid_lines[#valid_lines - 1]:lower()
---@type string
local arch = valid_lines[#valid_lines]:lower()
if os:match('msys') or os:match('windows') or os:match('mingw') or os:match('cygwin') then
error('Not implemented yet: Windows targets are not supported.')
end
return os, arch
end
--- Gets the Nvim release URL/dir/version based on the local version + remote platform.
---
---@param os string
---@param arch string
---@return string dir Install dir, relative to the remote `$HOME`, e.g.
--- `.local/share/nvim-remote/releases/v0.12.0-linux-x86_64`. Per platform too: a shared `$HOME`
--- may be mounted on remote hosts of different platforms.
---@return string url Release archive.
---@return string? version Minimum version of an `nvim` on the remote `$PATH` to use instead, or nil
--- for a prerelease (nightlies are not ordered by version).
local function get_release(os, arch)
local ver = vim.version()
local base = ('v%d.%d.%d'):format(ver.major, ver.minor, ver.patch)
-- prereleases: install the current nightly, once per version
local tag = ver.prerelease and 'nightly' or base
local install_name = ver.prerelease and ('nightly-%s'):format(base) or base
local os_map = { linux = 'linux', darwin = 'macos' }
local arch_map = { x86_64 = 'x86_64', aarch64 = 'arm64', arm64 = 'arm64' }
local target_os = os_map[os]
local target_arch = arch_map[arch]
if not target_os or not target_arch then
error(('Unsupported OS/Arch combination: %s/%s'):format(os, arch))
end
local release_name = ('nvim-%s-%s'):format(target_os, target_arch)
local release_url = ('https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz'):format(
tag,
release_name
)
local dir = ('%s/releases/%s-%s-%s'):format(remote_data_dir, install_name, target_os, target_arch)
return dir, release_url, not ver.prerelease and base or nil
end
--- Sends a request to the Nvim server at `addr`, and waits for its result. Over a plain socket, not
--- an RPC channel: that would also let the (remote) server call the API of this (local) Nvim.
---@param addr string
---@param method string
---@param params any[]
---@return boolean ok
local function request(addr, method, params)
local pipe = assert(vim.uv.new_pipe())
local received = ''
local response --- @type any
local done = false
pipe:connect(addr, function(err)
if err then
done = true
return
end
pipe:write(vim.mpack.encode({ 0, 0, method, params }))
pipe:read_start(function(read_err, data)
if read_err or not data then
done = true
return
end
received = ('%s%s'):format(received, data)
local ok, msg = pcall(vim.mpack.decode, received)
if ok then
response = msg
done = true
end
end)
end)
vim.wait(30000, function()
return done
end)
pipe:close()
-- Response: `[1, msgid, error, result]`.
return response ~= nil and response[1] == 1 and response[3] == vim.NIL
end
--- See `M.start()`.
---@param chan? integer
---@param report fun(status: 'running'|'success'|'failed', msg: string)
local function start(uri_str, chan, report)
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
report('running', ('Connecting to %s...'):format(uri_str))
local dir, url, version = get_release(M.get_system_info(uri))
local local_sock = ('%s_remote_nvim.sock'):format(vim.fn.tempname())
local token = vim.text.hexencode(assert(vim.uv.random(8)))
local remote_sock = ('/tmp/nvim-remote.%s/nvim.sock'):format(token)
local forward = ('%s:%s'):format(local_sock, remote_sock)
local cleaned_up = false
local tunnel --- @type vim.SystemRunWaitResult
--- Stops this (local) server when the session ends (remote quit, crash, lost connection),
--- unless it is still in use: a UI is attached or a buffer is modified.
local function on_exit()
vim.schedule(function()
-- Without NVIM_READY there was no session (`start()` fails instead).
if cleaned_up or not (tunnel and tunnel.stdout:match('NVIM_READY')) then
return
end
local modified = vim.iter(vim.api.nvim_list_bufs()):any(function(buf)
return vim.bo[buf].modified
end)
if modified or #vim.api.nvim_list_uis() > 0 then
log().info(('session ended: %s; local server in use, keeping it'):format(uri_str))
return
end
log().info(('session ended: %s; stopping local server'):format(uri_str))
-- Not `qall!`, so a running :terminal job keeps this server alive.
local ok, err = pcall(vim.cmd.qall)
if not ok then
log().info(('local server kept: %s'):format(err))
end
end)
end
--- Stops the tunnel (and thus the remote server), and cancels its forward, which lives in the ssh
--- master.
local function teardown()
if cleaned_up then
return
end
cleaned_up = true
log().info(('teardown: stopping tunnel for %s'):format(uri_str))
if not tunnel:is_closing() then
tunnel:kill('sigterm')
end
-- Waits for the tunnel to close its stdio handles.
tunnel:wait(1000)
vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait(1000)
end
report('running', 'Starting remote Nvim...')
log().debug('start: local_sock', local_sock)
tunnel = run_script(uri, { remote_sock, dir, url, version }, {
ssh_args = { '-L', forward },
wait_until = function(stdout)
return stdout:match('NVIM_READY') ~= nil
end,
}, on_exit)
log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)
local err --- @type string?
if not tunnel.stdout:match('NVIM_READY') then
-- The tunnel already exited.
err = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'
or 'SSH tunnel exited before the remote Nvim was ready'
local detail = vim.trim(tunnel.stderr)
err = detail ~= '' and ('%s: %s'):format(err, detail) or err
elseif not request(local_sock, 'nvim_get_api_info', {}) then
-- sshd may refuse the forward (e.g. `AllowStreamLocalForwarding no`) but run the script.
err = 'Remote Nvim is not reachable through the SSH forward'
elseif
chan
and not vim.iter(vim.api.nvim_list_uis()):any(function(ui)
return ui.chan == chan
end)
then
err = ('UI detached: channel %d'):format(chan)
end
if err then
log().error(err, 'code', tunnel.code, 'stderr', tunnel.stderr)
teardown()
error(err)
end
report('success', ('Connected to %s'):format(uri_str))
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = teardown,
})
return local_sock, teardown
end
--- Asks to confirm, then connects.
---
--- @param uri_str string
--- @param chan? integer UI to connect: fails (and stops the session) if it detached meanwhile.
--- @return string? local_socket Path to the local forwarded socket, or nil if declined.
--- @return fun()? teardown Stops this session's tunnel (and thus the remote server). Other sessions
--- sharing the SSH master are unaffected.
function M.start(uri_str, chan)
local msg = ('This will install Nvim on %s (in ~/%s/), if missing.\n'):format(
uri_str,
remote_data_dir
)
if vim.fn.confirm(msg, 'Connect? &Yes\n&No', 1, 'Question') ~= 1 then
return nil
end
local report = new_progress_report()
local ok, local_sock, teardown = pcall(start, uri_str, chan, report)
if not ok then
report('failed', ('Failed to connect to %s'):format(uri_str))
error(local_sock, 0)
end
return local_sock, teardown
end
return M