Пример #1
0
    def __init__(self, *args, **kwargs):
        self.tls = threading.local()
        self.version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'}
        self.http_request = HTTPRequest()

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
Пример #2
0
    def __init__(self, *args, **kwargs):

        self.http_request = HTTPRequest()
        self.tsdb_query = None

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.scheme = "http"
        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
Пример #3
0
    def __init__(self, *args, **kwargs):
        self.tls = threading.local()
        self.version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'}
        self.http_request = HTTPRequest()

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
Пример #4
0
class ProxyRequestHandler(BaseHTTPRequestHandler):

    protector = None
    backend_address = None
    timeout = None

    def __init__(self, *args, **kwargs):

        self.http_request = HTTPRequest()
        self.tsdb_query = None

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.scheme = "http"
        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)

    def log_error(self, log_format, *args):

        # Suppress "Request timed out: timeout('timed out',)"
        # if isinstance(args[0], socket.timeout):

        # logging.error("{}".format(traceback.format_exc()))
        # logging.error(pprint.pprint(args))

        self.log_message(log_format, *args)

    def log_message(self, format, *args):
        """Log an arbitrary message.

        This is used by all other logging functions.  Override
        it if you have specific logging wishes.

        The first argument, FORMAT, is a format string for the
        message to be logged.  If the format string contains
        any % escapes requiring parameters, they should be
        specified as subsequent arguments (it's just like
        printf!).

        The client ip address and current date/time are prefixed to every
        message.

        """
        xff = '-'
        xgo = '-'
        ua = '-'
        try:
            xff = self.headers.getheader('X-Forwarded-For', '-')
            xgo = self.headers.getheader('X-Grafana-Org-Id', '-')
            ua = self.headers.getheader('User-Agent', '-')
        except AttributeError as e:
            requestline = getattr(self, 'raw_requestline', '-')
            logging.warning(
                "Malformed/missing request header. Requestline: %s" %
                requestline)

        logging.info(
            "%s - - [%s] %s [X-Forwarded-For: %s, X-Grafana-Org-Id: %s, User-Agent: %s]"
            % (self.client_address[0], self.log_date_time_string(),
               format % args, xff, xgo, ua))

    def do_GET(self):

        top = re.match("^/top/(duration|dps)$", self.path)

        if self.path == "/metrics":

            data = generate_latest()

            self.send_response(httplib.OK)
            self.send_header("Content-Type", CONTENT_TYPE_LATEST)
            self.send_header("Content-Length", str(len(data)))
            self.send_header('Connection', 'close')
            self.end_headers()
            self.wfile.write(data)

        elif top:

            data = self.protector.get_top(top.group(1))

            self.send_response(httplib.OK)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.send_header('Connection', 'close')
            self.end_headers()
            self.wfile.write(data)

        else:
            self.headers['Host'] = self.backend_netloc
            self.filter_headers(self.headers)
            self._handle_request(self.scheme, self.backend_netloc, self.path,
                                 self.headers)

        self.finish()
        self.connection.close()

    def do_POST(self):

        length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(length)

        self.headers['Host'] = self.backend_netloc
        self.filter_headers(self.headers)

        # Deny put requests
        if self.path == "/api/put":
            logging.warning("OpenTSDBQuery blocked. Reason: %s",
                            "/api/put not allowed")
            self.send_error(httplib.FORBIDDEN, "/api/put not allowed")
            return

        # Process query requests
        if self.path == "/api/query":

            self.tsdb_query = OpenTSDBQuery(post_data)
            self.headers['X-Protector'] = self.tsdb_query.get_id()

            # Check the payload against the Protector rule set
            result = self.protector.check(self.tsdb_query)
            if not result.is_ok():
                self.protector.REQUESTS_BLOCKED.labels(
                    self.protector.safe_mode, result.value["rule"]).inc()

                if not self.protector.safe_mode:
                    logging.warning("OpenTSDBQuery blocked: %s. Reason: %s",
                                    self.tsdb_query.get_id(),
                                    result.value["msg"])
                    self.send_error(httplib.FORBIDDEN, result.value["msg"])
                    return

            post_data = self.tsdb_query.to_json()

            self.headers['Content-Length'] = str(len(post_data))

        status = self._handle_request(self.scheme,
                                      self.backend_netloc,
                                      self.path,
                                      self.headers,
                                      body=post_data,
                                      method="POST")

        #['method', 'path', 'return_code']
        self.protector.REQUESTS_COUNT.labels('POST', self.path, status).inc()

        self.finish()
        self.connection.close()

    def send_error(self, code, message=None):
        """
        Send and log plain text error reply.
        :param code:
        :param message:
        """
        message = message.strip()
        self.log_error("code %d, message: %s", code, message)
        self.send_response(code)

        self.send_header("Content-Type", "application/json")
        self.send_header('Connection', 'close')
        self.end_headers()
        if message:
            # Grafana style
            j = {'message': message, 'error': message}
            self.wfile.write(json.dumps(j))

    def _handle_request(self,
                        scheme,
                        netloc,
                        path,
                        headers,
                        body=None,
                        method="GET"):
        """
        Run the actual request
        """
        backend_url = "{}://{}{}".format(scheme, netloc, path)
        startTime = time.time()

        try:
            response = self.http_request.request(backend_url,
                                                 self.timeout,
                                                 method=method,
                                                 body=body,
                                                 headers=dict(headers))

            respTime = time.time()
            duration = respTime - startTime

            self.protector.TSDB_REQUEST_LATENCY.labels(
                response.status, path, method).observe(duration)
            self._return_response(response, method, duration)

            return response.status

        except socket.timeout, e:

            respTime = time.time()
            duration = respTime - startTime

            if method == "POST":
                self.protector.save_stats(self.tsdb_query, None, duration,
                                          True)

            self.protector.TSDB_REQUEST_LATENCY.labels(
                httplib.GATEWAY_TIMEOUT, path, method).observe(duration)
            self.send_error(
                httplib.GATEWAY_TIMEOUT,
                "Query timed out. Configured timeout: {}s".format(
                    self.timeout))

            return httplib.GATEWAY_TIMEOUT

        except Exception as e:

            respTime = time.time()
            duration = respTime - startTime

            err = "Invalid response from backend: '{}'".format(e)
            logging.debug(err)
            self.protector.TSDB_REQUEST_LATENCY.labels(
                httplib.BAD_GATEWAY, path, method).observe(duration)
            self.send_error(httplib.BAD_GATEWAY, err)

            return httplib.BAD_GATEWAY
