7

I'm trying to run a command on a series of servers via ssh. I have recently changed ssh ports to avoid Internet scanners, but it broke my script. Does anyone know "an easy way" to specify different ports per user@server entry in a for loop?

for i in '[email protected] -p 12345' '[email protected] -p 54321'
 do printf "\e[%sm%s\e[00m\n" 32 $i
 ssh $i crontab -l
done

The second line is just to print the server name before the output (for readability).

Instead of happiness it gave me: ssh: Could not resolve hostname server1.domain1.com -p 12345: Name or service not known

It worked before when all ports were the default 22 and didn't need to be specified.

3
  • 1
    What shell are you using? This would work as you expected in bash because variable expansion performs word splitting. You'd get your symptoms only if you quoted the variable: ssh "$i" crontab -l
    – Barmar
    Commented Nov 5, 2023 at 12:25
  • First, 123245 is an invalid port number; second, you added quotes into your command: ssh "$i" crontab -l Commented Nov 7, 2023 at 12:06
  • thanks, I've corrected the typo. It was meant to be 12345 Commented Nov 7, 2023 at 17:33

5 Answers 5

16

Since version 7.7, OpenSSH's ssh (along with its scp and sftp) will accept the destination in the form of a URI. You can specify the port number as part of the URI if you like, for example "ssh://someuser@somehost:42" to connect to port 42. A URI has the advantage of being a single string, as far as the shell is concerned. So you can do this:

for i in 'ssh://[email protected]:12345' 'ssh://[email protected]:54321'
 do printf ...
 ssh "$i" ...
done

The general form of an SSH URI is "ssh://user@host:port". The "user" and "port" parts are both optional and can be left out (along with the "@" or ":" punctuation) if you don't need to specify values for those.

15

The right way would be to store that on config, rather than specifying everything on every connection.

On ~/.ssh/config you could have:

Host server1.domain1.com  # When connecting to this
User user1  # Use this user
Port 12345  # with this port

Host serv2  # We can also use a different, internal, name
Hostname server1.domain1.com  # The actual hostname or IP it will use to connect
User user2
Port 54321

Then ssh server1.domain1.com and ssh serv2 would do the Right Thing™ No need to remember the usernames or port you used for each machine. Benefiting both scripts and humans.

And should you later change the ports, there's a single place to modify.

It is also possible to use wildcards, so if all machines on domain1.com had its sshd listening on port 1234, you could match all of them with Host *.domain1.com, without having to list them individually.

0
10

For a for loop that can loop over more than one variable, switch to zsh. You're already using zsh syntax by not quoting your variables:

# zsh
for colour host port (
  green [email protected] 12345
  blue  [email protected] 54321
) {
  print -rP "%F{$colour}%B$host:$port%b%f"
  ssh -p $port $host 'crontab -l'
}

To loop over lists of arguments, you could use a shell with multidimensional arrays such as ksh93:

# ksh93
ssh_args=(
  (-p 12345 [email protected])
  (-p 54321 [email protected])
)
for i in "${!ssh_args[@]}"}; do
  printf '\e[1;32m%s\e[m\n' "${ssh_args[i][2]}"
  ssh "${ssh_args[i][@]}" 'crontab -l'
done

Or have the elements of the array containing strings with the arguments concatenated using a character that you know doesn't occur in the strings and split them. zsh does have a ${(s[x])var} splitting character.

ksh and bash also do but it's only via the clunky split+glob one that is invoked implicitly when you forget to quote an expansion there. You could use it as:

# ksh93 / bash / yash / mksh / zsh --emulate ksh
ssh_args=(
  -p:12345:[email protected]
  -p:54321:[email protected]
)
IFS=:; set -o noglob # tune your split+glob operator by specifying
                     # the separator and disabling globbing
for joined_args in "${ssh_args[@]}"; do
  args=( $joined_args ) # invoke split+glob
  printf '\e[1;32m%s\e[m\n' "${args[2]}"
  ssh "${args[@]}" 'crontab -l'
done

