add a generic proxy application (#72300)
gitea-wip/passerelle/pipeline/pr-main This commit looks good Details

This commit is contained in:
Thomas NOËL 2023-01-05 18:32:45 +01:00
parent 709a29d551
commit adb08a9ef8
6 changed files with 346 additions and 0 deletions

View File

View File

@ -0,0 +1,82 @@
# Generated by Django 2.2.26 on 2023-01-08 12:18
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('base', '0030_resourcelog_base_resour_appname_298cbc_idx'),
]
operations = [
migrations.CreateModel(
name='Resource',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('title', models.CharField(max_length=50, verbose_name='Title')),
('slug', models.SlugField(unique=True, verbose_name='Identifier')),
('description', models.TextField(verbose_name='Description')),
(
'basic_auth_username',
models.CharField(
blank=True, max_length=128, verbose_name='Basic authentication username'
),
),
(
'basic_auth_password',
models.CharField(
blank=True, max_length=128, verbose_name='Basic authentication password'
),
),
(
'client_certificate',
models.FileField(
blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
),
),
(
'trusted_certificate_authorities',
models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
),
(
'verify_cert',
models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
),
(
'http_proxy',
models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
),
('upstream_base_url', models.URLField(verbose_name='Upstream Service Base URL')),
(
'http_timeout',
models.PositiveIntegerField(default=20, verbose_name='Timeout on upstream (in seconds)'),
),
(
'forced_headers',
models.TextField(
blank=True,
help_text='Headers to always add (one per line, format "Header-Name: value")',
verbose_name='Headers',
),
),
(
'users',
models.ManyToManyField(
blank=True,
related_name='_resource_users_+',
related_query_name='+',
to='base.ApiUser',
),
),
],
options={
'verbose_name': 'Proxy',
},
),
]

View File

@ -0,0 +1,94 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from urllib.parse import parse_qsl
from django.db import models
from django.http.response import HttpResponse
from django.utils.translation import gettext_lazy as _
from passerelle.base.models import BaseResource, HTTPResource
from passerelle.utils.api import endpoint
PASS_HEADERS_REQUEST = (
'accept',
'accept-encoding',
'accept-language',
'cookie',
'user-agent',
)
class Resource(BaseResource, HTTPResource):
category = _('Misc')
upstream_base_url = models.URLField(_('Upstream Service Base URL'))
http_timeout = models.PositiveIntegerField(_('Timeout on upstream (in seconds)'), default=20)
forced_headers = models.TextField(
_('Headers'),
blank=True,
help_text=_('Headers to always add (one per line, format "Header-Name: value")'),
)
class Meta:
verbose_name = _('Proxy')
@endpoint(
name='request',
perm='can_access',
methods=['get', 'post', 'delete', 'put', 'patch'],
pattern=r'^(?P<path>.*)$',
description=_('Make a request'),
example_pattern='{path}',
parameters={
'path': {
'description': _('request will be made on Upstream Service Base URL + path'),
'example_value': 'foo/bar',
}
},
)
def request(self, request, path, *args, **kwargs):
params = parse_qsl(request.META.get('QUERY_STRING'))
if params and params[-1][0] == 'signature':
# remove Publik signature parts: orig, algo, timestamp, nonce, signature
params = [
(k, v) for k, v in params if k not in ('orig', 'algo', 'timestamp', 'nonce', 'signature')
]
headers = {k: v for k, v in request.headers.items() if v and k.lower() in PASS_HEADERS_REQUEST}
if request.method != 'GET':
headers['Content-Type'] = request.headers.get('content-type')
for header in self.forced_headers.splitlines():
header = header.strip()
if header.startswith('#'):
continue
header = header.split(':', 1)
if len(header) == 2:
headers[header[0].strip()] = header[1].strip()
upstream = self.requests.request(
method=request.method,
url=self.upstream_base_url + path,
headers=headers,
params=params,
data=request.body,
timeout=self.http_timeout,
)
response = HttpResponse(
upstream.content,
content_type=upstream.headers.get('Content-Type'),
status=upstream.status_code,
reason=upstream.reason,
)
return response

