SSH 调起本地 VSCode

背景

VS Code 的一大优势是其远端开发能力,remote 插件已经和本地开发完全无感

在公司开发中,已经将全部开发工作移至服务器。Go 相关开发借助 remote SSH(部分构件任务需要用到 docker,由于使用别人构建好的镜像,折腾 Docker in Docker 比较麻烦),前端、脚本 语言开发已经通过 dev container(ssh 到服务器上的 docker)开发

remote 全家桶里还有 WSL 插件,非公司的开发任务会在自己的 WSL 环境开发。

由于开发往往通常需要在多个代码中切换,而 code ~/xxx 命令只有在 vscode 的集成终端里才存在,那么如何在 ssh 窗口中实现类似的功能呢?

注: 该需求也可以借助 project manager 插件 部分解决

文件 URI

与大部分程序一样,直接给 vscode 二进制文件传入文件夹/文件路径,即可打开对应的内容。但是 remote 文件/文件夹则满足特定的格式

  • vscode-remote://ssh-remote+${SSH_SERVER}${ABS_PATH}'
  • vscode-remote://dev-container+$(printf "{\"hostPath\":\"${ABS_PATH}\",\"localDocker\":false,\"settings\":{\"host\":\"ssh://${SSH_SERVER}\"}" | od -A n -t x1 | tr -d '[\n\r ]')/workspaces/$(basename $ABS_PATH)'"

可以看出,所有需要远端服务器的文件/文件夹都需要通过特殊的 URI 来标记文件地址,这里需要给出使用的插件、SSH 地址、绝对路径地址。对于 dev container,这部分参数以 json 形式传输,并且需要进行十六进制转码

需要特别注意的是,对于 文件 和 文件夹,需要的参数不同

  • 文件 --file-uri
  • 文件夹 --folder-uri

调用 vscode

如果已经通过 vscode 连接到服务器,那么我们知道可以通过 code xxx 在新窗口打开对应的远端目录,但是如果是直接通过 SSH 则会提示没有对应的命令

简单来说,这个的实现原理是,vscode 在连接到远端后,会通过建立一个 Unix sock 连接。在 /run/user/{用户 ID} 或是 /tmp 目录下会看到大量 vscode-ipc-* 的文件,只需要对这个文件进行读写操作,即可和本地 vscode 通信(万物皆文件)

由于未知原因,sock 文件并不一定会在连接断开后删除,因此往往这里会有很多文件,需要先判断哪个是可用的。可以使用 nc 命令(netcat)判断对应的连接是否还存活,如果存活则可以通过这个连接传输要打开的文件/文件夹

找到可用的连接后,还需要按照特定的格式进行传输(和 tcp 一样,需要知道应用层的数据格式)。由于 vscode 连到服务端后,实际上服务器上也会安装 vscode 服务端二进制文件,因此可以直接调用这个二进制文件去组装格式。

通过 VSCODE_IPC_HOOK_CLI 可以告诉 vscode 应该使用哪个 Unix 连接。

至此,理论上已经可以在任意 ssh 连接里实现 vscode 集成终端里的 code 命令了

如果没有打开 vscode 怎么办

前面的操作需要已经有一个 vscode 连接,但是如果重启电脑后想直接在 ssh 连接打开文件呢?

上面的大部分内容实际上并不需要改变,唯一要解决的问题时,如何让本地执行一个指定的命令。
为了 SSH 连接的安全性,SSH 本身并不支持类似的操作,因此需要实现 SSH 反向连接 —— 从服务器 SSH 到本地机器,并执行命令

具体实现可以看下面的脚本

已知问题

服务端地址来自于 SSH 连接建立后自动写入的环境变量 SSH_CONNECTION。如果 SSH 连接中通过跳板,那么拿到的 SSH 地址是跳板机和目标服务器的连接地址,本地机器可能无法联通

如果通过反向 SSH 连回本地,需要本地开启 SSH 端口,并且 IP 可以连通(非公司网络下,这通常意味着需要公网 IP)
可以考虑 SSH 到服务器时,映射下本地 SSH 端口

最后结果

将下面的内容放到 ~/.bashrc 后重新加载即可通过 rcode 来打开文件