That's basically what you did in your approach except you used space instead of : to separate the elements, but didn't set $IFS and didn't disable glob.

The fact that your $i wasn't split could be explained either by $IFS being set to the empty string, or the code being run by zsh instead of bash, where neither splitting nor globbing is done implicitly upon parameter expansion.

In zsh, if you want IFS-splitting, use $=var instead of $var, and if you want globbing: $~var. bash's $var is like zsh's $=~var. So in zsh, you'd need:

# zsh
ssh_args=(
  -p:12345:[email protected]
  -p:54321:[email protected]
)
IFS=:
for joined_args in $ssh_args; do
  args=( $=joined_args ) # split on $IFS or better:
  args=( ${(s[:])joined_args} ) # split explicitly on : without
                               # having to touch $IFS
  print -rP "%F{green}%B$host[-1]%b%f"
  ssh $args 'crontab -l'
done

A portable approach that would work in sh would be to write it:

# sh / bash / dash / ksh / yash / zsh...
while IFS=' ' read<&3 host port; do
  {
    printf '\33[1;32m%s\e[m\n' "$host:$port"
    ssh -p "$port" "$host" 'crontab -l'
  } 3<&-
done 3<< 'EOF'
[email protected] 12345
[email protected] 54321
EOF

Omitting the -r option to read allows the values to contain the separator (here space) by prefixing them with a backslash like in:

john\ doe@server1 1234

But contrary to the other approaches, it doesn't allow those values to contain newline characters (likely not a problem for user, host, service names or port numbers).

Another portable one to loop over lists all of which have the same $n number of elements and that doesn't have that limitation is to store all the elements in the positional parameters and loop over them in a while [ "$#" -ge "$n" ]; do something with "$1" "$2"...; shift "$n"; done loop. So here:

# sh / bash / dash / ksh / yash / zsh...
eval "$(
  printf "fg_%s='\33[3%sm' " \
    black 0 red     1 green 2 yellow 3 \
    blue  4 magenta 5 cyan  6 white  7
  printf "bg_%s='\33[4%sm' " \
    black 0 red     1 green 2 yellow 3 \
    blue  4 magenta 5 cyan  6 white  7
  printf "attr_%s='\33[%sm' " \
    reset '' bold     1 dim     2 italics 3 underline 4 \
    blink 5  standout 7 reverse 7 secure  8
)"
    
set -- \
  "$fg_green$attr_bold" [email protected] 12345 \
  "$bg_blue$fg_white"   [email protected] 54321

while [ "$#" -ge 3 ]; do
  colour=$1 host=$2 port=$3
  printf '%s\n' "$colour$host:$port$attr_reset"
  ssh -p "$port" "$host" 'crontab -l'
  shift 3
done
1
  • Yay, zsh - it's working. I will remove the 'bash' tag. Thanks for the nicer print command as well - it's more readable. Fortunately, I've switched to zsh a few months ago. Glad to see it paying off. Commented Nov 4, 2023 at 16:03
1

A general solution for looping through two lists, using a pair of elements (one from either list) in each iteration of the loop, in a POSIX shell:

#!/bin/sh

# The list of port numbers
set -- 123245 54321

# Loop over the list of user@host strings
for remote in \
    [email protected] \
    [email protected]
do
    # Use "$remote" together with the port number in "$1", then shift

    printf 'Connecting to %s, port %s\n' "$remote" "$1"

    ssh -p "$1" "$remote" crontab -l
    shift
done
0

Use pdsh for multiple hosts.. It is widely used in data centers with large number of clustered hosts..

From pdsh documentation ..

Run with user 'foo' on hosts h0,h1,h2, and user 'bar' on hosts h3,h5:

pdsh -w foo@h[0-2],bar@h[3,5]

https://code.google.com/archive/p/pdsh/wikis/UsingPDSH.wiki

This tool along side with ssh config file as Ángel mentioned.

Full example below ..

~/.ssh/config

Host server1
Hostname server1
User user1  
Port 12345  

Host server2
Hostname server2
User user2
Port 54321

Now simple run

pdsh -w server1,server2 crontab -l 

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .