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.phonecalls',
|
||||||
'passerelle.apps.photon',
|
'passerelle.apps.photon',
|
||||||
'passerelle.apps.plone_restapi',
|
'passerelle.apps.plone_restapi',
|
||||||
|
'passerelle.apps.proxy',
|
||||||
'passerelle.apps.sector',
|
'passerelle.apps.sector',
|
||||||
'passerelle.apps.sfr_dmc',
|
'passerelle.apps.sfr_dmc',
|
||||||
'passerelle.apps.signal_arretes',
|
'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