Commit 8157811b authored by Jens Langhammer's avatar Jens Langhammer

Implement GRPC Support in p2, use GRPC to implement serve

parent 88453a93
Pipeline #3872 failed
......@@ -10,6 +10,7 @@ omit =
p2/management/commands/web.py
p2/management/commands/worker.py
docs/
*_pb2.py
[report]
sort = Cover
......
......@@ -115,8 +115,8 @@ vendor/**
debian/p2/**
debian/files
debian/*.substvars
/storage
p2.crt
p2.key
static/**
storage/**
......@@ -102,3 +102,13 @@ package-installer:
only:
- tags
- /^version/.*$/
# Manual tasks
build-protos:
stage: manual
script:
- python -m grpc_tools.protoc -I=. --python_out=p2/grpc/ --grpc_python_out=p2/grpc/ protos/*
- protoc -I=. protos/*.proto --go_out=plugins=grpc:tier0/internal/
# - sed -i -E 's/^\(import.*_pb2\)/from . \1/' p2/grpc/protos/*.py
only:
- never
......@@ -7,6 +7,7 @@ ignore-paths:
- migrations
- docs
- node_modules
- protos
uses:
- django
......@@ -2,6 +2,8 @@
disable=E0611,R0401,W0621,I0011,RP0401,I0021,arguments-differ,no-self-use,too-many-ancestors
load-plugins=pylint_django
ignore=migrations,protos
[SIMILARITIES]
# Minimum lines number of a similarity.
......
"""p2 GRPC management command"""
import time
from concurrent import futures
from contextlib import contextmanager
from logging import getLogger
import grpc
from django.core.management.base import BaseCommand
from django.utils import autoreload
from grpc_reflection.v1alpha import reflection
from p2.grpc.protos import serve_pb2, serve_pb2_grpc
from p2.serve.grpc import Serve
LOGGER = getLogger(__name__)
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
@contextmanager
def serve_forever():
"""Run GRPC server, blocking"""
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
serve_pb2_grpc.add_ServeServicer_to_server(Serve(), server)
service_names = (
serve_pb2.DESCRIPTOR.services_by_name['Serve'].full_name,
reflection.SERVICE_NAME,
)
reflection.enable_server_reflection(service_names, server)
server.add_insecure_port('127.0.0.1:50051')
server.start()
LOGGER.debug('Successfully started grpc server on port 50051')
while True:
time.sleep(_ONE_DAY_IN_SECONDS)
class Command(BaseCommand):
"""Run GRPC Server"""
def handle(self, *args, **options):
"""Start GRPC server and register services"""
autoreload.run_with_reloader(serve_forever)
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: protos/serve.proto
import sys
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor.FileDescriptor(
name='protos/serve.proto',
package='p2',
syntax='proto3',
serialized_options=None,
serialized_pb=_b('\n\x12protos/serve.proto\x12\x02p2\"\x8c\x01\n\x0cServeRequest\x12\x0f\n\x07session\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12.\n\x07headers\x18\x03 \x03(\x0b\x32\x1d.p2.ServeRequest.HeadersEntry\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x9b\x01\n\nServeReply\x12\x0f\n\x07session\x18\x01 \x01(\t\x12\x10\n\x08matching\x18\x02 \x01(\x08\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12,\n\x07headers\x18\x04 \x03(\x0b\x32\x1b.p2.ServeReply.HeadersEntry\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x32;\n\x05Serve\x12\x32\n\x0cRetrieveFile\x12\x10.p2.ServeRequest\x1a\x0e.p2.ServeReply\"\x00\x62\x06proto3')
)
_SERVEREQUEST_HEADERSENTRY = _descriptor.Descriptor(
name='HeadersEntry',
full_name='p2.ServeRequest.HeadersEntry',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='key', full_name='p2.ServeRequest.HeadersEntry.key', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='value', full_name='p2.ServeRequest.HeadersEntry.value', index=1,
number=2, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=_b('8\001'),
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=121,
serialized_end=167,
)
_SERVEREQUEST = _descriptor.Descriptor(
name='ServeRequest',
full_name='p2.ServeRequest',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='session', full_name='p2.ServeRequest.session', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='url', full_name='p2.ServeRequest.url', index=1,
number=2, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='headers', full_name='p2.ServeRequest.headers', index=2,
number=3, type=11, cpp_type=10, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
],
extensions=[
],
nested_types=[_SERVEREQUEST_HEADERSENTRY, ],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=27,
serialized_end=167,
)
_SERVEREPLY_HEADERSENTRY = _descriptor.Descriptor(
name='HeadersEntry',
full_name='p2.ServeReply.HeadersEntry',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='key', full_name='p2.ServeReply.HeadersEntry.key', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='value', full_name='p2.ServeReply.HeadersEntry.value', index=1,
number=2, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=_b('8\001'),
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=121,
serialized_end=167,
)
_SERVEREPLY = _descriptor.Descriptor(
name='ServeReply',
full_name='p2.ServeReply',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='session', full_name='p2.ServeReply.session', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='matching', full_name='p2.ServeReply.matching', index=1,
number=2, type=8, cpp_type=7, label=1,
has_default_value=False, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='data', full_name='p2.ServeReply.data', index=2,
number=3, type=12, cpp_type=9, label=1,
has_default_value=False, default_value=_b(""),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='headers', full_name='p2.ServeReply.headers', index=3,
number=4, type=11, cpp_type=10, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
],
extensions=[
],
nested_types=[_SERVEREPLY_HEADERSENTRY, ],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=170,
serialized_end=325,
)
_SERVEREQUEST_HEADERSENTRY.containing_type = _SERVEREQUEST
_SERVEREQUEST.fields_by_name['headers'].message_type = _SERVEREQUEST_HEADERSENTRY
_SERVEREPLY_HEADERSENTRY.containing_type = _SERVEREPLY
_SERVEREPLY.fields_by_name['headers'].message_type = _SERVEREPLY_HEADERSENTRY
DESCRIPTOR.message_types_by_name['ServeRequest'] = _SERVEREQUEST
DESCRIPTOR.message_types_by_name['ServeReply'] = _SERVEREPLY
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
ServeRequest = _reflection.GeneratedProtocolMessageType('ServeRequest', (_message.Message,), dict(
HeadersEntry = _reflection.GeneratedProtocolMessageType('HeadersEntry', (_message.Message,), dict(
DESCRIPTOR = _SERVEREQUEST_HEADERSENTRY,
__module__ = 'protos.serve_pb2'
# @@protoc_insertion_point(class_scope:p2.ServeRequest.HeadersEntry)
))
,
DESCRIPTOR = _SERVEREQUEST,
__module__ = 'protos.serve_pb2'
# @@protoc_insertion_point(class_scope:p2.ServeRequest)
))
_sym_db.RegisterMessage(ServeRequest)
_sym_db.RegisterMessage(ServeRequest.HeadersEntry)
ServeReply = _reflection.GeneratedProtocolMessageType('ServeReply', (_message.Message,), dict(
HeadersEntry = _reflection.GeneratedProtocolMessageType('HeadersEntry', (_message.Message,), dict(
DESCRIPTOR = _SERVEREPLY_HEADERSENTRY,
__module__ = 'protos.serve_pb2'
# @@protoc_insertion_point(class_scope:p2.ServeReply.HeadersEntry)
))
,
DESCRIPTOR = _SERVEREPLY,
__module__ = 'protos.serve_pb2'
# @@protoc_insertion_point(class_scope:p2.ServeReply)
))
_sym_db.RegisterMessage(ServeReply)
_sym_db.RegisterMessage(ServeReply.HeadersEntry)
_SERVEREQUEST_HEADERSENTRY._options = None
_SERVEREPLY_HEADERSENTRY._options = None
_SERVE = _descriptor.ServiceDescriptor(
name='Serve',
full_name='p2.Serve',
file=DESCRIPTOR,
index=0,
serialized_options=None,
serialized_start=327,
serialized_end=386,
methods=[
_descriptor.MethodDescriptor(
name='RetrieveFile',
full_name='p2.Serve.RetrieveFile',
index=0,
containing_service=None,
input_type=_SERVEREQUEST,
output_type=_SERVEREPLY,
serialized_options=None,
),
])
_sym_db.RegisterServiceDescriptor(_SERVE)
DESCRIPTOR.services_by_name['Serve'] = _SERVE
# @@protoc_insertion_point(module_scope)
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
import grpc
from . import serve_pb2 as protos_dot_serve__pb2
class ServeStub(object):
# missing associated documentation comment in .proto file
pass
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.RetrieveFile = channel.unary_unary(
'/p2.Serve/RetrieveFile',
request_serializer=protos_dot_serve__pb2.ServeRequest.SerializeToString,
response_deserializer=protos_dot_serve__pb2.ServeReply.FromString,
)
class ServeServicer(object):
# missing associated documentation comment in .proto file
pass
def RetrieveFile(self, request, context):
# missing associated documentation comment in .proto file
pass
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ServeServicer_to_server(servicer, server):
rpc_method_handlers = {
'RetrieveFile': grpc.unary_unary_rpc_method_handler(
servicer.RetrieveFile,
request_deserializer=protos_dot_serve__pb2.ServeRequest.FromString,
response_serializer=protos_dot_serve__pb2.ServeReply.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'p2.Serve', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
......@@ -12,3 +12,6 @@ pyyaml
user-agents
colorlog
sentry-sdk
grpcio
grpcio-reflection
protobuf
......@@ -166,7 +166,6 @@ MIDDLEWARE = [
'p2.log.middleware.StartRequestMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'p2.serve.middleware.ServeRoutingMiddleware',
'p2.s3.middleware.S3RoutingMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.middleware.common.CommonMiddleware',
......@@ -275,6 +274,7 @@ with CONFIG.cd('log'):
'celery': 'WARNING',
'botocore': 'WARNING',
'werkzeug': 'DEBUG',
'grpc': 'DEBUG',
}
LOGGING = {
'version': 1,
......
"""p2 serve routing middleware"""
import hashlib
"""Serve GRPC functionality"""
from logging import getLogger
from django.core.cache import cache
from guardian.shortcuts import get_objects_for_user
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from p2.core.constants import ATTR_BLOB_HEADERS
from p2.core.http import BlobResponse
from p2.lib.shortcuts import get_object_for_user_or_404
from p2.grpc.protos.serve_pb2 import ServeReply, ServeRequest
from p2.grpc.protos.serve_pb2_grpc import ServeServicer
from p2.serve.models import ServeRule
LOGGER = getLogger(__name__)
LOGGER = getLogger(__name__)
# pylint: disable=too-few-public-methods
class ServeRoutingMiddleware:
"""Check if request matches any ServeRules and routes it to serve URLs"""
request = None
class Serve(ServeServicer):
"""GRPC Service for Serve Application"""
def __init__(self, get_response):
self.get_response = get_response
def fingerprint(self):
"""Return request's fingerprint"""
fingerprint_data = [
self.request.path,
str(self.request.user.pk or 0),
str(hash(frozenset(self.request.META.items()))),
]
_hash = hashlib.sha256()
_hash.update("".join(fingerprint_data).encode('utf-8'))
return _hash.hexdigest()
def rule_lookup(self, rule):
def rule_lookup(self, request: ServeRequest, rule):
"""Build blob lookup from rule"""
lookups = {}
# FIXME: Capture LOGGER output instead of returning a message array
......@@ -44,57 +25,54 @@ class ServeRoutingMiddleware:
debug_messages.append("Found new token '%s'" % lookup_token)
lookup_key, lookup_value = lookup_token.split('=')
lookups[lookup_key] = lookup_value.format(
path=self.request.path,
path_relative=self.request.path[1:],
host=self.request.META.get('HTTP_HOST', ''),
meta=self.request.META,
path=request.url,
path_relative=request.url[1:],
host=request.headers.get('Host', ''),
meta=request.headers,
)
debug_messages.append("Formatted to '%s'='%s'" % (lookup_key, lookups[lookup_key]))
debug_messages.append("Final lookup %r" % lookups)
return lookups, debug_messages
def get_blob_from_rule(self):
def get_user(self, request: ServeRequest) -> User:
"""Get user from cookie"""
session = Session.objects.filter(session_key=request.session)
if not session.exists():
return get_anonymous_user()
uid = session.first().get_decoded().get('_auth_user_id')
user = User.objects.get(pk=uid)
return user
def get_blob_from_rule(self, request: ServeRequest):
"""Try to lookup blob from ServeRule, raise Http404 if none found"""
for rule in ServeRule.objects.all():
LOGGER.debug("Trying rule %s", rule.name)
if rule.matches(self.request):
if rule.matches(request):
LOGGER.debug("Rule %s matched", rule)
lookups, messages = self.rule_lookup(rule)
lookups, messages = self.rule_lookup(request, rule)
# Output debug messages on log
for msg in messages:
LOGGER.debug(msg)
blobs = get_objects_for_user(
self.request.user, 'p2_core.view_blob').filter(**lookups)
self.get_user(request), 'p2_core.view_blob').filter(**lookups)
if not blobs.exists():
LOGGER.debug("No blob found matching ")
continue
# Log rule_id for debugging
self.request.log(rule_pk=rule.pk)
return blobs.first()
return None
def __call__(self, request):
self.request = request
cache_key = 'p2_serve:%s' % self.fingerprint()
# Quickly check if path exists in cache and has blob mapped to it
cached_value = cache.get(cache_key, default=None)
if cached_value:
LOGGER.debug("Using Blob PK from cache")
blob = get_object_for_user_or_404(request.user, 'p2_core.view_blob', pk=cached_value)
else:
blob = self.get_blob_from_rule()
if not blob:
response = self.get_response(request)
return response
# save blob pk so we don't need to re-evaluate rules
cache.set(cache_key, blob.pk)
request.log(blob_pk=blob.pk)
def RetrieveFile(self, request: ServeRequest, context):
blob = self.get_blob_from_rule(request)
if not blob:
return ServeReply(
matching=False,
data=b'',
headers=[])
# request.log(blob_pk=blob.pk)
# Since we don't use any extra views or URLs here, we don't have to
# trick SecurityMiddleware into not returning a 302
headers = blob.attributes.get(ATTR_BLOB_HEADERS, {})
response = BlobResponse(blob)
for header_key, header_value in headers.items():
if header_key == 'Location':
response.status_code = 302
response[header_key] = header_value
return response
return ServeReply(
matching=True,
data=blob.read(),
headers=headers)
......@@ -4,6 +4,7 @@ from logging import getLogger
from django.db import models
from p2.grpc.protos.serve_pb2 import ServeRequest
from p2.lib.models import TagModel, UUIDModel
from p2.serve.constants import (TAG_SERVE_MATCH_HOST, TAG_SERVE_MATCH_META,
TAG_SERVE_MATCH_PATH,
......@@ -21,19 +22,19 @@ class ServeRule(TagModel, UUIDModel):
name = models.TextField()
blob_query = models.TextField()
def matches(self, request):
def matches(self, request: ServeRequest):
"""Return true if request matches our tags, false if not"""
for tag_key, tag_value in self.tags.items():
request_value = None
if tag_key == TAG_SERVE_MATCH_PATH:
request_value = request.path
request_value = request.url
elif tag_key == TAG_SERVE_MATCH_PATH_RELATIVE:
request_value = request.path[1:]
request_value = request.url[1:]
elif tag_key == TAG_SERVE_MATCH_HOST:
request_value = request.META.get('HTTP_HOST')
request_value = request.headers.get('Host', '')
elif tag_key.startswith(TAG_SERVE_MATCH_META):
meta_key = tag_key.replace(TAG_SERVE_MATCH_META, '')
request_value = request.META.get(meta_key, '')
request_value = request.headers.get(meta_key, '')
LOGGER.debug("Checking '%s' against '%s'", request_value, tag_value)
regex = re.compile(tag_value)
match = regex.match(request_value)
......
"""p2 serve tests"""
import os
from shutil import rmtree
from uuid import uuid4
import requests
from django.contrib.auth.models import User
from django.test import LiveServerTestCase
from guardian.shortcuts import assign_perm, get_anonymous_user
from p2.api.models import APIKey
from p2.core.models import Blob, Storage, Volume
from p2.serve.constants import TAG_SERVE_MATCH_PATH
from p2.serve.models import ServeRule
from p2.storage.local.constants import TAG_ROOT_PATH
class ServeViewTests(LiveServerTestCase):
"""Test ServeView"""
def setUp(self):
super().setUp()
self.storage_path = './storage/local-unittest-serve/'
self.user = User.objects.create_user(
username='p2_unittest',
email='test@test.test',
password=uuid4().hex)
self.access_key, _ = APIKey.objects.get_or_create(
user=self.user)
self.storage = Storage.objects.create(
name='p2_serve_unittest',
controller_path='p2.storage.local.controller.LocalStorageController',
tags={
TAG_ROOT_PATH: self.storage_path
})
self.volume = Volume.objects.create(
name='test-1', storage=self.storage)
assign_perm('p2_core.use_volume', self.user, self.volume)
def tearDown(self):
rmtree(self.storage_path)
def test_serve_simple(self):
"""Test Simple Serving"""
ServeRule.objects.create(
name='unittest-simple',
tags={
TAG_SERVE_MATCH_PATH: '.*'
},
blob_query='path={path}')
blob = Blob.objects.create(
path='/test-aA0!-\\|/[.png',
volume=self.volume)
blob.write(os.urandom(2048))
blob.save()
assign_perm('p2_core.view_blob', get_anonymous_user(), blob)
response = requests.get(self.live_server_url + blob.path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, blob.read())
self.assertEqual(response.headers['content-type'], 'application/octet-stream')
......@@ -10,10 +10,11 @@ from django.views.generic import DeleteView, FormView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from guardian.shortcuts import get_objects_for_user
from p2.grpc.protos.serve_pb2 import ServeRequest
from p2.lib.shortcuts import get_object_for_user_or_404
from p2.lib.views import CreateAssignPermView
from p2.serve.forms import ServeRuleDebugForm, ServeRuleForm
from p2.serve.middleware import ServeRoutingMiddleware
from p2.serve.grpc import Serve
from p2.serve.models import ServeRule
......@@ -98,9 +99,12 @@ class ServeRuleDebugView(PermissionRequiredMixin, FormView):
})
def form_valid(self, form):
_mw = ServeRoutingMiddleware(None)
_mw.request = self.request
lookup, messages = _mw.rule_lookup(self.get_object())
_mw = Serve()
# _mw.request = self.request
request = ServeRequest(
url=self.request.get_full_path()
)
lookup, messages = _mw.rule_lookup(request, self.get_object())
blob = get_objects_for_user(self.request.user, 'p2_core.view_blob').filter(**lookup)
messages.append("Found object %r" % blob)
form = ServeRuleDebugForm(
......
syntax = "proto3";
package p2;
service Serve {
rpc RetrieveFile(ServeRequest) returns (ServeReply) {}
}
message ServeRequest {
string session = 1;
string url = 2;
map<string, string> headers = 3;
}
message ServeReply {
string session = 1;
bool matching = 2;
bytes data = 3;
map<string, string> headers = 4;