IPv6 address formatting

IPv6 has a monumental amount of flexibility. That flexibility flow over into the address format, which can be - in some cases - a bit frustrating. Since IPv6 address can be presented in several ways, and this can be frustrating for those writing code to suport IPv6 or engineers trying to create templates. It can also be painful during troubleshooting. In an effort to craft something that is similar to my mac address shell tool , I wanted something similar for IPv6 addresses.

Remembering that my programming skills are not teriffic, and all self-taught (ok, I leanred some Apple Basic in the 80s, some C in college, and used Perl in the “olden days”) This proved the rule that developing for all permutations of a valid IPv6 address can be somewhat daunting.

After a lot of attempts this is what works:

└─[$] v6fmt 3fff:0209:0001:0000:0000:0000:0000:0001                                                                                                                        [13:33:44]
Expanded:      3fff:0209:0001:0000:0000:0000:0000:0001
Compressed:    3fff:209:1::1
Uppercase:     3FFF:209:1::1
URL format:    [3fff:209:1::1]
Dotted:        3.f.f.f.0.2.0.9.0.0.0.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1
Binary:        0011111111111111:0000001000001001:0000000000000001:0000000000000000:0000000000000000:0000000000000000:0000000000000000:0000000000000001
Reverse DNS:   1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.9.0.2.0.f.f.f.3.ip6.arpa
Type:          Global Unicast

Compression permutations:
  3fff:209:1:0:0:0:0:1
  3fff:209:1::0:0:1
  3fff:209:1::0:1
  3fff:209:1::1
  3fff:209:1:0::0:1
  3fff:209:1:0::1
  3fff:209:1:0:0::1

Even this seemingly simple bit of text wrangling was painful. there are probably better ways to do this, and likely more simple tools, but I wanted this to be part of my operating shell, so here we are. Add this into your .zshrc and it should work. I am unsure if it works with bash, but I suspect because of the parameter expansion flags, globbing, and array work that is unique to zsh, it likely will not.

This is also available in as a web service on ipv6.army .

