Accessing TCP Options from iRules

I’ve written several articles on the TCP profile and enjoy digging into TCP.  It’s a beast, and I am constantly re-learning the inner workings.  Still etched in my visual memory map, however, is the TCP header format, shown in Figure 1 below.

Since 9.0 was released, TCP payload data (that which comes after the header) has been consumable in iRules via the TCP::payload and the port information has been available in the contextual commands TCP::local_port/TCP::remote_port and of course TCP::client_port/TCP::server_port.  Options, however, have been inaccessible.  But beginning with version 10.2.0-HF2, it is now possible to retrieve data from the options fields.

Preparing the BIG-IP

Prior to version 11.0, it was necessary to set a bigpipe database key with the option (or options) of interest:

bigpipe db Rules.Tcpoption.settings [option, first|last], [option, first|last]

In version 11.0 and forward, the DB keys are no more and you need to create a tcp profile with the these options defined, like so:

ltm profile tcp tcp_opt {
    app-service none
    tcp-options "{option first|last} {option first|last}"
}
The option is an integer between 2 and 255, and the first/last setting indicates whether the system will retain the first or last instance of the specified option.  Once that key is set, you’ll need to do a bigstart restart for it to take (warning: service impacting).  Note also that the LTM only collects option data starting with the ACK of a connection.  The initial SYN is ignored even if you select the first keyword.  This is done to prevent a SYN flood attack (in keeping with SYN-cookies).

A New iRules Command: TCP::option

The TCP::option command has the following syntax:

TCP::option get <option>

v11 Additions/Changes:
TCP::option set <option number> <value> <next|all>
TCP::option noset <option number>

Pretty simple, no? So now that you can access them, what fun can be had? 

Real World Scenario: Akamai

In Akamai’s IPA and SXL product lines, they support client IP visibility by embedding a version number (one byte) and an IPv4 address (four bytes) as part of their overlay path feature in tcp option number 28.  To access this data, we first set the database key:

tmsh create ltm profile tcp tcp_opt tcp-options "{28 first}"

Now, the iRule utilizing the TCP::option command:

when CLIENT_ACCEPTED {
  set opt28 [TCP::option get 28]
  if { [string length $opt28] == 5 } {
    binary scan $opt28 cH8 ver addr
    if { $ver != 1 } {
      log local0. "Unsupported Akamai version: $ver"
    } else {
        scan $addr "%2x%2x%2x%2x" ip1 ip2 ip3 ip4
        set optaddr "$ip1.$ip2.$ip3.$ip4"
    }
   }
}
when HTTP_REQUEST {
  if { [info exists optaddr] } {
    HTTP::header insert "X-Forwarded-For" $optaddr
  }
}

The Akamai version should be one, so we log if not.  Otherwise, we take the address (stored in the variable addr in hex) and scan it to get the decimal equivalents to build the address for inserting in the X-Forwarded-For header.  Cool, right?  Also cool—along with the new TCP::option command , an extension was made to the IP::addr command to parse binary fields into a dotted decimal IP address.  This extension is also available beginning in 10.2.0-HF2, but extended in 11.0.  Here’s the syntax:

IP::addr parse [-ipv4 | -ipv6 [swap]] <binary field> [<offset>]

So for example, if you had an IPv6 address in option 28 with a 1 byte offset, you would parse that like:

log local0. "IP::addr parse IPv6 output: [IP::addr parse -ipv6 [TCP::option get 28] 1]"

## Log Result ##
May 27 21:51:34 ltm13 info tmm[27207]: Rule /Common/tcpopt_test <CLIENT_ACCEPTED>: IP::addr parse IPv6 output: 2601:1930:bd51:a3e0:20cd:a50b:1cc1:ad13

But in the context of our TCP option, we have 5-bytes of data with the first byte not mattering in the context of an address, so we get at the address with this:

set optaddr [IP::addr parse -ipv4 [TCP::option get 28] 1]

This cleans up the rule a bit:

when CLIENT_ACCEPTED {
  set opt28 [TCP::option get 28]
  if { [string length $opt28] == 5 } {
    binary scan $opt c ver
    if { $ver != 1 } {
      log local0. "Unsupported Akamai version: $ver"
    } else {
        set optaddr [IP::addr parse -ipv4 $opt28 1]
    }
  }
}
when HTTP_REQUEST {
  if { [info exists optaddr] } {
    HTTP::header insert "X-Forwarded-For" $optaddr
  }
}

No need to store the address in the first binary scan and no need for the scan command at all so I eliminated those.  Setting a forwarding header is not the only thing we can do with this data.  It could also be shipped off to a logging server, or used as a snat address (assuming the server had either a default route to the BIG-IP, or specific routes for the customer destinations, which is doubtful).  Logging is trivial, shown below with the log command.  The HSL commands could be used in lieu of log if sending off-box to a log server.

when CLIENT_ACCEPTED {
  set opt28 [TCP::option get 28]
  if { [string length $opt28] == 5 } {
    binary scan $opt c ver
    if { $ver != 1 } {
      log local0. "Unsupported Akamai version: $ver"
    } else {
        set optaddr [IP::addr parse -ipv4 $opt28 1]
        log local0. "Client IP extracted from Akamai TCP option is $optaddr"
    }
  }
}

If setting the provided IP as a snat address, you’ll want to make sure it’s a valid IP address before doing so.  You can use the TCL catch command and IP::addr to perform this check as seen in the iRule below:

