add a generic proxy application (#72300)
gitea-wip/passerelle/pipeline/pr-main This commit looks good
Details
gitea-wip/passerelle/pipeline/pr-main This commit looks good
Details
This commit is contained in:
parent
709a29d551
commit
adb08a9ef8
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
@ -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¶m2=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¶m2=2&orig=coucou&algo=foo&nonce=bar×tamp=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'
|
Loading…
Reference in New Issue