diff --git a/.gitignore b/.gitignore index 1e1017c8d1309f6d805dd8dfef53def18929b1c8..8b511703444291a612645e398d5b568f6e717a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ front/selenium-debug.log docs/_build data/ +.env diff --git a/CHANGELOG b/CHANGELOG index 0b91987235f620475b00f26af87787c43670913b..15eacce4f09d9b5083bcd2625daf333102f3bb67 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,53 @@ Changelog .. towncrier +Release notes: + +Preparing for federation +^^^^^^^^^^^^^^^^^^^^^^^^ + +In order to prepare for federation (see #136 and #137), new API endpoints +have been added under /federation and /.well-known/webfinger. + +For these endpoints to work, you will need to update your nginx configuration, +and add the following snippets:: + + location /federation/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/federation/; + } + + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + +This will ensure federation endpoints will be reachable in the future. +You can of course skip this part if you know you will not federate your instance. + +A new ``FEDERATION_ENABLED`` env var have also been added to control wether +federation is enabled or not on the application side. This settings defaults +to True, which should have no consequencies at the moment, since actual +federation is not implemented and the only available endpoints are for +testing purposes. + +Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation +on the application side. + +The last step involves generating RSA private and public keys for signing +your instance requests on the federation. This can be done via:: + + # on docker setups + docker-compose --rm api python manage.py generate_keys --no-input + + # on non-docker setups + source /srv/funkwhale/virtualenv/bin/activate + source /srv/funkwhale/load_env + python manage.py generate_keys --no-input + +That's it :) + + 0.7 (2018-03-21) ---------------- diff --git a/README.rst b/README.rst index 93281d26fb6abd65d0590183a416236a585b68e9..2d5d2011d2f368332458bd567284e2121a4dfca8 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,19 @@ via the following command:: docker-compose -f dev.yml build +Creating your env file +^^^^^^^^^^^^^^^^^^^^^^ + +We provide a working .env.dev configuration file that is suitable for +development. However, to enable customization on your machine, you should +also create a .env file that will hold your personal environment +variables (those will not be commited to the project). + +Create it like this:: + + touch .env + + Database management ^^^^^^^^^^^^^^^^^^^ diff --git a/api/config/settings/common.py b/api/config/settings/common.py index a6a46a85a2f1394197a53597ea1fc215d1b20165..32cdb5b7f53e54dedcab0a3665d647e349553170 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/ """ from __future__ import absolute_import, unicode_literals +from urllib.parse import urlsplit import os import environ from funkwhale_api import __version__ @@ -24,8 +25,13 @@ try: except FileNotFoundError: pass -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') FUNKWHALE_URL = env('FUNKWHALE_URL') +FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc + +FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True) +FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME) + +ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') # APP CONFIGURATION # ------------------------------------------------------------------------------ @@ -395,4 +401,5 @@ ACCOUNT_USERNAME_BLACKLIST = [ 'owner', 'superuser', 'staff', + 'service', ] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[]) diff --git a/api/config/urls.py b/api/config/urls.py index 8f7e37bc26ae56ba9967682f4ec3f19f04cc71f4..90598ea841f474e5b887fda7fe42f23975cd4c00 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -13,6 +13,9 @@ urlpatterns = [ url(settings.ADMIN_URL, admin.site.urls), url(r'^api/', include(("config.api_urls", 'api'), namespace="api")), + url(r'^', include( + ('funkwhale_api.federation.urls', 'federation'), + namespace="federation")), url(r'^api/v1/auth/', include('rest_auth.urls')), url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')), url(r'^accounts/', include('allauth.urls')), diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..83d0285be263d228ffbb564a9bfc9f898fe77dbf --- /dev/null +++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py @@ -0,0 +1,34 @@ +from django.forms import widgets + +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +federation = types.Section('federation') + + +@global_preferences_registry.register +class FederationPrivateKey(types.StringPreference): + show_in_api = False + section = federation + name = 'private_key' + default = '' + help_text = ( + 'Instance private key, used for signing federation HTTP requests' + ) + verbose_name = ( + 'Instance private key (keep it secret, do not change it)' + ) + + +@global_preferences_registry.register +class FederationPublicKey(types.StringPreference): + show_in_api = False + section = federation + name = 'public_key' + default = '' + help_text = ( + 'Instance public key, used for signing federation HTTP requests' + ) + verbose_name = ( + 'Instance public key (do not change it)' + ) diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..96fd24a7ed7eb2962b52aaccb578957911647249 --- /dev/null +++ b/api/funkwhale_api/federation/exceptions.py @@ -0,0 +1,4 @@ + + +class MalformedPayload(ValueError): + pass diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 29aed4baf728a316cb0398537a7cbbed0bb961ab..f5d612b0dad5133197fb4385b583833539920126 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -4,16 +4,16 @@ import requests_http_signature from funkwhale_api.factories import registry -from . import signing +from . import keys -registry.register(signing.get_key_pair, name='federation.KeyPair') +registry.register(keys.get_key_pair, name='federation.KeyPair') @registry.register(name='federation.SignatureAuth') class SignatureAuthFactory(factory.Factory): algorithm = 'rsa-sha256' - key = factory.LazyFunction(lambda: signing.get_key_pair()[0]) + key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) key_id = factory.Faker('url') class Meta: diff --git a/api/funkwhale_api/federation/keys.py b/api/funkwhale_api/federation/keys.py new file mode 100644 index 0000000000000000000000000000000000000000..432560ef7446d5973d23850cff7a2cb253c9fba7 --- /dev/null +++ b/api/funkwhale_api/federation/keys.py @@ -0,0 +1,43 @@ +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend as crypto_default_backend + +import requests + +from . import exceptions + + +def get_key_pair(size=2048): + key = rsa.generate_private_key( + backend=crypto_default_backend(), + public_exponent=65537, + key_size=size + ) + private_key = key.private_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption()) + public_key = key.public_key().public_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PublicFormat.PKCS1 + ) + + return private_key, public_key + + +def get_public_key(actor_url): + """ + Given an actor_url, request it and extract publicKey data from + the response payload. + """ + response = requests.get(actor_url) + response.raise_for_status() + payload = response.json() + try: + return { + 'public_key_pem': payload['publicKey']['publicKeyPem'], + 'id': payload['publicKey']['id'], + 'owner': payload['publicKey']['owner'], + } + except KeyError: + raise exceptions.MalformedPayload(str(payload)) diff --git a/api/funkwhale_api/federation/management/__init__.py b/api/funkwhale_api/federation/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/__init__.py b/api/funkwhale_api/federation/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/generate_keys.py b/api/funkwhale_api/federation/management/commands/generate_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..eafe9aae3477753a7b61cbc854152e3d95e26e59 --- /dev/null +++ b/api/funkwhale_api/federation/management/commands/generate_keys.py @@ -0,0 +1,53 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from dynamic_preferences.registries import global_preferences_registry + +from funkwhale_api.federation import keys + + +class Command(BaseCommand): + help = ( + 'Generate a public/private key pair for your instance,' + ' for federation purposes. If a key pair already exists, does nothing.' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--replace', + action='store_true', + dest='replace', + default=False, + help='Replace existing key pair, if any', + ) + parser.add_argument( + '--noinput', '--no-input', action='store_false', dest='interactive', + help="Do NOT prompt the user for input of any kind.", + ) + + @transaction.atomic + def handle(self, *args, **options): + preferences = global_preferences_registry.manager() + existing_public = preferences['federation__public_key'] + existing_private = preferences['federation__public_key'] + + if existing_public or existing_private and not options['replace']: + raise CommandError( + 'Keys are already present! ' + 'Replace them with --replace if you know what you are doing.') + + if options['interactive']: + message = ( + 'Are you sure you want to do this?\n\n' + "Type 'yes' to continue, or 'no' to cancel: " + ) + if input(''.join(message)) != 'yes': + raise CommandError("Operation cancelled.") + private, public = keys.get_key_pair() + preferences['federation__public_key'] = public.decode('utf-8') + preferences['federation__private_key'] = private.decode('utf-8') + + self.stdout.write( + 'Your new key pair was generated.' + 'Your public key is now:\n\n{}'.format(public.decode('utf-8')) + ) diff --git a/api/funkwhale_api/federation/renderers.py b/api/funkwhale_api/federation/renderers.py new file mode 100644 index 0000000000000000000000000000000000000000..642b634628f2787044eae9b6a96b9e7004b05244 --- /dev/null +++ b/api/funkwhale_api/federation/renderers.py @@ -0,0 +1,9 @@ +from rest_framework.renderers import JSONRenderer + + +class ActivityPubRenderer(JSONRenderer): + media_type = 'application/activity+json' + + +class WebfingerRenderer(JSONRenderer): + media_type = 'application/jrd+json' diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..62a4500db5f39e92bc9d9dc316deedcca2ab5d76 --- /dev/null +++ b/api/funkwhale_api/federation/serializers.py @@ -0,0 +1,21 @@ +from django.urls import reverse +from django.conf import settings + +from . import utils + + +def repr_instance_actor(): + """ + We do not use a serializer here, since it's pretty static + """ + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': utils.full_url(reverse('federation:instance-actor')), + 'type': 'Service', + 'inbox': utils.full_url(reverse('federation:instance-inbox')), + 'outbox': utils.full_url(reverse('federation:instance-outbox')), + } diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py index e8d79097c5bf154e7f6edd4661b0f74d831241b8..87ac82bac0b9a5b599ce7d3721e8f4f876c02e8c 100644 --- a/api/funkwhale_api/federation/signing.py +++ b/api/funkwhale_api/federation/signing.py @@ -1,28 +1,6 @@ import requests import requests_http_signature -from cryptography.hazmat.primitives import serialization as crypto_serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.backends import default_backend as crypto_default_backend - - -def get_key_pair(size=2048): - key = rsa.generate_private_key( - backend=crypto_default_backend(), - public_exponent=65537, - key_size=size - ) - private_key = key.private_bytes( - crypto_serialization.Encoding.PEM, - crypto_serialization.PrivateFormat.PKCS8, - crypto_serialization.NoEncryption()) - public_key = key.public_key().public_bytes( - crypto_serialization.Encoding.PEM, - crypto_serialization.PublicFormat.PKCS1 - ) - - return private_key, public_key - def verify(request, public_key): return requests_http_signature.HTTPSignatureAuth.verify( diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..0c59a1414aee428deadbf0be58ec348bbf3f5317 --- /dev/null +++ b/api/funkwhale_api/federation/urls.py @@ -0,0 +1,15 @@ +from rest_framework import routers + +from . import views + +router = routers.SimpleRouter(trailing_slash=False) +router.register( + r'instance', + views.InstanceViewSet, + 'instance') +router.register( + r'.well-known', + views.WellKnownViewSet, + 'well-known') + +urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e83f54b5d54c79d684d80c057e7e0fbb7cd5d1f1 --- /dev/null +++ b/api/funkwhale_api/federation/utils.py @@ -0,0 +1,14 @@ +from django.conf import settings + + +def full_url(path): + """ + Given a relative path, return a full url usable for federation purpose + """ + root = settings.FUNKWHALE_URL + if path.startswith('/') and root.endswith('/'): + return root + path[1:] + elif not path.startswith('/') and not root.endswith('/'): + return root + '/' + path + else: + return root + path diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py new file mode 100644 index 0000000000000000000000000000000000000000..5f1ee36f76fada4bc3aa90e6941958fe1781f7a1 --- /dev/null +++ b/api/funkwhale_api/federation/views.py @@ -0,0 +1,74 @@ +from django import forms +from django.conf import settings +from django.http import HttpResponse + +from rest_framework import viewsets +from rest_framework import views +from rest_framework import response +from rest_framework.decorators import list_route + +from . import renderers +from . import serializers +from . import webfinger + + +class FederationMixin(object): + def dispatch(self, request, *args, **kwargs): + if not settings.FEDERATION_ENABLED: + return HttpResponse(status=405) + return super().dispatch(request, *args, **kwargs) + + +class InstanceViewSet(FederationMixin, viewsets.GenericViewSet): + authentication_classes = [] + permission_classes = [] + renderer_classes = [renderers.ActivityPubRenderer] + + @list_route(methods=['get']) + def actor(self, request, *args, **kwargs): + return response.Response(serializers.repr_instance_actor()) + + @list_route(methods=['get']) + def inbox(self, request, *args, **kwargs): + raise NotImplementedError() + + @list_route(methods=['get']) + def outbox(self, request, *args, **kwargs): + raise NotImplementedError() + + +class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): + authentication_classes = [] + permission_classes = [] + renderer_classes = [renderers.WebfingerRenderer] + + @list_route(methods=['get']) + def webfinger(self, request, *args, **kwargs): + try: + resource_type, resource = webfinger.clean_resource( + request.GET['resource']) + cleaner = getattr(webfinger, 'clean_{}'.format(resource_type)) + result = cleaner(resource) + except forms.ValidationError as e: + return response.Response({ + 'errors': { + 'resource': e.message + } + }, status=400) + except KeyError: + return response.Response({ + 'errors': { + 'resource': 'This field is required', + } + }, status=400) + + handler = getattr(self, 'handler_{}'.format(resource_type)) + data = handler(result) + + return response.Response(data) + + def handler_acct(self, clean_result): + username, hostname = clean_result + if username == 'service': + return webfinger.serialize_system_acct() + return {} diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py new file mode 100644 index 0000000000000000000000000000000000000000..a9281c2b596899b7094ca659994441f9212f24e8 --- /dev/null +++ b/api/funkwhale_api/federation/webfinger.py @@ -0,0 +1,52 @@ +from django import forms +from django.conf import settings +from django.urls import reverse + +from . import utils +VALID_RESOURCE_TYPES = ['acct'] + + +def clean_resource(resource_string): + if not resource_string: + raise forms.ValidationError('Invalid resource string') + + try: + resource_type, resource = resource_string.split(':', 1) + except ValueError: + raise forms.ValidationError('Missing webfinger resource type') + + if resource_type not in VALID_RESOURCE_TYPES: + raise forms.ValidationError('Invalid webfinger resource type') + + return resource_type, resource + + +def clean_acct(acct_string): + try: + username, hostname = acct_string.split('@') + except ValueError: + raise forms.ValidationError('Invalid format') + + if hostname != settings.FEDERATION_HOSTNAME: + raise forms.ValidationError('Invalid hostname') + + if username != 'service': + raise forms.ValidationError('Invalid username') + + return username, hostname + + +def serialize_system_acct(): + return { + 'subject': 'acct:service@{}'.format(settings.FEDERATION_HOSTNAME), + 'aliases': [ + utils.full_url(reverse('federation:instance-actor')) + ], + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': utils.full_url(reverse('federation:instance-actor')), + } + ] + } diff --git a/api/requirements/test.txt b/api/requirements/test.txt index b909a51ab6c6d4697313e9a7e64741bc20933d93..e11f26ca75ad36ab5efe1f91d86cd51b04928f9b 100644 --- a/api/requirements/test.txt +++ b/api/requirements/test.txt @@ -10,3 +10,4 @@ pytest-mock pytest-sugar pytest-xdist pytest-cov +requests-mock diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e6bfd0f4ecc963119141aa5069e9e940ac74c30e..41c03856d83aff2da6fc9b82e6a38d0a00e33213 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -2,6 +2,7 @@ import factory import tempfile import shutil import pytest +import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache @@ -148,3 +149,9 @@ def media_root(settings): settings.MEDIA_ROOT = tmp_dir yield settings.MEDIA_ROOT shutil.rmtree(tmp_dir) + + +@pytest.fixture +def r_mock(): + with requests_mock.mock() as m: + yield m diff --git a/api/tests/federation/__init__.py b/api/tests/federation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..7c533306821a24a664c260089c0f15201c5a9870 --- /dev/null +++ b/api/tests/federation/test_commands.py @@ -0,0 +1,14 @@ +from django.core.management import call_command + + +def test_generate_instance_key_pair(preferences, mocker): + mocker.patch( + 'funkwhale_api.federation.keys.get_key_pair', + return_value=(b'private', b'public')) + assert preferences['federation__public_key'] == '' + assert preferences['federation__private_key'] == '' + + call_command('generate_keys', interactive=False) + + assert preferences['federation__private_key'] == 'private' + assert preferences['federation__public_key'] == 'public' diff --git a/api/tests/federation/test_keys.py b/api/tests/federation/test_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..1c30c30b17ed6450e7ec133498f3788be16a9ee6 --- /dev/null +++ b/api/tests/federation/test_keys.py @@ -0,0 +1,16 @@ +from funkwhale_api.federation import keys + + +def test_public_key_fetching(r_mock): + payload = { + 'id': 'https://actor.mock/users/actor#main-key', + 'owner': 'test', + 'publicKeyPem': 'test_pem', + } + actor = 'https://actor.mock/' + r_mock.get(actor, json={'publicKey': payload}) + r = keys.get_public_key(actor) + + assert r['id'] == payload['id'] + assert r['owner'] == payload['owner'] + assert r['public_key_pem'] == payload['publicKeyPem'] diff --git a/api/tests/federation/test_signing.py b/api/tests/federation/test_signing.py index 5187faa52d02ab4af621f98b3b49295a5b35cd9a..dc678f749bb0d7063ff58842c24de2391261b9ef 100644 --- a/api/tests/federation/test_signing.py +++ b/api/tests/federation/test_signing.py @@ -4,6 +4,7 @@ import pytest import requests_http_signature from funkwhale_api.federation import signing +from funkwhale_api.federation import keys def test_can_sign_and_verify_request(factories): @@ -45,7 +46,7 @@ def test_verify_fails_with_wrong_key(factories): def test_can_verify_django_request(factories, api_request): - private_key, public_key = signing.get_key_pair() + private_key, public_key = keys.get_key_pair() signed_request = factories['federation.SignedRequest']( auth__key=private_key ) @@ -61,7 +62,7 @@ def test_can_verify_django_request(factories, api_request): def test_can_verify_django_request_digest(factories, api_request): - private_key, public_key = signing.get_key_pair() + private_key, public_key = keys.get_key_pair() signed_request = factories['federation.SignedRequest']( auth__key=private_key, method='post', @@ -81,7 +82,7 @@ def test_can_verify_django_request_digest(factories, api_request): def test_can_verify_django_request_digest_failure(factories, api_request): - private_key, public_key = signing.get_key_pair() + private_key, public_key = keys.get_key_pair() signed_request = factories['federation.SignedRequest']( auth__key=private_key, method='post', @@ -102,7 +103,7 @@ def test_can_verify_django_request_digest_failure(factories, api_request): def test_can_verify_django_request_failure(factories, api_request): - private_key, public_key = signing.get_key_pair() + private_key, public_key = keys.get_key_pair() signed_request = factories['federation.SignedRequest']( auth__key=private_key ) diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8bada65bb03c2b17bbc3428b3846a8c1a3f416b7 --- /dev/null +++ b/api/tests/federation/test_utils.py @@ -0,0 +1,14 @@ +import pytest + +from funkwhale_api.federation import utils + + +@pytest.mark.parametrize('url,path,expected', [ + ('http://test.com', '/hello', 'http://test.com/hello'), + ('http://test.com/', 'hello', 'http://test.com/hello'), + ('http://test.com/', '/hello', 'http://test.com/hello'), + ('http://test.com', 'hello', 'http://test.com/hello'), +]) +def test_full_url(settings, url, path, expected): + settings.FUNKWHALE_URL = url + assert utils.full_url(path) == expected diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..3d8218c230293211f45b16594d32cdbdc131f2f4 --- /dev/null +++ b/api/tests/federation/test_views.py @@ -0,0 +1,69 @@ +from django.urls import reverse + +import pytest + +from funkwhale_api.federation import webfinger + + +def test_instance_actor(db, settings, api_client): + settings.FUNKWHALE_URL = 'http://test.com' + url = reverse('federation:instance-actor') + response = api_client.get(url) + assert response.data['id'] == ( + settings.FUNKWHALE_URL + url + ) + assert response.data['type'] == 'Service' + assert response.data['inbox'] == ( + settings.FUNKWHALE_URL + reverse('federation:instance-inbox') + ) + assert response.data['outbox'] == ( + settings.FUNKWHALE_URL + reverse('federation:instance-outbox') + ) + assert response.data['@context'] == [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ] + + +@pytest.mark.parametrize('route', [ + 'instance-outbox', + 'instance-inbox', + 'instance-actor', + 'well-known-webfinger', +]) +def test_instance_inbox_405_if_federation_disabled( + db, settings, api_client, route): + settings.FEDERATION_ENABLED = False + url = reverse('federation:{}'.format(route)) + response = api_client.get(url) + + assert response.status_code == 405 + + +def test_wellknown_webfinger_validates_resource( + db, api_client, settings, mocker): + clean = mocker.spy(webfinger, 'clean_resource') + settings.FEDERATION_ENABLED = True + url = reverse('federation:well-known-webfinger') + response = api_client.get(url, data={'resource': 'something'}) + + clean.assert_called_once_with('something') + assert url == '/.well-known/webfinger' + assert response.status_code == 400 + assert response.data['errors']['resource'] == ( + 'Missing webfinger resource type' + ) + + +def test_wellknown_webfinger_system( + db, api_client, settings, mocker): + settings.FEDERATION_ENABLED = True + settings.FEDERATION_HOSTNAME = 'test.federation' + url = reverse('federation:well-known-webfinger') + response = api_client.get( + url, data={'resource': 'acct:service@test.federation'}) + + assert response.status_code == 200 + assert response['Content-Type'] == 'application/jrd+json' + assert response.data == webfinger.serialize_system_acct() diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py new file mode 100644 index 0000000000000000000000000000000000000000..4cee9c8c71083798e8f0156140e0ca5303d358e3 --- /dev/null +++ b/api/tests/federation/test_webfinger.py @@ -0,0 +1,66 @@ +import pytest + +from django import forms +from django.urls import reverse + +from funkwhale_api.federation import webfinger + + +def test_webfinger_clean_resource(): + t, r = webfinger.clean_resource('acct:service@test.federation') + assert t == 'acct' + assert r == 'service@test.federation' + + +@pytest.mark.parametrize('resource,message', [ + ('', 'Invalid resource string'), + ('service@test.com', 'Missing webfinger resource type'), + ('noop:service@test.com', 'Invalid webfinger resource type'), +]) +def test_webfinger_clean_resource_errors(resource, message): + with pytest.raises(forms.ValidationError) as excinfo: + webfinger.clean_resource(resource) + + assert message == str(excinfo) + + +def test_webfinger_clean_acct(settings): + settings.FEDERATION_HOSTNAME = 'test.federation' + username, hostname = webfinger.clean_acct('service@test.federation') + assert username == 'service' + assert hostname == 'test.federation' + + +@pytest.mark.parametrize('resource,message', [ + ('service', 'Invalid format'), + ('service@test.com', 'Invalid hostname'), + ('noop@test.federation', 'Invalid account'), +]) +def test_webfinger_clean_acct_errors(resource, message, settings): + settings.FEDERATION_HOSTNAME = 'test.federation' + + with pytest.raises(forms.ValidationError) as excinfo: + webfinger.clean_resource(resource) + + assert message == str(excinfo) + + +def test_service_serializer(settings): + settings.FEDERATION_HOSTNAME = 'test.federation' + settings.FUNKWHALE_URL = 'https://test.federation' + + expected = { + 'subject': 'acct:service@test.federation', + 'links': [ + { + 'rel': 'self', + 'href': 'https://test.federation/instance/actor', + 'type': 'application/activity+json', + } + ], + 'aliases': [ + 'https://test.federation/instance/actor', + ] + } + + assert expected == webfinger.serialize_system_acct() diff --git a/deploy/funkwhale_proxy.conf b/deploy/funkwhale_proxy.conf index 1b1dd0d20e951455eb999e17aacc1c45e6fed669..312986f43a0bd2a15169eea427d9a2f54dd0e7fb 100644 --- a/deploy/funkwhale_proxy.conf +++ b/deploy/funkwhale_proxy.conf @@ -3,8 +3,8 @@ proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $host:$server_port; -proxy_set_header X-Forwarded-Port $server_port; +proxy_set_header X-Forwarded-Host $host:$server_port; +proxy_set_header X-Forwarded-Port $server_port; proxy_redirect off; # websocket support diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 1c7b9ae8357d8222aba78e9c6dc34f45fbbb8ca4..1c304b4938892bf87ba50ab05f2394784b020248 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -62,6 +62,16 @@ server { proxy_pass http://funkwhale-api/api/; } + location /federation/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/federation/; + } + + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + location /media/ { alias /srv/funkwhale/data/media/; } diff --git a/dev.yml b/dev.yml index dd3a55ddcb2eba19927a13ab1ae4b3e307f907f2..126efa683e7fe3650b82c03f02862acbc18cf4be 100644 --- a/dev.yml +++ b/dev.yml @@ -1,27 +1,35 @@ -version: '2' +version: '3' services: - front: build: front - env_file: .env.dev + env_file: + - .env.dev + - .env environment: - "HOST=0.0.0.0" + - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}" ports: - - "8080:8080" + - "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}" volumes: - './front:/app' postgres: - env_file: .env.dev + env_file: + - .env.dev + - .env image: postgres redis: - env_file: .env.dev + env_file: + - .env.dev + - .env image: redis:3.0 celeryworker: - env_file: .env.dev + env_file: + - .env.dev + - .env build: context: ./api dockerfile: docker/Dockerfile.test @@ -36,12 +44,14 @@ services: - C_FORCE_ROOT=true - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - - "FUNKWHALE_URL=http://funkwhale.test" + - "FUNKWHALE_URL=http://localhost" volumes: - ./api:/app - ./data/music:/music api: - env_file: .env.dev + env_file: + - .env.dev + - .env build: context: ./api dockerfile: docker/Dockerfile.test @@ -56,19 +66,26 @@ services: - "DJANGO_SECRET_KEY=dev" - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - - "FUNKWHALE_URL=http://funkwhale.test" + - "FUNKWHALE_URL=http://localhost" links: - postgres - redis nginx: - env_file: .env.dev + command: /entrypoint.sh + env_file: + - .env.dev + - .env image: nginx + environment: + - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}" links: - api - front volumes: - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf + - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro + - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro - ./api/funkwhale_api/media:/protected/media ports: - "0.0.0.0:6001:6001" diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 9847c2dcbcc71bf3040802d6289c7336ff40c340..e832a5ae3ee5b4d96833bd1cf5bf23fee6d5033f 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -37,19 +37,7 @@ http { listen 6001; charset utf-8; client_max_body_size 20M; - - # global proxy pass config - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host localhost:8080; - proxy_set_header X-Forwarded-Port 8080; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_redirect off; - + include /etc/nginx/funkwhale_proxy.conf; location /_protected/media { internal; alias /protected/media; @@ -63,8 +51,7 @@ http { if ($request_uri ~* "[^\?]+\?(.*)$") { set $query $1; } - proxy_set_header X-Forwarded-Host localhost:8080; - proxy_set_header X-Forwarded-Port 8080; + include /etc/nginx/funkwhale_proxy.conf; proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query; proxy_pass_request_body off; proxy_set_header Content-Length ""; @@ -78,6 +65,7 @@ http { if ($args ~ (.*)jwt=[^&]*(.*)) { set $cleaned_args $1$2; } + include /etc/nginx/funkwhale_proxy.conf; proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args"; proxy_cache transcode; proxy_cache_valid 200 7d; @@ -87,6 +75,7 @@ http { proxy_pass http://api:12081; } location / { + include /etc/nginx/funkwhale_proxy.conf; proxy_pass http://api:12081/; } } diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh new file mode 100755 index 0000000000000000000000000000000000000000..93b4a0533fe4edd3a7ba815cf9a7b1d0ac118824 --- /dev/null +++ b/docker/nginx/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash -eux + +echo "Copying template file..." +cp /etc/nginx/funkwhale_proxy.conf{.template,} +sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host localhost:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf +sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf + +cat /etc/nginx/funkwhale_proxy.conf +nginx -g "daemon off;" diff --git a/front/config/index.js b/front/config/index.js index 14cbe3e4388ea6a5e18a6053a1611ffd0cd6ab38..925b4defe3fb89ca4879702c7e3eb5b4cdfbbabf 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -23,25 +23,37 @@ module.exports = { }, dev: { env: require('./dev.env'), - port: 8080, + port: parseInt(process.env.WEBPACK_DEVSERVER_PORT), host: '127.0.0.1', autoOpenBrowser: true, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: { - '/api': { + '**': { target: 'http://nginx:6001', changeOrigin: true, - ws: true + ws: true, + filter: function (pathname, req) { + let proxified = ['.well-known', 'staticfiles', 'media', 'instance', 'api'] + let matches = proxified.filter(e => { + return pathname.match(`^/${e}`) + }) + return matches.length > 0 + } }, - '/media': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '/staticfiles': { - target: 'http://nginx:6001', - changeOrigin: true, - } + // '/.well-known': { + // target: 'http://nginx:6001', + // changeOrigin: true + // }, + // '/media': { + // target: 'http://nginx:6001', + // changeOrigin: true, + // }, + // '/staticfiles': { + // target: 'http://nginx:6001', + // changeOrigin: true, + // }, + }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README diff --git a/front/package.json b/front/package.json index 201694e43648e08c6bd23b2fa869ca83c29c7e2d..d67375f7e5851f7cf2af379ac298a6f85f973f78 100644 --- a/front/package.json +++ b/front/package.json @@ -41,7 +41,7 @@ "autoprefixer": "^6.7.2", "babel-core": "^6.22.1", "babel-eslint": "^7.1.1", - "babel-loader": "^6.2.10", + "babel-loader": "7", "babel-plugin-istanbul": "^4.1.1", "babel-plugin-transform-runtime": "^6.22.0", "babel-preset-env": "^1.3.2", @@ -101,7 +101,7 @@ "vue-loader": "^12.1.0", "vue-style-loader": "^3.0.1", "vue-template-compiler": "^2.3.3", - "webpack": "^2.6.1", + "webpack": "3", "webpack-bundle-analyzer": "^2.2.1", "webpack-dev-middleware": "^1.10.0", "webpack-hot-middleware": "^2.18.0",