View File

@ -168,6 +168,7 @@ INSTALLED_APPS = (
'passerelle.apps.phonecalls',
'passerelle.apps.photon',
'passerelle.apps.plone_restapi',
'passerelle.apps.proxy',
'passerelle.apps.sector',
'passerelle.apps.sfr_dmc',
'passerelle.apps.signal_arretes',

169
tests/test_proxy.py Normal file
View File

@ -0,0 +1,169 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import responses
import responses.matchers
import tests.utils
from passerelle.apps.proxy.models import Resource
@pytest.fixture
def proxy(db):
return tests.utils.setup_access_rights(
Resource.objects.create(slug='echo', upstream_base_url='https://example.org/')
)
@pytest.fixture
def endpoint(proxy):
return tests.utils.generic_endpoint_url('proxy', 'request', slug=proxy.slug)
@pytest.fixture
def mocked_responses():
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsp:
yield rsp
def test_get(mocked_responses, app, proxy, endpoint):
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[
responses.matchers.query_string_matcher(''),
],
)
resp = app.get(endpoint + '/foo/bar', status=200)
assert resp.text == 'ok'
def test_status(mocked_responses, app, proxy, endpoint):
mocked_responses.get(url='https://example.org/foo/bar', body='bad request', status=400)
resp = app.get(endpoint + '/foo/bar', status=400)
assert resp.text == 'bad request'
def test_post(mocked_responses, app, proxy, endpoint):
mocked_responses.post(
url='https://example.org/post',
body='ok',
match=[
responses.matchers.json_params_matcher({'foo': 'bar'}),
],
)
resp = app.post_json(endpoint + '/post', params={'foo': 'bar'}, status=200)
assert resp.text == 'ok'
def no_header_matcher(header_name):
def inner(request):
if header_name in request.headers:
return False, f'Found header "{header_name} in requests headers.'
return True, None
return inner
def test_headers(mocked_responses, app, proxy, endpoint):
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[
responses.matchers.header_matcher({'User-Agent': 'test-ua'}),
no_header_matcher('x-foo'),
no_header_matcher('dontpass'),
],
)
resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test-ua', 'dontpass': 'x'}, status=200)
assert resp.text == 'ok'
def test_forced_headers(mocked_responses, app, proxy, endpoint):
proxy.forced_headers = '''
x-foo : bar
badentry
# comment: do not use me
'''
proxy.save()
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[
responses.matchers.header_matcher({'x-foo': 'bar'}),
no_header_matcher('dontpass'),
],
)
resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test', 'dontpass': 'x'}, status=200)
assert resp.text == 'ok'
def test_query_parameters(mocked_responses, app, proxy, endpoint):
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[
responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True),
],
)
resp = app.get(endpoint + '/foo/bar?param1=1&param2=2', status=200)
assert resp.text == 'ok'
def test_publik_signature_is_removed(mocked_responses, app, proxy, endpoint):
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[
responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True),
],
)
resp = app.get(
endpoint + '/foo/bar?param1=1&param2=2&orig=coucou&algo=foo&nonce=bar&timestamp=xxx&signature=okok',
status=200,
)
assert resp.text == 'ok'
def test_auth(mocked_responses, app, proxy, endpoint):
proxy.basic_auth_username = 'test-login'
proxy.basic_auth_password = 'test-pass'
proxy.save()
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[responses.matchers.header_matcher({'Authorization': 'Basic dGVzdC1sb2dpbjp0ZXN0LXBhc3M='})],
)
resp = app.get(endpoint + '/foo/bar', status=200)
assert resp.text == 'ok'
def test_timeout(mocked_responses, app, proxy, endpoint):
proxy.http_timeout = 5
proxy.save()
mocked_responses.get(
url='https://example.org/foo/bar',
body='ok',
match=[responses.matchers.request_kwargs_matcher({'timeout': 5})],
)
resp = app.get(endpoint + '/foo/bar', status=200)
assert resp.text == 'ok'