Using local .bashrc over ssh and consolidating command history

If you have to work with a large number of remote machines via ssh, then the question arises how to unify the shell environment on these machines. Copying .bashrc in advance is not very convenient, and often impossible. Let's consider copying directly during the connection:



[ -z "$PS1" ] && return

sshb() {
    scp ~/.bashrc ${1}:
    ssh $1
}

# the rest of the .bashrc
alias c=cat
...


This is a very naive way with several obvious disadvantages:



  • You can overwrite an existing .bashrc
  • Instead of one connection, we establish 2
  • As a result, you will also have to log in 2 times.
  • The function argument can only be the address of the remote machine


Improved option:



[ -z "$PS1" ] && return

sshb() {
    local ssh="ssh -S ~/.ssh/control-socket-$(tr -cd '[:alnum:]' < /dev/urandom|head -c8)"
    $ssh -fNM "$@"
    $ssh placeholder "cat >~/.bash-ssh" <~/.bashrc
    $ssh "$@" -t "bash --rcfile ~/.bash-ssh -i"
    $ssh placeholder -O exit >/dev/null 2>&1
}

# the rest of the .bashrc
alias c=cat
...


Now we only use one connection through multiplexing. .bashrc is copied to a file that is not used by bash by default and we explicitly specify it via the --rcfile option. The function argument can be not only the address of the remote machine, but also other ssh options.



In principle, one could stop at this, but the resulting solution has an unpleasant drawback. If you run screen or tmux, the .bashrc on the remote machine will be used and all your aliases and functions will be lost. Fortunately, this can be overcome. To do this, we need to create a wrapper script, which we will declare as our new shell. Let's assume for simplicity that we already have a wrapper script on the remote machine and is located in ~ / bin / bash-ssh. The script looks like this:



#!/bin/bash
exec /bin/bash --rcfile ~/.bash-ssh “$@


And .bashrc like this:



[ -n "$SSH_TTY" ] && export SHELL="$HOME/bin/bash-ssh"

[ -z "$PS1" ] && return

sshb() {
    local ssh="ssh -S ~/.ssh/control-socket-$(tr -cd '[:alnum:]' < /dev/urandom|head -c8)"
    $ssh -fNM "$@"
    $ssh placeholder "cat >~/.bash-ssh" <~/.bashrc
    $ssh "$@" -t "bash --rcfile ~/.bash-ssh -i"
    $ssh placeholder -O exit >/dev/null 2>&1
}

# the rest of the .bashrc
alias c=cat
...


If the SSH_TTY variable exists, we understand that we are on the remote machine and override the SHELL variable. From now on, when starting a new interactive shell, a script will be launched that will start bash with a non-standard config saved when an ssh session was established.



To get a convenient working solution, it remains to figure out how to create a wrapper script on a remote machine. In principle, you can create it in the bash config we save like this:



[ -n "$SSH_TTY" ] && {
    mkdir -p "$HOME/bin"
    export SHELL="$HOME/bin/bash-ssh"
    echo -e '#!/bin/bash\nexec /bin/bash --rcfile ~/.bash-ssh "$@"' >$SHELL
    chmod +x $SHELL
}


But you can actually get by with a single ~ / .bash-ssh file:



#!/bin/bash

[ -n "$SSH_TTY" ] && [ "${BASH_SOURCE[0]}" == "${0}" ] && exec bash --rcfile "$SHELL" "$@"

[ -z "$PS1" ] && return

sshb() {
    local ssh="ssh -S ~/.ssh/control-socket-$(tr -cd '[:alnum:]' < /dev/urandom|head -c8)"
    $ssh -fNM "$@"
    $ssh placeholder "cat >~/.bash-ssh" <~/.bashrc
    $ssh "$@" -t 'SHELL=~/.bash-ssh; chmod +x $SHELL; bash --rcfile $SHELL -i'
    $ssh placeholder -O exit >/dev/null 2>&1
}


# the rest of the .bashrc
alias c=cat
...


Now the ~ / .bash-ssh file is both a standalone script and a bash config. It works like this. On the local machine, commands after [-n "$ SSH_TTY"] are ignored. On the remote machine, the sshb function creates a ~ / .bash-ssh file and uses it as a config to start an interactive session. The construction ["$ {BASH_SOURCE [0]}" == "$ {0}"] allows you to determine whether a file is uploaded by another script or launched as a standalone script. As a result, when ~ / .bash-ssh is used



  • as config - exec is ignored
  • as a script - control passes to the bash and the execution of ~ / .bash-ssh ends with the exec.


Now, when connecting via ssh, your environment will look the same everywhere. It is much more convenient to work this way, but the history of command execution will remain on the machines with which you connected. Personally, I would like to save history locally so that I can brush up on what exactly I've done on some machines in the past. In order to do this, we need the following components:



  • Tcp server on the local machine that would receive data from a socket and redirect it to a file
  • Forward the listening port of this server to the machine with which we are connecting via ssh
  • PROMPT_COMMAND in bash settings, which would send history update to the forwarded port upon completion of the command


This can be done like this:



#!/bin/bash

[ -n "$SSH_TTY" ] && [ "${BASH_SOURCE[0]}" == "${0}" ] && exec bash --rcfile "$SHELL" "$@"

[ -z "$PS1" ] && return

[ -z "$SSH_TTY" ] && {
    history_port=26574
    netstat -lnt|grep -q ":${history_port}\b" || {
        umask 077 && nc -kl 127.0.0.1 "$history_port" >>~/.bash_eternal_history &
    }
}