v6fmt() {
  local ip="$1"

  [[ -z "$ip" ]] && { print -r -- "Usage: v6fmt <ipv6-address>[/prefix-length]" >&2; return 1 }

  # Normalize: lowercase, strip whitespace
  ip="${ip:l}"
  ip="${ip//[[:space:]]}"

  # Strip and save optional prefix length
  local prefix_len=""
  if [[ "$ip" == */* ]]; then
    prefix_len="${ip#*/}"
    ip="${ip%/*}"
  fi

  # Strip URL brackets and zone ID
  ip="${ip#\[}"
  ip="${ip%\]}"
  ip="${ip%%\%*}"

  # Handle embedded IPv4 (e.g., ::ffff:192.0.2.1)
  if [[ "$ip" == *:*.* ]]; then
    local v4="${ip##*:}"
    local v6pfx="${ip%:*}"
    if [[ "$v4" == *.*.*.* ]]; then
      local -a octs=(${(s:.:)v4})
      local h1 h2
      printf -v h1 '%02x%02x' "${octs[1]}" "${octs[2]}"
      printf -v h2 '%02x%02x' "${octs[3]}" "${octs[4]}"
      ip="${v6pfx}:${h1}:${h2}"
    fi
  fi

  # Basic validation
  if [[ ! "$ip" =~ ^[0-9a-f:]+$ ]]; then
    print -r -- "Error: Invalid IPv6 address" >&2
    return 1
  fi

  # Expand :: notation
  local prefix suffix zeros_needed full_ip i g
  if [[ "$ip" == *::* ]]; then
    prefix="${ip%%::*}"
    suffix="${ip#*::}"

    local pre_count=0 suf_count=0
    for g in ${(s.:.)prefix}; [[ -n "$g" ]] && ((pre_count++))
    for g in ${(s.:.)suffix}; [[ -n "$g" ]] && ((suf_count++))

    zeros_needed=$((8 - pre_count - suf_count))
    full_ip="$prefix"
    for ((i = 0; i < zeros_needed; i++)); do
      full_ip="${full_ip}:0000"
    done
    [[ -n "$suffix" ]] && full_ip="${full_ip}:${suffix}"
    full_ip="${full_ip#:}"
  else
    full_ip="$ip"
  fi

  # Split into 8 groups
  local groups=(${(s.:.)full_ip})
  while (( $#groups < 8 )); do groups+=('0000'); done
  (( $#groups > 8 )) && groups=("${(@)groups[1,8]}")

  # Pad each group to 4 hex digits
  local full_groups=() padded
  for g in $groups; do
    g="${g#0}"
    [[ -z "$g" ]] && g=0
    printf -v padded '%04x' "0x${g}"
    full_groups+=("$padded")
  done

  # Build expanded string
  local expanded="${full_groups[1]}"
  for ((i = 2; i <= 8; i++)); do
    expanded="${expanded}:${full_groups[i]}"
  done

  # Strip leading zeros per group
  local short_groups=()
  for g in $full_groups; do
    while [[ "$g" == 0?* && "$g" != "0" ]]; do g="${g#0}"; done
    short_groups+=("$g")
  done

  # Build no-compression form
  local nocompress="${short_groups[1]}"
  for ((i = 2; i <= 8; i++)); do
    nocompress="${nocompress}:${short_groups[i]}"
  done

  # Find longest run of consecutive zero groups (RFC 5952)
  local best_start=-1 best_len=0 cur_start=-1 cur_len=0
  for ((i = 1; i <= 8; i++)); do
    if [[ "${short_groups[i]}" == "0" ]]; then
      [[ "$cur_start" == -1 ]] && cur_start=$((i - 1))
      ((cur_len++))
    else
      if (( cur_len > best_len )); then
        best_len=$cur_len
        best_start=$cur_start
      fi
      cur_start=-1
      cur_len=0
    fi
  done
  if (( cur_len > best_len )); then
    best_len=$cur_len
    best_start=$cur_start
  fi

  # Build canonical (compressed) form per RFC 5952
  local canonical
  if (( best_len > 1 )); then
    canonical=""
    for ((i = 1; i <= 8; i++)); do
      if (( i == best_start + 1 )); then
        canonical="${canonical}::"
        ((i = best_start + best_len))
        continue
      fi
      [[ -n "$canonical" && "${canonical[-1]}" != ':' ]] && canonical="${canonical}:"
      canonical="${canonical}${short_groups[i]}"
    done
  else
    canonical="$nocompress"
  fi

  # === Derived formats ===

  # Suffix for prefix length display
  local sfx=""
  [[ -n "$prefix_len" ]] && sfx="/${prefix_len}"

  # Hex string (32 nibbles, no separators)
  local hex_str="${expanded//:/}"

  # Dotted notation (each nibble separated by dots)
  local dotted=""
  for ((i = 1; i <= ${#hex_str}; i++)); do
    (( i > 1 )) && dotted="${dotted}."
    dotted="${dotted}${hex_str[i]}"
  done

  # Binary representation (16-bit groups separated by :)
  local -A h2b=(
    0 0000 1 0001 2 0010 3 0011
    4 0100 5 0101 6 0110 7 0111
    8 1000 9 1001 a 1010 b 1011
    c 1100 d 1101 e 1110 f 1111
  )
  local binary=""
  for ((i = 1; i <= ${#hex_str}; i++)); do
    binary="${binary}${h2b[${hex_str[i]}]}"
    (( i % 4 == 0 && i < ${#hex_str} )) && binary="${binary}:"
  done

  # Reverse DNS (ip6.arpa)
  local rdns=""
  for ((i = ${#hex_str}; i >= 1; i--)); do
    (( i < ${#hex_str} )) && rdns="${rdns}."
    rdns="${rdns}${hex_str[i]}"
  done
  rdns="${rdns}.ip6.arpa"

  # Address type classification
  local addr_type
  if [[ "$expanded" == "0000:0000:0000:0000:0000:0000:0000:0001" ]]; then
    addr_type="Loopback"
  elif [[ "$expanded" == "0000:0000:0000:0000:0000:0000:0000:0000" ]]; then
    addr_type="Unspecified"
  elif [[ "$expanded" == 0000:0000:0000:0000:0000:ffff:* ]]; then
    addr_type="IPv4-Mapped"
  elif [[ "$expanded" == 0064:ff9b:0000:0000:0000:0000:* ]]; then
    addr_type="NAT64 Well-Known Prefix (RFC 6052)"
  elif [[ "$expanded" == 0064:ff9b:0001:* ]]; then
    addr_type="NAT64 Local-Use Prefix (RFC 8215)"
  elif [[ "$expanded" == 0100:0000:0000:0000:* ]]; then
    addr_type="Discard-Only (RFC 6666)"
  elif [[ "$expanded" == 2001:0db8:* ]]; then
    addr_type="Documentation (RFC 3849)"
  elif [[ "$expanded" == 2001:0000:* ]]; then
    addr_type="Teredo (RFC 4380)"
  elif [[ "$expanded" == 2002:* ]]; then
    addr_type="6to4 (RFC 3056)"
  elif [[ "${expanded[1,2]}" == "fe" ]]; then
    case "${expanded[3]}" in
      [89ab]) addr_type="Link-Local" ;;
      [cdef]) addr_type="Site-Local (Deprecated)" ;;
      *) addr_type="Unknown" ;;
    esac
  elif [[ "${expanded[1,2]}" == "fc" || "${expanded[1,2]}" == "fd" ]]; then
    addr_type="Unique Local Address (ULA)"
  elif [[ "${expanded[1,2]}" == "ff" ]]; then
    case "${expanded[4]}" in
      1) addr_type="Multicast (Interface-Local)" ;;
      2) addr_type="Multicast (Link-Local)" ;;
      4) addr_type="Multicast (Admin-Local)" ;;
      5) addr_type="Multicast (Site-Local)" ;;
      8) addr_type="Multicast (Organization-Local)" ;;
      e) addr_type="Multicast (Global)" ;;
      *) addr_type="Multicast" ;;
    esac
  elif [[ "${expanded[1]}" == [23] ]]; then
    addr_type="Global Unicast"
  else
    addr_type="Reserved"
  fi

  # === Output ===

  printf '%-14s %s\n' "Expanded:" "${expanded}${sfx}"
  printf '%-14s %s\n' "Compressed:" "${canonical}${sfx}"
  printf '%-14s %s\n' "Uppercase:" "${canonical:u}${sfx}"
  printf '%-14s %s\n' "URL format:" "[${canonical}]"
  printf '%-14s %s\n' "Dotted:" "$dotted"
  printf '%-14s %s\n' "Binary:" "$binary"
  printf '%-14s %s\n' "Reverse DNS:" "$rdns"
  printf '%-14s %s\n' "Type:" "$addr_type"

  # Compression permutations
  print ""
  print "Compression permutations:"
  print "  $nocompress"

  local start end sub_start sub_len out
  for ((start = 0; start < 8; )); do
    [[ "${short_groups[start+1]}" != "0" ]] && { ((start++)); continue }

    end=$start
    while (( end < 8 )) && [[ "${short_groups[end+1]}" == "0" ]]; do
      ((end++))
    done

    for ((sub_start = start; sub_start <= end - 2; sub_start++)); do
      for ((sub_len = 2; sub_len <= end - sub_start; sub_len++)); do
        out=""
        for ((i = 1; i <= sub_start; i++)); do
          [[ -n "$out" ]] && out="${out}:"
          out="${out}${short_groups[i]}"
        done
        out="${out}::"
        for ((i = sub_start + sub_len + 1; i <= 8; i++)); do
          [[ -n "$out" && "${out[-1]}" != ':' ]] && out="${out}:"
          out="${out}${short_groups[i]}"
        done
        print "  $out"
      done
    done

    ((start = end))
  done
}