From eb9e93ebd4d36a77a58a1f9a8f666e34a6a92652 Mon Sep 17 00:00:00 2001 From: Colin Curtin Date: Fri, 4 Jan 2013 18:05:42 -0800 Subject: [PATCH 1/5] Provide binary support. --- lib/cmdline.rb | 4 ++++ lib/sniffer.rb | 63 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/lib/cmdline.rb b/lib/cmdline.rb index 5197d2b..bf92e16 100644 --- a/lib/cmdline.rb +++ b/lib/cmdline.rb @@ -6,6 +6,10 @@ def self.parse(args) @config = {} opts = OptionParser.new do |opt| + opt.on('-b', '--binary', 'Binary protocol (default plaintext)') do |binary| + @config[:binary] = true + end + opt.on('-i', '--interface=NIC', 'Network interface to sniff (required)') do |nic| @config[:nic] = nic end diff --git a/lib/sniffer.rb b/lib/sniffer.rb index f75fee2..c97d6ed 100644 --- a/lib/sniffer.rb +++ b/lib/sniffer.rb @@ -2,6 +2,8 @@ require 'thread' class MemcacheSniffer + HEADER = "CCnCCnNNQ" + FIELDS = [:magic, :opcode, :keylen, :extlen, :datatype, :status, :bodylen, :opaque, :cas] attr_accessor :metrics, :semaphore def initialize(config) @@ -29,20 +31,20 @@ def start cap.loop do |packet| @metrics[:stats] = cap.stats - # parse key name, and size from VALUE responses - if packet.raw_data =~ /VALUE (\S+) \S+ (\S+)/ - key = $1 - bytes = $2 - - @semaphore.synchronize do - if @metrics[:calls].has_key?(key) - @metrics[:calls][key] += 1 - else - @metrics[:calls][key] = 1 - end - - @metrics[:objsize][key] = bytes.to_i + if @config[:binary] + response = parse_binary(packet.raw_data) + # Response + if response[:magic] == 0x81 + metric(response[:key], response[:bodylen]) end + else + # parse key name, and size from VALUE responses + if packet.raw_data =~ /VALUE (\S+) \S+ (\S+)/ + key = $1 + bytes = $2 + end + + metric(key, bytes) end break if @done @@ -51,6 +53,41 @@ def start cap.close end + def metric(key, bytes) + @semaphore.synchronize do + if @metrics[:calls].has_key?(key) + @metrics[:calls][key] += 1 + else + @metrics[:calls][key] = 1 + end + + @metrics[:objsize][key] = bytes.to_i + end + end + + def parse_binary(data) + response = {} + index = 0 + header = Hash[FIELDS.zip(data[0..23].unpack(HEADER))] + index = 24 + if header[:extlen] != 0 + response[:extras] = data[index..(index + header[:extlen])] + index += header[:extlen] + end + + if header[:keylen] != 0 + response[:key] = data[index..(index + header[:keylen])] + index += header[:keylen] + end + + if header[:bodylen] != 0 + response[:body] = data[index..(index + header[:bodylen])] + index += header[:bodylen] + end + + response + end + def done @done = true end From 9a720bffc7677e9df5915fce2f19493860c5faf3 Mon Sep 17 00:00:00 2001 From: Colin Curtin Date: Fri, 4 Jan 2013 19:03:32 -0800 Subject: [PATCH 2/5] Try detecting where the header starts. --- lib/sniffer.rb | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/lib/sniffer.rb b/lib/sniffer.rb index c97d6ed..9aae4a2 100644 --- a/lib/sniffer.rb +++ b/lib/sniffer.rb @@ -9,6 +9,7 @@ class MemcacheSniffer def initialize(config) @source = config[:nic] @port = config[:port] + @binary = config[:binary] @metrics = {} @metrics[:calls] = {} @@ -31,11 +32,15 @@ def start cap.loop do |packet| @metrics[:stats] = cap.stats - if @config[:binary] - response = parse_binary(packet.raw_data) - # Response - if response[:magic] == 0x81 - metric(response[:key], response[:bodylen]) + if @binary + # Assume the header starts at the first 0x81 we see. + header_start = (packet.raw_data.force_encoding("BINARY") =~ Regexp.new("\x81", nil, 'n')) + if header_start + response = parse_binary(packet.raw_data[header_start..-1]) + # See that we parsed it correctly. + if response[:magic] == 0x81 && response[:opcode] <= 26 && response[:datatype] == 0 + metric(response[:key], response[:bodylen]) + end end else # parse key name, and size from VALUE responses @@ -66,25 +71,23 @@ def metric(key, bytes) end def parse_binary(data) - response = {} - index = 0 - header = Hash[FIELDS.zip(data[0..23].unpack(HEADER))] + response = Hash[FIELDS.zip(data[0..23].unpack(HEADER))] index = 24 - if header[:extlen] != 0 - response[:extras] = data[index..(index + header[:extlen])] - index += header[:extlen] + if response[:extlen] != 0 + response[:extras] = data[index..(index + response[:extlen])] + index += response[:extlen] end - if header[:keylen] != 0 - response[:key] = data[index..(index + header[:keylen])] - index += header[:keylen] + if response[:keylen] != 0 + response[:key] = data[index..(index + response[:keylen])] + index += response[:keylen] end - if header[:bodylen] != 0 - response[:body] = data[index..(index + header[:bodylen])] - index += header[:bodylen] + if response[:bodylen] != 0 + response[:body] = data[index..(index + response[:bodylen])] + index += response[:bodylen] end - + response end From ab75bc9805dc994b3f4cff1c9cb68d648e631341 Mon Sep 17 00:00:00 2001 From: Colin Curtin Date: Fri, 4 Jan 2013 20:37:11 -0800 Subject: [PATCH 3/5] Binary parsing now mostly works. --- lib/sniffer.rb | 134 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 18 deletions(-) diff --git a/lib/sniffer.rb b/lib/sniffer.rb index 9aae4a2..cb7f321 100644 --- a/lib/sniffer.rb +++ b/lib/sniffer.rb @@ -2,8 +2,61 @@ require 'thread' class MemcacheSniffer + # The following constants are adapted from + # https://github.com/mperham/dalli/blob/master/lib/dalli/server.rb HEADER = "CCnCCnNNQ" FIELDS = [:magic, :opcode, :keylen, :extlen, :datatype, :status, :bodylen, :opaque, :cas] + + MAGIC = { + 0x80 => 'Request', + 0x81 => 'Response' + } + + RESPONSE_CODES = { + 0 => 'No error', + 1 => 'Key not found', + 2 => 'Key exists', + 3 => 'Value too large', + 4 => 'Invalid arguments', + 5 => 'Item not stored', + 6 => 'Incr/decr on a non-numeric value', + 0x20 => 'Authentication required', + 0x81 => 'Unknown command', + 0x82 => 'Out of memory', + } + + OPCODES = { + :get => 0x00, + :set => 0x01, + :add => 0x02, + :replace => 0x03, + :delete => 0x04, + :incr => 0x05, + :decr => 0x06, + :flush => 0x08, + :noop => 0x0A, + :version => 0x0B, + :getk => 0x0C, + :getkq => 0x0D, + :append => 0x0E, + :prepend => 0x0F, + :stat => 0x10, + :setq => 0x11, + :addq => 0x12, + :replaceq => 0x13, + :deleteq => 0x14, + :incrq => 0x15, + :decrq => 0x16, + :quitq => 0x17, + :flushq => 0x18, + :appendq => 0x19, + :prependq => 0x1A, + :auth_negotiation => 0x20, + :auth_request => 0x21, + :auth_continue => 0x22, + :touch => 0x1C, + }.invert + attr_accessor :metrics, :semaphore def initialize(config) @@ -33,13 +86,25 @@ def start @metrics[:stats] = cap.stats if @binary - # Assume the header starts at the first 0x81 we see. - header_start = (packet.raw_data.force_encoding("BINARY") =~ Regexp.new("\x81", nil, 'n')) + # Assume the header starts at the first magic/[opcode/keylen/extlen]/datatype we see. + header_start = (packet.raw_data.force_encoding("BINARY") =~ Regexp.new("(\x80|\x81)....\x00", nil, 'n')) if header_start - response = parse_binary(packet.raw_data[header_start..-1]) - # See that we parsed it correctly. - if response[:magic] == 0x81 && response[:opcode] <= 26 && response[:datatype] == 0 - metric(response[:key], response[:bodylen]) + data = packet.raw_data[header_start..-1] + header = parse_header(data) + # See that we found the right part of the packet for the header. + if header[:opcode] && header[:opcode] <= 26 + puts data.unpack('H*').inspect, header.inspect if $dump + response = parse_binary(header, data) + puts response.inspect if $dump + + # TODO: We can't get the response length for GET requests yet, + # since in binary mode the response usually doesn't include the key. + # We'll have to track req/resp :/ + # Then break it apart into metric_key for request + # And metric_bytes for response + if response[:key] + metric(response[:key].gsub("\0",'\0'), header[:bodylen]) + end end end else @@ -59,6 +124,7 @@ def start end def metric(key, bytes) + return unless key && bytes @semaphore.synchronize do if @metrics[:calls].has_key?(key) @metrics[:calls][key] += 1 @@ -70,24 +136,56 @@ def metric(key, bytes) end end - def parse_binary(data) - response = Hash[FIELDS.zip(data[0..23].unpack(HEADER))] - index = 24 - if response[:extlen] != 0 - response[:extras] = data[index..(index + response[:extlen])] - index += response[:extlen] + def metric_key(key) + @semaphore.synchronize do + if @metrics[:calls].has_key?(key) + @metrics[:calls][key] += 1 + else + @metrics[:calls][key] = 1 + end + end + end + + def metric_bytes(key, bytes) + @semaphore.synchronize do + @metrics[:objsize][key] = bytes.to_i end + end - if response[:keylen] != 0 - response[:key] = data[index..(index + response[:keylen])] - index += response[:keylen] + def parse_header(data) + return {} if data.size < 24 + + header = Hash[FIELDS.zip(data[0..23].unpack(HEADER))] + + if $dump + header[:magic_name] = MAGIC[header[:magic]] + header[:opcode_name] = OPCODES[header[:opcode]] + header[:status_name] = RESPONSE_CODES[header[:status]] end - if response[:bodylen] != 0 - response[:body] = data[index..(index + response[:bodylen])] - index += response[:bodylen] + header + end + + def parse_binary(header, data) + index = 24 + response = {} + + if header[:extlen] != 0 + response[:extras] = data[index..(index + header[:extlen] - 1)] + index += header[:extlen] end + if header[:keylen] != 0 + response[:key] = data[index..(index + header[:keylen] - 1)] + index += header[:keylen] + end + + # We don't really care about bodies. This errors out with bad header lengths. + # if header[:bodylen] != 0 + # response[:body] = data[index..(index + header[:bodylen] -1)] + # index += header[:bodylen] + # end + response end From 39feb6c1de504b005040e5496b8243fac5ac75a0 Mon Sep 17 00:00:00 2001 From: Colin Curtin Date: Fri, 4 Jan 2013 20:39:29 -0800 Subject: [PATCH 4/5] The highest opcode is 0x22 currently. --- lib/sniffer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/sniffer.rb b/lib/sniffer.rb index cb7f321..6eb53e5 100644 --- a/lib/sniffer.rb +++ b/lib/sniffer.rb @@ -92,7 +92,7 @@ def start data = packet.raw_data[header_start..-1] header = parse_header(data) # See that we found the right part of the packet for the header. - if header[:opcode] && header[:opcode] <= 26 + if header[:opcode] && header[:opcode] <= 34 puts data.unpack('H*').inspect, header.inspect if $dump response = parse_binary(header, data) puts response.inspect if $dump @@ -103,7 +103,7 @@ def start # Then break it apart into metric_key for request # And metric_bytes for response if response[:key] - metric(response[:key].gsub("\0",'\0'), header[:bodylen]) + metric(response[:key].gsub(/\0/,'[null]'), header[:bodylen]) end end end From 25ea69acd0bb47e6c19fa6650c4a3e9bea3b0695 Mon Sep 17 00:00:00 2001 From: Colin Curtin Date: Fri, 4 Jan 2013 20:41:39 -0800 Subject: [PATCH 5/5] Might as well express that in hex. --- lib/sniffer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sniffer.rb b/lib/sniffer.rb index 6eb53e5..437a15e 100644 --- a/lib/sniffer.rb +++ b/lib/sniffer.rb @@ -92,7 +92,7 @@ def start data = packet.raw_data[header_start..-1] header = parse_header(data) # See that we found the right part of the packet for the header. - if header[:opcode] && header[:opcode] <= 34 + if header[:opcode] && header[:opcode] <= 0x22 puts data.unpack('H*').inspect, header.inspect if $dump response = parse_binary(header, data) puts response.inspect if $dump