HISTSIZE=$((1024 * 1024))
HISTFILESIZE=$HISTSIZE
HISTTIMEFORMAT='%t%F %T%t'

update_eternal_history() {
    local histfile_size=$(stat -c %s $HISTFILE)
    history -a
    ((histfile_size == $(stat -c %s $HISTFILE))) && return
    local history_line="${USER}\t${HOSTNAME}\t${PWD}\t$(history 1)"
    local history_sink=$(readlink ~/.bash-ssh.history 2>/dev/null)
    [ -n "$history_sink" ] && echo -e "$history_line" >"$history_sink" 2>/dev/null && return
    local old_umask=$(umask)
    umask 077
    echo -e "$history_line" >> ~/.bash_eternal_history
    umask $old_umask
}

[[ "$PROMPT_COMMAND" == *update_eternal_history* ]] || export PROMPT_COMMAND="update_eternal_history;$PROMPT_COMMAND"

sshb() {
    local ssh="ssh -S ~/.ssh/control-socket-$(tr -cd '[:alnum:]' < /dev/urandom|head -c8)"
    $ssh -fNM "$@"
    local bashrc=~/.bashrc
    [ -r ~/.bash-ssh ] && bashrc=~/.bash-ssh && history_port=$(basename $(readlink ~/.bash-ssh.history))
    local history_remote_port="$($ssh -O forward -R 0:127.0.0.1:$history_port placeholder)"
    $ssh placeholder "cat >~/.bash-ssh; ln -nsf /dev/tcp/127.0.0.1/$history_remote_port ~/.bash-ssh.history" < $bashrc
    $ssh "$@" -t 'SHELL=~/.bash-ssh; chmod +x $SHELL; bash --rcfile $SHELL -i'
    $ssh placeholder -O exit >/dev/null 2>&1
}

# the rest of the .bashrc
alias c=cat
...


The block after [-z "$ SSH_TTY"] only works on the local machine. We check if the port is busy and if not we run netcat on it, the output of which is redirected to a file.



The update_eternal_history function is called just before the bash prompt is displayed. This function checks if the last command was a duplicate and if not, sends it to the forwarded port. If the port is not configured (in the case of a local machine) or if an error occurred while sending, the save goes to a local file.



The sshb function was supplemented by setting a port forwarding and creating a symlink that will be used by update_eternal_history to send data to the server.



This solution is not without its drawbacks:



  • Port for netcat is hardcoded, there is a chance to run into conflict
  • ( - - ), , ,


My own .bashrc can be viewed here .



If you have ideas on how to improve the proposed solution, please share in the comments.



Update. On ubuntu 16.04, I ran into a problem: netcat freezes on multiple connections and takes up 100% cpu. I switched to socat, preliminary testing showed that everything is fine. Also added logic for managing the symlink, which determines the address where the history is sent. It turned out like this:



#!/bin/bash

[ -n "$SSH_TTY" ] && [ "${BASH_SOURCE[0]}" == "${0}" ] && exec bash --rcfile "$SHELL" "$@"

[ -z "$PS1" ] && return

[ -z "$SSH_TTY" ] && command -v socat >/dev/null && {
    history_port=26574
    netstat -lnt|grep -q ":${history_port}\b" || {
        umask 077 && socat -u TCP4-LISTEN:$history_port,bind=127.0.0.1,reuseaddr,fork OPEN:$HOME/.bash_eternal_history,creat,append &
    }
}

HISTSIZE=$((1024 * 1024))
HISTFILESIZE=$HISTSIZE
HISTTIMEFORMAT='%t%F %T%t'

update_eternal_history() {
    local histfile_size=$(stat -c %s $HISTFILE)
    history -a
    ((histfile_size == $(stat -c %s $HISTFILE))) && return
    local history_line="${USER}\t${HOSTNAME}\t${PWD}\t$(history 1)"
    local history_sink=$(readlink ~/.bash-ssh.history 2>/dev/null)
    [ -n "$history_sink" ] && echo -e "$history_line" >"$history_sink" 2>/dev/null && return
    local old_umask=$(umask)
    umask 077
    echo -e "$history_line" >> ~/.bash_eternal_history
    umask $old_umask
}

[[ "$PROMPT_COMMAND" == *update_eternal_history* ]] || PROMPT_COMMAND="update_eternal_history;$PROMPT_COMMAND"

sshb() {
    local ssh="ssh -S ~/.ssh/control-socket-$(tr -cd '[:alnum:]' < /dev/urandom|head -c8)"
    local bashrc=~/.bashrc
    local history_command="rm -f ~/.bash-ssh.history"
    [ -r ~/.bash-ssh ] && bashrc=~/.bash-ssh && history_port=$(basename $(readlink ~/.bash-ssh.history 2>/dev/null))
    $ssh -fNM "$@"
    [ -n "$history_port" ] && {
        local history_remote_port="$($ssh -O forward -R 0:127.0.0.1:$history_port placeholder)"
        history_command="ln -nsf /dev/tcp/127.0.0.1/$history_remote_port ~/.bash-ssh.history"
    }
    $ssh placeholder "${history_command}; cat >~/.bash-ssh" < $bashrc
    $ssh "$@" -t 'SHELL=~/.bash-ssh; chmod +x $SHELL; bash --rcfile $SHELL -i'
    $ssh placeholder -O exit >/dev/null 2>&1
}

# the rest of the .bashrc
alias c=cat
...



All Articles