function clear_vscode_socks() {
    arr=()
    arr+=$(ls -t1 /run/user/$(id -u)/vscode-ipc-* 2>>/dev/null)
    arr+=$(ls -t1 /tmp/vscode-ipc-* 2>>/dev/null)
    
    for item in $arr; do 
        if [ "$(_check_unix_sock $item)" == "1" ] || [ "$1" == "-f" ]; then
            echo "delete $item"            
            rm -f $item; 
        else
            echo "keep $item"
        fi
    done
}

function _check_unix_sock() {
    nc -U -z $1 2>&1 >/dev/null;
    echo $?;
}

function rcode() { ## SSH VSCode 唤起
    # https://stackoverflow.com/questions/66581313/open-files-from-remote-ssh-host-in-vscode
    # https://github.com/microsoft/vscode-remote-release/issues/3324
    # https://github.com/microsoft/vscode-remote-release/issues/3738

    [ -z "$TMUX" ] && eval $(tmux showenv -s SSH_CONNECTION)
    SSH_CLIENT=$(cut -d ' ' -f1 <<<"$SSH_CONNECTION")
    SSH_SERVER=$(cut -d ' ' -f3 <<<"$SSH_CONNECTION")
    VSCODE_BIN=$(ls -t1 ${HOME}/.vscode-server/bin/*/bin/code | tail -n 1)

    # 获取 VSCODE 通信 sock
    if [ -n $VSCODE_IPC_HOOK_CLI ]; then
        if [ ! -f $VSCODE_IPC_HOOK_CLI ] || [ "$(_check_unix_sock $VSCODE_IPC_HOOK_CLI)" == "1" ]; then
            unset VSCODE_IPC_HOOK_CLI
        fi
    fi

    if [ -z "$VSCODE_IPC_HOOK_CLI" ]; then
        arr=()
        arr+=$(ls -t1 /run/user/$(id -u)/vscode-ipc-* 2>>/dev/null)
        arr+=$(ls -t1 /tmp/vscode-ipc-* 2>>/dev/null)
        for item in $arr; do
            if [[ $(_check_unix_sock $item) == "0" ]]; then
                VSCODE_IPC_HOOK_CLI=$item
                break
            else
                echo "rm $item"
                rm -f $item
            fi
        done
    fi

    export VSCODE_IPC_HOOK_CLI
    
    for ARG in "$@"; do
        ABS_PATH=$(realpath $ARG)
        
        CODE_ARGS=""
        if [[ -f $ABS_PATH ]]; then
            # open file
            CODE_ARGS="--file-uri 'vscode-remote://ssh-remote+${SSH_SERVER}${ABS_PATH}'";
        fi
        if [[ -d $ABS_PATH ]]; then
            if [ -e $ABS_PATH/.devcontainer ]; then
                # open container, disabled
                RAW="{\"hostPath\":\"${ABS_PATH}\",\"localDocker\":false,\"settings\":{\"host\":\"ssh://${SSH_SERVER}\"}}"
                CODE_ARGS="--folder-uri 'vscode-remote://dev-container+$(printf $RAW | od -A n -t x1 | tr -d '[\n\r ]')/workspaces/$(basename $ABS_PATH)'"
            else
                # open folder
                CODE_ARGS="--folder-uri 'vscode-remote://ssh-remote+${SSH_SERVER}${ABS_PATH}'";
            fi
        fi
       
        if [[ -z $VSCODE_IPC_HOOK_CLI ]]; then
            # no connected socks, using ssh
            echo "Using reverse ssh, input client ssh password"
            if [ -z $LOCAL_USER ]; then
                LOCAL_USER=$(whoami)
                echo "ssh client username(LOCAL_USER) not set, using $LOCAL_USER"
            fi
            CMD="ssh ${LOCAL_USER}@${SSH_CLIENT} bash -c \"code ${CODE_ARGS}\""
        else
            echo "Using sock $VSCODE_IPC_HOOK_CLI"
            CMD="${VSCODE_BIN} ${CODE_ARGS}"
        fi
        
        echo "code ${CODE_ARGS}"
        sh -c "$CMD"
            echo $CMD
    done
}