Permalink
Browse files

First incarnation of HTTPProtocolParser.

git-svn-id: http://maatkit.googlecode.com/svn/trunk@5079 dfb901c2-3250-0410-b216-0b33211c9131
  • Loading branch information...
1 parent 09b9ac1 commit 74a067b28a06350c50175f139b9b25a37dd4e95d daniel@percona.com committed Nov 9, 2009
Showing with 513 additions and 0 deletions.
  1. +317 −0 common/HTTPProtocolParser.pm
  2. +89 −0 common/t/HTTPProtocolParser.t
  3. +107 −0 common/t/samples/http_tcpdump001.txt
View
317 common/HTTPProtocolParser.pm
@@ -0,0 +1,317 @@
+# This program is copyright 2009 Percona Inc.
+# Feedback and improvements are welcome.
+#
+# THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+# WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+# MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+#
+# This program is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, version 2; OR the Perl Artistic License. On UNIX and similar
+# systems, you can issue `man perlgpl' or `man perlartistic' to read these
+# licenses.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
+# Place, Suite 330, Boston, MA 02111-1307 USA.
+# ###########################################################################
+# HTTPProtocolParser package $Revision$
+# ###########################################################################
+package HTTPProtocolParser;
+
+use strict;
+use warnings FATAL => 'all';
+use English qw(-no_match_vars);
+
+use Data::Dumper;
+$Data::Dumper::Indent = 1;
+$Data::Dumper::Sortkeys = 1;
+$Data::Dumper::Quotekeys = 0;
+
+use constant MKDEBUG => $ENV{MKDEBUG};
+
+# server is the "host:port" of the sever being watched. It's auto-guessed if
+# not specified.
+sub new {
+ my ( $class, %args ) = @_;
+
+ my ( $server_port )
+ = $args{server} ? $args{server} =~ m/:(\w+)/ : ('80');
+ $server_port ||= '80'; # In case $args{server} doesn't have a port.
+
+ my $self = {
+ server => $args{server},
+ server_port => $server_port,
+ sessions => {},
+ o => $args{o},
+ };
+ return bless $self, $class;
+}
+
+# The packet arg should be a hashref from TcpdumpParser::parse_event().
+# misc is a placeholder for future features.
+sub parse_packet {
+ my ( $self, $packet, $misc ) = @_;
+
+ my $src_host = "$packet->{src_host}:$packet->{src_port}";
+ my $dst_host = "$packet->{dst_host}:$packet->{dst_port}";
+
+ if ( my $server = $self->{server} ) { # Watch only the given server.
+ if ( $src_host ne $server && $dst_host ne $server ) {
+ MKDEBUG && _d('Packet is not to or from', $server);
+ return;
+ }
+ }
+
+ # Auto-detect the server by looking for port 80.
+ my $packet_from;
+ my $client;
+ if ( $src_host =~ m/:$self->{server_port}$/ ) {
+ $packet_from = 'server';
+ $client = $dst_host;
+ }
+ elsif ( $dst_host =~ m/:$self->{server_port}$/ ) {
+ $packet_from = 'client';
+ $client = $src_host;
+ }
+ else {
+ warn 'Packet is not to or from web server: ', Dumper($packet);
+ return;
+ }
+ MKDEBUG && _d('Client:', $client);
+
+ # Get the client's session info or create a new session if the
+ # client hasn't been seen before.
+ if ( !exists $self->{sessions}->{$client} ) {
+ MKDEBUG && _d('New session');
+ $self->{sessions}->{$client} = {
+ client => $client,
+ state => undef,
+ raw_packets => [],
+ # ts -- wait for ts later.
+ };
+ };
+ my $session = $self->{sessions}->{$client};
+
+ # Return early if there's no TCP data. These are usually ACK packets, but
+ # they could also be FINs in which case, we should close and delete the
+ # client's session.
+ if ( $packet->{data_len} == 0 ) {
+ MKDEBUG && _d('No TCP data');
+ return;
+ }
+
+ # Save raw packets to dump later in case something fails.
+ push @{$session->{raw_packets}}, $packet->{raw_packet};
+
+ # Finally, parse the packet and maybe create an event.
+ $packet->{data} = pack('H*', $packet->{data});
+ my $event;
+ if ( $packet_from eq 'server' ) {
+ $event = $self->_packet_from_server($packet, $session, $misc);
+ }
+ elsif ( $packet_from eq 'client' ) {
+ $event = $self->_packet_from_client($packet, $session, $misc);
+ }
+ else {
+ # Should not get here.
+ die 'Packet origin unknown';
+ }
+
+ MKDEBUG && _d('Done with packet; event:', Dumper($event));
+ return $event;
+}
+
+# Handles a packet from the server given the state of the session. Returns an
+# event if one was ready to be created, otherwise returns nothing.
+sub _packet_from_server {
+ my ( $self, $packet, $session, $misc ) = @_;
+ die "I need a packet" unless $packet;
+ die "I need a session" unless $session;
+
+ MKDEBUG && _d('Packet is from server; client state:', $session->{state});
+
+ my $data = $packet->{data};
+
+ # If there's no session state, then we're catching a server response
+ # mid-stream.
+ if ( !$session->{state} ) {
+ MKDEBUG && _d('Ignoring mid-stream server response');
+ return;
+ }
+
+ # Assume that the server is returning only one value.
+ # TODO: make it handle multiple.
+ if ( $session->{state} eq 'awaiting headers' ) {
+ MKDEBUG && _d('State:', $session->{state});
+
+ my ($line1, $header) = $packet->{data} =~ m/\A(.*?)\r\n(.+)?/s;
+ # First header val should be: version code phrase
+ # E.g.: HTTP/1.1 200 OK
+ my ($version, $code, $phrase) = $line1 =~ m/(\S+)/g;
+
+ $session->{response} = $code;
+ MKDEBUG && _d('Reponse code for last',
+ $session->{request}, $session->{page},
+ 'request:', $session->{response});
+
+ MKDEBUG && _d('HTTP header:', $header);
+ my @headers;
+ foreach my $val ( split(/\r\n/, $header) ) {
+ last unless $val;
+ # Capture and save any useful header values.
+ if ( $val =~ m/^Content-Length/i ) {
+ ($session->{bytes}) = $val =~ /: (\d+)/;
+ }
+ }
+ }
+ else {
+ return; # Prevent firing event.
+ }
+
+ MKDEBUG && _d('Creating event, deleting session');
+ my $event = make_event($session, $packet);
+ delete $self->{sessions}->{$session->{client}}; # http is stateless!
+ $session->{raw_packets} = []; # Avoid keeping forever
+ return $event;
+}
+
+# Handles a packet from the client given the state of the session.
+sub _packet_from_client {
+ my ( $self, $packet, $session, $misc ) = @_;
+ die "I need a packet" unless $packet;
+ die "I need a session" unless $session;
+
+ MKDEBUG && _d('Packet is from client; state:', $session->{state});
+
+ my $event;
+ if ( ($session->{state} || '') =~ m/awaiting / ) {
+ # Whoa, we expected something from the server, not the client. Fire an
+ # INTERRUPTED with what we've got, and create a new session.
+ MKDEBUG && _d("Expected data from the client, looks like interrupted");
+ $session->{res} = 'INTERRUPTED';
+ $event = make_event($session, $packet);
+ my $client = $session->{client};
+ delete @{$session}{keys %$session};
+ $session->{client} = $client;
+ }
+
+ my ($line1, $val);
+ my ($request, $page);
+ if ( !$session->{state} ) {
+ MKDEBUG && _d('Session state: ', $session->{state});
+ $session->{state} = 'awaiting headers';
+
+ # Split up the first line into its parts.
+ ($line1, $val) = $packet->{data} =~ m/\A(.*?)\r\n(.+)?/s;
+ my @vals = $line1 =~ m/(\S+)/g;
+ $request = lc shift @vals;
+ MKDEBUG && _d('Request:', $request);
+ if ( $request eq 'get' ) {
+ ($page) = shift @vals;
+ MKDEBUG && _d('Page:', $page);
+ }
+ else {
+ MKDEBUG && _d("Don't know how to handle", $request);
+ }
+
+ @{$session}{qw(request page)} = ($request, $page);
+ $session->{host} = $packet->{src_host};
+ $session->{pos_in_log} = $packet->{pos_in_log};
+ $session->{ts} = $packet->{ts};
+ }
+ else {
+ MKDEBUG && _d('Session state: ', $session->{state});
+ $val = $packet->{data};
+ }
+
+ return $event;
+}
+
+# The event is not yet suitable for mk-query-digest. It lacks, for example,
+# an arg and fingerprint attribute. The event should be passed to
+# HTTPEvent::make_event() to transform it.
+sub make_event {
+ my ( $session, $packet ) = @_;
+ my $event = {
+ request => $session->{request},
+ page => $session->{page},
+ response => $session->{response},
+ ts => $session->{ts},
+ host => $session->{host},
+ bytes => $session->{bytes} || 0,
+ reponse_time => timestamp_diff($session->{ts}, $packet->{ts}),
+ pos_in_log => $session->{pos_in_log},
+ };
+ return $event;
+}
+
+sub _get_errors_fh {
+ my ( $self ) = @_;
+ my $errors_fh = $self->{errors_fh};
+ return $errors_fh if $errors_fh;
+
+ # Errors file isn't open yet; try to open it.
+ my $o = $self->{o};
+ if ( $o && $o->has('tcpdump-errors') && $o->got('tcpdump-errors') ) {
+ my $errors_file = $o->get('tcpdump-errors');
+ MKDEBUG && _d('tcpdump-errors file:', $errors_file);
+ open $errors_fh, '>>', $errors_file
+ or die "Cannot open tcpdump-errors file $errors_file: $OS_ERROR";
+ }
+
+ $self->{errors_fh} = $errors_fh;
+ return $errors_fh;
+}
+
+sub fail_session {
+ my ( $self, $session, $reason ) = @_;
+ my $errors_fh = $self->_get_errors_fh();
+ if ( $errors_fh ) {
+ $session->{reason_for_failure} = $reason;
+ my $session_dump = '# ' . Dumper($session);
+ chomp $session_dump;
+ $session_dump =~ s/\n/\n# /g;
+ print $errors_fh "$session_dump\n";
+ {
+ local $LIST_SEPARATOR = "\n";
+ print $errors_fh "@{$session->{raw_packets}}";
+ print $errors_fh "\n";
+ }
+ }
+ MKDEBUG && _d('Failed session', $session->{client}, 'because', $reason);
+ delete $self->{sessions}->{$session->{client}};
+ return;
+}
+
+sub _d {
+ my ($package, undef, $line) = caller 0;
+ @_ = map { (my $temp = $_) =~ s/\n/\n# /g; $temp; }
+ map { defined $_ ? $_ : 'undef' }
+ @_;
+ print STDERR "# $package:$line $PID ", join(' ', @_), "\n";
+}
+
+# Returns the difference between two tcpdump timestamps. TODO: this is in
+# MySQLProtocolParser too, best to factor it out somewhere common.
+sub timestamp_diff {
+ my ( $start, $end ) = @_;
+ my $sd = substr($start, 0, 11, '');
+ my $ed = substr($end, 0, 11, '');
+ my ( $sh, $sm, $ss ) = split(/:/, $start);
+ my ( $eh, $em, $es ) = split(/:/, $end);
+ my $esecs = ($eh * 3600 + $em * 60 + $es);
+ my $ssecs = ($sh * 3600 + $sm * 60 + $ss);
+ if ( $sd eq $ed ) {
+ return sprintf '%.6f', $esecs - $ssecs;
+ }
+ else { # Assume only one day boundary has been crossed, no DST, etc
+ return sprintf '%.6f', ( 86_400 - $ssecs ) + $esecs;
+ }
+}
+
+1;
+
+# ###########################################################################
+# End HTTPProtocolParser package
+# ###########################################################################
View
89 common/t/HTTPProtocolParser.t
@@ -0,0 +1,89 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings FATAL => 'all';
+use English qw(-no_match_vars);
+use Test::More tests => 2;
+
+require "../HTTPProtocolParser.pm";
+require "../TcpdumpParser.pm";
+
+use Data::Dumper;
+$Data::Dumper::Quotekeys = 0;
+$Data::Dumper::Sortkeys = 1;
+$Data::Dumper::Indent = 1;
+
+my $tcpdump = new TcpdumpParser();
+my $protocol; # Create a new HTTPProtocolParser for each test.
+
+sub load_data {
+ my ( $file ) = @_;
+ open my $fh, '<', $file or BAIL_OUT("Cannot open $file: $OS_ERROR");
+ my $contents = do { local $/ = undef; <$fh> };
+ close $fh;
+ (my $data = join('', $contents =~ m/(.*)/g)) =~ s/\s+//g;
+ return $data;
+}
+
+sub run_test {
+ my ( $def ) = @_;
+ map { die "What is $_ for?" }
+ grep { $_ !~ m/^(?:misc|file|result|num_events|desc)$/ }
+ keys %$def;
+ my @e;
+ my $num_events = 0;
+
+ my @callbacks;
+ push @callbacks, sub {
+ my ( $packet ) = @_;
+ return $protocol->parse_packet($packet, undef);
+ };
+ push @callbacks, sub {
+ push @e, @_;
+ };
+
+ eval {
+ open my $fh, "<", $def->{file}
+ or BAIL_OUT("Cannot open $def->{file}: $OS_ERROR");
+ $num_events++ while $tcpdump->parse_event($fh, undef, @callbacks);
+ close $fh;
+ };
+ is($EVAL_ERROR, '', "No error on $def->{file}");
+ if ( defined $def->{result} ) {
+ is_deeply(
+ \@e,
+ $def->{result},
+ $def->{file} . ($def->{desc} ? ": $def->{desc}" : '')
+ ) or print "Got: ", Dumper(\@e);
+ }
+ if ( defined $def->{num_events} ) {
+ is($num_events, $def->{num_events}, "$def->{file} num_events");
+ }
+
+ # Uncomment this if you're hacking the unknown.
+ # print "Events for $def->{file}: ", Dumper(\@e);
+
+ return;
+}
+
+# GET a very simple page.
+$protocol = new HTTPProtocolParser();
+run_test({
+ file => 'samples/http_tcpdump001.txt',
+ result => [
+ { ts => '2009-11-09 11:31:52.341907',
+ bytes => '715',
+ host => '10.112.2.144',
+ pos_in_log => 0,
+ request => 'get',
+ page => '/contact',
+ response => '200',
+ reponse_time => '0.651419',
+ },
+ ],
+});
+
+# #############################################################################
+# Done.
+# #############################################################################
+exit;
View
107 common/t/samples/http_tcpdump001.txt
@@ -0,0 +1,107 @@
+2009-11-09 11:31:52.341907 IP 10.112.2.144.58680 > 64.13.232.157.80: tcp 428
+ 0x0000: 4500 01e0 9928 4000 4006 6a45 0a70 0290
+ 0x0010: 400d e89d e538 0050 ff78 7c13 9c69 ff04
+ 0x0020: 8018 006a 0f76 0000 0101 080a 0032 fa31
+ 0x0030: 0cea bf27 4745 5420 2f63 6f6e 7461 6374
+ 0x0040: 2048 5454 502f 312e 310d 0a48 6f73 743a
+ 0x0050: 2068 6163 6b6d 7973 716c 2e63 6f6d 0d0a
+ 0x0060: 5573 6572 2d41 6765 6e74 3a20 4d6f 7a69
+ 0x0070: 6c6c 612f 352e 3020 2858 3131 3b20 553b
+ 0x0080: 204c 696e 7578 2078 3836 5f36 343b 2065
+ 0x0090: 6e2d 5553 3b20 7276 3a31 2e39 2e30 2e31
+ 0x00a0: 3529 2047 6563 6b6f 2f32 3030 3931 3032
+ 0x00b0: 3831 3520 5562 756e 7475 2f39 2e30 3420
+ 0x00c0: 286a 6175 6e74 7929 2046 6972 6566 6f78
+ 0x00d0: 2f33 2e30 2e31 350d 0a41 6363 6570 743a
+ 0x00e0: 2074 6578 742f 6874 6d6c 2c61 7070 6c69
+ 0x00f0: 6361 7469 6f6e 2f78 6874 6d6c 2b78 6d6c
+ 0x0100: 2c61 7070 6c69 6361 7469 6f6e 2f78 6d6c
+ 0x0110: 3b71 3d30 2e39 2c2a 2f2a 3b71 3d30 2e38
+ 0x0120: 0d0a 4163 6365 7074 2d4c 616e 6775 6167
+ 0x0130: 653a 2065 6e2d 7573 2c65 6e3b 713d 302e
+ 0x0140: 350d 0a41 6363 6570 742d 456e 636f 6469
+ 0x0150: 6e67 3a20 677a 6970 2c64 6566 6c61 7465
+ 0x0160: 0d0a 4163 6365 7074 2d43 6861 7273 6574
+ 0x0170: 3a20 4953 4f2d 3838 3539 2d31 2c75 7466
+ 0x0180: 2d38 3b71 3d30 2e37 2c2a 3b71 3d30 2e37
+ 0x0190: 0d0a 4b65 6570 2d41 6c69 7665 3a20 3330
+ 0x01a0: 300d 0a43 6f6e 6e65 6374 696f 6e3a 206b
+ 0x01b0: 6565 702d 616c 6976 650d 0a52 6566 6572
+ 0x01c0: 6572 3a20 6874 7470 3a2f 2f68 6163 6b6d
+ 0x01d0: 7973 716c 2e63 6f6d 2f66 6171 0d0a 0d0a
+2009-11-09 11:31:52.406461 IP 64.13.232.157.80 > 10.112.2.144.58680: tcp 0
+ 0x0000: 4500 0034 4ae8 4000 3306 c731 400d e89d
+ 0x0010: 0a70 0290 0050 e538 9c69 ff04 ff78 7dbf
+ 0x0020: 8010 0014 7b14 0000 0101 080a 0cea c06c
+ 0x0030: 0032 fa31
+2009-11-09 11:31:52.993326 IP 64.13.232.157.80 > 10.112.2.144.58680: tcp 967
+ 0x0000: 4500 03fb 4ae9 4000 3306 c369 400d e89d
+ 0x0010: 0a70 0290 0050 e538 9c69 ff04 ff78 7dbf
+ 0x0020: 8018 0014 bcb2 0000 0101 080a 0cea c0a6
+ 0x0030: 0032 fa31 4854 5450 2f31 2e31 2032 3030
+ 0x0040: 204f 4b0d 0a44 6174 653a 204d 6f6e 2c20
+ 0x0050: 3039 204e 6f76 2032 3030 3920 3138 3a33
+ 0x0060: 303a 3536 2047 4d54 0d0a 5365 7276 6572
+ 0x0070: 3a20 4170 6163 6865 2f32 2e30 2e35 340d
+ 0x0080: 0a58 2d50 6f77 6572 6564 2d42 793a 2050
+ 0x0090: 4850 2f34 2e34 2e38 0d0a 5661 7279 3a20
+ 0x00a0: 4163 6365 7074 2d45 6e63 6f64 696e 670d
+ 0x00b0: 0a43 6f6e 7465 6e74 2d45 6e63 6f64 696e
+ 0x00c0: 673a 2067 7a69 700d 0a43 6f6e 7465 6e74
+ 0x00d0: 2d4c 656e 6774 683a 2037 3135 0d0a 4b65
+ 0x00e0: 6570 2d41 6c69 7665 3a20 7469 6d65 6f75
+ 0x00f0: 743d 352c 206d 6178 3d39 370d 0a43 6f6e
+ 0x0100: 6e65 6374 696f 6e3a 204b 6565 702d 416c
+ 0x0110: 6976 650d 0a43 6f6e 7465 6e74 2d54 7970
+ 0x0120: 653a 2074 6578 742f 6874 6d6c 0d0a 0d0a
+ 0x0130: 1f8b 0800 0000 0000 0003 9d54 5b6f d330
+ 0x0140: 147e cfaf 3818 8981 b4c6 eb26 04db 9208
+ 0x0150: e83a 31a9 63b0 96db d3e4 3a27 8b35 c74e
+ 0x0160: 6da7 1710 ff1d 3b69 7763 6288 97d8 3997
+ 0x0170: ef3b f677 8e93 2747 6783 c9f7 8f43 285d
+ 0x0180: 25e1 e3e7 77a3 9301 901e a55f f706 941e
+ 0x0190: 4d8e e0db fbc9 e908 faf1 0e8c 9d11 dc51
+ 0x01a0: 3afc 4080 94ce d507 942e 168b 78b1 176b
+ 0x01b0: 7349 27e7 7419 50fa 216d bded d936 27ce
+ 0x01c0: 5d4e b228 6949 9695 5436 7d00 a0bf bfbf
+ 0x01d0: dfe5 9110 7420 99ba 4c09 2a02 d7bb 8081
+ 0x01e0: 2cf7 8b13 4e62 36d0 ca31 eee0 3de3 5770
+ 0x01f0: ba1a 7f1a 25b4 f344 4985 8e81 6215 a6c4
+ 0x0200: e8a9 7696 00f7 e1a8 5c4a 9416 2ac7 e536
+ 0x0210: 285d 6829 f582 00dd a484 c27a 386b c43c
+ 0x0220: 2583 2ea1 3759 d578 2bdd e1d2 d150 e821
+ 0x0230: f092 198b 2e3d 199f f55e bf7e b9df eb77
+ 0x0240: 5052 a82b 3028 5362 dd4a a22d 111d 81d2
+ 0x0250: 6071 7df4 d217 5dad ec4c c65c 57b4 0b8b
+ 0x0260: b9f5 753a 4fb7 6669 ff03 60eb bfef c9a2
+ 0x0270: b814 15fc 9c6a 93a3 e9f9 533a 5d1d ecd6
+ 0x0280: 4bb0 5a8a 1c9e 163b af8a 9d9d c35f 51d2
+ 0x0290: 1164 91df 7557 1825 539d af3c 722e e620
+ 0x02a0: f294 9ca2 6a3c 2200 5c9b a66e 6d60 c025
+ 0x02b0: b341 3651 1168 a152 c2b5 d4e6 60c3 f1b7
+ 0x02c0: d391 ecb6 422c 0b90 d493 dc65 13aa d01d
+ 0x02d0: df1d 4aa5 dd45 4bdb e14f a5be a424 7be7
+ 0x02e0: 9700 f54c 4d6d 7d78 fbfb 1840 c166 243b
+ 0x02f0: 7efb e91f b31f 3f31 efba 906c daf1 ffca
+ 0x0300: 0a1d e315 1d85 2520 dcc4 774d 7c71 e174
+ 0x0310: 4db2 8d6b 7d7f eb25 ba91 71dd b3e4 96b2
+ 0x0320: bcbf 96b1 dc7d 7064 bcb9 75d7 1bd2 9302
+ 0x0330: 56ba 8192 cdd1 8f88 ea39 e4a5 129c 4998
+ 0x0340: 3568 9dd0 6a1b 6a89 cc22 60c5 8404 e620
+ 0x0350: e13a c76c 3c1c 0d07 1338 1f7e 199e 8f87
+ 0x0360: cfb7 2acd 6339 b3ab ea8a b3f2 0d17 72da
+ 0x0370: d45b 2f12 da06 c7c9 d484 e6ee 488f b581
+ 0x0380: 1b22 61ad a7ba a671 6605 aec4 701d 7faa
+ 0x0390: 084c e5ad 77ce 8cd0 8d85 5c73 db1a 2f1b
+ 0x03a0: 91a3 8542 18eb eeb2 9d29 a8b1 7150 3323
+ 0x03b0: d100 2a28 0c53 cf9e eeee f50f 99b0 6005
+ 0x03c0: cc03 94ff 48fc 01cf ab60 f413 7837 4a6d
+ 0x03d0: f9eb f018 162a d63e 530d fa9d 43a3 504a
+ 0x03e0: 7c11 774a d519 44f7 f44a e87a fada 7724
+ 0x03f0: 8b7e 035a 975d 6f8a 0500 00
+2009-11-09 11:31:52.993366 IP 10.112.2.144.58680 > 64.13.232.157.80: tcp 0
+ 0x0000: 4500 0034 9929 4000 4006 6bf0 0a70 0290
+ 0x0010: 400d e89d e538 0050 ff78 7dbf 9c6a 02cb
+ 0x0020: 8010 007e 7606 0000 0101 080a 0032 fad4
+ 0x0030: 0cea c0a6
+

0 comments on commit 74a067b

Please sign in to comment.