12

We can use the syntax ${var##pattern} and ${var%%pattern} to extract the last and first section of an IPv4 address:

IP=109.96.77.15
echo IP: $IP
echo 'Extract the first section using ${var%%pattern}: ' ${IP%%.*}
echo 'Extract the last section using ${var##pattern}: ' ${IP##*.}

How we can extract the second or third section of an IPv4 address using parameter expansion?

Here is my solution: I use an array and change the IFS variable.

:~/bin$ IP=109.96.77.15
:~/bin$ IFS=. read -a ArrIP<<<"$IP"
:~/bin$ echo ${ArrIP[1]}
    96
:~/bin$ printf "%s\n" "${ArrIP[@]}"
    109
    96
    77
    15

Also I have written some solutions using the awk, sed, and cut commands.

Now, my question is: Is there a simpler solution based on parameter expansion which does not use array and IFS changing?

sci9
  • 527
  • 1
    You should only set IFS for the read there: IFS=. read -a ArrIP<<<"$IP" – muru Jan 29 '18 at 04:08
  • 1
    Not in bash without using multiple variables at least. A single parameter expansion cannot get the second or third components. Zsh can nest parameters expansions, so it might be possible there. – muru Jan 29 '18 at 04:20
  • @muru Could you please provide the Zsh solution? – sci9 Jan 29 '18 at 04:38
  • 4
    What guarantee do you have that you will always be dealing with IP v4 addresses, and will never have an IP v6 address? – Mawg says reinstate Monica Jan 29 '18 at 10:46
  • 4
    Is there some reason IFS=. read a b c d <<< "$IP" isn't acceptable (if you're using Bash, that is)? Why does it have to be done with parameter expansion? – ilkkachu Jan 29 '18 at 13:17
  • Also, one can just change IFS within subshell to avoid changing it in current shell. I don't see how that's not a potential solution – Sergiy Kolodyazhnyy Jan 29 '18 at 19:47
  • @muru Never say never! See Stéphane Chazelas answer :) – Levi Uzodike Aug 17 '21 at 13:30
  • @Levi none of them use a single parameter expansion in bash. They're all combinations, or for shells other than bash. – muru Aug 17 '21 at 15:02
  • @muru Apologies for not being clear. I'm saying that his answer is not using multiple variables. – Levi Uzodike Aug 17 '21 at 16:42

10 Answers10

18

Assuming the default value of IFS you extract each octet into it's own variable with:

read A B C D <<<"${IP//./ }"

Or into an array with:

