From c3dea0b98e0ff39ed1cd186c53c7514ba0120efd Mon Sep 17 00:00:00 2001 From: Huang Qiangxiong Date: Wed, 23 Feb 2022 00:37:28 +0800 Subject: [PATCH] GRPC: Add support for gRPC-Web Supporting both application/grpc-web and application/grpc-web-text. Add test case for grpc-web(-text). close #17939 --- docbook/release-notes.adoc | 1 + epan/dissectors/packet-grpc.c | 223 +++++++++++++----- epan/dissectors/packet-protobuf.c | 4 + test/captures/grpc_web.pcapng.gz | Bin 0 -> 14420 bytes .../user_defined_types/greet.proto | 22 ++ test/suite_dissection.py | 140 +++++++++++ 6 files changed, 336 insertions(+), 54 deletions(-) create mode 100644 test/captures/grpc_web.pcapng.gz create mode 100644 test/protobuf_lang_files/user_defined_types/greet.proto diff --git a/docbook/release-notes.adoc b/docbook/release-notes.adoc index 82090bc4ae..89c0792146 100644 --- a/docbook/release-notes.adoc +++ b/docbook/release-notes.adoc @@ -130,6 +130,7 @@ Secure File Transfer Protocol (sftp) Secure Host IP Configuration Protocol (SHICP) USB Attached SCSI (UASP) ZBOSS NCP +gRPC Web (gRPC-Web) -- === Updated Protocol Support diff --git a/epan/dissectors/packet-grpc.c b/epan/dissectors/packet-grpc.c index 93dccddc40..388fcd5da5 100644 --- a/epan/dissectors/packet-grpc.c +++ b/epan/dissectors/packet-grpc.c @@ -1,6 +1,6 @@ /* packet-grpc.c * Routines for GRPC dissection - * Copyright 2017, Huang Qiangxiong + * Copyright 2017,2022 Huang Qiangxiong * * Wireshark - Network traffic analyzer * By Gerald Combs @@ -12,10 +12,12 @@ /* * The information used comes from: * https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md +* https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md * -* This GRPC dissector must be invoked by HTTP2 dissector. +* This GRPC dissector must be invoked by HTTP2 or HTTP dissector. +* The native GRPC is always over HTTP2, the GRPC-Web is over either HTTP2 or HTTP. * -* The main task of grpc dissector includes: +* The main task of GRPC dissector for native GRPC includes: * * 1. Parse grpc message header first, if header shows message is compressed, * it will find grpc-encoding http2 header by invoking http2_get_header_value() @@ -47,14 +49,15 @@ * Content-type level subdissector can use this information to locate * the request/response message type. * -* -* TODO -* Support tap. -* Support statistics. +* For GRPC-WEB, the ways to get information like content-type, path (request uri) +* are different. And for GRPC-WEB-TEXT, the dissector will first decode the base64 +* payload and then dissect the data as GRPC-WEB. */ #include "config.h" +#include +#include #include #include #include @@ -62,6 +65,7 @@ #include #include +#include "packet-http.h" #include "wsutil/pint.h" #define GRPC_MESSAGE_HEAD_LEN 5 @@ -72,6 +76,9 @@ /* http2 for grpc */ #define HTTP2_HEADER_GRPC_ENCODING "grpc-encoding" +/* calculate the size of a bytes after decoding as base64 */ +#define BASE64_ENCODE_SIZE(len) ((len) / 3 * 4 + ((len) % 3 == 0 ? 0 : 4)) + /* * Decompression of zlib encoded entities. */ @@ -83,20 +90,39 @@ static gboolean grpc_decompress_body = FALSE; /* detect json automatically */ static gboolean grpc_detect_json_automatically = TRUE; -/* whether embed GRPC messages under HTTP2 protocol tree items */ +/* whether embed GRPC messages under HTTP2 (or other) protocol tree items */ static gboolean grpc_embedded_under_http2 = FALSE; void proto_register_grpc(void); void proto_reg_handoff_grpc(void); static int proto_grpc = -1; +static int proto_http = -1; /* message header */ +static int hf_grpc_frame_type = -1; static int hf_grpc_compressed_flag = -1; static int hf_grpc_message_length = -1; /* message body */ static int hf_grpc_message_data = -1; +/* grpc protocol type */ +#define grpc_protocol_type_vals_VALUE_STRING_LIST(XXX) \ + XXX(GRPC_PTYPE_GRPC, 0, "GRPC") \ + XXX(GRPC_PTYPE_GRPC_WEB, 1, "GRPC-Web") \ + XXX(GRPC_PTYPE_GRPC_WEB_TEXT, 2, "GRPC-Web-Text") + +typedef VALUE_STRING_ENUM(grpc_protocol_type_vals) grpc_protocol_type_t; +VALUE_STRING_ARRAY(grpc_protocol_type_vals); + +/* grpc frame type (grpc-web extension) */ +#define grpc_frame_type_vals_VALUE_STRING_LIST(XXX) \ + XXX(GRPC_FRAME_TYPE_DATA, 0, "Data") \ + XXX(GRPC_FRAME_TYPE_TRAILER, 1, "Trailer") + +VALUE_STRING_ENUM(grpc_frame_type_vals); +VALUE_STRING_ARRAY(grpc_frame_type_vals); + /* compressed flag vals */ #define grpc_compressed_flag_vals_VALUE_STRING_LIST(XXX) \ XXX(GRPC_NOT_COMPRESSED, 0, "Not Compressed") \ @@ -115,6 +141,15 @@ static int ett_grpc_message = -1; static int ett_grpc_encoded_entity = -1; static dissector_handle_t grpc_handle; +static dissector_handle_t data_text_lines_handle; + +/* the information used during dissecting a grpc message */ +typedef struct { + gboolean is_request; /* is request or response message */ + const gchar* path; /* is http2 ":path" or http request_uri, format: "/" Service-Name "/" {method name} */ + const gchar* content_type; /* is http2 or http content-type, like: application/grpc */ + const gchar* encoding; /* is grpc-encoding header containing compressed method, for example "gzip" */ +} grpc_context_info_t; /* GRPC message type dissector table list. * Dissectors can register themselves in this table as grpc message data dissectors. @@ -128,13 +163,25 @@ static dissector_handle_t grpc_handle; */ static dissector_table_t grpc_message_type_subdissector_table; +static grpc_protocol_type_t +get_grpc_protocol_type(const gchar* content_type) { + if (content_type != NULL) { + if (g_str_has_prefix(content_type, "application/grpc-web-text")) { + return GRPC_PTYPE_GRPC_WEB_TEXT; + } else if (g_str_has_prefix(content_type, "application/grpc-web")) { + return GRPC_PTYPE_GRPC_WEB; + } + } + return GRPC_PTYPE_GRPC; +} + /* Try to dissect grpc message according to grpc message info or http2 content_type. */ static void dissect_body_data(proto_tree *grpc_tree, packet_info *pinfo, tvbuff_t *tvb, const gint offset, gint length, gboolean continue_dissect, - const gchar* http2_path, gboolean is_request) + guint32 frame_type, grpc_context_info_t *grpc_ctx) { - const gchar *http2_content_type; + const gchar *http2_content_type = grpc_ctx->content_type; gchar *grpc_message_info; tvbuff_t *next_tvb; int dissected; @@ -142,12 +189,16 @@ dissect_body_data(proto_tree *grpc_tree, packet_info *pinfo, tvbuff_t *tvb, cons proto_tree_add_bytes_format_value(grpc_tree, hf_grpc_message_data, tvb, offset, length, NULL, "%u bytes", length); + if (frame_type == GRPC_FRAME_TYPE_TRAILER) { + call_dissector(data_text_lines_handle, tvb_new_subset_length(tvb, offset, length), pinfo, grpc_tree); + return; + } + if (!continue_dissect) { return; /* if uncompress failed, we don't continue dissecting. */ } - http2_content_type = http2_get_header_value(pinfo, HTTP2_HEADER_CONTENT_TYPE, FALSE); - if (http2_content_type == NULL || http2_path == NULL) { + if (http2_content_type == NULL || grpc_ctx->path == NULL) { return; /* not continue if there is not enough grpc information */ } @@ -189,7 +240,7 @@ dissect_body_data(proto_tree *grpc_tree, packet_info *pinfo, tvbuff_t *tvb, cons * application/grpc,/helloworld.Greeter/SayHello,request */ grpc_message_info = wmem_strconcat(pinfo->pool, http2_content_type, ",", - http2_path, ",", (is_request ? "request" : "response"), NULL); + grpc_ctx->path, ",", (grpc_ctx->is_request ? "request" : "response"), NULL); parent_tree = proto_tree_get_parent_tree(grpc_tree); @@ -208,11 +259,8 @@ dissect_body_data(proto_tree *grpc_tree, packet_info *pinfo, tvbuff_t *tvb, cons } static gboolean -can_uncompress_body(packet_info *pinfo, const gchar **compression_method) +can_uncompress_body(const gchar *grpc_encoding) { - const gchar *grpc_encoding = http2_get_header_value(pinfo, HTTP2_HEADER_GRPC_ENCODING, FALSE); - *compression_method = grpc_encoding; - /* check http2 have a grpc-encoding header appropriate */ return grpc_decompress_body && grpc_encoding != NULL @@ -223,20 +271,27 @@ can_uncompress_body(packet_info *pinfo, const gchar **compression_method) to 5 + message_length according to grpc wire format definition. */ static guint dissect_grpc_message(tvbuff_t *tvb, guint offset, guint length, packet_info *pinfo, proto_tree *grpc_tree, - const gchar* http2_path, gboolean is_request) + grpc_context_info_t* grpc_ctx) { - guint32 compressed_flag, message_length; - const gchar *compression_method; + guint32 frame_type, compressed_flag, message_length; + const gchar *compression_method = grpc_ctx->encoding; /* GRPC message format: Delimited-Message -> Compressed-Flag Message-Length Message Compressed-Flag -> 0 / 1 # encoded as 1 byte unsigned integer Message-Length -> {length of Message} # encoded as 4 byte unsigned integer Message -> *{binary octet} (may be protobuf or json) + + Note: GRPC-WEB extend the MSB of Compressed-Flag as frame type (0-data, 1-trailer) */ + proto_tree_add_item_ret_uint(grpc_tree, hf_grpc_frame_type, tvb, offset, 1, ENC_BIG_ENDIAN, &frame_type); proto_tree_add_item_ret_uint(grpc_tree, hf_grpc_compressed_flag, tvb, offset, 1, ENC_BIG_ENDIAN, &compressed_flag); offset += 1; + if (frame_type == GRPC_FRAME_TYPE_TRAILER) { + proto_item_append_text(proto_tree_get_parent(grpc_tree), " (Trailer)"); + } + proto_tree_add_item(grpc_tree, hf_grpc_message_length, tvb, offset, 4, ENC_BIG_ENDIAN); message_length = length - 5; /* should be equal to tvb_get_ntohl(tvb, offset) */ offset += 4; @@ -247,7 +302,7 @@ dissect_grpc_message(tvbuff_t *tvb, guint offset, guint length, packet_info *pin /* uncompressed message data if compressed_flag is set */ if (compressed_flag & GRPC_COMPRESSED) { - if (can_uncompress_body(pinfo, &compression_method)) { + if (can_uncompress_body(compression_method)) { proto_item *compressed_proto_item = NULL; tvbuff_t *uncompressed_tvb = tvb_child_uncompress(tvb, tvb, offset, message_length); @@ -261,33 +316,37 @@ dissect_grpc_message(tvbuff_t *tvb, guint offset, guint length, packet_info *pin guint uncompressed_length = tvb_captured_length(uncompressed_tvb); add_new_data_source(pinfo, uncompressed_tvb, "Uncompressed entity body"); proto_item_append_text(compressed_proto_item, " -> %u bytes", uncompressed_length); - dissect_body_data(grpc_tree, pinfo, uncompressed_tvb, 0, uncompressed_length, TRUE, - http2_path, is_request); + dissect_body_data(grpc_tree, pinfo, uncompressed_tvb, 0, uncompressed_length, TRUE, frame_type, grpc_ctx); } else { proto_tree_add_expert(compressed_entity_tree, pinfo, &ei_grpc_body_decompression_failed, tvb, offset, message_length); - dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, FALSE, http2_path, is_request); + dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, FALSE, frame_type, grpc_ctx); } } else { /* compressed flag is set, but we can not uncompressed */ - dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, FALSE, http2_path, is_request); + dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, FALSE, frame_type, grpc_ctx); } } else { - dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, TRUE, http2_path, is_request); + dissect_body_data(grpc_tree, pinfo, tvb, offset, message_length, TRUE, frame_type, grpc_ctx); } return offset + message_length; } static int -dissect_grpc(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_) +dissect_grpc_common(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, grpc_context_info_t *grpc_ctx) { proto_item *ti; proto_tree *grpc_tree; guint32 message_length; guint offset = 0; - const gchar* http2_path; - gboolean is_request; guint tvb_len = tvb_reported_length(tvb); + grpc_protocol_type_t proto_type; + const gchar* proto_name; + + DISSECTOR_ASSERT_HINT(grpc_ctx && grpc_ctx->content_type && grpc_ctx->path, "The content_type and path of grpc context must be set."); + + proto_type = get_grpc_protocol_type(grpc_ctx->content_type); + proto_name = val_to_str_const(proto_type, grpc_protocol_type_vals, "GRPC"); if (!grpc_embedded_under_http2 && proto_tree_get_parent_tree(tree)) { tree = proto_tree_get_parent_tree(tree); @@ -323,50 +382,99 @@ dissect_grpc(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_ } /* ready to add information into protocol columns and tree */ if (offset == 0) { /* change columns only when there is at least one grpc message will be parsed */ - col_set_str(pinfo->cinfo, COL_PROTOCOL, "GRPC"); - col_append_str(pinfo->cinfo, COL_INFO, " (GRPC)"); + col_set_str(pinfo->cinfo, COL_PROTOCOL, proto_name); + col_append_fstr(pinfo->cinfo, COL_INFO, " (%s)", proto_name); col_set_fence(pinfo->cinfo, COL_PROTOCOL); } ti = proto_tree_add_item(tree, proto_grpc, tvb, offset, message_length + GRPC_MESSAGE_HEAD_LEN, ENC_NA); grpc_tree = proto_item_add_subtree(ti, ett_grpc_message); + proto_item_set_text(ti, "%s Message", proto_name); - /* http2_path contains: "/" Service-Name "/" {method name} */ - http2_path = http2_get_header_value(pinfo, HTTP2_HEADER_PATH, FALSE); - is_request = (http2_path != NULL); - - if (http2_path == NULL) { /* this response, so we get it from http2 request stream */ - http2_path = http2_get_header_value(pinfo, HTTP2_HEADER_PATH, TRUE); + if (grpc_ctx->path) { + proto_item_append_text(ti, ": %s, %s", grpc_ctx->path, (grpc_ctx->is_request ? "Request" : "Response")); } - if (http2_path) { - proto_item_append_text(ti, ": %s, %s", http2_path, (is_request ? "Request" : "Response")); - } - - offset = dissect_grpc_message(tvb, offset, GRPC_MESSAGE_HEAD_LEN + message_length, pinfo, grpc_tree, http2_path, is_request); + offset = dissect_grpc_message(tvb, offset, GRPC_MESSAGE_HEAD_LEN + message_length, pinfo, grpc_tree, grpc_ctx); } return tvb_captured_length(tvb); } +static int +dissect_grpc(tvbuff_t* tvb, packet_info* pinfo, proto_tree* tree, void* data) +{ + int ret; + http_conv_t* http_conv; + tvbuff_t* real_data_tvb; + grpc_context_info_t grpc_ctx = { 0 }; + conversation_t* conv = find_or_create_conversation(pinfo); + http_message_info_t* http_msg_info = (http_message_info_t*)data; + gboolean is_grpc_web_text = g_str_has_prefix(pinfo->match_string, "application/grpc-web-text"); + + if (is_grpc_web_text) { + real_data_tvb = base64_tvb_to_new_tvb(tvb, 0, tvb_reported_length(tvb)); + add_new_data_source(pinfo, real_data_tvb, "Decoded base64 body"); + } else { + real_data_tvb = tvb; + } + + if (proto_is_frame_protocol(pinfo->layers, "http2")) { + grpc_ctx.path = http2_get_header_value(pinfo, HTTP2_HEADER_PATH, FALSE); + grpc_ctx.is_request = (grpc_ctx.path != NULL); + if (grpc_ctx.path == NULL) { + /* this must be response, so we get it from http2 request stream */ + grpc_ctx.path = http2_get_header_value(pinfo, HTTP2_HEADER_PATH, TRUE); + } + grpc_ctx.content_type = http2_get_header_value(pinfo, HTTP2_HEADER_CONTENT_TYPE, FALSE); + grpc_ctx.encoding = http2_get_header_value(pinfo, HTTP2_HEADER_GRPC_ENCODING, FALSE); + } + else if (proto_is_frame_protocol(pinfo->layers, "http")) { + http_conv = (http_conv_t*)conversation_get_proto_data(conv, proto_http); + DISSECTOR_ASSERT_HINT(http_conv && http_msg_info, "Unexpected error: HTTP conversation or HTTP message info not available."); + grpc_ctx.is_request = (http_msg_info->type == HTTP_REQUEST); + grpc_ctx.path = http_conv->request_uri; + grpc_ctx.content_type = pinfo->match_string; /* only for grpc-web(-text) over http1.1 */ + } + else { + /* unexpected protocol error */ + DISSECTOR_ASSERT_NOT_REACHED(); + } + + ret = dissect_grpc_common(real_data_tvb, pinfo, tree, &grpc_ctx); + + if (is_grpc_web_text) { + /* convert reassembly the lengths of offset and remaining bytes back to the base64 lengths */ + pinfo->desegment_offset = BASE64_ENCODE_SIZE(pinfo->desegment_offset); + pinfo->desegment_len = BASE64_ENCODE_SIZE(pinfo->desegment_len); + } + + return ret; +} + void proto_register_grpc(void) { static hf_register_info hf[] = { + { &hf_grpc_frame_type, + { "Frame Type", "grpc.frame_type", + FT_UINT8, BASE_DEC, VALS(grpc_frame_type_vals), 0x80, + "The frame type of this grpc message (GRPC-WEB extension)", HFILL } + }, { &hf_grpc_compressed_flag, - { "Compressed Flag", "grpc.compressed_flag", - FT_UINT8, BASE_DEC, VALS(grpc_compressed_flag_vals), 0x0, - "Compressed-Flag value of 1 indicates that the binary octet sequence of Message is compressed", HFILL } + { "Compressed Flag", "grpc.compressed_flag", + FT_UINT8, BASE_DEC, VALS(grpc_compressed_flag_vals), 0x01, + "Compressed-Flag value of 1 indicates that the binary octet sequence of Message is compressed", HFILL } }, { &hf_grpc_message_length, - { "Message Length", "grpc.message_length", - FT_UINT32, BASE_DEC, NULL, 0x0, - "The length (32 bits) of message payload (not include itself)", HFILL } + { "Message Length", "grpc.message_length", + FT_UINT32, BASE_DEC, NULL, 0x0, + "The length (32 bits) of message payload (not include itself)", HFILL } }, { &hf_grpc_message_data, - { "Message Data", "grpc.message_data", - FT_BYTES, BASE_NONE, NULL, 0x0, - NULL, HFILL } + { "Message Data", "grpc.message_data", + FT_BYTES, BASE_NONE, NULL, 0x0, + NULL, HFILL } } }; @@ -409,8 +517,8 @@ proto_register_grpc(void) &grpc_detect_json_automatically); prefs_register_bool_preference(grpc_module, "embedded_under_http2", - "Embed gRPC messages under HTTP2 protocol tree items.", - "Embed gRPC messages under HTTP2 protocol tree items.", + "Embed gRPC messages under HTTP2 (or other) protocol tree items.", + "Embed gRPC messages under HTTP2 (or other) protocol tree items.", &grpc_embedded_under_http2); prefs_register_static_text_preference(grpc_module, "service_definition", @@ -438,15 +546,22 @@ proto_reg_handoff_grpc(void) "application/grpc", "application/grpc+proto", "application/grpc+json", + "application/grpc-web", + "application/grpc-web+proto", + "application/grpc-web-text", + "application/grpc-web-text+proto", NULL /* end flag */ }; int i; - /* register/deregister grpc_handle to/from tables */ + /* register native grpc handler */ for (i = 0; content_types[i]; i++) { dissector_add_string("streaming_content_type", content_types[i], grpc_handle); dissector_add_string("media_type", content_types[i], grpc_handle); } + + proto_http = proto_get_id_by_filter_name("http"); + data_text_lines_handle = find_dissector_add_dependency("data-text-lines", proto_grpc); } /* diff --git a/epan/dissectors/packet-protobuf.c b/epan/dissectors/packet-protobuf.c index 6ec4f59893..37aa74c2bc 100644 --- a/epan/dissectors/packet-protobuf.c +++ b/epan/dissectors/packet-protobuf.c @@ -2085,6 +2085,10 @@ proto_reg_handoff_protobuf(void) old_dissect_bytes_as_string = dissect_bytes_as_string; dissector_add_string("grpc_message_type", "application/grpc", protobuf_handle); dissector_add_string("grpc_message_type", "application/grpc+proto", protobuf_handle); + dissector_add_string("grpc_message_type", "application/grpc-web", protobuf_handle); + dissector_add_string("grpc_message_type", "application/grpc-web+proto", protobuf_handle); + dissector_add_string("grpc_message_type", "application/grpc-web-text", protobuf_handle); + dissector_add_string("grpc_message_type", "application/grpc-web-text+proto", protobuf_handle); } /* diff --git a/test/captures/grpc_web.pcapng.gz b/test/captures/grpc_web.pcapng.gz new file mode 100644 index 0000000000000000000000000000000000000000..1da44aba6c568777f388c5360f22be02e3531114 GIT binary patch literal 14420 zcmch82~?BUx3B-JIH96dL}iHmJD?y$Kx8KMTBS}L#blYsgI+b>oKM!IZo&$cL+* zUpeROJxBfGqT*iJ(JRo~ClBAgU-kOS1^%HM=f2M8S6gdMy_pMu2iYsenu-k^cmsuj z3We65rrEIr){?1JQN*&%@_m=dipSNbNW(N{aY!m#Qpcks>SieSu|-8?Wk}DR8+@Ub zv^S1?!lBWZk^7s)b(N>N`BTt^CF-eHzQ&ZXJ+P9nGXpK@UhPJ1ePrBmN(6V5McOK} z4q929@N7EUG1-%SwrsopIgj%+->vuUHa(6kY}QUaisJ?!frp(D3Cf%dPM)kSuUx1m z?I2Vh6(@SMm-ier3ug0#6~kuSD?9KjvpNpNTcB14^2j+7ivLDt#!~ahjkaL|>fQI5 z!Gwu0*pH=mv8VJ`GXuW6Fn9l@Z#MCUK=?hqH|N{=+O6>c+|T_>D+J( zSv1L|C&V{;otyIw+H-f|38rB>wrz5{EsB+Ko!f}s=f{ZVM!#t$q^IsTI)Fi*fJuvG z^_7P(LET^N;lq1SpEptCk{HnevLq9HpGRRlEfaNO{FO+-wkVe->Z7rUA>qwT)P+HE z*^3=z4#chPRHI&ID@`17$x%V*4sBs8hQINOk!XkMVv5#I5`Nt?)EtQ6-{`Z zFhhGx8I=i=vE@_P@~F|AMX$R)wKAd_^`0cY$FW+ZKx{f`ipN;s*rX9>$~|WVn8zsK zqT$i>VWJqw%mq5tJ#WfPee{ON^P;K8V}%@&`Z*NdlE4>#(O)7*aI7=i`_|Z zzCT(vm>H5NFoGH96%*&Qo&jND}AfPu|@oyvivsvzGvh;Slpz8jwTL|SPo3_2~wzGGLveCW;2 zJ~AUEW3)4nRoJdDw_g|OIV)050Uhm%UdAcM%Z4&2*^{k?Bi|THEAAyidL`BLFO6VO zGvdpG!tJ%DKlmATB8whyjT|7Vb9WFVIg|#fgJ)(_6C}rdUgqwqqe%QD=gx-!an6V<`i}n*B7+-%B=;knlCfQ}k#x?((yri~18y)w`;)2E zREt#Kx;hus$$A0BF>sy7FlA?^K9@SJFi3*Efz8_XI&!T94yI{OE)LB|AMVIc5n9!w zy9s@^2jq{3H|M|ia#>OITdr}uB~ykJIA!Vk@d?s#bJH0`m*D7F*TOjB9hNewN&ctZnTZM z-t|gM}T`Cn@7|DcQa;-u5hKBi0@(tQe z(6ulOb8dF#T?X3$p}1|-SD7^DT6j_85V3nRj*QJ2NWDb5-8+#H1c;03mGXC)p@nVH8aWj+>#43no*ojJ$HNFB7Bb1+&;+TU0jH zHGOR-6x{BN$6wSyvSf3@km4k?kp(|Nd{6jgGvBa)vlM|06)Z*3LXzYzt_=Ea=eSF@ ziM0_$R!5r2Ip5DhwLs_d;0dP#f7MKX*^SbnXtHb;iYCfMiJVCZUN3x6BLX#tB}yjw zG8R2;k>7{t_Q}m8S`x(}Ukm9>UK`>7^&Vbt%ugScCAvhK%NP)8g(Y^xLp11Ecv`yc zB%=H244X0l*y;#qU|2&698%D()yoPFc9v*<{rnTf9KPs=m$8(K$);G$R9p8fKkf`G zVg1J36ugMPJ)M-N-M8@4M#AraJDej6t(i4PsU{7L!*)vQ5a+&C#v~X0SyO2}w^S43 zs_eV)26CC9QAy1WOD?BYu!I+Co3Q5znRwQcO@ym*0=3-dJTGn7u8;58SbD$!&2N}< zQk2z}73GR*4mJ9F%xEmpE6~0DB0)ie|8-6-d(6n38Dw`#>LfdvhwNT>=`F5sb5M?l z^fqxvW@kFVl6Z5EFlkqtnQolik=RP>izJhWScEMIFxt`9OK87PNnLp)Qx=p3lx!)9 znUTe7c$YhCNn_AGe^mj_gLh3KSYjxt;~V*fMU9Yqu^u9Ai4V zv@eV;GDA;$dIS;h%hx&fyC&=Bdq?#ZtD*MtIR3|F2x` zjX7Mzc1^UxOWw=B>dfw%fMAwgHhy9ea*FC#)u&uG5(Iam==z~uh{W6eUv-PP7&^)G zYm^;Y>@)8bEL-V3_~ZLt&w%M=eUj(S#B8(F=3yA1{*NRT6e){euKU!#=Z<%xqVJs$ zF4Cq*j#7~@T^ZfJS`G2>|4J=lx`Bl)H+MWKW#pJuHs~~t*&9o%?j=&wj6*vXax;s3 z45(QGZu7)Xtgy)^xq`QgmW5e@o0bEDcS%v?tn?gGVm~V^=P?-0n(>tlSiB~9QKO#l;)xBjK+y{ zhS9}MdiPc|ahsYabKL9Bv68rFt$wB|QMd3~r&d;{#?UWDQQw^86!Kcn5+NhImL80B zRt8ra9o~Uowb7@Yrxk2%m;I`EE4XYsv zu}_ATp8Uu}AiZ;s{LdLV4BsM{SYVq!W0^<^+NS4aMq@L>11!O}Xv-w%uSu{nA$u9K zrOfI+4a%Pd3!_&e?u0HxI_tyD$6&8NX|BIQMLEPNp1s>uRQM5wvLyVjCv_gAsVHcD zDO&_Du(51<#ADxT_rE+A@{N9d*dj7qW_C;>9X5PyQ#K*VMHRI!Y5Yim3Ui0VS$-$> zVNR+MG#kfkjitG3md*D{I&TC?72IIsRKue;Hr(1?U~22I4cDGIzi_|xO+Oi$Lnp`5 zP4FHe2OFVh=R9!v>~bW$XHK~>q|X1C5ovcNgQy$9Sbdr}PQRm-Gf+KeqSHw#ynO*J zK1Xo-&EiO-Mn?jHzX{|e5#}1hd&VQ+#iruQ`FE-}Y=s-^O+}}bwiR5bXOH(+{8E3< zn)J1PdPbAE(7@!Bmn+>)`wS{6wB_+jo(N*-e#qYA~L;vJWaI8L^W4% zn1ag8TeuUkSlmqRSlf!Iird7%q35?zOMeXXqugQV$%c7UV3F^ z?2K)t=-K_5$AZgr(dj6193MMELM{q%!iVst4fbDma-kgk}xNsxw$K0eo!dVdk^Fsc-}b$e2=#Zy$mo$1&)CLxeGpQancumJ zGXH~?jbZM`lnLf0=3}Fe$uk=&N$mn-3i zan$n~Cqm@^F}P)86vJ?ux$M|595v`}aR+ENk-@ z|6p&2UQRrR2mhm&?mY3k8}{3#AGDmQW*!Ju+ViImRvEsl)V^06k9H@XB@*p9C{JFZ z=ybKw(H(9$?aiG{hx)jAqK8_w5KXA#NYm-Y#?v9NJHgmJ-J$a$rab@plJ|Kdln}p6 zBuLHc>uQcl1|MF={OYG&U!LclsoLVJy8{Z<%@Vz>pi8>~8XT&CrTaHd z6F)ms&QL&`9|Qkz_IvRjl^=rA9$Plc<)u?tqJBoEZ>a4#MkdP2&pPQDRnc6CA7z_a zBN&-FV=-}*YaPg>O4jYmYK@|&-IgaJ7|f^K$$lfdDkaPwKUN~!GQyakq)23FYhWUh z8q9KipFu*1(?o2JgzGIHz|`b6XaUy7We+zIF*1UXwV1uFpLnds-D*4`$*$}xE@%52 zC0`Ms#e4~oD_MTsl-pXt5T6ZDRr zd3g(Yvc{3gS#PFcYFQ&JRa&ClqpB}~ZEs+`J+c7#AAOR|H} zH5F{JLMbdz{JnY~Dyc;3?hAA>VbNd!qQ27Jc*3-icKx3|WKt^l4Jl9B1~e~jj(qrV zF*uga!c+J3Xqswxc55QcEge<;Sb1{-anOh8OqiL7U5;r1=`>$)c|SO$Mg(I_8-tq3 z1MtA9O`qbF{iKa_kw7o!Hzg0N70)*1LJ~^Q?|bFZnQWheE}O-Q9nLY7d*Z2SwTuSY z{&91mTOYgO#HA*Os%e5&SlR5MkZ7{_GTW`s*G?d~OhO}1Nj2Rno~~&%0k%59ClWZd zi`m`Xnt#*j*>nHk4z`TW{j**1yYcrGJ;^fBgLE7Yn;1=gypXu_bas zdeD}R?`Up?j{b(S3N`89H=h{$d8W9 znCO19rVlQBUwq?RXh*7o2`XIIj+5cQ;*O0YB}r znWPqhc;*O6*8)p43UbiLLZ)X7yl#bC+Kmb}RD*Ly#m$iCEP>rAEJA}sDrj;STct?C zc*QICZ+_$Lw6ojXXLZUQ$xra#O#CT=_0ZtDie$wJyU#?D?-1o7S+2yS3q(t!%+q_~ zd5*?z?4frG0_IGPok4J;!F7V*t5f#IGn%*>@VJagRTgeVamrEACdcO=dMP|C{f9FxSJK z>m+-9eH>~_^h7!t4*NwqW@TnAOF`!uO!WISF3LiLGgPzC3VL_^u+j;{#q4p@%@Kqs>P+&-DI6ij6fBvNbvUFL!xXk&HG~A~V5%8)ES7b0~_8Ux7qJleO z)O+zr+2yFb9YI{BkeHC*(bWHHB=hUg(8XPt} z2k+G=m?E}rYNZ*@Fn3yTJHsel=3G-qPT)A^%cPhXSSrT+)E;wLan5%`s%6*Ypg(6smA$ z*~L^<=pw(3b5K>t# z2DmqRqoep2k=Q=f>jg?mgHgoy!?M-tev%=wl_{f>49^DDd9130SPBq5JZ&uT3ELe1 z1ZxFKb<$SPo2v7!}s}$LlUmC$y!fwf>red3Tg&4RSKQ@O<{Jl=iZnWI}1KC+wf)4EW z0*NvxR3lUU@sjzK)xjtl{3K`#V7e^TcTmuMo&33UO;O6|+$M5;P50U5|0+`>cD(40 z6aRAO@Bx(9PjxnKqOC@-~=%_ z=5LMlm=7B@IbXJF6orWYM3uV0$glK$C@z|}K z!;$WHZmB#8f0{yeD93*$>&;eOUg%7m&QZ*xTDZMBhB@&d(nFA+De{xKg$j^SG|UZ9 zG?eu=@fG`eK(%^RUagk4=;5k8&#)DVK0BHU(t^Mu za-GC0yD??U3B5AW$o?e{x80T^O_(m-aMHoXIACDe4A-b%|5=kXLe_;9bG)ib7<>JT zW`4LmEs+_*EuKO-q43%!hjV2x6+iUqFt)EIUpgPp{WGRHM3EcJh3e>@HU;AI&tXz5 zBWDnAEI$HKPg0nz)JcM!$M*YKK0jZ%a%Il0907Hy3l6doRd}lG6`xmJw{D%)OK;bw zp^~X(Z`9KO$-l2_A73D(UnomO(`l>muj4uzaJMVRZHu&ty|*p}yY^immon_U(9-omV~ZEgsvbwhpqx^>@q#eTD( z;$Ivpx#3WSW@bMwJ=TQ2hdj5S(r|tlY7M@!`gU#HAy+_;@DeP_KaUd!RY#!aIB_I< zFaVdgpb|6$mA)wU0P_Ys)2c=@LDLp$2`Pb#$7#4^Hv<1EAc%#y68I8mG2C4J_mRiJ zNHQ3S-&F#SSn+~@sr5JpfTswLK*^dQo-inp4G8ho0 z0-_`Sk||77&^9pp4jka7OUsQAdg3~vb`m1PvxRM4+TfkLfFh(AE-tG`{MWj5uP48` zBf0TQKPOJsJt z41UULL4{7Jt_SoGylWNcKJ$9?y~mumL$PbD8HPK!GR9z+(qc z;NTE6@v9*{Ffrc)5WMHqwxHqz7HA8_x`j)odX!7x#pxQIptlSZ zm>Jrh#0Zc0a01W&fzuh)qV2FJq`rh3tbf4rQt4y{3px@eWmQ#gGBcd%y51H=Ciq5Q)CWMrP0XDkVbR7`|K(40N$j=4JYVAd z{+d$`4wpdY7a_~mJ9@4U{Z}eoEr^v4A2m8Bszgd7h4n`+rDP!|JHH>y#OXTM_z8}* zs-{9`O~y890Kw-Jwxp8Br9Pj5XJf6fQMnVe(t(PAO;5a-_Jo0tYSoR4n}E;DOG%Z0 zj{$F2D)!M$%coLPCw+cX!>yRrk#%N<=Z z0`=vDNndqC&C5uc_8+a5fB^@a*@h`*{&>h0)K#QMu6CHqPPJu;0+{-}fV2hN{)g+e zgAYG)nec?|ZZY8mLBcUR!ODKyb#s2)$Rw1*9$kuS?RwZg>qC-uBt0lJBtIx`i!H-& zepqj`g!h?`6V^zllb5#t^neuZacj(5tg6_KG1qHo0zHezsss=Pn{x{^WUsHlEj~YB z_U=4jz^60y!7p`1_)@P6BXx9c2nUNC&DgGT%C&wncc!#xQ~0}-QzfNkJ}=9P$}aiC z>LIAnX46&?vuOL0x=%}&&wZXpXUg+4;z9;dx@pJaNJHa7c3<++%ih%V)>e5tbAH<01)xRhBFISPb7@8ZlJQR3`0$}fsk|gH(znTUY-sA2 z3H~{j7LD&o=adE@%aRhQFh&Q2kgY%~lU+j+2PdbC6o~})wG-wJzipA+IQw>+qz`Aw zu0{6K>%%FQ?#+6G>4ekAC0?PPHxK^>uaR$~++*>M;r|0~A%~qsW+8MV7Nr=anvmjc z(6SS1viEYxhafYqAD7ho{H+I%u4Er5UfzPk!7Ngz6-XA&t27y>L3jBpU~P4Ecby!$ zE?oMIzQne#hnjG1)5+ka?d8W&>s_-^nVX_*OIW>rx1GM0h)pcccHF$fU7aPp|1B#h z+o42Ws{|Rc6X`ei;o>Xn3#B);B7P(J=8T{62{<;Y2J_`AbPW;dB)^RDieqR>M%;as zR)!)}C)WQ1CN1=;xKn`e+W7dJj(5MGkB!qZG%72Lv`x}7k{1~VM&6RCw$z5>quBW? zu0gj*gG~=?U)s!mkT(PmrrnXry}q(>rQ^}_+c@JC3QcvVKMwZXWha@mXgB>br@Y&z!ZDeQ6QloSIj zo30a0-iizJH^x3lLN&1ICQ`bl7T=sWChybGt2S7YcFEW9*4ixQ&3)U)U;s6^@@lk%Wh{c;;d*S zs-PlK-Xt=T{`!Chkv&uT(g>Px&Ngm*By6?5_iZ|JCW^^=tV+|lBFDlBN_4K2Oh!%n zF2E80NuO|s`#X5O4EY6Py|zXEFjQx3^@p;^b1${+H8(pRfpp&FRXA(Nvuj9)a5I#) zs$dZ#+9|inkFM8W8ifD*yH=FdN8hbIYW{8Dy6+BMTq_IzZQz9W-pfii&Rp5KVS72- zdGUd~%0HP6UEn9JRr#-CjrI5uJzV#@j)ql0lLeJx3B()KC64I0&)+}#f*0&D_Y zDny^ZgQ%ZDVVOu6BP zV>T{E#VnSgAFM)h;#{0iZ=gfH-+_Iys7EUbh%l+S|h_`AKZlUO_&V z)0L`)?GmzGC_iH_fkHlK0J`L;>gMKbERC1r4r=-B?CcLp8^Czs?92@Q$0|t(8@mgS(6X@ITKv&>k03Hc zX1!Ux91q2Jz77gyp;;8+^6ZS&jloH*og6gSzXrw)b2lC|mBVor1`|G6(cPDGzr7;o zX>+O5|3AdR{BF}tn?&IarnXJ%C5V2^x6b!z#n)Cl-#&>xi_!7^b@9Q=*)8u|l6@R_ zRZ!>P4dzB5Wn5?TpfBq8UHn}E_IJnh-$w?PAg&F|3cXm-@Q74;dD*pYDHCK{bp>~S z#q`qD9|)MkesjyAXG1L>iL!{zLDL{w)%ed?(#u&TlO-wt@1ZEwSxX{rg+R!ZpN;u_ zmsUY^0u@2eAtY~$ze444*HQ3$UKJ@hVQ%V(Ob;1TiaB6e1U72 z?$%+A#=$I4k4aTq>RiQrj1=izqCecV!^`Xz(f_iGc*N;@QZNXP-~IH5&;N8t{;QaN zeuP~zULa`+?-Gp`U8|oq0iV-ql1hikUU~SU#qTp)m~z9SLrQgcEald61Xw>QRVY3S zw2=1oUupe47;m;@cDrug968@-hOu52|7h12rW{A2hB7j>EHJe3K7lG-vWOQ8R54B` zrDM3Dkj3p##s?s(|K)BU3y{A>m3zC{{Ua{S)~Cyf3E~7p=CTvOWBzq1w|D6}O4^nw zI;~5%ztE|yvhA+9kKI7$bPvka%k`CDT1SOfrDHoOk0<7)YJ>G(tpDW=0^Q( zuqvGKadkTodi|^qDWzBxJVuBXk=3JS`)_Jw(%owBljkYizeKe{d12w>>uAOY6vf|z zn6W%T`OBqtH8Pd*zdPWs^WDRDD&C1axbFj6;u&Rpyida|@l>?w{BF&S1Am;N&A;n(;4*4{X$dp&I?>yC@-${t zz5xb`eut`Iag_}oe>ONhrY7>BNxw(40oocU_))gpF@7s|jIHo!vier~DTbH9CDL&& zm_LJR~lP9C22-&q5Q1bq<~;V#FTf>JQ}l5W8NI`_uSd;a6Pi^DL7w(%+-m>wb+- zqz+%5Tr&Ri>qKh{UkiE`qO0^E=p+Afh;@6*8F?qUp0Kq1qou{?{rgbzE#Cb0_aA!j zU;*w#AYj6`Hg|jxAWfUHL(0Vldi}B_?Ci3)A{3{b2=kSnp>!!}@qVSHS7gKLXI@Q+ zMwj-Gyx#9V$11eRa_o%&r#jgeRV4wLW-R~}!m)WyA6PAnovA!zgoRo7cSq=^p??&( zfUoGRWp)JqXROV9pO}wRBUF310#B$a>w9AioJ+TB#pg)PJi!U4iuYrR4t~z#WN<2| zpHAK8?}Gn@zL~$4jDKWj-)-?^f4MG3Bu_>Ea#_o>_rF|LPu$y~S@QJ#!!@!0@K+WG zHW;4S8FKcBa;^>=}_YAgKz(x&^NqhS0$qw@jCVqDA{_DVz~zK8h9@Exc&0q zu#{pwLFk77xSz1Y+3ek4fK}oGP%?kV#?2V zAU6ksnbBt5cgf?{%!uU#m7W5S{g|*!)^eYk7GJb^7HO=3al!(NaC7X7u*}IMEo2hA z>5+4wm#*#R2wt)w(X$1DXBs_8GD**HNU@?Eya~(LC&#GOg=D2SJ?@K0IpK7FIZ7K> zvg`}5xjVjkDcVRE{u^S0c7XByk(a9UG+$cEj4 z0LkU@8xm#3Za(=BnP9z0YT?Y&@dtPcaf?JQCtC%YY_=LO@n6VT|I2FP#nKPIL9o)7 z5adq$+MvU!0^ysVrn~1}CrXsQBrD=X2?5p!LhTV)L31QCHJXN#=f(6>C5tAgLq-J_ z{=i|CCP<58Z>;k0PL zzuDiNy$vBpF(nIOEz0@B?+mZ`o#n1F#(!sMunI4-nLPlk?H$DHgVTr9y_s*femLaM zV}a`KI5$@1+IDI{JebIJm2W5dlkWjGT+{l5m!