Пример #5
0
class ProxyRequestHandler(BaseHTTPRequestHandler):
    protector = None
    backend_address = None

    cakey = 'ca.key'
    cacert = 'ca.crt'
    certkey = 'cert.key'
    certdir = 'certs/'

    # Request timeout
    timeout = 60
    lock = threading.Lock()

    def __init__(self, *args, **kwargs):
        self.tls = threading.local()
        self.version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'}
        self.http_request = HTTPRequest()

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)

    def log_error(self, log_format, *args):
        # Suppress "Request timed out: timeout('timed out',)"
        if isinstance(args[0], socket.timeout):
            return
        self.log_message(log_format, *args)

    def do_CONNECT(self):
        if os.path.isfile(self.cakey) and os.path.isfile(self.cacert) and os.path.isfile(self.certkey):
            if os.path.isdir(self.certdir):
                self.connect_intercept()
        self.connect_relay()

    def connect_intercept(self):
        hostname = self.path.split(':')[0]
        certpath = "%s/%s.crt" % (self.certdir.rstrip('/'), hostname)

        with self.lock:
            if not os.path.isfile(certpath):
                epoch = "%d" % (time.time() * 1000)
                p1 = Popen(["openssl", "req", "-new", "-key", self.certkey, "-subj", "/CN=%s" % hostname], stdout=PIPE)
                p2 = Popen(["openssl", "x509", "-req", "-days", "3650", "-CA", self.cacert, "-CAkey", self.cakey,
                            "-set_serial", epoch, "-out", certpath], stdin=p1.stdout, stderr=PIPE)
                p2.communicate()

        self.wfile.write("%s %d %s\r\n" % (self.protocol_version, httplib.OK, 'Connection Established'))
        self.end_headers()

        self.connection = ssl.wrap_socket(self.connection, keyfile=self.certkey, certfile=certpath, server_side=True)
        self.rfile = self.connection.makefile("rb", self.rbufsize)
        self.wfile = self.connection.makefile("wb", self.wbufsize)

        conntype = self.headers.get('Proxy-Connection', '')
        if conntype.lower() == 'close':
            self.close_connection = 1
        elif conntype.lower() == 'keep-alive' and self.protocol_version >= "HTTP/1.1":
            self.close_connection = 0

    def connect_relay(self):
        address = self.path.split(':', 1)
        address[1] = int(address[1]) or 443
        try:
            s = socket.create_connection(address, timeout=self.timeout)
        except:
            self.send_error(httplib.BAD_GATEWAY)
            return
        self.send_response(httplib.OK, 'Connection Established')
        self.end_headers()

        conns = [self.connection, s]
        self.close_connection = 0
        while not self.close_connection:
            rlist, wlist, xlist = select.select(conns, [], conns, self.timeout)
            if xlist or not rlist:
                break
            for r in rlist:
                other = conns[1] if r is conns[0] else conns[0]
                data = r.recv(8192)
                if not data:
                    self.close_connection = 1
                    break
                other.sendall(data)

    def _check_query(self, query_string):
        """
        Check if the query_string is allowed by the Protector rule set
        """
        return self.protector.check(query_string)

    @staticmethod
    def get_queries(parameters):
        """
        Get a list of all queries (q=... parameters) from an URL parameter string
        :param parameters: The url parameter list
        """
        parsed_params = urlparse.parse_qs(parameters)
        if 'q' not in parsed_params:
            return []
        queries = parsed_params['q']

        # Check if only one query string is given
        # in this case make it a list
        if not isinstance(queries, list):
            queries = [queries]
        return queries

    @staticmethod
    def _analyze_url(path):
        url_parts = urlparse.urlsplit(path)
        parameters = url_parts.query if url_parts.query else url_parts.path
        scheme, netloc, path = url_parts.scheme, url_parts.netloc, (url_parts.path + '?' + parameters)
        assert scheme in ('http', 'https')
        return scheme, netloc, path, parameters

    def do_GET(self):
        self.path = self._build_url(self.path, self.headers['Host'])
        scheme, netloc, path, parameters = self._analyze_url(self.path)

        queries = self.get_queries(parameters)

        for query_string in queries:
            query_result = self._check_query(query_string)
            if not query_result.is_ok():
                logging.warning("Query blocked: %s. Reason: %s", query_string, query_result.value)
                self.send_error(httplib.BAD_REQUEST, query_result.value)
                return
            logging.debug("Query ok: %s", query_string)

        # TODO: Is this needed?
        # self.headers['Host'] = self.backend_netloc
        self.filter_headers(self.headers)
        self._handle_request(scheme, self.backend_netloc, path, self.headers)

    def _handle_request(self, scheme, netloc, path, headers, body=None, method="GET"):
        """
        Run the actual request
        """
        backend_url = "{}://{}{}".format(scheme, netloc, path)
        try:
            response = self.http_request.request(backend_url, method=method, body=body, headers=dict(headers))
            self._return_response(response)
        except Exception as e:
            body = "Invalid response from backend: '{}' Server might be busy".format(e.message)
            logging.debug(body)
            self.send_error(httplib.SERVICE_UNAVAILABLE, body)

    def do_POST(self):
        self.path = self._build_url(self.path, self.headers['Host'])
        scheme, netloc, path, parameters = self._analyze_url(self.path)

        length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(length)

        self.filter_headers(self.headers)
        self._handle_request(scheme, self.backend_netloc, path, self.headers, body=post_data, method="POST")

    def send_error(self, code, message=None):
        """
        Send and log plain text error reply.
        :param code:
        :param message:
        """
        message = message.strip()
        self.log_error("code %d, message %s", code, message)
        self.send_response(code)
        self.send_header("Content-Type", "text/plain")
        self.send_header('Connection', 'close')
        self.end_headers()
        if message:
            self.wfile.write(message)

    def _return_response(self, response):
        """
        :type result: HTTPResponse
        """
        self.filter_headers(response.msg)
        if "content-length" in response.msg:
            del response.msg["content-length"]

        self.send_response(response.status, response.reason)
        for header_key, header_value in response.msg.items():
            self.send_header(header_key, header_value)
        body = response.read()
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    do_HEAD = do_GET
    do_OPTIONS = do_GET

    @staticmethod
    def filter_headers(headers):
        # http://tools.ietf.org/html/rfc2616#section-13.5.1
        hop_by_hop = (
            'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers',
            'transfer-encoding', 'upgrade'
        )
        for k in hop_by_hop:
            if k in headers:
                del headers[k]

    @staticmethod
    def encode_content_body(text, encoding):
        if encoding == 'identity':
            return text
        if encoding in ('gzip', 'x-gzip'):
            io = StringIO()
            with gzip.GzipFile(fileobj=io, mode='wb') as f:
                f.write(text)
            return io.getvalue()
        if encoding == 'deflate':
            return zlib.compress(text)
        raise Exception("Unknown Content-Encoding: %s" % encoding)

    @staticmethod
    def decode_content_body(data, encoding):
        if encoding == 'identity':
            return data
        if encoding in ('gzip', 'x-gzip'):
            io = StringIO(data)
            with gzip.GzipFile(fileobj=io) as f:
                return f.read()
        if encoding == 'deflate':
            return zlib.decompress(data)

        raise Exception("Unknown Content-Encoding: %s" % encoding)

    def send_cacert(self):
        with open(self.cacert, 'rb') as f:
            data = f.read()

        self.wfile.write("%s %d %s\r\n" % (self.protocol_version, httplib.OK, 'OK'))
        self.send_header('Content-Type', 'application/x-x509-ca-cert')
        self.send_header('Content-Length', len(data))
        self.send_header('Connection', 'close')
        self.end_headers()
        self.wfile.write(data)

    def _build_url(self, path, host):
        if path[0] != '/':
            return path
        if isinstance(self.connection, ssl.SSLSocket):
            return "https://%s%s" % (host, path)
        else:
            return "http://%s%s" % (host, path)