A=(${IP//./ })
8

Your problem statement may be a bit more liberal than you intended.  At the risk of exploiting a loophole, here’s the solution muru alluded to:

first=${IP%%.*}
last3=${IP#*.}
second=${last3%%.*}
last2=${last3#*.}
third=${last2%.*}
fourth=${last2#*.}
echo "$IP -> $first, $second, $third, $fourth"

This is somewhat clunky.  It defines two throw-away variables, and it is not readily adapted to handle more sections (e.g., for a MAC or IPv6 address).  Sergiy Kolodyazhnyy’s answer inspired me to generalize the above to this:

slice="$IP"
count=1
while [ "$count" -le 4 ]
do
    declare sec"$count"="${slice%%.*}"
    slice="${slice#*.}"
    count=$((count+1))
done

This sets sec1, sec2, sec3 and sec4, which can be verified with

printf 'Section 1: %s\n' "$sec1"
printf 'Section 2: %s\n' "$sec2"
printf 'Section 3: %s\n' "$sec3"
printf 'Section 4: %s\n' "$sec4"
  • The while loop should be easy to understand — it iterates four times.
  • Sergiy chose slice as the name for a variable that takes the place of last3 and last2 in my first solution (above).
  • declare sec"$count"="value" is a way to assign to sec1, sec2, sec3 and sec4 when count is 1, 2, 3 and 4.  It’s a little like eval, but safer.
  • The value, "${slice%%.*}", is analogous to the values my original answer assigns to first, second and third.
8

I do realize that you specifically asked for a solution that DID NOT temporarily redefine IFS, but I have a sweet and simple solution that you didn't cover, so here goes:

IFS=. ; set -- $IP

That short command will put the elements of your IP address in the shell's positional parameters $1, $2, $3, $4. However, you'll probably want to first save the original IFS and restore it afterwards.

Who knows? Maybe you'll reconsider and accept this answer for its brevity and efficiency.

(This was previously incorrectly given as IFS=. set -- $IP)

muru
  • 72,889
user1404316
  • 3,078
  • A nice one. Note that in a longer script, you have to store the start IFS, then do your method, then restore IFS to starting. Technically your answer does not involve explicit array so I think it qualifies since it's actually visually easier to understand than the more complex methods. If I had been asking this question this is the answer I would have liked the most. – Lizardx Jan 29 '18 at 05:14
  • 1
    Thanks. For the record, there does exist in bash another elegant solution, that DOES use an explicit array: IFS=. foo=( $IP ) will put each element in array foo, accessible with the form ${foo[n]}, with n starting at zero. – user1404316 Jan 29 '18 at 06:35
  • 5
    I don't think that works: if you change IFS on the same command line, the new value doesn't take effect when variables on the same command line are expanded. Same as with x=1; x=2 echo $x – ilkkachu Jan 29 '18 at 13:15
  • 1
    Since set is a special built-in, bash is supposed to retain the change to IFS after set returns, but it only does so in POSIX mode (e.g., bash --posix). – chepner Jan 29 '18 at 14:56
  • 6
    @chepner, but again, set doesn't make use of $IFS at all, $IFS is only used for the word splitting of $IP, but here it's assigned too late, so that has no effect. This answer is basically wrong. IP=109.96.77.15 bash -c 'IFS=. set -- $IP; echo "$2"' outputs nothing whether in POSIX mode or not. You'd need IFS=. command eval 'set -- $IP', or IFS=. read a b c d << "$IP" – Stéphane Chazelas Jan 29 '18 at 15:39
  • @StéphaneChazelas. You commented "this answer is basically wrong", but it works for me. Does it not work for you? – user1404316 Jan 29 '18 at 18:12
  • 4
    It probably works for you because you had set IFS to . in one of your previous tests. Run IP=1.2.3.4 bash -xc 'IFS=. set $IP; echo "$2"' and you'll see it doesn't work. And see IP=1.2.3.4 bash -o posix -xc 'IFS=. set $IP; echo "\$1=$1 \$2=$2 IFS=$IFS"' to illustrate @chepner's point. – Stéphane Chazelas Jan 29 '18 at 19:21
  • 1
    So many upvotes on an answer that is wrong...I wonder how long before someone's application breaks from using this answer without reading the comments. – Wildcard Jan 29 '18 at 20:57
  • 2
    haste did make waste, the answer is apparently wrong, and should be corrected to work, since it's still roughly how I'd do it, only with more code. – Lizardx Jan 29 '18 at 21:31
  • @StéphaneChazelas - I am coming to believe that you haven't bothered to try this. I just did two more tests. First, I manually reset IFS to some other value and then re-ran the solution successfully. Then, I ran the solution with a "wrong" IFS to verify that it would fail. Both tests succeed. – user1404316 Jan 29 '18 at 21:32
  • @StéphaneChazelas: Perform your test, but also add $IFS to your echo statement and you will see that your test is faulty in that the modified $IFS is not being passed to the inferior shell process. – user1404316 Jan 29 '18 at 21:36
  • 3
    @user1404316, can you post the exact set of commands you used to test it? (Plus the version of your shell, perhaps.) There are four other users who've told you here in the comments that it doesn't work as written in the answer. With examples. – ilkkachu Jan 29 '18 at 21:40
  • 1
    I get it to sometimes output the desired field, but usually not. I'm not sure what actually sets it to do so. But the people who say it does not work out of the box are right, I tried with new terminals/shells and it never works the first time, nor subsequent ones unless you do a specific thing.ip=2.4.5.6; IFS=. set -- $ip;echo $2 - output: ''; Once the shell IFS is set, it does work, but I'm not clear on how to set it. – Lizardx Jan 29 '18 at 21:44
  • 1
    D**m! @StéphaneChazelas, @Lizardx, @Wilcard are all correct. At some point, I inserted a the assignment of $IP after setting $IFS and put a ; in between them. Apologies, upvotes for all, but before I correct the "answer" let me double check with you all: IFS=. ; IP="1.2.3.4"; set -- $IP; printf "%s %s %s %s\n" "$IFS" $4 $3 $2 $1 – user1404316 Jan 29 '18 at 21:54
  • 2
    @user1404316, (the printf is missing one %s) But yeah, that would work. Except that it leaves IFS set to . for the rest of the script, so there's that issue. You'd need to save it in a temporary variable and then it's not that short and nice any more... – ilkkachu Jan 29 '18 at 22:02
  • 2
  • The fix fixed it, it now works. ip=123.2.45.234; IFS=. ; set -- $ip; echo $3 results in: 45 on a fresh terminal. However, the data then gets stuck in bash, so the next line, if you do this: ip=3.4.5.6;echo $4 it gives from the previous line: 234 To me that's too confusing to really get into more, so I'd avoid this solution because it's got unintended consequences that aren't all that intuitive. – Lizardx Jan 30 '18 at 03:02
  • @Lizardx, it's the set -- ... that assigns the positional parameters ($1, $2, they usually hold the arguments to the script or function, so it does overwrite them). The assignment is just a copy of the current state, it doesn't cause it to automatically update. There's something about them here, and the actual splitting action here is done with by word splitting on the set. (Greg's wiki is a good resource on Bash otherwise, too) – ilkkachu Jan 30 '18 at 12:35
5

Not the easiest, but you could do something like:

$ IP=109.96.77.15
$ echo "$((${-+"(${IP//./"+256*("}))))"}&255))"
109
$ echo "$((${-+"(${IP//./"+256*("}))))"}>>8&255))"
96
$ echo "$((${-+"(${IP//./"+256*("}))))"}>>16&255))"
77
$ echo "$((${-+"(${IP//./"+256*("}))))"}>>24&255))"
15

That should work in ksh93 (where that ${var//pattern/replacement} operator comes from), bash 4.3+, busybox sh, yash, mksh and zsh, though of course in zsh, there are much simpler approaches. In older versions of bash, you'd need to remove the inner quotes. It works with those inner quotes removed in most other shells as well, but not ksh93.

That assumes $IP contains a valid quad-decimal representation of an IPv4 address (though that would also work for quad-hexadecimal representations like 0x6d.0x60.0x4d.0xf (and even octal in some shells) but would output the values in decimal). If the content of $IP comes from an untrusted source, that would amount to a command injection vulnerability.

Basically, as we're replacing every . in $IP with +256*(, we end up evaluating:

 $(( (109+256*(96+256*(77+256*(15))))>> x &255 ))

So we're constructing a 32 bit integer out of those 4 bytes like an IPv4 address ultimately is (though with the bytes reversed)¹ and then using the >>, & bitwise operators to extract the relevant bytes.

We use the ${param+value} standard operator (here on $- which is guaranteed to be always set) instead of just value because otherwise the arithmetic parser would complain about mismatched parenthesis. The shell here can find the closing )) for the opening $((, and then perform the expansions inside that will result in the arithmetic expression to evaluate.

With $(((${IP//./"+256*("}))))&255)) instead, the shell would treat the second and third )s there as the closing )) for $(( and report a syntax error.

In ksh93, you can also do:

$ echo "${IP/@(*).@(*).@(*).@(*)/\2}"
96

bash, mksh, zsh have copied ksh93's ${var/pattern/replacement} operator but not that capture-group handling part. zsh supports it with a different syntax:

$ setopt extendedglob # for (#b)
$ echo ${IP/(#b)(*).(*).(*).(*)/$match[2]}'
96

bash does support some form of capture group handling in its regexp matching operator, but not in ${var/pattern/replacement}.

POSIXly, you'd use:

(IFS=.; set -o noglob; set -- $IP; printf '%s\n' "$2")

The noglob to avoid bad surprises for values of $IP like 10.*.*.*, the subshell to limit the scope of those changes to options and $IFS.


¹ An IPv4 address is just a 32 bit integer and 127.0.0.1 for instance is just one of many (though the most common) textual representations. That same typical IPv4 address of the loopback interface can also be represented as 0x7f000001 or 127.1 (maybe a more appropriate one here to say it's the 1 address on the 127.0/8 class A network), or 0177.0.1, or the other combinations of 1 to 4 numbers expressed as octal, decimal or hexadecimal. You can pass all those to ping for instance and you'll see they will all ping localhost.

If you don't mind the side effect of setting an arbitrary temporary variable (here $n), in bash or ksh93 or zsh -o octalzeroes or lksh -o posix, you can simply convert all those representations back to a 32 bit integer with:

$((n=32,(${IP//./"<<(n-=8))+("})))

And then extract all the components with >>/& combinations like above.

$ IP=0x7f000001
$ echo "$((n=32,(${IP//./"<<(n-=8))+("})))"
2130706433
$ IP=127.1
$ echo "$((n=32,(${IP//./"<<(n-=8))+("})))"
2130706433
$ echo "$((n=32,((${IP//./"<<(n-=8))+("}))>>24&255))"
127
$ perl -MSocket -le 'print unpack("L>", inet_aton("127.0.0.1"))'
2130706433

mksh uses signed 32 bit integers for its arithmetic expressions, you can use $((# n=32,...)) there to force the use of unsigned 32 bit numbers (and the posix option for it to recognise octal constants).

  • I understand the big concept, but I've never seen ${-+ before. I can't find any documentation on it either. It works, but I'm just curious to confirm, is it just to turn the string into a mathematical expression? Where can I find the formal definition? Also, the extra quotations inside the parameter expansion replacement section don't work in GNU bash, version 4.1.2(2)-release CentOS 6.6. I had to do this instead echo "$((${-+"(${IP//./+256*(}))))"}>>16&255))" – Levi Uzodike Mar 03 '20 at 23:13
  • 1
    @LeviUzodike, see edit. – Stéphane Chazelas Mar 04 '20 at 08:23
  • To construct a 32 bit integer out of those 4 bytes like an IPv4 address ultimately is (with the bytes NOT reversed): echo $((${-+"(256**3*${IP//./+(256**3*})/256)/256)/256)"})) (GNU bash, version 4.2.46(2)-release CentOS 7.9.2009; I'm guessing later versions require more quotations). And then with the original example 109.96.77.15, &255=>15, >>8&255=>77, >>16&255=>96, >>24&255=>109. Not as elegant since $(()) won't do 1/256 as a coefficient, but it works. Thanks, I learned a lot from this post! – Levi Uzodike Jul 27 '21 at 16:32
4

With zsh, you can nest parameter substitutions:

$ ip=12.34.56.78
$ echo ${${ip%.*}##*.}
56
$ echo ${${ip#*.}%%.*}
34

This is not possible in bash.

muru
  • 72,889
4

Sure, let's play the elephant game.

$ ipsplit() { local IFS=.; ip=(.$*); }
$ ipsplit 10.1.2.3
$ echo ${ip[1]}
10

or

$ ipsplit() { local IFS=.; echo $*; }
$ set -- `ipsplit 10.1.2.3`
$ echo $1
10
jthill
  • 2,710
  • 2
    What's the "elephant game"? – Wildcard Jan 29 '18 at 20:57
  • 1
    @Wildcard kind of an elliptical pun/joke, it's a reference both to the blind-people-describing an elephant story, there's so may parts to look at everybody's going to have their own take, and to the ancient custom of a king who wanted to give gifts with ruinously expensive upkeep, softened over the centuries to gifts of merely dubious value that tend to be regifted for entertainment value.. Both seemed to apply here :-) – jthill Jan 30 '18 at 01:16
4

With IP=12.34.56.78.

IFS=. read a b c d <<<"$IP"

And

#!/bin/bash
IP=$1

regex="(${IP//\./)\.(})"
[[ $IP =~ $regex ]]
echo "${BASH_REMATCH[@]:1}"

Description:

Use of parameter expansion ${IP// } to convert each dot in the ip to an opening parenthesis a dot and a closing parenthesis. Adding an initial parenthesis and a closing parenthesis, we get:

regex=(12)\.(34)\.(56)\.(78)

which will create four capture parenthesis for the regex match in the test syntax:

[[ $IP =~ $regex ]]

That allows the printing of the array BASH_REMATCH without the first component (the whole regex match):

echo "${BASH_REMATCH[@]:1}"

The amount of parenthesis is automatically adjusted to the matched string. So, this will match either a MAC or an EUI-64 of an IPv6 address despite them being of different length:

#!/bin/bash
IP=$1

regex="(${IP//:/):(})"
[[ $IP =~ $regex ]]
echo "${BASH_REMATCH[@]:1}"

Using it:

$ ./script 00:0C:29:0C:47:D5
00 0C 29 0C 47 D5

$ ./script 00:0C:29:FF:FE:0C:47:D5
00 0C 29 FF FE 0C 47 D5
3

Here's a small solution done with POSIX /bin/sh ( in my case that's dash ), a function that repeatedly uses parameter expansion (so no IFS here), and named pipes, and includes noglob option for reasons mentioned in Stephane's answer.

#!/bin/sh
set -o noglob
get_ip_sections(){
    slice="$1"
    count=0
    while [ -n "${slice}" ] && [ "$count" -ne 4 ]
    do
        num="${slice%%.*}"
        printf '%s ' "${num}"
        slice="${slice#*${num}.}"
        count=$((count+1))
    done
}

ip="109.96.77.15"
named_pipe="/tmp/ip_stuff.fifo"
mkfifo "${named_pipe}"
get_ip_sections "$ip" > "${named_pipe}" &
read sec1 sec2 sec3 sec4 < "${named_pipe}"
printf 'Actual ip:%s\n' "${ip}"
printf 'Section 1:%s\n' "${sec1}"
printf 'Section 3:%s\n' "${sec3}"
rm  "${named_pipe}"

This works as so:

$ ./get_ip_sections.sh 
Actual ip:109.96.77.15
Section 1:109
Section 3:77

And with ip changed to 109.*.*.*

$ ./get_ip_sections.sh 
Actual ip:109.*.*.*
Section 1:109
Section 3:*

The loop keeping counter of 4 iterations accounts for 4 sections of a valid IPv4 address, while acrobatics with named pipes account for need to further use sections of ip address within script as opposed to having variables stuck in a subshell of a loop.

0

Why don't use simple solution with awk?

$ IP="192.168.1.1" $ echo $IP | awk -F '.' '{ print $1" "$2" "$3" "$4;}'

Result $ 192 168 1 1

-2
$ ip_=192.168.2.3

$ echo $ip_ 

192.168.2.3

$ echo $ip_ |cut -d "." -f 1

192
GAD3R
  • 66,769