when CLIENT_ACCEPTED {
  set addrs [list \
    "192.168.1.1" \
    "256.168.1.1" \
    "192.256.1.1" \
    "192.168.256.1" \
    "192.168.1.256" \
  ]
  foreach x $addrs {
    if { [catch {IP::addr $x mask 255.255.255.255}] } {
      log local0. "IP $x is invalid"
    } else { log local0. "IP $x is valid" }
  }
}

The output of this iRule:

<CLIENT_ACCEPTED>: IP 192.168.1.1 is valid 
<CLIENT_ACCEPTED>: IP 256.168.1.1 is invalid 
<CLIENT_ACCEPTED>: IP 192.256.1.1 is invalid 
<CLIENT_ACCEPTED>: IP 192.168.256.1 is invalid 
<CLIENT_ACCEPTED>: IP 192.168.1.256 is invalid

Adding this logic into a functional rule with snat:

when CLIENT_ACCEPTED {
  set opt28 [TCP::option get 28]
  if { [string length $opt28] == 5 } {
    binary scan $opt c ver
    if { $ver != 1 } {
      log local0. "Unsupported Akamai version: $ver"
    } else {
        set optaddr [IP::addr parse -ipv4 $opt28 1]
        if { [catch {IP::addr $x mask 255.255.255.255}] } {
          log local0. "$optaddr is not a valid address"
          snat automap
        } else {
            log local0. "Akamai inserted Client IP is $optaddr.  Setting as snat address."
            snat $optaddr
        }
   }
}

Alternative TCP Option Use Cases

The Akamai solution shows an application implementation taking advantage of normally unused space in TCP headers.    There are, however, defined uses for several option “kind” numbers.  The list is available here: http://www.iana.org/assignments/tcp-parameters/tcp-parameters.xml.  Some options that might be useful in troubleshooting efforts:

  • Opkind 2 – Max Segment Size
  • Opkind 3 – Window Scaling
  • Opkind 5 – Selective Acknowledgements
  • Opkind 8 – Timestamps

Of course, with tcpdump you get all this plus the context of other header information and data, but hey, another tool in the toolbox, right?

Addendum

I've been working with F5 SE Leonardo Simon on on additional examples I wanted to share here that uses option 28 or 253 to extract an IPv6 address if the version is 34 and otherwise extracts an IPv4 address if the version is 1 or 2.

Option 28

when CLIENT_ACCEPTED {
  set opt28 [TCP::option get 28]
  binary scan $opt28 c ver
  #log local0. "version: $ver"
    if { $ver == 34 } {
        set optaddr [IP::addr parse -ipv6 $opt28 1]
        log local0. "opt28 ipv6 address: $optaddr"
    }
    elseif { $ver == 1 || $ver == 2 } {
        set optaddr [IP::addr parse -ipv4 $opt28 1]
        log local0. "opt28 ipv4 address: $optaddr"
    }
}

Option 253

when CLIENT_ACCEPTED {
  set opt253 [TCP::option get 253]
  binary scan $opt253 c ver
  #log local0. "version: $ver"
    if { $ver == 34 } {
        set optaddr [IP::addr parse -ipv6 $opt253 1]
        log local0. "opt253 ipv6 address: $optaddr"
    }
    elseif { $ver == 1 || $ver == 2 } {
        set optaddr [IP::addr parse -ipv4 $opt253 1]
        log local0. "opt253 ipv4 address: $optaddr"
    }
}
Published Mar 25, 2011
Version 1.0
  • Note: "Once that key is set, you’ll need to do a bigstart restart for it to take (warning: service impacting)."

     

     

    That is required for all releases 10.2.0 HF2 through 10.2.2. Starting in 10.2.2 HF1 the restart is no longer required.
  • Note that beginning with v11.0.0 the 'b db Rules.tcpoption.settings' no longer exists. It is now 'baked in' to the TCP profile and configured as so:

     

     

    create ltm profile tcp tcp_options profile-name “{option } {option }”

     

  • Whoops, my angle brackets got eaten as HTML in my last comment:

     

    create ltm profile tcp tcp_options profile-name “{option } {option }”
  • Quite informative and gives enough information to play with TCP Options using iRule. Thanks Jason! Cheers!
  • when I try to create this profile. when I apply it to the VIP I get an error. create ltm profile tcp tcp_options tcp-options "{8 first}{28 last}" Bad tcp options value in profile: Could not parse TCP options settings
  • Jason,

     

    Shouldn't the line:

     

    if { [catch {IP::addr $x mask 255.255.255.255}] } {

     

    Be:

     

    if { [catch {IP::addr $optaddr mask 255.255.255.255}] } {

     

    Also, does [IP::addr parse] throw an exception if the bytearray doesn't contain a valid IP address?

     

    If so, then the catch block around the [IP:addr] on the next line should be moved up a line.

     

    Joel

     

  • Yes, you're correct:

    ---

    } else {

    set optaddr [IP::addr parse -ipv4 $opt28 1]

    if { [catch {IP::addr $x mask 255.255.255.255}] } {

    log local0. "$optaddr is not a valid address"

    snat automap

    ---

     

    That $x should be $optaddr in this example.

     

    It isn't clear from https://clouddocs.f5.com/api/irules/IP__addr.html if it throws an exception, and I'm not sure offhand - probably something to check.

  • Is there an example to insert an IPv6 address inside the TCP header with tcp::option? I want to preserve the IPv6 client IP when using NAT64 with CGNAT.

  • I don't have an example, but you could use 'tcp::option set' https://clouddocs.f5.com/api/irules/TCP__option.html

     

    And use option 28 or 253, version 34, which is used elsewhere for IPv6 - as you can see reading it in the addendum above.