Пример #6
0
class ProxyRequestHandler(BaseHTTPRequestHandler):
    protector = None
    backend_address = None

    cakey = 'ca.key'
    cacert = 'ca.crt'
    certkey = 'cert.key'
    certdir = 'certs/'

    # Request timeout
    timeout = 60
    lock = threading.Lock()

    def __init__(self, *args, **kwargs):
        self.tls = threading.local()
        self.version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'}
        self.http_request = HTTPRequest()

        # Address to time series backend
        backend_host, backend_port = self.backend_address
        self.backend_netloc = "{}:{}".format(backend_host, backend_port)

        self.path = None
        self.connection = None
        self.rfile = None
        self.wfile = None
        self.close_connection = 0

        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)

    def log_error(self, log_format, *args):
        # Suppress "Request timed out: timeout('timed out',)"
        if isinstance(args[0], socket.timeout):
            return
        self.log_message(log_format, *args)

    def do_CONNECT(self):
        if os.path.isfile(self.cakey) and os.path.isfile(
                self.cacert) and os.path.isfile(self.certkey):
            if os.path.isdir(self.certdir):
                self.connect_intercept()
        self.connect_relay()

    def connect_intercept(self):
        hostname = self.path.split(':')[0]
        certpath = "%s/%s.crt" % (self.certdir.rstrip('/'), hostname)

        with self.lock:
            if not os.path.isfile(certpath):
                epoch = "%d" % (time.time() * 1000)
                p1 = Popen([
                    "openssl", "req", "-new", "-key", self.certkey, "-subj",
                    "/CN=%s" % hostname
                ],
                           stdout=PIPE)
                p2 = Popen([
                    "openssl", "x509", "-req", "-days", "3650", "-CA",
                    self.cacert, "-CAkey", self.cakey, "-set_serial", epoch,
                    "-out", certpath
                ],
                           stdin=p1.stdout,
                           stderr=PIPE)
                p2.communicate()

        self.wfile.write(
            "%s %d %s\r\n" %
            (self.protocol_version, httplib.OK, 'Connection Established'))
        self.end_headers()

        self.connection = ssl.wrap_socket(self.connection,
                                          keyfile=self.certkey,
                                          certfile=certpath,
                                          server_side=True)
        self.rfile = self.connection.makefile("rb", self.rbufsize)
        self.wfile = self.connection.makefile("wb", self.wbufsize)

        conntype = self.headers.get('Proxy-Connection', '')
        if conntype.lower() == 'close':
            self.close_connection = 1
        elif conntype.lower(
        ) == 'keep-alive' and self.protocol_version >= "HTTP/1.1":
            self.close_connection = 0

    def connect_relay(self):
        address = self.path.split(':', 1)
        address[1] = int(address[1]) or 443
        try:
            s = socket.create_connection(address, timeout=self.timeout)
        except:
            self.send_error(httplib.BAD_GATEWAY)
            return
        self.send_response(httplib.OK, 'Connection Established')
        self.end_headers()

        conns = [self.connection, s]
        self.close_connection = 0
        while not self.close_connection:
            rlist, wlist, xlist = select.select(conns, [], conns, self.timeout)
            if xlist or not rlist:
                break
            for r in rlist:
                other = conns[1] if r is conns[0] else conns[0]
                data = r.recv(8192)
                if not data:
                    self.close_connection = 1
                    break
                other.sendall(data)

    def _check_query(self, query_string):
        """
        Check if the query_string is allowed by the Protector rule set
        """
        return self.protector.check(query_string)

    @staticmethod
    def get_queries(parameters):
        """
        Get a list of all queries (q=... parameters) from an URL parameter string
        :param parameters: The url parameter list
        """
        parsed_params = urlparse.parse_qs(parameters)
        if 'q' not in parsed_params:
            return []
        queries = parsed_params['q']

        # Check if only one query string is given
        # in this case make it a list
        if not isinstance(queries, list):
            queries = [queries]
        return queries

    @staticmethod
    def _analyze_url(path):
        url_parts = urlparse.urlsplit(path)
        parameters = url_parts.query if url_parts.query else url_parts.path
        scheme, netloc, path = url_parts.scheme, url_parts.netloc, (
            url_parts.path + '?' + parameters)
        assert scheme in ('http', 'https')
        return scheme, netloc, path, parameters

    def do_GET(self):
        self.path = self._build_url(self.path, self.headers['Host'])
        scheme, netloc, path, parameters = self._analyze_url(self.path)

        queries = self.get_queries(parameters)

        for query_string in queries:
            query_result = self._check_query(query_string)
            if not query_result.is_ok():
                logging.warning("Query blocked: %s. Reason: %s", query_string,
                                query_result.value)
                self.send_error(httplib.BAD_REQUEST, query_result.value)
                return
            logging.debug("Query ok: %s", query_string)

        # TODO: Is this needed?
        # self.headers['Host'] = self.backend_netloc
        self.filter_headers(self.headers)
        self._handle_request(scheme, self.backend_netloc, path, self.headers)

    def _handle_request(self,
                        scheme,
                        netloc,
                        path,
                        headers,
                        body=None,
                        method="GET"):
        """
        Run the actual request
        """
        backend_url = "{}://{}{}".format(scheme, netloc, path)
        try:
            response = self.http_request.request(backend_url,
                                                 method=method,
                                                 body=body,
                                                 headers=dict(headers))
            self._return_response(response)
        except Exception as e:
            body = "Invalid response from backend: '{}' Server might be busy".format(
                e.message)
            logging.debug(body)
            self.send_error(httplib.SERVICE_UNAVAILABLE, body)

    def do_POST(self):
        self.path = self._build_url(self.path, self.headers['Host'])
        scheme, netloc, path, parameters = self._analyze_url(self.path)

        length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(length)

        self.filter_headers(self.headers)
        self._handle_request(scheme,
                             self.backend_netloc,
                             path,
                             self.headers,
                             body=post_data,
                             method="POST")

    def send_error(self, code, message=None):
        """
        Send and log plain text error reply.
        :param code:
        :param message:
        """
        message = message.strip()
        self.log_error("code %d, message %s", code, message)
        self.send_response(code)
        self.send_header("Content-Type", "text/plain")
        self.send_header('Connection', 'close')
        self.end_headers()
        if message:
            self.wfile.write(message)

    def _return_response(self, response):
        """
        :type result: HTTPResponse
        """
        self.filter_headers(response.msg)
        if "content-length" in response.msg:
            del response.msg["content-length"]

        self.send_response(response.status, response.reason)
        for header_key, header_value in response.msg.items():
            self.send_header(header_key, header_value)
        body = response.read()
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    do_HEAD = do_GET
    do_OPTIONS = do_GET

    @staticmethod
    def filter_headers(headers):
        # http://tools.ietf.org/html/rfc2616#section-13.5.1
        hop_by_hop = ('connection', 'keep-alive', 'proxy-authenticate',
                      'proxy-authorization', 'te', 'trailers',
                      'transfer-encoding', 'upgrade')
        for k in hop_by_hop:
            if k in headers:
                del headers[k]

    @staticmethod
    def encode_content_body(text, encoding):
        if encoding == 'identity':
            return text
        if encoding in ('gzip', 'x-gzip'):
            io = StringIO()
            with gzip.GzipFile(fileobj=io, mode='wb') as f:
                f.write(text)
            return io.getvalue()
        if encoding == 'deflate':
            return zlib.compress(text)
        raise Exception("Unknown Content-Encoding: %s" % encoding)

    @staticmethod
    def decode_content_body(data, encoding):
        if encoding == 'identity':
            return data
        if encoding in ('gzip', 'x-gzip'):
            io = StringIO(data)
            with gzip.GzipFile(fileobj=io) as f:
                return f.read()
        if encoding == 'deflate':
            return zlib.decompress(data)

        raise Exception("Unknown Content-Encoding: %s" % encoding)

    def send_cacert(self):
        with open(self.cacert, 'rb') as f:
            data = f.read()

        self.wfile.write("%s %d %s\r\n" %
                         (self.protocol_version, httplib.OK, 'OK'))
        self.send_header('Content-Type', 'application/x-x509-ca-cert')
        self.send_header('Content-Length', len(data))
        self.send_header('Connection', 'close')
        self.end_headers()
        self.wfile.write(data)

    def _build_url(self, path, host):
        if path[0] != '/':
            return path
        if isinstance(self.connection, ssl.SSLSocket):
            return "https://%s%s" % (host, path)
        else:
            return "http://%s%s" % (host, path)