├── example ├── __init__.py ├── wsgi.py ├── urls.py └── settings.py ├── pyas2 ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── sendas2bulk.py │ │ ├── sendas2message.py │ │ └── manageas2server.py ├── migrations │ ├── __init__.py │ ├── 0003_auto_20221208_1310.py │ ├── 0002_auto_20190603_1329.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── pyas2.py ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── testmessage.edi │ │ ├── client_public.pem │ │ ├── server_public.pem │ │ ├── client_private.pem │ │ └── server_private.pem │ ├── conftest.py │ ├── test_views.py │ ├── test_commands.py │ └── test_advanced.py ├── __init__.py ├── apps.py ├── urls.py ├── settings.py ├── utils.py ├── templates │ ├── admin │ │ └── pyas2 │ │ │ ├── mdn │ │ │ └── change_form.html │ │ │ └── message │ │ │ └── change_form.html │ └── pyas2 │ │ └── send_as2_message.html ├── admin.py ├── forms.py ├── views.py └── models.py ├── docs ├── changelog.rst ├── requirements.txt ├── images │ ├── P1_Home.png │ ├── SendFile1.png │ ├── P1_SendFile.png │ └── P2_SendFile.png ├── Makefile ├── detailed-guide │ ├── index.rst │ ├── admin-commands.rst │ ├── organizations.rst │ ├── docker.rst │ ├── sendreceive-mdns.rst │ ├── sendreceive-messages.rst │ ├── certificates.rst │ ├── configuration.rst │ ├── partners.rst │ └── extending.rst ├── make.bat ├── index.rst ├── conf.py ├── installation.rst └── quickstart.rst ├── pytest.ini ├── requirements ├── tox.txt ├── base.txt └── test.txt ├── AUTHORS.md ├── MANIFEST.in ├── Makefile ├── .coveragerc ├── tox.ini ├── setup.cfg ├── manage.py ├── .github └── workflows │ ├── run-tests.yml │ └── python-publish.yml ├── .gitignore ├── setup.py ├── CHANGELOG.rst └── README.rst /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyas2/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyas2/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyas2/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyas2/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=example.settings -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | pyas2lib==1.3.3 2 | requests 3 | -r test.txt 4 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | pyas2lib==1.3.3 2 | requests==2.25.1 3 | django>=2.2.18 4 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | * Abhishek Ram @abhishek-ram 2 | * Wassilios Lytras @chadgates -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==2.0.1 2 | sphinx-version-warning==1.1.2 3 | alabaster==0.7.12 4 | -------------------------------------------------------------------------------- /docs/images/P1_Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishek-ram/django-pyas2/HEAD/docs/images/P1_Home.png -------------------------------------------------------------------------------- /docs/images/SendFile1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishek-ram/django-pyas2/HEAD/docs/images/SendFile1.png -------------------------------------------------------------------------------- /docs/images/P1_SendFile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishek-ram/django-pyas2/HEAD/docs/images/P1_SendFile.png -------------------------------------------------------------------------------- /docs/images/P2_SendFile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishek-ram/django-pyas2/HEAD/docs/images/P2_SendFile.png -------------------------------------------------------------------------------- /pyas2/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | TEST_DIR = os.path.join((os.path.dirname(os.path.abspath(__file__))), "fixtures") 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include AUTHORS.md 4 | 5 | graft pyas2 6 | 7 | global-exclude .DS_Store 8 | global-exclude *.py[co] 9 | -------------------------------------------------------------------------------- /pyas2/__init__.py: -------------------------------------------------------------------------------- 1 | # Set the version 2 | __version__ = "1.2.2" 3 | 4 | default_app_config = "pyas2.apps.Pyas2Config" 5 | 6 | __all__ = [ 7 | "default_app_config", 8 | ] 9 | -------------------------------------------------------------------------------- /pyas2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Pyas2Config(AppConfig): 5 | """App config for the pyas2 app.""" 6 | 7 | name = "pyas2" 8 | verbose_name = "pyAS2 File Transfer Server" 9 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest==5.4.3 2 | pytest-cov==2.11.1 3 | pytest-django==3.9.0 4 | pytest-mock==3.5.1 5 | pylama==7.7.1 6 | pylint==2.7.3 7 | pytest-black==0.3.12 8 | black==21.5b0 9 | django-environ==0.4.5 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @(py.test --cov-report term --cov-config .coveragerc --cov=pyas2 --color=yes pyas2/tests/ --black --pylama pyas2) 3 | 4 | serve: 5 | @(ENV=example python manage.py migrate && python manage.py runserver) 6 | 7 | release: 8 | @(python setup.py bdist_wheel register upload -s) -------------------------------------------------------------------------------- /pyas2/templatetags/pyas2.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def readfilefield(field): 8 | """Template filter for rendering data from a file field""" 9 | with field.open("r") as f: 10 | return f.read() 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # Omit the django apps file 4 | pyas2/apps.py 5 | 6 | # Omit the django forms and admin file 7 | pyas2/forms.py 8 | pyas2/admin.py 9 | 10 | # Omit the template tags 11 | pyas2/templatetags/pyas2.py 12 | 13 | # Omit tests 14 | pyas2/tests/* -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py36,py37,py38}-django{31} 4 | {py37}-django{22} 5 | [testenv] 6 | basepython = 7 | py36: python3.6 8 | py37: python3.7 9 | py38: python3.8 10 | deps = 11 | -r{toxinidir}/requirements/tox.txt 12 | {py37,py38}-django31: django==3.1.7 13 | py36-django31: django==3.1.7 14 | py36-django31: dataclasses 15 | {py37}-django22: django==2.2.18 16 | 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | whitelist_externals = 20 | make 21 | changedir = {toxinidir} 22 | commands = 23 | make test 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [pylama:pycodestyle] 5 | max_line_length = 100 6 | 7 | [pylama:pylint] 8 | max_line_length = 100 9 | ignore = E1101,R0902,R0903,W1203,C0103,C0302,C0209,R0913,R0915,W0612,R1705,R0201,W0613 10 | 11 | [pylama:pydocstyle] 12 | convention = numpy 13 | ignore = D202 14 | 15 | [pylama:pep8] 16 | max_line_length = 100 17 | 18 | [pylama] 19 | format = pep8 20 | skip = venv/*,.tox/*,*/tests/*,setup.py,*/migrations/* 21 | linters= pycodestyle,pyflakes,pylint,pep8 22 | ignore = D203,D212,E231,C0330,R0912,R0914,W1202,R1702,C0114,C0302 23 | 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | SUPPORTED_ENVS = ('test', 'example') 6 | 7 | SETTINGS_MODULES = { 8 | 'example': 'example.settings' 9 | } 10 | 11 | ENV = os.environ.get('ENV', 'example') 12 | ENV = ENV.lower() 13 | 14 | if ENV not in SUPPORTED_ENVS: 15 | raise Exception('Unsupported environment: %s' % ENV) 16 | 17 | 18 | if __name__ == "__main__": 19 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', SETTINGS_MODULES[ENV]) 20 | from django.core.management import execute_from_command_line 21 | execute_from_command_line(sys.argv) 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/detailed-guide/index.rst: -------------------------------------------------------------------------------- 1 | Detailed Guide 2 | ============== 3 | 4 | We have seen how to send a file to the partner with the basic settings. Now lets go through each of the components 5 | of ``django-pyas2`` in greater detail. In this section we will cover topics related to configuration of partners, organizations and 6 | certificates; sending messages and MDNs; monitoring messages and MDNs; and usage of the admin commands. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | organizations 12 | partners 13 | certificates 14 | configuration 15 | sendreceive-messages 16 | sendreceive-mdns 17 | admin-commands 18 | docker 19 | extending -------------------------------------------------------------------------------- /pyas2/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.contrib.auth.decorators import login_required 3 | 4 | from pyas2 import views 5 | 6 | 7 | urlpatterns = [ 8 | path("as2receive/", views.ReceiveAs2Message.as_view(), name="as2-receive"), 9 | # Add the url again without slash for backwards compatibility 10 | path("as2receive", views.ReceiveAs2Message.as_view(), name="as2-receive"), 11 | path("as2send/", login_required(views.SendAs2Message.as_view()), name="as2-send"), 12 | path( 13 | "download///", 14 | login_required(views.DownloadFile.as_view()), 15 | name="download-file", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /pyas2/tests/fixtures/testmessage.edi: -------------------------------------------------------------------------------- 1 | UNB+UNOA:2+:14+:14+140407:0910+5++++1+EANCOM' 2 | UNH+1+ORDERS:D:96A:UN:EAN008' 3 | BGM+220+1AA1TEST+9' 4 | DTM+137:20140407:102' 5 | DTM+63:20140421:102' 6 | DTM+64:20140414:102' 7 | RFF+ADE:1234' 8 | RFF+PD:1704' 9 | NAD+BY+5450534000024::9' 10 | NAD+SU+::9' 11 | NAD+DP+5450534000109::9+++++++GB' 12 | NAD+IV+5450534000055::9++AMAZON EU SARL:5 RUE PLAETIS LUXEMBOURG+CO PO BOX 4558+SLOUGH++SL1 0TX+GB' 13 | RFF+VA:GB727255821' 14 | CUX+2:EUR:9' 15 | LIN+1++9783898307529:EN' 16 | QTY+21:5' 17 | PRI+AAA:27.5' 18 | LIN+2++390787706322:UP' 19 | QTY+21:1' 20 | PRI+AAA:10.87' 21 | LIN+3' 22 | PIA+5+3899408268X-39:SA' 23 | QTY+21:3' 24 | PRI+AAA:3.85' 25 | UNS+S' 26 | CNT+2:3' 27 | UNT+26+1' 28 | UNZ+1+5' 29 | -------------------------------------------------------------------------------- /pyas2/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | APP_SETTINGS = getattr(settings, "PYAS2", {}) 6 | 7 | # Get the root directory for saving messages 8 | DATA_DIR = None 9 | if APP_SETTINGS.get("DATA_DIR") and os.path.isdir(APP_SETTINGS["DATA_DIR"]): 10 | DATA_DIR = APP_SETTINGS["DATA_DIR"] 11 | 12 | # Max number of times to retry failed sends 13 | MAX_RETRIES = APP_SETTINGS.get("MAX_RETRIES", 5) 14 | 15 | # URL for receiving asynchronous MDN from partners 16 | MDN_URL = APP_SETTINGS.get("MDN_URL", "http://localhost:8080/pyas2/as2receive") 17 | 18 | # Max time to wait for asynchronous MDN in minutes 19 | ASYNC_MDN_WAIT = APP_SETTINGS.get("ASYNC_MDN_WAIT", 30) 20 | 21 | # Max number of days worth of messages to be saved in archive 22 | MAX_ARCH_DAYS = APP_SETTINGS.get("MAX_ARCH_DAYS", 30) 23 | -------------------------------------------------------------------------------- /pyas2/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define the test fixtures and other configurations for the test cases.""" 2 | import pytest 3 | from pyas2.models import Organization, Partner 4 | 5 | 6 | @pytest.fixture 7 | def organization(): 8 | """Create a organization object for use in the test cases.""" 9 | return Organization.objects.create( 10 | name="AS2 Server", 11 | as2_name="as2server", 12 | confirmation_message="Custom confirmation message.", 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def partner(): 18 | """Create a partner object for use in the test cases.""" 19 | return Partner.objects.create( 20 | name="AS2 Client", 21 | as2_name="as2client", 22 | target_url="http://localhost:8080/pyas2/as2receive", 23 | confirmation_message="Custom confirmation message.", 24 | compress=False, 25 | mdn=False, 26 | ) 27 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | path("pyas2/", include('pyas2.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: 7 | branches: 8 | - "master" 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.7, 3.8, 3.9, "3.10"] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e ".[tests]" 25 | - name: Run Tests 26 | run: | 27 | pytest --cov-report term --cov-config .coveragerc --cov=pyas2 --black --pylama pyas2 28 | - name: Generate CodeCov Report 29 | run: | 30 | pip install codecov 31 | codecov 32 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /pyas2/migrations/0003_auto_20221208_1310.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-12-08 13:10 2 | 3 | from django.db import migrations, models 4 | import pyas2.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("pyas2", "0002_auto_20190603_1329"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="mdn", 16 | name="payload", 17 | field=models.FileField( 18 | blank=True, 19 | max_length=4096, 20 | null=True, 21 | upload_to=pyas2.models.get_mdn_store, 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="message", 26 | name="payload", 27 | field=models.FileField( 28 | blank=True, 29 | max_length=4096, 30 | null=True, 31 | upload_to=pyas2.models.get_message_store, 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /pyas2/tests/fixtures/client_public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpDCCAYwCCQCiwkkE1grbWDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlh 3 | czJjbGllbnQwHhcNMTgwNDE4MDI0NDA1WhcNMjgwNDE1MDI0NDA1WjAUMRIwEAYD 4 | VQQDDAlhczJjbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD 5 | tF9MG7tefcTRc8hIgU9sJo6T6vInIozKqqZPKcHBDVeVqhWxNtzCq3v2CwL7dWLb 6 | RY1FQZgDseU6tLj3/3n105TGgoxSsSTFqGmuFVMV1HeE64qIunABRVVcESV0Z6UN 7 | kVLmdhxxF+xGwe0xUP2Mz7p3Js7b8qZvnbrm4ffgQW4wJZiw4O+sOvJfXDFfxB0t 8 | BNObeRK4OlNo6u8XSIKld5NHGu1tdhJekWODTR9A3RmA9pcN2PG9uwG7Fgq7WTkQ 9 | qvCbr9dUMx6WvMLZCXaLfJJruyUgF+Pmq29l1an8Ho4C8SdBxUy/t093O720gn61 10 | Nns1fNRwzVR0k8h6J4pZAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJYAmfR/D8X6 11 | htleIhmI7z+itiFFtUzbbrrmWJszPXDQ3R234+epPgEQGOCMXh9SiFUNH/VX8cH9 12 | 6uumQvlLjHvI64RjcWGiYEzpc47exRTp4g8m+fal6EjiKPiikU8Yv2E/M/c/sh6T 13 | ajG53Nv5+hO1sIpZwbYGXJJ3joboM0LAT6Z5oNCO4AQTyzJ6e82zJ4u2HA+GS0eG 14 | OG9O+r3G0q+fPGv1bnSd7GNd9/9V926ioJIfuLRPPKNWJQNXLGzsNaJm7dOVf6iQ 15 | diEMuhQyIw3IKIkUK/6YYA4kriRxIIn5AbhUWZfcGaFxHFK6yYl9R5dAm0qjdCdB 16 | Uq+fKG6UlGI= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /pyas2/tests/fixtures/server_public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpDCCAYwCCQDfhApNFLfwRDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlh 3 | czJzZXJ2ZXIwHhcNMTgwNDE4MDI0NTIyWhcNMjgwNDE1MDI0NTIyWjAUMRIwEAYD 4 | VQQDDAlhczJzZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDL 5 | A+Ye3Lx7vNXhS4sblLzYuCNXCrWoxFMUyemcvQ5FtORuhn1s9eC6f+3pIimdIj7V 6 | YCl7X6AFm2MnpSe5nQXhMik4uHacRbVUbqZGLDloqdpHK9N8byGu14treBoaopjG 7 | efFk1Jt7AuvTsqVZwTCfgYlz3fk1d5RhtlAo6RtyPbP9uZ5QkP1R7L2OBjJE1xc6 8 | 4vBuh/i2UOIJvB8ZIL9VMnm4ylmHxxuhSANvCITR1ozl06ZWevSLOuLIylvtjoP7 9 | CUpFQ6YxgtKtPB3NwYwelo3LBELGp1FwrJyeKtvV0WeHrffc/qPTwY2WrN0MA3kD 10 | 13EgUnuToVHdQEZiwTEPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAK9Dq71bWohC 11 | EwcEWQ7D/sHWeknv6oeD50xn2UvKtDsgeP7p1XGRskx9mk8G3TgGgzKY9mXrL2gD 12 | He7bghHImsn/iJ2CEG+TNpAcbnHr5hzGF7nO8mq4/dRugoPaHN18KyMyt0PsllTO 13 | HFwliAF/Q+KNGOxh0IjM6WNqdxG2dZvDHpvaxpYezLTQh0DS83FuBHboxY70pcrL 14 | V5UyVP7wsuRcqPASVWrWRhJj3uJYlCEy0eoEJCdh845Rp+NxFMYYjv6yOAv7bJnR 15 | ra+Zv2YaxKM8iCSUu7yUYgV2m+6XzYKvsE/UVMqUQZEueGO0zXVMmTlm2NoXQIty 16 | Hc1VFOnmSrg= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /docs/detailed-guide/admin-commands.rst: -------------------------------------------------------------------------------- 1 | Admin Commands 2 | ============== 3 | ``django-pyas2`` provides a set of Django ``manage.py`` admin commands that perform various functions. We have 4 | already seen the usage of some of these commands in the previous sections. Let us now go through the list 5 | of available commands: 6 | 7 | sendas2message 8 | -------------- 9 | The ``sendas2message`` command triggers a file transfer, it takes the mandatory arguments organization id, partner id and 10 | the full path to the file to be transferred. The command can be used by other applications to integrate with ``django-pyas2``. 11 | 12 | sendas2bulk 13 | ----------- 14 | The ``sendas2bulk`` command looks in the outbox folder for each partner setup on the as2 server. It then triggers a transfer for each file found in the outbox. 15 | 16 | manageas2server 17 | --------------- 18 | The ``manageas2server`` command performs various management operation on the AS2 server. The following options are available which can either be used together or alone: 19 | 20 | * ``--async-mdns``: This operation performs two functions; it sends asynchronous MDNs for messages received from your partners and also checks if we have received asynchronous MDNs for sent messages so that the message status can be updated appropriately. 21 | * ``--retry``: This operation checks for any messages that have been set for retries and then re-triggers the transfer for these messages. 22 | * ``--clean``: This operation deletes all messages objects and related files older that the ``MAX_ARCH_DAYS`` setting. 23 | 24 | -------------------------------------------------------------------------------- /docs/detailed-guide/organizations.rst: -------------------------------------------------------------------------------- 1 | Organizations 2 | ============= 3 | Organizations in ``django-pyas2`` mean the host of the AS2 server, i.e. it is the sender when sending messages and the 4 | receiver when receiving the messages. Organizations can be managed from the Django Admin. 5 | The admin lists the existing organizations and also you gives the option to create new ones. Each 6 | organization is characterized by the following fields: 7 | 8 | ========================= ============================================ ========= 9 | Field Name Description Mandatory 10 | ========================= ============================================ ========= 11 | ``Organization Name`` The descriptive name of the organization. Yes 12 | ``As2 Identifier`` The as2 identifies for this organization, Yes 13 | must be a unique value as it identifies 14 | the as2 host. 15 | ``Email Address`` The email address for the organization. No 16 | ``Encryption Key`` The ``Private Key`` used for decrypting No 17 | incoming messages from trading partners. 18 | ``Signature Key`` The ``Private Key`` used to sign outgoing No 19 | messages to trading partners 20 | ``Confirmation Message`` Use this field to customize the confirmation No 21 | message sent in MDNs to partners. 22 | ========================= ============================================ ========= 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | db.sqlite3 103 | .idea/ 104 | messages/ 105 | .coverage 106 | .DS_Store 107 | .pytest_cache/ 108 | -------------------------------------------------------------------------------- /pyas2/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import os 4 | from string import Template 5 | 6 | logger = logging.getLogger("pyas2") 7 | 8 | 9 | def run_post_send(message): 10 | """Execute command after successful send, can be used to notify 11 | successful sends""" 12 | 13 | command = message.partner.cmd_send 14 | if command: 15 | logger.debug(f"Execute post successful send command {command}") 16 | # Create command template and replace variables in the command 17 | command = Template(command) 18 | variables = { 19 | "filename": os.path.basename(message.payload.name), 20 | "sender": message.organization.as2_name, 21 | "receiver": message.partner.as2_name, 22 | "messageid": message.message_id, 23 | } 24 | variables.update(message.as2message.headers) 25 | 26 | # Execute the command 27 | os.system(command.safe_substitute(variables)) 28 | 29 | 30 | def run_post_receive(message, full_filename): 31 | """Execute command after successful receive, can be used to call the 32 | edi program for further processing""" 33 | 34 | command = message.partner.cmd_receive 35 | if command: 36 | logger.debug(f"Execute post successful receive command {command}") 37 | 38 | # Create command template and replace variables in the command 39 | command = Template(command) 40 | variables = { 41 | "filename": os.path.basename(full_filename), 42 | "fullfilename": full_filename, 43 | "sender": message.partner.as2_name, 44 | "receiver": message.organization.as2_name, 45 | "messageid": message.message_id, 46 | } 47 | variables.update(message.as2message.headers) 48 | 49 | # Execute the command 50 | os.system(command.safe_substitute(variables)) 51 | -------------------------------------------------------------------------------- /pyas2/migrations/0002_auto_20190603_1329.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-06-04 10:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("pyas2", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="partner", 15 | name="https_verify_ssl", 16 | field=models.BooleanField( 17 | default=True, 18 | help_text="Uncheck this option to disable SSL " 19 | "certificate verification to HTTPS.", 20 | verbose_name="Verify SSL Certificate", 21 | ), 22 | ), 23 | migrations.AddField( 24 | model_name="publiccertificate", 25 | name="valid_from", 26 | field=models.DateTimeField(blank=True, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name="publiccertificate", 30 | name="valid_to", 31 | field=models.DateTimeField(blank=True, null=True), 32 | ), 33 | migrations.AddField( 34 | model_name="privatekey", 35 | name="valid_from", 36 | field=models.DateTimeField(blank=True, null=True), 37 | ), 38 | migrations.AddField( 39 | model_name="privatekey", 40 | name="valid_to", 41 | field=models.DateTimeField(blank=True, null=True), 42 | ), 43 | migrations.AddField( 44 | model_name="privatekey", 45 | name="serial_number", 46 | field=models.CharField(blank=True, max_length=64, null=True), 47 | ), 48 | migrations.AddField( 49 | model_name="publiccertificate", 50 | name="serial_number", 51 | field=models.CharField(blank=True, max_length=64, null=True), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from setuptools import setup 4 | 5 | root = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | with open(os.path.join(root, 'README.rst')) as f: 8 | README = f.read() 9 | 10 | install_requires = [ 11 | 'pyas2lib==1.4.3', 12 | 'django>=2.2.18', 13 | 'requests' 14 | ] 15 | 16 | tests_require = [ 17 | "pytest==6.2.5", 18 | "pytest-cov==2.8.1", 19 | "coverage==5.0.4", 20 | "pytest-django==4.5.2", 21 | "pytest-mock==3.5.1", 22 | "pylama==8.3.7", 23 | "pylint==2.12.1", 24 | "pytest-black==0.3.12", 25 | "black==22.6.0", 26 | "django-environ==0.4.5", 27 | "pyflakes==2.4.0", 28 | ] 29 | 30 | 31 | setup( 32 | name='django-pyas2', 33 | version='1.2.3', 34 | description='AS2 file transfer Server built on Python and Django.', 35 | license="GNU GPL v3.0", 36 | long_description=README, 37 | author='Abhishek Ram', 38 | author_email='abhishek8816@gmail.com', 39 | url='http://github.com/abhishek-ram/django-pyas2', 40 | packages=['pyas2'], 41 | zip_safe=False, 42 | include_package_data=True, 43 | classifiers=[ 44 | 'Environment :: Web Environment', 45 | 'Framework :: Django', 46 | 'Intended Audience :: Developers', 47 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 48 | 'Operating System :: OS Independent', 49 | 'Programming Language :: Python', 50 | 'Programming Language :: Python :: 3.7', 51 | 'Programming Language :: Python :: 3.8', 52 | 'Programming Language :: Python :: 3.9', 53 | 'Programming Language :: Python :: 3.10', 54 | "Topic :: Security :: Cryptography", 55 | "Topic :: Communications", 56 | ], 57 | setup_requires=["pytest-runner"], 58 | install_requires=install_requires, 59 | tests_require=tests_require, 60 | extras_require={ 61 | "tests": tests_require, 62 | }, 63 | ) 64 | -------------------------------------------------------------------------------- /docs/detailed-guide/docker.rst: -------------------------------------------------------------------------------- 1 | Docker 2 | ====== 3 | ``django-pyas2`` can easily be run as a docker container. Following instruction can be used to 4 | configure a Dockerfile for the application. 5 | 6 | The assumption is that a directory containtain the django-project exists already, as described in 7 | the installation section. Create a Dockerfile in the project path and the directory should look as 8 | follows: 9 | 10 | .. code-block:: console 11 | 12 | {PROJECT DIRECTORY} 13 | └──django_pyas2 14 | ├── django_pyas2 15 | │ ├── db.sqlite3 16 | │ ├── manage.py 17 | │ └── django_pyas2 18 | │ ├── settings.py 19 | │ ├── urls.py 20 | │ └── wsgi.py 21 | └── Dockerfile 22 | 23 | 24 | Populate the Dockerfile with following content: 25 | 26 | .. code-block:: docker 27 | 28 | FROM python:3.7-alpine3.9 29 | 30 | # Update the index of available packages 31 | RUN apk update 32 | 33 | # Install packages required for Python cryptography 34 | RUN apk add --no-cache openssl-dev gcc libffi-dev musl-dev 35 | 36 | # Install django-pyas2 with pip 37 | RUN pip install django-pyas2 38 | 39 | # Copy the files from the project directory to the container 40 | WORKDIR / 41 | COPY django_pyas2 django_pyas2 42 | CMD ["/usr/local/bin/python", "/django_pyas2/manage.py", "runserver", "0.0.0.0:8000"] 43 | 44 | # AS2 Server 45 | EXPOSE 8000 46 | 47 | 48 | Then build and run the container from the command line as follows: 49 | 50 | .. code-block:: console 51 | 52 | $ docker build -t docker_pyas2 . && docker run -p 8000:8000 docker_pyas2 53 | 54 | 55 | In case the files on the host file system should be used, connect the directory to the host by 56 | running to docker run command with the -v option: 57 | 58 | .. code-block:: console 59 | 60 | $ docker build -t docker_pyas2 . && docker run -p 8000:8000 -v $PWD/django_pyas2:/django_pyas2 docker_pyas2 61 | 62 | 63 | -------------------------------------------------------------------------------- /pyas2/templates/admin/pyas2/mdn/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls static pyas2%} 3 | 4 | {% block title %} View MDN Details{% endblock title %} 5 | {% block content %} 6 | {% block content_title %}{% endblock %} 7 |

View MDN Details

8 |
9 |
10 |
11 |
12 | 13 |

{{ original.mdn_id }}

14 |
15 |
16 |
17 |
18 | 19 |

20 | 21 | {{ original.message.message_id}} 22 |

23 |
24 |
25 |
26 |
27 | 28 |

{{ original.get_status_display }}

29 |
30 |
31 |
32 |
33 | 34 |

35 | {% if original.signed %} 36 | Yes 37 | {% else %} 38 | No 39 | {% endif %} 40 |

41 |
42 |
43 |
44 |
45 | 46 |

{{ original.headers|readfilefield|linebreaksbr }}

47 |
48 |
49 |
50 |
51 | 52 |

{{ original.payload|readfilefield|linebreaksbr }} 53 |

54 |
55 |
56 |
57 | {% endblock content %} 58 | -------------------------------------------------------------------------------- /docs/detailed-guide/sendreceive-mdns.rst: -------------------------------------------------------------------------------- 1 | Send & Receive MDNs 2 | =================== 3 | Message Disposition Notifications or MDNs are return receipts used to notify the sender of a message of any of 4 | the several conditions that may occur after successful delivery. In the context of the AS2 protocol, the MDN is used 5 | to notify if the message was successfully processed by the receiver's system or not and in case of failures the 6 | reason for the failure is sent with the MDN. 7 | 8 | MDNs can be transmitted either in a synchronous manner or in an asynchronous manner. The synchronous transmission uses 9 | the same HTTP session as that of the AS2 message and the MDN is returned as an HTTP response message. The asynchronous 10 | transmission uses a new HTTP session to send the MDN to the original AS2 message sender. 11 | 12 | Send MDNs 13 | --------- 14 | The choice of whether to send an MDN and its transfer mode is with the sender of the AS2 message. The sender lets us know what 15 | to do through an AS2 header field. In case the partner requests a synchronous MDN no action is needed as ``django-pyas2`` 16 | takes care of this internally, however in the case of an asynchronous MDN the admin command ``manageas2server --async-mdns`` needs to be 17 | run to send the MDN to the trading partner. 18 | 19 | The command ``{PYTHONPATH}/python {DJANGOPROJECTPATH}/manage.py manageas2server --async-mdns`` should be scheduled every 10 minutes so 20 | that ``django-pyas2`` sends any pending asynchronous MDN requests received from your trading partners. 21 | 22 | Receive MDNs 23 | ------------ 24 | The choice of whether or not to receive MDN and its transfer mode is with us. The `MDN Settings `__ 25 | for the partner should be used to specify your preference. In case of synchronous mode ``django-pyas2`` processes the received MDN 26 | without any action from you. 27 | 28 | In the case of asynchronous mode we do need to take care of a couple of details to enable the receipt of the MDNs. 29 | The :doc:`global setting` ``MDNURL`` should be set to the URL ``http://{hostname}:{port}/pyas2/as2receive`` 30 | so that the trading partner knows where to send the MDN. The other setting of note here is the ``ASYNCMDNWAIT`` 31 | that decides how long ``django-pyas2`` waits for an MDN before setting the message as failed so that it can be retried. The admin 32 | command ``manageas2server --async-mdns`` makes this check for all pending messages so it must be scheduled to run regularly. 33 | -------------------------------------------------------------------------------- /pyas2/management/commands/sendas2bulk.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.core.management import call_command 3 | from django.core.management.base import BaseCommand 4 | from django.core.files.storage import default_storage 5 | 6 | from pyas2 import settings 7 | from pyas2.models import Organization 8 | from pyas2.models import Partner 9 | 10 | 11 | class Command(BaseCommand): 12 | """Command to send all pending messages.""" 13 | 14 | help = "Command for sending all pending messages in the outbox folders" 15 | 16 | def handle(self, *args, **options): 17 | for partner in Partner.objects.all(): 18 | self.stdout.write( 19 | "Process files in the outbox directory for " 20 | 'partner "%s".' % partner.as2_name 21 | ) 22 | for org in Organization.objects.all(): 23 | if settings.DATA_DIR: 24 | outbox_folder = os.path.join( 25 | settings.DATA_DIR, 26 | "messages", 27 | partner.as2_name, 28 | "outbox", 29 | org.as2_name, 30 | ) 31 | else: 32 | outbox_folder = os.path.join( 33 | "messages", partner.as2_name, "outbox", org.as2_name 34 | ) 35 | 36 | # Check of the directory exists and if not create it 37 | try: 38 | _, pending_files = default_storage.listdir(outbox_folder) 39 | except FileNotFoundError: 40 | pending_files = [] 41 | os.makedirs(default_storage.path(outbox_folder)) 42 | 43 | # For each file found call send message to send it to the server 44 | pending_files = filter(lambda x: x != ".", pending_files) 45 | for pending_file in pending_files: 46 | pending_file = os.path.join(outbox_folder, pending_file) 47 | self.stdout.write( 48 | 'Sending file "%s" from organization "%s" to partner ' 49 | '"%s".' % (pending_file, org.as2_name, partner.as2_name) 50 | ) 51 | call_command( 52 | "sendas2message", 53 | org.as2_name, 54 | partner.as2_name, 55 | pending_file, 56 | delete=True, 57 | ) 58 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pyAS2 documentation master file, created by 2 | sphinx-quickstart on Wed May 1 12:24:04 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ******************************************** 7 | django-pyas2: pythonic AS2 server 8 | ******************************************** 9 | 10 | Release v\ |release|. (:doc:`Changelog `) 11 | 12 | ``django-pyas2`` is an AS2 server/client written in python and built on the `Django framework`_. 13 | The application supports AS2 version 1.2 as defined in the `RFC 4130`_. Our goal is to 14 | provide a native python library for implementing the AS2 protocol. It supports Python 3.6+. 15 | 16 | The application includes a server for receiving files from partners, a front-end web interface for 17 | configuration and monitoring, a set of ``django-admin`` commands that serves as a client 18 | for sending messages, asynchronous MDNs and a daemon process that monitors directories 19 | and sends files to partners when they are placed in the partner's watched directory. 20 | 21 | Features 22 | ======== 23 | 24 | * Technical 25 | 26 | - Asynchronous and Synchronous MDN 27 | - Partner and Organization management 28 | - Digital signatures 29 | - Message encryption 30 | - Secure transport (SSL) 31 | - Support for SSL client authentication 32 | - System task to auto clear old log entries 33 | - Data compression (AS2 1.1) 34 | - Multinational support: Uses Django's internationalization feature 35 | 36 | * Integration 37 | 38 | - Easy integration to existing systems, using a partner based file system interface 39 | - Message post processing (scripting on receipt) 40 | 41 | * Monitoring 42 | 43 | - Web interface for transaction monitoring 44 | - Email event notification 45 | 46 | * The following encryption algorithms are supported: 47 | 48 | - Triple DES 49 | - RC2-128 50 | - RC4-128 51 | - AES-128 52 | - AES-192 53 | - AES-256 54 | 55 | * The following hash algorithms are supported: 56 | 57 | - SHA-1 58 | - SHA-224 59 | - SHA-256 60 | - SHA-384 61 | - SHA-512 62 | 63 | Dependencies 64 | ============ 65 | * Python 3.6+ 66 | * Django (1.9+) 67 | * requests 68 | * pyas2lib 69 | 70 | Guide 71 | ===== 72 | .. toctree:: 73 | :maxdepth: 2 74 | 75 | installation 76 | quickstart 77 | detailed-guide/index 78 | changelog 79 | 80 | .. _`RFC 4130`: https://www.ietf.org/rfc/rfc4130.txt 81 | .. _`Django framework`: https://www.djangoproject.com/ 82 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Release History 2 | =============== 3 | 4 | 1.2.3 - 2023-02-25 5 | ------------------ 6 | 7 | * Bump version of pyas2lib to 1.4.3 8 | * Update variables in run_post_receive to fit the meaning (#82 by @timfanda35) 9 | * Fix link to AUTHORS since now it's a Markdown file (#85 by @adiroiban) 10 | * Update the lengths of the payload fields to allow longer file names (#87 by @pouldenton) 11 | * Update documentation to use django-admin instead of django-admin.py (#89 by @bkc) 12 | 13 | 1.2.2 - 2022-02-06 14 | ------------------ 15 | 16 | * Bump version of pyas2lib to 1.4.0 (PR #70 ) 17 | * Use github actions for running test pipeline instead of travis 18 | * Add support for python 3.10 and upgrade pytest* packages 19 | * Deprecate support for python 3.6 20 | * Replace deprecated ugettext with gettext_lazy (PR #68 by @liquidxinc ) 21 | 22 | 1.2.1 - 2021-05-08 23 | ------------------ 24 | 25 | * Bump version of pyas2lib to 1.3.3 26 | * Use orig_message_id as Message ID for MDN if no message_id was provided 27 | * Retry when no ASYNC MDN is received, before finally failing after retries 28 | * Bump version of django to 2.2.18 29 | 30 | 1.2.0 - 2020-04-12 31 | ------------------ 32 | 33 | * Bump version of pyas2lib to 1.3.1 34 | * Improve the test coverage for the repo 35 | * Use django storage framework when dealing with the file system 36 | * Handle cases where we get a 200 response without an MDN when sending messages 37 | * Set login required for the download and send message endpoints 38 | 39 | 1.1.1 - 2019-06-25 40 | ------------------ 41 | 42 | * Bump version of pyas2lib to 1.2.2 43 | * Add more logging for better debugging 44 | * Removing X-Frame-Options header from AS2 response object 45 | 46 | 47 | 1.1.0 - 2019-06-13 48 | ------------------ 49 | 50 | * Use original filename when saving to store and allow search by filename. 51 | * Bump version of pyas2lib to 1.2.0 to fix issue #5 52 | * Minimum version of django is now 2.1.9 which fixes issue #8 53 | * Extract and save certificate information on upload. 54 | 55 | 1.0.2 - 2019-05-16 56 | ------------------ 57 | 58 | * Add command `sendas2bulk` for sending messages in the outbox folders. 59 | * Add command `manageas2server` for cleanup, async mdns and retries. 60 | 61 | 1.0.1 - 2019-05-02 62 | ------------------ 63 | 64 | * Use current date as sub-folder in message store 65 | * Use password widget for `key_pass` field of PrivateKey 66 | * Better rendering of headers and payload in messages 67 | * Include templates in the distribution 68 | 69 | 1.0.0 - 2018-05-01 70 | ------------------ 71 | 72 | * Initial release. 73 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'django-pyas2' 21 | copyright = '2019, Abhishek Ram' 22 | author = 'Abhishek Ram' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '1.2.3' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.intersphinx', 36 | 'sphinx.ext.viewcode', 37 | 'versionwarning.extension', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | 51 | # The theme to use for HTML and HTML Help pages. See the documentation for 52 | # a list of builtin themes. 53 | # 54 | html_theme = 'alabaster' 55 | 56 | html_theme_options = { 57 | 'description': 'AS2 file transfer Server built on Python and Django.', 58 | 'description_font_style': 'italic', 59 | 'github_user': 'abhishek-ram', 60 | 'github_repo': 'django-pyas2', 61 | 'github_banner': True, 62 | 'github_type': 'star', 63 | 'warn_bg': '#FFC', 64 | 'warn_border': '#EEE', 65 | } 66 | 67 | 68 | # Add any paths that contain custom static files (such as style sheets) here, 69 | # relative to this directory. They are copied after the builtin static files, 70 | # so a file named "default.css" will overwrite the builtin "default.css". 71 | html_static_path = ['_static'] 72 | -------------------------------------------------------------------------------- /docs/detailed-guide/sendreceive-messages.rst: -------------------------------------------------------------------------------- 1 | Send & Receive Messages 2 | ======================= 3 | We have so far covered all the topics related to configuration of the ``pyAS2`` server. Now we will see how 4 | to use these configurations to send messages to your trading partners using the AS2 protocol. We can send files 5 | using any of the following techniques: 6 | 7 | Send Messages From the Django Admin 8 | ----------------------------------- 9 | 10 | The simplest method for sending messages to your trading partner is by using the Django Admin. This method is generally used 11 | for testing the AS2 connection with your trading partner. The steps are as follows: 12 | 13 | * Navigate to ``pyAS2 File Transfer Server->Partners``. 14 | * Check the partner you want to send the message to and select action ``Send Message to Partner``. 15 | * Select the Organization and choose the file to be transmitted. 16 | * Click on ``Send Message`` to initiate the file transfer and monitor the transfers at ``pyAS2 File Transfer Server->Messages``. 17 | 18 | .. image:: ../images/SendFile1.png 19 | 20 | 21 | Send Messages From the Command-Line 22 | ----------------------------------- 23 | The next method for sending messages involves the ``django-pyas2`` admin command ``sendas2message``. The command is invoked 24 | from the shell prompt and can be used by other applications to invoke an AS2 file transfer. The command usage is 25 | as follows: 26 | 27 | .. code-block:: console 28 | 29 | $ python manage.py sendas2message --help 30 | Usage: python manage.py sendas2message [options] 31 | 32 | Send an as2 message to your trading partner 33 | 34 | Options: 35 | --delete Delete source file after processing 36 | -h, --help show this help message and exit 37 | 38 | The mandatory arguments to be passed to the command include ``organization_as2name`` i.e. the AS2 Identifier of this organization, 39 | ``partner_as2name`` i.e. the AS2 Identifier of your trading partner and ``path_to_payload`` the full path to the file to be transmitted. 40 | The command also lets you set the ``--delete`` option to delete the file once it is begins the transfer. A sample usage of the command: 41 | 42 | .. code-block:: console 43 | 44 | $ python manage.py sendas2message p1as2 p2as2 /path_to_payload/payload.txt 45 | 46 | Receive Messages 47 | ---------------- 48 | In order to receive files from your trading partners they need to post the AS2 message to the URL 49 | ``http://{hostname}:{port}/pyas2/as2receive``. The configuration of the :doc:`Organization `, 50 | :doc:`Partner ` and :doc:`Certificates ` need to be completed for successfully receiving 51 | messages from your trading partner. Once the message has been received it will be placed in the organizations 52 | `inbox `__ folder. 53 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install using ``pip``... 5 | 6 | .. code-block:: console 7 | 8 | $ pip install django-pyas2 9 | 10 | Create a new ``django`` project 11 | 12 | .. code-block:: console 13 | 14 | $ django-admin startproject django_pyas2 . 15 | 16 | Add ``pyas2`` to your ``INSTALLED_APPS`` setting. 17 | 18 | .. code-block:: python 19 | 20 | INSTALLED_APPS = ( 21 | ... 22 | 'pyas2', 23 | ) 24 | 25 | Include the pyAS2 URL configuration in your project's ``urls.py``. 26 | 27 | .. code-block:: python 28 | 29 | from django.urls import include 30 | urlpatterns = [ 31 | path('pyas2/', include('pyas2.urls')), 32 | ... 33 | ] 34 | 35 | Run the following commands to complete the installation and start the server. 36 | 37 | .. code-block:: console 38 | 39 | $ python manage.py migrate 40 | Operations to perform: 41 | Apply all migrations: admin, auth, contenttypes, pyas2, sessions 42 | Running migrations: 43 | Applying contenttypes.0001_initial... OK 44 | Applying auth.0001_initial... OK 45 | Applying admin.0001_initial... OK 46 | Applying admin.0002_logentry_remove_auto_add... OK 47 | Applying admin.0003_logentry_add_action_flag_choices... OK 48 | Applying contenttypes.0002_remove_content_type_name... OK 49 | Applying auth.0002_alter_permission_name_max_length... OK 50 | Applying auth.0003_alter_user_email_max_length... OK 51 | Applying auth.0004_alter_user_username_opts... OK 52 | Applying auth.0005_alter_user_last_login_null... OK 53 | Applying auth.0006_require_contenttypes_0002... OK 54 | Applying auth.0007_alter_validators_add_error_messages... OK 55 | Applying auth.0008_alter_user_username_max_length... OK 56 | Applying auth.0009_alter_user_last_name_max_length... OK 57 | Applying auth.0010_alter_group_name_max_length... OK 58 | Applying auth.0011_update_proxy_permissions... OK 59 | Applying pyas2.0001_initial... OK 60 | Applying sessions.0001_initial... OK 61 | 62 | $ python manage.py createsuperuser 63 | Username (leave blank to use 'abhishekram'): admin 64 | Email address: admin@domain.com 65 | Password: 66 | Password (again): 67 | Superuser created successfully. 68 | 69 | $ python manage.py runserver 70 | Watching for file changes with StatReloader 71 | Performing system checks... 72 | 73 | System check identified no issues (0 silenced). 74 | May 01, 2019 - 07:33:27 75 | Django version 2.2, using settings 'django_pyas2.settings' 76 | Starting development server at http://127.0.0.1:8000/ 77 | Quit the server with CONTROL-C. 78 | 79 | The ``django-pyas2`` server is now up and running, the web UI for configuration and monitoring can be accessed at 80 | ``http://localhost:8000/admin/pyas2/`` and the endpoint for receiving AS2 messages from your partners will be at 81 | ``http://localhost:8080/pyas2/as2receive`` 82 | -------------------------------------------------------------------------------- /pyas2/tests/fixtures/client_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQI5f9EfdFYaCoCAggA 3 | MB0GCWCGSAFlAwQBKgQQ8yUhP3pwteHWgvpcT2fOVASCBNDW/A7pva1hplOqtMpQ 4 | AqyH4PB8tv6wsCNX0uJkKXHjR8MYD4IEFxhRfGCfJpWRG86/5HgqQfmf1d4MRe5B 5 | czIdOUQ5MEC+BPImLU6jyP9CfvAR1jrMW2pMwgqKTDNyi1Vu+tjTTZtq0pHS+BoJ 6 | X6zz14SGQBDSvh4cYCy6HUPXrWAYkBbVH2TSi1xJ0EyBkRk+pD6vYxGi3RvEURGV 7 | 8gcEO4WBG8jZ1daEgsK6zYcsp19FTAJDZjIQvvHagjaP7uXWhaRuj97wiPNfLlc6 8 | M5MuayyKE79uveAaTa1CuwFw/A77JguoTZj/QDf9JRCBUsnon/XeKu0vAvtAGnUy 9 | nzqa25rTZ0tiv8s+8/cQmAOYSNFFtsVls4czBN54qYSEF5rVpVM/RfzrprbahBV9 10 | p/vXmCTKZkkc8+FObbH/KavilmtLmMlUnZD9Tdsh3QxB/thup8H1fuOq6Ph8yF5w 11 | 69csHsqD8fYq20hkBC5ghoysk8UKttKMZf5gcF7DGz2quSsVnKQV+KbtE0Zeq9KS 12 | X8hyt43PHBsVoDh9ImJ03uWrlBtCYvUVPBHXHKzknrnq3gyA13toswIXnYIBagl/ 13 | Brsd1FI1HXh5G4sw+nu6rkS3v+/I/OTlanVZbYUncQqnFax+zsNQr9YRrXd/iG6N 14 | r1bInaqbfv01pasqjrdUFy9K8HuVznOC0u3s7hhOEACWNoarLB+5HgthyLn1O5Wo 15 | LCHzdvAD7RWiglT3CDq6p5QMRyO+W8z5A6V6wIumQLZZr7K20z2ZNEGk1ViMsEBK 16 | Q5nUUtuqXle9FoI5moPKbDuTRc8WmOwAgGCYF72zxxbpNSRr2tYMrDKHsE1pdUzS 17 | pwADbNf1aqfO4wGKNuLLiz+E6XgS+vl3zHmSLFK4igVzUvi25vUHLjBWuxIIaCfA 18 | 1iaIZvkGYKd9x14izZD1RW5oF0jsv5tQIAk4i5sp+YFFaBkAMHgGReIGpaSE0+Hv 19 | XVpmi8O6jAaEbjn+2LOSwhqBJQ3l/FJxgPUddwPEjp7knFo5AbkJ3nr5/7+mVOwv 20 | jN5tYjn2+4r1dhvORc5ERP4aUe7IADVnNEZqpeD7m0UfofR8K4HPpbmGgIoelwM+ 21 | RY4TRix20UJiknxzIFi3+6nWXdPOVifdeCTApHHAGVs/0k37/trU7gEAGGIpsj0K 22 | HXbcBG9l9FbmfokASzVLMWBo6yEQzO/9IYVz5y1o0JvBWQG3MTb3WA2W7xzUY0Hf 23 | 5ZDz3IhqVSlStq7a0plTO2M01bz8Hx9UzfwV+2F7FRdlKtoWeJHfBS3/MVoVO3YX 24 | 2W5r2y8rToWpTnkj1JmyiecgXU0R1bbfVbQ+DvgoadZFTGH/AuiIZi0VGSMbOuB6 25 | SCNbtiAp7vRrCpF+Y/wXCPK7PDmJ2XOr3aVOfW4r20vUZHJsd+OwYf1WW6j56q0O 26 | M0bi8ZQfppHCY6RJDOx9kTKxDurc39SghMIoDFLt2i009Gs7x+qrX7BmW8yi2CUD 27 | f221RtH/grcqke3swTCXXUdH7KqB6STyKE4hrQncOxEel4GryorNaa2IuwJnPt6v 28 | KdKEseYEVgMox1mL5O19RE6CKWwpVHe4XbVP9WTMSsfrVwDwhQg3MveKaec8LAE+ 29 | WxGc8nLf0yzp2N99yUqEQNRbVg== 30 | -----END ENCRYPTED PRIVATE KEY----- 31 | -----BEGIN CERTIFICATE----- 32 | MIICpDCCAYwCCQCiwkkE1grbWDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlh 33 | czJjbGllbnQwHhcNMTgwNDE4MDI0NDA1WhcNMjgwNDE1MDI0NDA1WjAUMRIwEAYD 34 | VQQDDAlhczJjbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD 35 | tF9MG7tefcTRc8hIgU9sJo6T6vInIozKqqZPKcHBDVeVqhWxNtzCq3v2CwL7dWLb 36 | RY1FQZgDseU6tLj3/3n105TGgoxSsSTFqGmuFVMV1HeE64qIunABRVVcESV0Z6UN 37 | kVLmdhxxF+xGwe0xUP2Mz7p3Js7b8qZvnbrm4ffgQW4wJZiw4O+sOvJfXDFfxB0t 38 | BNObeRK4OlNo6u8XSIKld5NHGu1tdhJekWODTR9A3RmA9pcN2PG9uwG7Fgq7WTkQ 39 | qvCbr9dUMx6WvMLZCXaLfJJruyUgF+Pmq29l1an8Ho4C8SdBxUy/t093O720gn61 40 | Nns1fNRwzVR0k8h6J4pZAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJYAmfR/D8X6 41 | htleIhmI7z+itiFFtUzbbrrmWJszPXDQ3R234+epPgEQGOCMXh9SiFUNH/VX8cH9 42 | 6uumQvlLjHvI64RjcWGiYEzpc47exRTp4g8m+fal6EjiKPiikU8Yv2E/M/c/sh6T 43 | ajG53Nv5+hO1sIpZwbYGXJJ3joboM0LAT6Z5oNCO4AQTyzJ6e82zJ4u2HA+GS0eG 44 | OG9O+r3G0q+fPGv1bnSd7GNd9/9V926ioJIfuLRPPKNWJQNXLGzsNaJm7dOVf6iQ 45 | diEMuhQyIw3IKIkUK/6YYA4kriRxIIn5AbhUWZfcGaFxHFK6yYl9R5dAm0qjdCdB 46 | Uq+fKG6UlGI= 47 | -----END CERTIFICATE----- 48 | -------------------------------------------------------------------------------- /pyas2/tests/fixtures/server_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIuTFTKVhl2zwCAggA 3 | MB0GCWCGSAFlAwQBKgQQifcdRuoN75ITjzHYzrhsswSCBNDs65NXiznL/hJpw+JD 4 | UDfxuxV2QZVGvrSkJj5dpNFNXdyEy48T9bSP5dt9GG8pmQE8ZZHsPgbsyapA7h30 5 | GUPnZ+aolSD8hP6w09MB7KcETHacDyc/WDj9TG8S5FWxQ1jFZKTXXYgc71HWymPC 6 | 5KVzK5vKAQcCn1WVmtkxf5pvJqmFIOYSC90dO1QYCbwHoVsbW2kpHvFcY2j7NV0S 7 | 1N6EGMuAiUmBJb3vUs6Dys/cZfG7+w4gzI16cj2xxpxOJrjydNQwkgAcBXtWGeVZ 8 | qptStLy+Fq/GkJT+Cv/YTuGX32V1NysxGDqDoUAB5e5Bhu6FdVSVhsHJzNitIhAi 9 | wIOsr3F6XGYjLhv/fOelhoGdFjLDrp04/shOPjZdlisArtH4ErCP16m32nXYfjvP 10 | sKjmnbH4Xm5nvRrm5wz2jL852R7nqqIezMuSaqknkmcGchmiF7YbkILraMlb3fl+ 11 | 3MAST7AEz1f9gl5zugR5tnundWLbJPgTCtNmXWygyxxne39OzcD3a3rirEh97F/L 12 | baVURWTA960vOVF2CTQ3Ky079cfdm4JJeiBk8iUkscF+iNFPaab8EhGdGKY5zuoS 13 | Y5QypE/RYv4sYWxRTzM/UjfTwv4ofUe0A9CicsMSAZo939998sv6fh2mkvcnjUM4 14 | FfcBKytUy9HDvXosQj3sIrDtpwGQm+cGMXe6l4PAIC46hGLsxOb+acCAnxCkCgMh 15 | YAe420R7C8cCfbWxKszpgv0VjrKU8NSWPJo9RLr3R1JAHz09HSHFJYDPuBd9Ojba 16 | KHJJxBgas5IYhEsEFZ14WHasQe4csPZ7nJkAYdowPogSiHipSmrLZhSNS5ThEQjF 17 | GxPwCsF/fHeP87A+3lX3aUvQXchXHzvwhZafOw5ZRFdKeNnawhYnR/+IEYmLaJJB 18 | TGtW1mfVKTsNSTuHMmxwss27uqv3sVccHh9imoqM1lyCIybSzZheznMoNnn5Uzw4 19 | lH1UGc+jCBAx4aIhchISYCPDw7+6MFAszEAmLDT89PUwttgrLOAXlgYVgl1uChO/ 20 | FiDo8algLpq4aZYi7TNuaAO9I9vY/gZybkoFobaeDb0Arh4JbzzzzoeQyXjkU5kA 21 | pmzd4+7egIPEF0WUfuNMT8AteFEaF5Ayc5R4i+fiwaS43rY2CQTUV8qiVdQudSpT 22 | oPqOHbAP0mX1XrQU38PUje8+IfZAvA6qMY828jHUdCd9wnxbqMqDgooD9doIUQrD 23 | mmHXnPfGMA/rr8Nfway0iYxHWUepIP/+z6aTLLeqZ9tS1aZxQ2rZW3HDlhg+iJ1/ 24 | /9BaLlL7nvIr3GfGqCXIa3QBkj4M3WYizCuTR3Fk9VC1lGdx1cTicnJmKJWbPSwU 25 | RNl4+aa8vb7tBP85owUruN5pdPJVjjXfQKegUN82VQEThrKJOpRKIy34zAyDkXz1 26 | RvYInOXRMMQ5TIuj9evZWvy9KgYn/9x1ap6eAfzUIi30b1iqMevYK/gD2ZZRKarF 27 | EFPGHvSXsRgAVLYURR+GPejUAgZF47zeSWJJ6ScGPr6ZwXNQLOUy3ZjGq2gxMKjm 28 | Krutm3O0Vhd5+gYn4r74E4lcaEKH+WRjVsw/I5UcKhJhZpjH3ekF0wBZYmZIyZVd 29 | YAu01qgR1c+awf/9zD89fEgXMg== 30 | -----END ENCRYPTED PRIVATE KEY----- 31 | -----BEGIN CERTIFICATE----- 32 | MIICpDCCAYwCCQDfhApNFLfwRDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlh 33 | czJzZXJ2ZXIwHhcNMTgwNDE4MDI0NTIyWhcNMjgwNDE1MDI0NTIyWjAUMRIwEAYD 34 | VQQDDAlhczJzZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDL 35 | A+Ye3Lx7vNXhS4sblLzYuCNXCrWoxFMUyemcvQ5FtORuhn1s9eC6f+3pIimdIj7V 36 | YCl7X6AFm2MnpSe5nQXhMik4uHacRbVUbqZGLDloqdpHK9N8byGu14treBoaopjG 37 | efFk1Jt7AuvTsqVZwTCfgYlz3fk1d5RhtlAo6RtyPbP9uZ5QkP1R7L2OBjJE1xc6 38 | 4vBuh/i2UOIJvB8ZIL9VMnm4ylmHxxuhSANvCITR1ozl06ZWevSLOuLIylvtjoP7 39 | CUpFQ6YxgtKtPB3NwYwelo3LBELGp1FwrJyeKtvV0WeHrffc/qPTwY2WrN0MA3kD 40 | 13EgUnuToVHdQEZiwTEPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAK9Dq71bWohC 41 | EwcEWQ7D/sHWeknv6oeD50xn2UvKtDsgeP7p1XGRskx9mk8G3TgGgzKY9mXrL2gD 42 | He7bghHImsn/iJ2CEG+TNpAcbnHr5hzGF7nO8mq4/dRugoPaHN18KyMyt0PsllTO 43 | HFwliAF/Q+KNGOxh0IjM6WNqdxG2dZvDHpvaxpYezLTQh0DS83FuBHboxY70pcrL 44 | V5UyVP7wsuRcqPASVWrWRhJj3uJYlCEy0eoEJCdh845Rp+NxFMYYjv6yOAv7bJnR 45 | ra+Zv2YaxKM8iCSUu7yUYgV2m+6XzYKvsE/UVMqUQZEueGO0zXVMmTlm2NoXQIty 46 | Hc1VFOnmSrg= 47 | -----END CERTIFICATE----- 48 | -------------------------------------------------------------------------------- /pyas2/management/commands/sendas2message.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.core.management.base import CommandError 6 | from django.core.files.storage import default_storage 7 | from pyas2lib import Message as AS2Message 8 | 9 | from pyas2.models import Message 10 | from pyas2.models import Organization 11 | from pyas2.models import Partner 12 | 13 | logger = logging.getLogger("pyas2") 14 | 15 | 16 | class Command(BaseCommand): 17 | """Command to send an AS2 message.""" 18 | 19 | help = "Send an as2 message to your trading partner" 20 | args = "" 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument("org_as2name", type=str) 24 | parser.add_argument("partner_as2name", type=str) 25 | parser.add_argument("path_to_payload", type=str) 26 | 27 | parser.add_argument( 28 | "--delete", 29 | action="store_true", 30 | dest="delete", 31 | default=False, 32 | help="Delete source file after processing", 33 | ) 34 | 35 | def handle(self, *args, **options): 36 | 37 | # Check if organization and partner exists 38 | try: 39 | org = Organization.objects.get(as2_name=options["org_as2name"]) 40 | except Organization.DoesNotExist as e: 41 | raise CommandError( 42 | f'Organization "{options["org_as2name"]}" does not exist' 43 | ) from e 44 | try: 45 | partner = Partner.objects.get(as2_name=options["partner_as2name"]) 46 | except Partner.DoesNotExist as e: 47 | raise CommandError( 48 | f'Partner "{options["partner_as2name"]}" does not exist' 49 | ) from e 50 | 51 | # Check if file exists 52 | if not default_storage.exists(options["path_to_payload"]): 53 | raise CommandError( 54 | f'Payload at location "{options["path_to_payload"]}" does not exist.' 55 | ) 56 | 57 | # Build and send the AS2 message 58 | original_filename = os.path.basename(options["path_to_payload"]) 59 | with default_storage.open(options["path_to_payload"], "rb") as in_file: 60 | payload = in_file.read() 61 | as2message = AS2Message(sender=org.as2org, receiver=partner.as2partner) 62 | as2message.build( 63 | payload, 64 | filename=original_filename, 65 | subject=partner.subject, 66 | content_type=partner.content_type, 67 | disposition_notification_to=org.email_address or "no-reply@pyas2.com", 68 | ) 69 | message, _ = Message.objects.create_from_as2message( 70 | as2message=as2message, 71 | payload=payload, 72 | filename=original_filename, 73 | direction="OUT", 74 | status="P", 75 | ) 76 | message.send_message(as2message.headers, as2message.content) 77 | 78 | # Delete original file if option is set 79 | if options["delete"]: 80 | default_storage.delete(options["path_to_payload"]) 81 | -------------------------------------------------------------------------------- /pyas2/templates/pyas2/send_as2_message.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n l10n admin_urls static %} 3 | 4 | {% block extrastyle %}{{ block.super }}{% endblock %} 5 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | 14 | {% endblock %} 15 | 16 | {% block title %} Send Message to Partner {% endblock title %} 17 | 18 | {% block content %} 19 |

{% trans 'Send Message to Partner' %}

20 |
21 | 22 |
23 | {% csrf_token %} 24 |
25 | {% for field in form %} 26 | {% if field.errors %} 27 |

28 | Please correct the errors below. 29 |

30 | {% endif %} 31 | {% endfor %} 32 | 33 |
34 | 35 |
37 | {{ form.organization.errors }} 38 |
39 | 40 | 45 |
46 |
47 | 48 |
50 | {{ form.partner.errors }} 51 |
52 | 53 | 59 |
60 |
61 | 62 |
64 | {{ form.file.errors }} 65 |
66 | 67 | 68 |
69 |
70 | 71 |
72 | 73 | 74 | 75 |
76 |
77 |
78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /pyas2/templates/admin/pyas2/message/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls static pyas2%} 3 | 4 | {% block title %} View Message Details{% endblock title %} 5 | {% block content %} 6 | {% block content_title %}{% endblock %} 7 |

View Message Details

8 |
9 |
10 |
11 |
12 | 13 |

{{ original.message_id }}

14 |
15 |
16 |
17 |
18 | 19 |

{{ original.organization }}

20 |
21 |
22 |
23 |
24 | 25 |

{{ original.partner }}

26 |
27 |
28 |
29 |
30 | 31 |

{{ original.get_direction_display }}

32 |
33 |
34 |
35 |
36 | 37 |

38 | {% if original.compressed %} 39 | Yes 40 | {% else %} 41 | No 42 | {% endif %} 43 |

44 |
45 |
46 |
47 |
48 | 49 |

50 | {% if original.encrypted %} 51 | Yes 52 | {% else %} 53 | No 54 | {% endif %} 55 |

56 |
57 |
58 |
59 |
60 | 61 |

62 | {% if original.signed %} 63 | Yes 64 | {% else %} 65 | No 66 | {% endif %} 67 |

68 |
69 |
70 |
71 |
72 | 73 |

74 | {{ original.get_status_display }} {{ original.get_status_display }} 76 |

77 |
78 |
79 | {% if original.retries %} 80 |
81 |
82 | 83 |

{{ original.retries }}

84 |
85 |
86 | {% endif %} 87 | {% if original.detailed_status %} 88 |
89 |
90 | 91 |

{{ original.detailed_status|linebreaksbr }}

92 |
93 |
94 | {% endif %} 95 |
96 |
97 | 98 |

{{ original.headers|readfilefield|linebreaksbr }}

99 |
100 |
101 | {% if original.payload %} 102 |
103 |
104 | 105 |

106 | 107 | Download File 108 |

109 |
110 |
111 | {% endif %} 112 |
113 |
114 | {% endblock content %} -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.10. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | import environ 15 | 16 | # reading .env file 17 | env = environ.Env() 18 | environ.Env.read_env() 19 | 20 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 21 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 26 | 27 | # SECURITY WARNING: keep the secret key used in production secret! 28 | SECRET_KEY = '$-k4qd0ywk_81e9+#+2y72frhw@p)bhnxr!upva1y=6ol6%9n$' 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = True 32 | 33 | ALLOWED_HOSTS = [] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.messages', 44 | 'django.contrib.staticfiles', 45 | 'pyas2', 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | 'django.middleware.security.SecurityMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | ] 57 | 58 | ROOT_URLCONF = 'example.urls' 59 | # APPEND_SLASH = False 60 | 61 | TEMPLATES = [ 62 | { 63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 64 | 'DIRS': [], 65 | 'APP_DIRS': True, 66 | 'OPTIONS': { 67 | 'context_processors': [ 68 | 'django.template.context_processors.debug', 69 | 'django.template.context_processors.request', 70 | 'django.contrib.auth.context_processors.auth', 71 | 'django.contrib.messages.context_processors.messages', 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = 'example.wsgi.application' 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 82 | 83 | DATABASES = { 84 | 'default': { 85 | 'ENGINE': 'django.db.backends.sqlite3', 86 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 106 | }, 107 | ] 108 | 109 | 110 | # Internationalization 111 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 112 | 113 | LANGUAGE_CODE = 'en-us' 114 | 115 | TIME_ZONE = 'UTC' 116 | 117 | USE_I18N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 124 | 125 | STATIC_URL = '/static/' 126 | 127 | if env.bool("USE_S3_FILE_STORAGE", False): 128 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 129 | AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') 130 | AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') 131 | AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME') 132 | AWS_LOCATION = 'pyas2_data' 133 | AWS_DEFAULT_ACL = None 134 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-pyAS2 2 | ============ 3 | 4 | .. image:: https://img.shields.io/pypi/v/django-pyas2.svg 5 | :target: https://pypi.python.org/pypi/django-pyas2 6 | 7 | .. image:: https://readthedocs.org/projects/django-pyas2/badge/?version=latest 8 | :target: http://django-pyas2.readthedocs.org 9 | :alt: Latest Docs 10 | 11 | .. image:: https://travis-ci.org/abhishek-ram/django-pyas2.svg?branch=master 12 | :target: https://travis-ci.org/abhishek-ram/django-pyas2 13 | 14 | .. image:: https://github.com/abhishek-ram/django-pyas2/actions/workflows/run-tests.yml/badge.svg?branch=master&event=push 15 | :target: https://github.com/abhishek-ram/django-pyas2/actions/workflows/run-tests.yml?query=branch%3Amaster++ 16 | 17 | .. image:: https://codecov.io/gh/abhishek-ram/django-pyas2/branch/master/graph/badge.svg 18 | :target: https://codecov.io/gh/abhishek-ram/django-pyas2 19 | 20 | ``django-pyas2`` is an AS2 server/client written in python and built on the django framework. 21 | The application supports AS2 version 1.2 as defined in the `RFC 4130`_. Our goal is to provide a native 22 | python library for implementing the AS2 protocol. It supports Python 3.6+ 23 | 24 | ``django-pyas2`` includes a set of django-admin commands that can be used to send files as 25 | a client, send asynchronous MDNs and so on. It also has a web based front end interface for 26 | configuring partners and organizations, monitoring message transfers and also initiating new transfers. 27 | 28 | Features 29 | ~~~~~~~~ 30 | 31 | * Technical 32 | 33 | - Asynchronous and Synchronous MDN 34 | - Partner and Organization management 35 | - Digital signatures 36 | - Message encryption 37 | - Secure transport (SSL) 38 | - Support for SSL client authentication 39 | - System task to auto clear old log entries 40 | - Data compression (AS2 1.1) 41 | - Multinational support: Uses Django's internationalization feature 42 | 43 | * Integration 44 | 45 | - Easy integration to existing systems, using a partner based file system interface 46 | - Message post processing (scripting on receipt) 47 | 48 | * Monitoring 49 | 50 | - Web interface for transaction monitoring 51 | - Email event notification 52 | 53 | * The following encryption algorithms are supported: 54 | 55 | - Triple DES 56 | - RC2-128 57 | - RC4-128 58 | - AES-128 59 | - AES-192 60 | - AES-256 61 | 62 | * The following hash algorithms are supported: 63 | 64 | - SHA-1 65 | - SHA-224 66 | - SHA-256 67 | - SHA-384 68 | - SHA-512 69 | 70 | Documentation 71 | ~~~~~~~~~~~~~ 72 | 73 | You can find more information in the `documentation`_. 74 | 75 | Discussion 76 | ~~~~~~~~~~ 77 | 78 | If you run into bugs, you can file them in our `issue tracker`_. 79 | 80 | Contribute 81 | ~~~~~~~~~~ 82 | 83 | #. Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug. 84 | #. Fork `the repository`_ on GitHub to start making your changes to the **master** branch (or branch off of it). 85 | #. Create your feature branch: `git checkout -b my-new-feature` 86 | #. Commit your changes: `git commit -am 'Add some feature'` 87 | #. Push to the branch: `git push origin my-new-feature` 88 | #. Send a pull request and bug the maintainer until it gets merged and published. :) Make sure to add yourself to AUTHORS_. 89 | 90 | Running Tests 91 | ~~~~~~~~~~~~~ 92 | 93 | Install `django-environ` and `pytest` into your environment to support the 94 | example.settings module and test framework. 95 | 96 | To run ``django-pyas2's`` test suite: 97 | 98 | ``django-admin.py test pyas2 --settings=example.settings --pythonpath=.`` 99 | 100 | License 101 | ~~~~~~~ 102 | 103 | GNU GENERAL PUBLIC LICENSE 104 | Version 3, 29 June 2007 105 | 106 | Copyright (C) 2007 Free Software Foundation, Inc. 107 | Everyone is permitted to copy and distribute verbatim copies 108 | of this license document, but changing it is not allowed. 109 | 110 | .. _`RFC 4130`: https://www.ietf.org/rfc/rfc4130.txt 111 | .. _`documentation`: http://django-pyas2.readthedocs.org 112 | .. _`the repository`: http://github.com/abhishek-ram/django-pyas2 113 | .. _AUTHORS: https://github.com/abhishek-ram/django-pyas2/blob/master/AUTHORS.md 114 | .. _`issue tracker`: https://github.com/abhishek-ram/django-pyas2/issues 115 | -------------------------------------------------------------------------------- /docs/detailed-guide/certificates.rst: -------------------------------------------------------------------------------- 1 | Keys & Certificates 2 | =================== 3 | The AS2 protocol strongly encourages the use of RSA certificates to sign and encrypt messages for enhanced security. 4 | A signed and encrypted message received from your partner ensures message repudiation and integrity. The RSA 5 | certificate consists of a public key and a private key which are together used for encrypting, decrypting, signing 6 | and verifying messages. 7 | 8 | 9 | Generating Certificates 10 | ----------------------- 11 | When you set up a new AS2 server you will need to generate a Public/Private key pair. The private key will be 12 | added to your server and the public key needs to be shared with your trading partners. 13 | 14 | One of the ways of generating a certificate is by using the ``openssl`` command line utility, the following command 15 | needs to be used: 16 | 17 | .. code-block:: console 18 | 19 | $ openssl req -x509 -newkey rsa:2048 -sha256 -keyout private.pem -out public.pem -days 365 20 | Generating a 2048 bit RSA private key 21 | .....+++ 22 | ................................................................................................+++ 23 | writing new private key to 'private.pem' 24 | Enter PEM pass phrase: 25 | Verifying - Enter PEM pass phrase: 26 | ----- 27 | You are about to be asked to enter information that will be incorporated 28 | into your certificate request. 29 | What you are about to enter is what is called a Distinguished Name or a DN. 30 | There are quite a few fields but you can leave some blank 31 | For some fields there will be a default value, 32 | If you enter '.', the field will be left blank. 33 | ----- 34 | Country Name (2 letter code) [AU]:IN 35 | State or Province Name (full name) [Some-State]:Karnataka 36 | Locality Name (eg, city) []:Bangalore 37 | Organization Name (eg, company) [Internet Widgits Pty Ltd]:Name 38 | Organizational Unit Name (eg, section) []:AS2 39 | Common Name (e.g. server FQDN or YOUR name) []:as2id 40 | Email Address []: 41 | $ cat public.pem >> private.pem 42 | 43 | The above commands will generate a PEM encoded private key called ``private.pem`` and a PEM encoded public key called ``public.pem``. 44 | 45 | Private Keys 46 | ------------ 47 | ``Private Keys`` are used for signing outbound messages to your partners and decrypting incoming messages 48 | from your partners. We can manage them in ``django-pyas2`` from the Django Admin. The 49 | admin lists all your private keys and lets you add new ones. Each ``Private Key`` is 50 | characterized by the following fields: 51 | 52 | ========================== ========================================== ========= 53 | Field Name Description Mandatory 54 | ========================== ========================================== ========= 55 | ``Key File`` Select the **PEM** or **DER** encoded Yes 56 | [#f1]_ private key file [#f2]_. 57 | ``Private Key Password`` The pass phrase entered at the time of the Yes 58 | certificate generation. 59 | ========================== ========================================== ========= 60 | 61 | Public Certificates 62 | ------------------- 63 | ``Public Certificates`` are used for verifying signatures of inbound messages and encrypting outbound messages to your partners. The public key file will be shared by your partner. We can manage them in ``django-pyas2`` from the Django Admin. The admin screen lists all your public certificates and lets you add new ones. Each ``Public Certificate`` is characterized by the following fields: 64 | 65 | ========================== ========================================== ========= 66 | Field Name Description Mandatory 67 | ========================== ========================================== ========= 68 | ``Certificate File`` Select the **PEM** or **DER** encoded Yes 69 | [#f1]_ public key file. 70 | ``Certificate CA Store`` In case the certificate has been signed by No 71 | an unknown CA then select the CA 72 | certificate here. 73 | ``Verify Certificate`` Uncheck this option to disable certificate No 74 | verification at the time of signature 75 | verification. 76 | ========================== ========================================== ========= 77 | 78 | .. rubric:: Footnotes 79 | 80 | .. [#f1] ``django-pyas2`` supports only PEM/DER encoded certificates. 81 | .. [#f2] The private key file must contain **both the private and public** parts of the RSA certificate. 82 | -------------------------------------------------------------------------------- /docs/detailed-guide/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | The global settings for ``pyAS2`` are kept in a single configuration dictionary named ``PYAS2`` in 5 | your `project's settings.py `_ module. Below is a sample configuration: 6 | 7 | .. code-block:: python 8 | 9 | PYAS2 = { 10 | 'DATA_DIR' : '/path_to_datadir/data', 11 | 'MAX_RETRIES': 5, 12 | 'MDN_URL' : 'https://192.168.1.115:8888/pyas2/as2receive', 13 | 'ASYNC_MDN_WAIT' : 30, 14 | 'MAX_ARCH_DAYS' : 30, 15 | } 16 | 17 | The available settings along with their usage is described below: 18 | 19 | +------------------------+----------------------------+------------------------------------------------+ 20 | | Settings Name | Default Value | Usage | 21 | +========================+============================+================================================+ 22 | | DATA_DIR | MEDIA_ROOT or BASE_DIR | Full path to the base directory for storing | 23 | | | | messages | 24 | +------------------------+----------------------------+------------------------------------------------+ 25 | | MAX_RETRIES | 10 | Maximum number of retries for failed outgoing | 26 | | | | messages | 27 | +------------------------+----------------------------+------------------------------------------------+ 28 | | MDN_URL | ``None`` | Return URL for receiving asynchronous MDNs from| 29 | | | | partners. | 30 | +------------------------+----------------------------+------------------------------------------------+ 31 | | ASYNC_MDN_WAIT | 30 | Number of minutes to wait for asynchronous MDNs| 32 | | | | after which message will be marked as failed. | 33 | +------------------------+----------------------------+------------------------------------------------+ 34 | | MAX_ARCH_DAYS | 30 | Number of days files and messages are kept in | 35 | | | | storage. | 36 | +------------------------+----------------------------+------------------------------------------------+ 37 | 38 | 39 | The Data Directory 40 | ------------------ 41 | 42 | The ``Data Directory`` is a file system directory that stores sent and received files. 43 | The location of this directory is set to either the ``MEDIA_ROOT`` or the project base folder by default. 44 | We can also change this directory by updating the ``DATA_DIR`` setting. 45 | The structure of the directory is below: 46 | 47 | .. code-block:: console 48 | 49 | {DATA DIRECTORY} 50 | └── messages 51 | ├── __store 52 | │ ├── mdn 53 | │ │ ├── received 54 | │ │ └── sent 55 | │ │ ├── 20150908 56 | │ │ │ ├── 20150908115337.7244.44635@Abhisheks-MacBook-Air.local.mdn 57 | │ │ │ └── 20150908121942.7244.71894@Abhisheks-MacBook-Air.local.mdn 58 | │ │ └── 20150913 59 | │ │ ├── 20150913071324.20065.47671@Abhisheks-MacBook-Air.local.mdn 60 | │ │ └── 20150913083125.20403.32480@Abhisheks-MacBook-Air.local.mdn 61 | │ └── payload 62 | │ ├── received 63 | │ │ ├── 20150908 64 | │ │ │ ├── 20150908115458.7255.98107@Abhisheks-MacBook-Air.local 65 | │ │ │ └── 20150908121933.7343.83150@Abhisheks-MacBook-Air.local 66 | │ │ └── 20150913 67 | │ │ ├── 20150913071323.20074.48016@Abhisheks-MacBook-Air.local 68 | │ │ └── 20150913083125.20475.14667@Abhisheks-MacBook-Air.local 69 | │ └── sent 70 | ├── p1as2 71 | │ └── outbox 72 | │ └── p2as2 73 | └── p2as2 74 | └── inbox 75 | └── p1as2 76 | ├── 20150908115458.7255.98107@Abhisheks-MacBook-Air.local.msg 77 | └── 20150913083125.20475.14667@Abhisheks-MacBook-Air.local.msg 78 | 79 | inbox 80 | ----- 81 | The inbox directory stores files received from your partners. The path of this directory is ``{DATA DIRECTORY}/messages/{ORG AS2 ID}/inbox/{PARTNER AS2 ID}``. 82 | We need to take this location into account when integrating ``django-pyas2`` with other applications. 83 | 84 | outbox 85 | ------ 86 | The outbox directory works in conjunction with the ``sendas2bulk`` process. The bulk process looks in all of the outbox 87 | directories and will trigger a transfer for each file found. The path of this directory is ``{DATA DIRECTORY}/messages/{PARTNER AS2 ID}/outbox/{ORG AS2 ID}``. 88 | 89 | __store 90 | ------ 91 | The __store directory contains the payloads and MDNs. The payload and MDN files are stored in the sent and received sub-directories respectively, and are further seperated by additional sub-directories for each day, named as YYYYMMDD. 92 | 93 | -------------------------------------------------------------------------------- /docs/detailed-guide/partners.rst: -------------------------------------------------------------------------------- 1 | Partners 2 | ======== 3 | Partners in ``django-pyas2`` mean all your trading partners with whom you will exchanges messages, i.e. they are the receivers 4 | when you send messages and the senders when you receive messages. Partners can be managed from the Django Admin. 5 | The admin lists the existing partners and also you gives the option to search them and create new ones. Each 6 | partner is characterized by the following fields: 7 | 8 | General Settings 9 | ---------------- 10 | 11 | ========================= ============================================ ========= 12 | Field Name Description Mandatory 13 | ========================= ============================================ ========= 14 | ``Partner Name`` The descriptive name of the partner. Yes 15 | ``As2 Identifier`` The as2 identifies for this partner as Yes 16 | communicated by the partner. 17 | ``Email Address`` The email address for the partner. No 18 | ``Target Url`` The HTTP/S endpoint of the partner to Yes 19 | which files need to be posted. 20 | ``Subject`` The MIME subject header to be sent along Yes 21 | with the file. 22 | ``Content Type`` The content type of the message being Yes 23 | transmitted, can be XML, X12 or EDIFACT. 24 | ``Confirmation Message`` Use this field to customize the confirmation No 25 | message sent in MDNs to partners. 26 | ========================= ============================================ ========= 27 | 28 | HTTP Authentication 29 | ------------------- 30 | Use these settings if basic authentication has been enabled for the partners AS2 server. 31 | 32 | ========================== =========================================== ========= 33 | Field Name Description Mandatory 34 | ========================== =========================================== ========= 35 | ``Enable Authentication`` Check this option to enable basic AUTH. No 36 | ``Http auth user`` User name to access the partners server. No 37 | ``Http auth pass`` Password to access the partners server. No 38 | ========================== =========================================== ========= 39 | 40 | Security Settings 41 | ----------------- 42 | 43 | ====================== ========================================== ========= 44 | Field Name Description Mandatory 45 | ====================== ========================================== ========= 46 | ``Compress Message`` Check this option to enable AS2 message No 47 | compression. 48 | ``Encrypt Message`` Select the algorithm to be used for No 49 | encrypting messages, defaults to None. 50 | ``Encryption Key`` Select the ``Public Key`` used for No 51 | encrypting the outbound messages 52 | to this partner. 53 | ``Sign Message`` Select the hash algorithm to be used for No 54 | signing messages, defaults to None. 55 | incoming messages from trading partners. 56 | ``Signature key`` The ``Public Key`` used to verify inbound No 57 | signed messages and MDNs from this partner 58 | ====================== ========================================== ========= 59 | 60 | MDN Settings 61 | ------------ 62 | 63 | ====================== ========================================== ========= 64 | Field Name Description Mandatory 65 | ====================== ========================================== ========= 66 | ``Request MDN`` Check this option to request MDN for Yes 67 | outbound messages to this partner. 68 | ``Mdn mode`` Select the MDN mode, defaults to No 69 | Synchronous 70 | ``Request Signed MDN`` Select the algorithm to be used in case No 71 | signed MDN is to be returned. 72 | ====================== ========================================== ========= 73 | 74 | Advanced Settings 75 | ----------------- 76 | 77 | ============================== ===================================================== ========= 78 | Field Name Description Mandatory 79 | ============================== ===================================================== ========= 80 | ``Keep Original Filename`` Use Original File name to to store file on receipt, No 81 | use this option only if you are sure partner sends 82 | unique names. 83 | ``Command on Message Send`` OS Command executed after successful message send, No 84 | replacements are ``$filename``, ``$sender``, 85 | ``$receiver``, ``$messageid`` and any message header 86 | such as ``$Subject`` 87 | ``Command on Message Receipt`` OS Command executed after successful message receipt, No 88 | replacements are ``$filename``, ``$fullfilename``, 89 | ``$sender``, ``$receiver``, ``$messageid`` and any 90 | message header such as ``$Subject``. 91 | ============================== ===================================================== ========= 92 | 93 | -------------------------------------------------------------------------------- /pyas2/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth.models import User 4 | from django.core.files.base import ContentFile 5 | from django.test import TestCase, Client 6 | from django.urls import reverse 7 | 8 | from pyas2.models import PublicCertificate, PrivateKey, Message, Mdn 9 | from pyas2.tests import TEST_DIR 10 | 11 | 12 | class TestDownloadFileView(TestCase): 13 | def setUp(self): 14 | self.user = User.objects.create(username="dummy") 15 | with open(os.path.join(TEST_DIR, "client_public.pem"), "rb") as fp: 16 | self.cert = PublicCertificate.objects.create( 17 | name="test-cert", certificate=fp.read() 18 | ) 19 | 20 | with open(os.path.join(TEST_DIR, "client_private.pem"), "rb") as fp: 21 | self.private_key = PrivateKey.objects.create( 22 | name="test-key", key=fp.read(), key_pass="test" 23 | ) 24 | 25 | with open(os.path.join(TEST_DIR, "testmessage.edi"), "rb") as fp: 26 | self.message = Message.objects.create( 27 | message_id="some-message-id", 28 | direction="IN", 29 | status="S", 30 | ) 31 | self.message.payload.save("testmessage.edi", fp) 32 | self.mdn = Mdn.objects.create( 33 | mdn_id="some-mdn-id", message=self.message, status="S" 34 | ) 35 | self.mdn.payload.save("some-mdn-id.mdn", ContentFile("MDN Content")) 36 | 37 | def test_view_is_protected(self): 38 | client = Client() 39 | response = client.get( 40 | reverse("download-file", kwargs={"obj_type": "public_cert", "obj_id": "1"}) 41 | ) 42 | self.assertEqual(response.status_code, 302) 43 | self.assertTrue(response.url.startswith("/accounts/login/")) 44 | 45 | def test_unknown_object_type(self): 46 | """Test that we get 404 when an uknown object type is sent.""" 47 | client = Client() 48 | client.force_login(self.user) 49 | 50 | response = client.get( 51 | reverse("download-file", kwargs={"obj_type": "some_type", "obj_id": "1"}) 52 | ) 53 | self.assertEqual(response.status_code, 404) 54 | 55 | def test_download_public_certificate(self): 56 | client = Client() 57 | client.force_login(self.user) 58 | 59 | response = client.get( 60 | reverse( 61 | "download-file", 62 | kwargs={"obj_type": "public_cert", "obj_id": self.cert.id}, 63 | ) 64 | ) 65 | self.assertEqual(str(self.cert), "test-cert") 66 | self.assertEqual(response.status_code, 200) 67 | self.assertEqual(bytes(self.cert.certificate), response.content) 68 | 69 | def test_download_private_key(self): 70 | client = Client() 71 | client.force_login(self.user) 72 | 73 | response = client.get( 74 | reverse( 75 | "download-file", 76 | kwargs={"obj_type": "private_key", "obj_id": self.private_key.id}, 77 | ) 78 | ) 79 | self.assertEqual(str(self.private_key), "test-key") 80 | self.assertEqual(response.status_code, 200) 81 | self.assertEqual(bytes(self.private_key.key), response.content) 82 | 83 | def test_download_message_payload(self): 84 | client = Client() 85 | client.force_login(self.user) 86 | 87 | response = client.get( 88 | reverse( 89 | "download-file", 90 | kwargs={"obj_type": "message_payload", "obj_id": self.message.id}, 91 | ) 92 | ) 93 | self.assertEqual(str(self.message), self.message.message_id) 94 | self.assertEqual(response.status_code, 200) 95 | self.assertEqual(bytes(self.message.payload.read()), response.content) 96 | 97 | def test_download_mdn_payload(self): 98 | client = Client() 99 | client.force_login(self.user) 100 | 101 | response = client.get( 102 | reverse( 103 | "download-file", 104 | kwargs={"obj_type": "mdn_payload", "obj_id": self.mdn.id}, 105 | ) 106 | ) 107 | self.assertEqual(str(self.mdn), self.mdn.mdn_id) 108 | self.assertEqual(response.status_code, 200) 109 | self.assertEqual(bytes(self.mdn.payload.read()), response.content) 110 | 111 | 112 | def test_as2_receive_view_options(client): 113 | """Test the options method of the AS2 Receive endpoint.""" 114 | response = client.options("/pyas2/as2receive") 115 | assert response.status_code == 200 116 | assert response.content == b"" 117 | 118 | 119 | def test_send_as2_message_view(mocker, client, admin_client, organization, partner): 120 | """Test the view for sending the AS2 message from the admin.""" 121 | mocked_send_message = mocker.patch("pyas2.models.Message.send_message") 122 | response = client.get(reverse("as2-send")) 123 | assert response.status_code == 302 124 | 125 | # Try with the admin client 126 | response = admin_client.get(reverse("as2-send")) 127 | assert response.status_code == 200 128 | 129 | # Try posting to the form 130 | with open(os.path.join(TEST_DIR, "testmessage.edi"), "rb") as fp: 131 | post_data = { 132 | "organization": organization.as2_name, 133 | "partner": partner.as2_name, 134 | "file": fp, 135 | } 136 | response = admin_client.post(reverse("as2-send"), data=post_data) 137 | assert response.status_code == 302 138 | assert mocked_send_message.call_count == 1 139 | -------------------------------------------------------------------------------- /pyas2/tests/test_commands.py: -------------------------------------------------------------------------------- 1 | """Test the management commands of the pyas2 app.""" 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | 6 | import pytest 7 | from django.conf import settings 8 | from django.core import management 9 | from django.core.files.base import ContentFile 10 | 11 | from pyas2 import settings as app_settings 12 | from pyas2.models import As2Message, Message, Mdn 13 | from pyas2.tests import TEST_DIR 14 | from pyas2.management.commands.sendas2bulk import Command as SendBulkCommand 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_sendbulk_command(mocker, partner, organization): 19 | """Test the command for sending all files in the outbox folder""" 20 | mocked_call_command = mocker.patch( 21 | "pyas2.management.commands.sendas2bulk.call_command" 22 | ) 23 | 24 | # Call the command 25 | command = SendBulkCommand() 26 | command.handle() 27 | 28 | # Create a file for testing and try again 29 | outbox_dir = os.path.join( 30 | "messages", partner.as2_name, "outbox", organization.as2_name 31 | ) 32 | test_file = Path(os.path.join(outbox_dir, "testmessage.edi")) 33 | test_file.touch() 34 | command.handle() 35 | mocked_call_command.assert_called_with( 36 | "sendas2message", 37 | organization.as2_name, 38 | partner.as2_name, 39 | str(test_file), 40 | delete=True, 41 | ) 42 | 43 | # Try with the data directory 44 | app_settings.DATA_DIR = settings.BASE_DIR 45 | command.handle() 46 | 47 | # Delete the folder 48 | app_settings.DATA_DIR = None 49 | shutil.rmtree(outbox_dir) 50 | 51 | 52 | @pytest.mark.django_db 53 | def test_sendmessage_command(mocker, organization, partner): 54 | """Test the command for sending an as2 message""" 55 | test_message = os.path.join(TEST_DIR, "testmessage.edi") 56 | 57 | # Try to run with invalid org and client 58 | with pytest.raises(management.CommandError): 59 | management.call_command( 60 | "sendas2message", "AS2 Server", "AS2 Client", test_message 61 | ) 62 | with pytest.raises(management.CommandError): 63 | management.call_command( 64 | "sendas2message", organization.as2_name, "AS2 Client", test_message 65 | ) 66 | 67 | with pytest.raises(management.CommandError): 68 | management.call_command( 69 | "sendas2message", organization.as2_name, partner.as2_name, "testmessage.edi" 70 | ) 71 | 72 | # Try again with a valid org 73 | management.call_command( 74 | "sendas2message", organization.as2_name, partner.as2_name, test_message 75 | ) 76 | 77 | # Try again with delete function 78 | mocked_delete = mocker.patch( 79 | "pyas2.management.commands.sendas2message.default_storage.delete" 80 | ) 81 | management.call_command( 82 | "sendas2message", 83 | organization.as2_name, 84 | partner.as2_name, 85 | test_message, 86 | delete=True, 87 | ) 88 | assert mocked_delete.call_count == 1 89 | 90 | 91 | @pytest.mark.django_db 92 | def test_manageserver_command(mocker, organization, partner): 93 | """Test the command for managing the as2 server.""" 94 | app_settings.MAX_ARCH_DAYS = -1 95 | 96 | # Create a message 97 | with open(os.path.join(TEST_DIR, "testmessage.edi"), "rb") as fp: 98 | payload = fp.read() 99 | as2message = As2Message(sender=organization.as2org, receiver=partner.as2partner) 100 | as2message.build( 101 | payload, 102 | filename="testmessage.edi", 103 | subject=partner.subject, 104 | content_type=partner.content_type, 105 | ) 106 | out_message, _ = Message.objects.create_from_as2message( 107 | as2message=as2message, payload=payload, direction="OUT", status="P" 108 | ) 109 | out_message.send_message(as2message.headers, as2message.content) 110 | 111 | # Test the retry command 112 | out_message.refresh_from_db() 113 | assert out_message.status == "R" 114 | management.call_command("manageas2server", retry=True) 115 | out_message.refresh_from_db() 116 | assert out_message.retries == 1 117 | 118 | # Test max retry setting 119 | app_settings.MAX_RETRIES = 1 120 | management.call_command("manageas2server", retry=True) 121 | out_message.refresh_from_db() 122 | assert out_message.retries == 2 123 | assert out_message.status == "E" 124 | 125 | # Test the async mdn command for outbound messages, retry when no MDN received 126 | app_settings.ASYNC_MDN_WAIT = 0 127 | out_message.status = "P" 128 | out_message.retries = 0 129 | out_message.save() 130 | management.call_command("manageas2server", async_mdns=True) 131 | out_message.refresh_from_db() 132 | assert out_message.status == "R" 133 | assert out_message.retries == 1 134 | 135 | # Test the async mdn command for outbound messages, finally fail when no MDN received 136 | app_settings.ASYNC_MDN_WAIT = 0 137 | out_message.status = "P" 138 | out_message.save() 139 | management.call_command("manageas2server", async_mdns=True) 140 | out_message.refresh_from_db() 141 | assert out_message.status == "E" 142 | 143 | # Test the async mdn command for outbound mdns 144 | mdn = Mdn.objects.create(mdn_id="some-mdn-id", message=out_message, status="P") 145 | mdn.headers.save("some-mdn-id.headers", ContentFile("")) 146 | mdn.payload.save("some-mdn-id.mdn", ContentFile("MDN Content")) 147 | management.call_command("manageas2server", async_mdns=True) 148 | mdn.refresh_from_db() 149 | assert mdn.status == "P" 150 | mocked_post = mocker.patch("requests.post") 151 | management.call_command("manageas2server", async_mdns=True) 152 | mdn.refresh_from_db() 153 | assert mocked_post.call_count == 1 154 | assert mdn.status == "S" 155 | 156 | # Test the clean command 157 | management.call_command("manageas2server", clean=True) 158 | assert Message.objects.filter(message_id=out_message.message_id).count() == 0 159 | assert Mdn.objects.filter(mdn_id=out_message.mdn.mdn_id).count() == 0 160 | 161 | # Test the clean command without MDN set 162 | out_message, _ = Message.objects.create_from_as2message( 163 | as2message=as2message, payload=payload, direction="OUT", status="P" 164 | ) 165 | management.call_command("manageas2server", clean=True) 166 | assert Message.objects.filter(message_id=out_message.message_id).count() == 0 167 | -------------------------------------------------------------------------------- /pyas2/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from django.contrib import admin 5 | from django.http import HttpResponseRedirect 6 | from django.urls import reverse_lazy 7 | from django.utils.html import format_html 8 | 9 | from pyas2.models import Mdn 10 | from pyas2.models import Message 11 | from pyas2.models import Organization 12 | from pyas2.models import Partner 13 | from pyas2.models import PrivateKey 14 | from pyas2.models import PublicCertificate 15 | from pyas2.forms import PartnerForm 16 | from pyas2.forms import PublicCertificateForm 17 | from pyas2.forms import PrivateKeyForm 18 | 19 | 20 | @admin.register(PrivateKey) 21 | class PrivateKeyAdmin(admin.ModelAdmin): 22 | """Admin class for the PrivateKey model.""" 23 | 24 | form = PrivateKeyForm 25 | list_display = ("name", "valid_from", "valid_to", "serial_number", "download_key") 26 | 27 | @staticmethod 28 | def download_key(obj): 29 | """Return the url to download the private key.""" 30 | download_url = reverse_lazy("download-file", args=["private_key", obj.id]) 31 | return format_html( 32 | 'Click to Download', download_url 33 | ) 34 | 35 | download_key.allow_tags = True 36 | download_key.short_description = "Key File" 37 | 38 | 39 | @admin.register(PublicCertificate) 40 | class PublicCertificateAdmin(admin.ModelAdmin): 41 | """Admin class for the PublicCertificate model.""" 42 | 43 | form = PublicCertificateForm 44 | list_display = ("name", "valid_from", "valid_to", "serial_number", "download_cert") 45 | 46 | @staticmethod 47 | def download_cert(obj): 48 | """Return the url to download the public cert.""" 49 | download_url = reverse_lazy("download-file", args=["public_cert", obj.id]) 50 | return format_html( 51 | 'Click to Download', download_url 52 | ) 53 | 54 | download_cert.allow_tags = True 55 | download_cert.short_description = "Certificate File" 56 | 57 | 58 | @admin.register(Partner) 59 | class PartnerAdmin(admin.ModelAdmin): 60 | """Admin class for the Partner model.""" 61 | 62 | form = PartnerForm 63 | list_display = [ 64 | "name", 65 | "as2_name", 66 | "target_url", 67 | "encryption", 68 | "encryption_cert", 69 | "signature", 70 | "signature_cert", 71 | "mdn", 72 | "mdn_mode", 73 | ] 74 | list_filter = ("name", "as2_name") 75 | fieldsets = ( 76 | ( 77 | None, 78 | { 79 | "fields": ( 80 | "name", 81 | "as2_name", 82 | "email_address", 83 | "target_url", 84 | "subject", 85 | "content_type", 86 | "confirmation_message", 87 | ) 88 | }, 89 | ), 90 | ( 91 | "Http Authentication", 92 | { 93 | "classes": ("collapse", "wide"), 94 | "fields": ( 95 | "http_auth", 96 | "http_auth_user", 97 | "http_auth_pass", 98 | "https_verify_ssl", 99 | ), 100 | }, 101 | ), 102 | ( 103 | "Security Settings", 104 | { 105 | "classes": ("collapse", "wide"), 106 | "fields": ( 107 | "compress", 108 | "encryption", 109 | "encryption_cert", 110 | "signature", 111 | "signature_cert", 112 | ), 113 | }, 114 | ), 115 | ( 116 | "MDN Settings", 117 | { 118 | "classes": ("collapse", "wide"), 119 | "fields": ("mdn", "mdn_mode", "mdn_sign"), 120 | }, 121 | ), 122 | ( 123 | "Advanced Settings", 124 | { 125 | "classes": ("collapse", "wide"), 126 | "fields": ("keep_filename", "cmd_send", "cmd_receive"), 127 | }, 128 | ), 129 | ) 130 | actions = ["send_message"] 131 | 132 | def send_message(self, request, queryset): # pylint: disable=W0613,R0201 133 | """Send the message to the first partner chosen by the user.""" 134 | partner = queryset.first() 135 | return HttpResponseRedirect( 136 | reverse_lazy("as2-send") + "?partner_id=%s" % partner.as2_name 137 | ) 138 | 139 | send_message.short_description = "Send a message to the selected partner" 140 | 141 | 142 | @admin.register(Organization) 143 | class OrganizationAdmin(admin.ModelAdmin): 144 | """Admin class for the Organization model.""" 145 | 146 | list_display = ["name", "as2_name"] 147 | list_filter = ("name", "as2_name") 148 | 149 | 150 | @admin.register(Message) 151 | class MessageAdmin(admin.ModelAdmin): 152 | """Admin class for the Message model.""" 153 | 154 | search_fields = ("message_id", "payload") 155 | 156 | list_filter = ("direction", "status", "organization__as2_name", "partner__as2_name") 157 | 158 | list_display = [ 159 | "message_id", 160 | "timestamp", 161 | "status", 162 | "direction", 163 | "organization", 164 | "partner", 165 | "compressed", 166 | "encrypted", 167 | "signed", 168 | "download_file", 169 | "mdn_url", 170 | ] 171 | 172 | @staticmethod 173 | def mdn_url(obj): 174 | """Return the URL to the related MDN if present for the message.""" 175 | # pylint: disable=W0212 176 | 177 | if hasattr(obj, "mdn"): 178 | view_url = reverse_lazy( 179 | f"admin:{Mdn._meta.app_label}_{Mdn._meta.model_name}_change", 180 | args=[obj.mdn.id], 181 | ) 182 | return format_html('{}', view_url, obj.mdn.mdn_id) 183 | return None 184 | 185 | mdn_url.allow_tags = True 186 | mdn_url.short_description = "MDN" 187 | 188 | @staticmethod 189 | def download_file(obj): 190 | """Return the URL to download the message payload.""" 191 | if obj.payload: 192 | view_url = reverse_lazy("download-file", args=["message_payload", obj.id]) 193 | return format_html( 194 | '{}', view_url, os.path.basename(obj.payload.name) 195 | ) 196 | return None 197 | 198 | download_file.allow_tags = True 199 | download_file.short_description = "Payload" 200 | 201 | def has_add_permission(self, request): 202 | return False 203 | 204 | 205 | @admin.register(Mdn) 206 | class MdnAdmin(admin.ModelAdmin): 207 | """Admin class for the Mdn model.""" 208 | 209 | search_fields = ( 210 | "mdn_id", 211 | "message__message_id", 212 | ) 213 | list_display = ("mdn_id", "message", "timestamp", "status") 214 | list_filter = ("status",) 215 | 216 | def has_add_permission(self, request): 217 | return False 218 | -------------------------------------------------------------------------------- /pyas2/management/commands/manageas2server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import timedelta 3 | from email.parser import BytesHeaderParser 4 | 5 | import requests 6 | from django.core.management.base import BaseCommand 7 | from django.utils import timezone 8 | from pyas2lib import Message as AS2Message 9 | 10 | from pyas2 import settings 11 | from pyas2.models import Message, Mdn 12 | 13 | 14 | class Command(BaseCommand): 15 | """Command to manage the django pyas2 server.""" 16 | 17 | help = ( 18 | "Command to manage the as2 server, includes options to cleanup, " 19 | "handle async mdns and message retries" 20 | ) 21 | 22 | def add_arguments(self, parser): 23 | 24 | parser.add_argument( 25 | "--clean", 26 | action="store_true", 27 | dest="clean", 28 | default=False, 29 | help="Cleans up all the old messages and archived files.", 30 | ) 31 | 32 | parser.add_argument( 33 | "--retry", 34 | action="store_true", 35 | dest="retry", 36 | default=False, 37 | help="Retrying all failed outbound communications.", 38 | ) 39 | 40 | parser.add_argument( 41 | "--async-mdns", 42 | action="store_true", 43 | dest="async_mdns", 44 | default=False, 45 | help="Handle sending and receiving of Asynchronous MDNs.", 46 | ) 47 | 48 | def retry(self, retry_msg): 49 | """Retry sending the message to the partner.""" 50 | # Increase the retry count 51 | if not retry_msg.retries: 52 | retry_msg.retries = 1 53 | else: 54 | retry_msg.retries += 1 55 | 56 | # if max retries has exceeded then mark message status as error 57 | if retry_msg.retries > settings.MAX_RETRIES: 58 | if retry_msg.status == "P": 59 | retry_msg.detailed_status = ( 60 | "Failed to receive asynchronous MDN within the threshold limit." 61 | ) 62 | elif retry_msg.status == "R": 63 | retry_msg.detailed_status = "Retry count exceeded the limit." 64 | 65 | retry_msg.status = "E" 66 | retry_msg.save() 67 | return 68 | 69 | self.stdout.write("Retry send the message with ID %s" % retry_msg.message_id) 70 | 71 | # Build and resend the AS2 message 72 | as2message = AS2Message( 73 | sender=retry_msg.organization.as2org, 74 | receiver=retry_msg.partner.as2partner, 75 | ) 76 | as2message.build( 77 | retry_msg.payload.read(), 78 | filename=os.path.basename(retry_msg.payload.name), 79 | subject=retry_msg.partner.subject, 80 | content_type=retry_msg.partner.content_type, 81 | ) 82 | retry_msg.send_message(as2message.headers, as2message.content) 83 | 84 | def handle(self, *args, **options): 85 | 86 | if options["retry"]: 87 | self.stdout.write("Retrying all failed outbound messages") 88 | # Get the list of all messages with status retry 89 | failed_msgs = Message.objects.filter(status="R", direction="OUT") 90 | 91 | for failed_msg in failed_msgs: 92 | self.retry(failed_msg) 93 | 94 | self.stdout.write("Processed all failed outbound messages") 95 | 96 | if options["async_mdns"]: 97 | # First part of script sends asynchronous MDNs for inbound messages 98 | # received from partners fetch all the pending asynchronous 99 | # MDN objects 100 | self.stdout.write("Sending all pending asynchronous MDNs") 101 | in_pending_mdns = Mdn.objects.filter(status="P") 102 | 103 | for pending_mdn in in_pending_mdns: 104 | # Parse the MDN headers from text 105 | header_parser = BytesHeaderParser() 106 | mdn_headers = header_parser.parsebytes(pending_mdn.headers.read()) 107 | try: 108 | # Set http basic auth if enabled in the partner profile 109 | auth = None 110 | if ( 111 | pending_mdn.message.partner 112 | and pending_mdn.message.partner.http_auth 113 | ): 114 | auth = ( 115 | pending_mdn.message.partner.http_auth_user, 116 | pending_mdn.message.partner.http_auth_pass, 117 | ) 118 | 119 | # Post the MDN message to the url provided on the 120 | # original as2 message 121 | requests.post( 122 | pending_mdn.return_url, 123 | auth=auth, 124 | headers=dict(mdn_headers.items()), 125 | data=pending_mdn.payload.read(), 126 | ) 127 | pending_mdn.status = "S" 128 | except requests.exceptions.RequestException as e: 129 | self.stdout.write( 130 | 'Failed to send MDN "%s", error: %s' % (pending_mdn.mdn_id, e) 131 | ) 132 | finally: 133 | pending_mdn.save() 134 | 135 | # Second Part checks if MDNs have been received for outbound 136 | # messages to partners 137 | self.stdout.write( 138 | 'Checking messages waiting for MDNs for more than "%s" ' 139 | "minutes." % settings.ASYNC_MDN_WAIT 140 | ) 141 | 142 | # Find all messages waiting MDNs for more than the set async m 143 | # dn wait time 144 | time_threshold = timezone.now() - timedelta(minutes=settings.ASYNC_MDN_WAIT) 145 | out_pending_msgs = Message.objects.filter( 146 | status="P", direction="OUT", timestamp__lt=time_threshold 147 | ) 148 | 149 | # Retry sending the message if not MDN received. 150 | for pending_msg in out_pending_msgs: 151 | self.retry(pending_msg) 152 | 153 | self.stdout.write("Successfully processed all pending mdns.") 154 | 155 | if options["clean"]: 156 | self.stdout.write("Cleanup maintenance process started") 157 | max_archive_dt = timezone.now() - timedelta(settings.MAX_ARCH_DAYS) 158 | self.stdout.write( 159 | "Delete all messages older than %s" % settings.MAX_ARCH_DAYS 160 | ) 161 | old_message = Message.objects.filter(timestamp__lt=max_archive_dt).order_by( 162 | "timestamp" 163 | ) 164 | 165 | for message in old_message: 166 | message.payload.delete() 167 | message.headers.delete() 168 | 169 | try: 170 | message.mdn.payload.delete() 171 | message.mdn.headers.delete() 172 | message.mdn.delete() 173 | except Mdn.DoesNotExist: 174 | pass 175 | message.delete() 176 | self.stdout.write("Cleanup maintenance process completed") 177 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | This guide will walk you through the basics of setting up an AS2 server and transferring files using the AS2 protocol. Let's get started by sending a signed and encrypted file from one pyAS2 server ``P1`` to another pyAS2 server ``P2``. Do note that these two are separate installations of pyAS2. 5 | 6 | Installing the Servers 7 | ---------------------- 8 | Create a Django project called ``P1`` and follow the :doc:`installation guide ` 9 | and run ``python manage.py runserver`` to start ``P1`` at http://127.0.0.1:8000/admin/ 10 | 11 | .. image:: images/P1_Home.png 12 | 13 | Create one more Django project called ``P2`` and follow the same installations instructions, 14 | and run ``python manage.py runserver 127.0.0.1:8001`` to start ``P2`` at http://127.0.0.1:8001/admin/ 15 | 16 | 17 | Creating the certificates 18 | ------------------------- 19 | We need to generate a Public and Private key pair each for the two servers. ``P1`` uses its private key 20 | to sign the message which is verified by ``P2`` using ``P1's`` public key. ``P1`` uses the ``P2's`` public key 21 | to encrypt the message which is decrypted by ``P2`` using its private key. 22 | 23 | To generate the public and private key pair use the below commands 24 | 25 | .. code-block:: console 26 | 27 | $ openssl req -x509 -newkey rsa:2048 -sha256 -keyout P1_private.pem -out P1_public.pem -days 365 28 | Generating a 2048 bit RSA private key 29 | .....+++ 30 | ................................................................................................+++ 31 | writing new private key to 'P1_private.pem' 32 | Enter PEM pass phrase: 33 | Verifying - Enter PEM pass phrase: 34 | ----- 35 | You are about to be asked to enter information that will be incorporated 36 | into your certificate request. 37 | What you are about to enter is what is called a Distinguished Name or a DN. 38 | There are quite a few fields but you can leave some blank 39 | For some fields there will be a default value, 40 | If you enter '.', the field will be left blank. 41 | ----- 42 | Country Name (2 letter code) [AU]:IN 43 | State or Province Name (full name) [Some-State]:Karnataka 44 | Locality Name (eg, city) []:Bangalore 45 | Organization Name (eg, company) [Internet Widgits Pty Ltd]:P1 46 | Organizational Unit Name (eg, section) []:AS2 47 | Common Name (e.g. server FQDN or YOUR name) []:p1as2 48 | Email Address []: 49 | $ cat P1_public.pem >> P1_private.pem 50 | 51 | $ openssl req -x509 -newkey rsa:2048 -sha256 -keyout P2_private.pem -out P2_public.pem -days 365 52 | Generating a 2048 bit RSA private key 53 | ..............................+++ 54 | ............................................................................................................+++ 55 | writing new private key to 'P2_private.pem' 56 | Enter PEM pass phrase: 57 | Verifying - Enter PEM pass phrase: 58 | ----- 59 | You are about to be asked to enter information that will be incorporated 60 | into your certificate request. 61 | What you are about to enter is what is called a Distinguished Name or a DN. 62 | There are quite a few fields but you can leave some blank 63 | For some fields there will be a default value, 64 | If you enter '.', the field will be left blank. 65 | ----- 66 | Country Name (2 letter code) [AU]:IN 67 | State or Province Name (full name) [Some-State]:Karnataka 68 | Locality Name (eg, city) []:Bangalore 69 | Organization Name (eg, company) [Internet Widgits Pty Ltd]:P2 70 | Organizational Unit Name (eg, section) []:AS2 71 | Common Name (e.g. server FQDN or YOUR name) []:p2as2 72 | Email Address []: 73 | $ cat P2_public.pem >> P2_private.pem 74 | 75 | Configure P1 76 | ------------ 77 | ``P1`` needs to be configured before it can start sending files, open the web UI and follow these instructions: 78 | 79 | * Navigate to ``Private Keys->Add private key``. 80 | * Choose the file ``P1_private.pem`` in the `key file` field, enter the passphrase and save the Private Certificate. 81 | * Next navigate to ``Public Certificates->Add public certificate``. 82 | * Choose the file ``P2_public.pem`` in the `certificate file` field and save the Public Certificate. 83 | * Now navigate to ``Organization->Add organization``. 84 | * Set Name to ``P1``, As2 Name to ``p1as2`` and set the Signature and Encryption keys to ``P1_private.pem`` and save the Organization. 85 | * Next navigate to ``Partner->Add partner``. 86 | * Set Name to ``P2``, As2 Name to ``p2as2`` and Target url to ``http://127.0.0.1:8001/pyas2/as2receive`` 87 | * Under security settings set Encrypt Message to ``3DES``, Sign Message to ``SHA-256``, Signature and Encryption keys to ``P2_public.pem``. 88 | * Under MDN settings set MDN mode to ``Synchronous`` and Request Signed MDN to ``SHA-256``. 89 | * Save the partner to complete the configuration. 90 | 91 | Configure P2 92 | ------------ 93 | ``P2`` needs to be configured before it can start receiving files, open the web UI and follow these instructions: 94 | 95 | * Navigate to ``Private Certificates->Add private key``. 96 | * Choose the file ``P2_private.pem`` in the `key file` field, enter the passphrase and save the Private Certificate. 97 | * Next navigate to ``ublic Certificates->Add public certifcate``. 98 | * Choose the file ``P1_public.pem`` in the `certificate file` field and save the Public Certificate. 99 | * Now navigate to ``Organization->Add organization``. 100 | * Set Name to ``P2``, As2 Name to ``p2as2`` and set the Signature and Encryption keys to ``P2_private.pem`` and save the Organization. 101 | * Next navigate to ``Partner->Add partner``. 102 | * Set Name to ``P1``, As2 Name to ``p1as2`` and Target url to ``http://127.0.0.1:8000/pyas2/as2receive`` 103 | * Under security settings set Encrypt Message to ``3DES``, Sign Message to ``SHA-256``, Signature and Encryption keys to ``P1_public.pem``. 104 | * Under MDN settings set MDN mode to ``Synchronous`` and Request Signed MDN to ``SHA-256``. 105 | * Save the partner to complete the configuration. 106 | 107 | 108 | Send a File 109 | ----------- 110 | We are now read to send a file from ``P1`` to ``P2``, to do so follow these steps: 111 | 112 | * Open the ``P1`` web UI and navigate to `Partners``. 113 | * Select the partner `P2` and action `Send a message to selected partner` and click Go. 114 | * Select the Organization as ``P1`` and Partner as ``P2``. 115 | * Now select the file to send and click ``Send Message``. 116 | * The status of the file transfer can be viewed by navigating to ``Messages``. 117 | * Once file transfer is completed you will a green tick in the status column. 118 | 119 | .. image:: images/P1_SendFile.png 120 | 121 | * We will also see a similar entry in the web UI of ``P2``. 122 | 123 | .. image:: images/P2_SendFile.png 124 | 125 | * We can see basic information on this screen such as Partner, Organization, Message ID and MDN. 126 | * We can also view the MDN and Payload by clicking on the respective links. 127 | 128 | Conclusion 129 | ---------- 130 | We have successfully demonstrated the core functionality of ``django-pyas2`` i.e. sending files from one system to another using 131 | the AS2 protocol. For a more detailed overview of all its functionality do go through the :doc:`detailed docs`. 132 | -------------------------------------------------------------------------------- /pyas2/forms.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django import forms 3 | from django.utils.translation import gettext_lazy as _ 4 | from pyas2lib import Organization as As2Organization 5 | from pyas2lib import Partner as As2Partner 6 | from pyas2lib.exceptions import AS2Exception 7 | 8 | from pyas2.models import Organization 9 | from pyas2.models import Partner 10 | from pyas2.models import PrivateKey 11 | from pyas2.models import PublicCertificate 12 | 13 | 14 | class PartnerForm(forms.ModelForm): 15 | """Form for creating and editing AS2 partners.""" 16 | 17 | def clean(self): 18 | cleaned_data = super().clean() 19 | 20 | # If http auth is set and credentials are missing raise error 21 | if cleaned_data.get("http_auth"): 22 | if not cleaned_data.get("http_auth_user"): 23 | raise forms.ValidationError( 24 | _( 25 | "HTTP username is mandatory when HTTP authentication " 26 | "is enabled" 27 | ) 28 | ) 29 | if not cleaned_data.get("http_auth_pass"): 30 | self._errors["http_auth_pass"] = self.error_class( 31 | _( 32 | "HTTP password is mandatory when HTTP authentication " 33 | "is enabled" 34 | ) 35 | ) 36 | 37 | # if encryption is set and no cert is mentioned set error 38 | if cleaned_data.get("encryption") and not cleaned_data.get("encryption_cert"): 39 | raise forms.ValidationError( 40 | _("Encryption Key is mandatory when message encryption is set") 41 | ) 42 | 43 | # if signature is set and no cert is mentioned set error 44 | if cleaned_data.get("signature") and not cleaned_data.get("signature_cert"): 45 | raise forms.ValidationError( 46 | _("Signature Key is required when message signature is set") 47 | ) 48 | 49 | # if mdn is set then the mode must also be set 50 | if cleaned_data.get("mdn") and not cleaned_data.get("mdn_mode"): 51 | raise forms.ValidationError(_("MDN Mode needs to be specified")) 52 | 53 | # if the mdn signature is set then the signature cert must be set 54 | if cleaned_data.get("mdn_sign") and not cleaned_data.get("signature_cert"): 55 | raise forms.ValidationError( 56 | _("Signature Key is mandatory when signed mdn is requested") 57 | ) 58 | 59 | return cleaned_data 60 | 61 | class Meta: 62 | """Define additional config for the PartnerForm class.""" 63 | 64 | model = Partner 65 | exclude = [] 66 | 67 | 68 | class PrivateKeyForm(forms.ModelForm): 69 | """Form for creating and editing AS2 Organization private keys.""" 70 | 71 | key_file = forms.FileField() 72 | 73 | def clean_key_file(self): 74 | """Validate that uploaded private key has the right extension.""" 75 | key_file = self.cleaned_data["key_file"] 76 | 77 | ext = os.path.splitext(key_file.name)[1] 78 | valid_extensions = [".pem", ".p12", ".pfx"] 79 | 80 | if not ext.lower() in valid_extensions: 81 | raise forms.ValidationError( 82 | _("Unsupported key format, supported formats " "include %s.") 83 | % ", ".join(valid_extensions) 84 | ) 85 | return key_file 86 | 87 | def clean(self): 88 | cleaned_data = super().clean() 89 | key_file = cleaned_data.get("key_file") 90 | 91 | if key_file: 92 | cleaned_data["key_filename"] = key_file.name 93 | cleaned_data["key_file"] = key_file.read() 94 | 95 | try: 96 | 97 | As2Organization.load_key( 98 | cleaned_data["key_file"], cleaned_data["key_pass"] 99 | ) 100 | except AS2Exception as e: 101 | raise forms.ValidationError(e.args[0]) 102 | 103 | return cleaned_data 104 | 105 | def save(self, commit=True): 106 | instance = super().save(commit=False) 107 | instance.name = self.cleaned_data["key_filename"] 108 | instance.key = self.cleaned_data["key_file"] 109 | if commit: 110 | instance.save() 111 | return instance 112 | 113 | class Meta: 114 | """Define additional config for the PrivateKeyForm class.""" 115 | 116 | model = PrivateKey 117 | fields = ["key_file", "key_pass"] 118 | widgets = { 119 | "key_pass": forms.PasswordInput(), 120 | } 121 | 122 | 123 | class PublicCertificateForm(forms.ModelForm): 124 | """Form for creating and editing AS2 Partner public certs.""" 125 | 126 | cert_file = forms.FileField(label="Certificate File") 127 | cert_ca_file = forms.FileField(label="Certificate CA File", required=False) 128 | 129 | def clean_cert_file(self): 130 | """Validate that uploaded cert file has the right extension.""" 131 | cert_file = self.cleaned_data["cert_file"] 132 | 133 | ext = os.path.splitext(cert_file.name)[1] 134 | valid_extensions = [".pem", ".der", ".cer"] 135 | 136 | if not ext.lower() in valid_extensions: 137 | raise forms.ValidationError( 138 | _("Unsupported certificate format, supported formats " "include %s.") 139 | % ", ".join(valid_extensions) 140 | ) 141 | 142 | return cert_file 143 | 144 | def clean_cert_ca_file(self): 145 | """Validate that uploaded cert ca file has the right extension.""" 146 | cert_ca_file = self.cleaned_data["cert_ca_file"] 147 | 148 | if cert_ca_file: 149 | ext = os.path.splitext(cert_ca_file.name)[1] 150 | valid_extensions = [".pem", ".der", ".cer", ".ca"] 151 | 152 | if not ext.lower() in valid_extensions: 153 | raise forms.ValidationError( 154 | _( 155 | "Unsupported certificate format, supported formats " 156 | "include %s." 157 | ) 158 | % ", ".join(valid_extensions) 159 | ) 160 | 161 | return cert_ca_file 162 | 163 | def clean(self): 164 | cleaned_data = super().clean() 165 | cert_file = cleaned_data.get("cert_file") 166 | cert_ca_file = cleaned_data.get("cert_ca_file", "") 167 | 168 | if cert_file and cert_ca_file != "": 169 | cleaned_data["cert_filename"] = cert_file.name 170 | cleaned_data["cert_file"] = cert_file.read() 171 | 172 | if cert_ca_file: 173 | cleaned_data["cert_ca_file"] = cert_ca_file.read() 174 | 175 | try: 176 | partner = As2Partner( 177 | "partner", 178 | verify_cert=cleaned_data["cert_file"], 179 | verify_cert_ca=cleaned_data["cert_ca_file"], 180 | validate_certs=cleaned_data["verify_cert"], 181 | ) 182 | partner.load_verify_cert() 183 | except AS2Exception as e: 184 | raise forms.ValidationError(e.args[0]) 185 | 186 | return cleaned_data 187 | 188 | def save(self, commit=True): 189 | instance = super().save(commit=False) 190 | instance.name = self.cleaned_data["cert_filename"] 191 | instance.certificate = self.cleaned_data["cert_file"] 192 | 193 | if self.cleaned_data["cert_ca_file"]: 194 | instance.certificate_ca = self.cleaned_data["cert_ca_file"] 195 | 196 | if commit: 197 | instance.save() 198 | return instance 199 | 200 | class Meta: 201 | """Define additional config for the PublicCertificateForm class.""" 202 | 203 | model = PublicCertificate 204 | fields = ["cert_file", "cert_ca_file", "verify_cert"] 205 | 206 | 207 | class SendAs2MessageForm(forms.Form): 208 | """Form for sending AS2 messages to Partners from the Admin.""" 209 | 210 | organization = forms.ModelChoiceField( 211 | queryset=Organization.objects.all(), empty_label=None 212 | ) 213 | partner = forms.ModelChoiceField(queryset=Partner.objects.all()) 214 | file = forms.FileField() 215 | -------------------------------------------------------------------------------- /docs/detailed-guide/extending.rst: -------------------------------------------------------------------------------- 1 | Extending django-pyas2 2 | ====================== 3 | A use case for extending django-pyas2 may be to have additional connectors, from which files are 4 | received, such as a message queue, or to run a directory monitor as a daemon to send messages as 5 | soon as a message has been written to an outbound directory (see directory structure), or to add 6 | additional functionalities, like a custom website to the root of the url etc. 7 | 8 | One way to extend ``django-pyas2`` is to use the django startapp command, that will create the 9 | directory structure needed for an app. In this example we call the app "extend_pyas2". 10 | 11 | Please consult the extensive django documentation to learn more about these command. Below simply 12 | a description for your convenience to get started: 13 | 14 | In the django_pyas2 project directory invoke the script as follows: 15 | 16 | .. code-block:: console 17 | 18 | $ python manage.py startapp extend_pyas2 19 | 20 | 21 | This has now created a new directory containing files that may be used for apps: 22 | 23 | .. code-block:: console 24 | 25 | {PROJECT DIRECTORY} 26 | └──django_pyas2 27 | ├── django_pyas2 28 | │ ├── db.sqlite3 29 | │ ├── manage.py 30 | │ └── django_pyas2 31 | │ ├── settings.py 32 | │ ├── urls.py 33 | │ └── wsgi.py 34 | └── extend_pyas2 35 | ├── apps.py 36 | ├── migrations 37 | ├── models.py 38 | ├── tests.py 39 | └── views.py 40 | 41 | In our example, we will add a new admin command that should monitor directories and trigger 42 | the sending of files to partners when they are written. For that purpose, we need to create 43 | some subfolders "management/commands" and a python file with the management command: 44 | 45 | .. code-block:: console 46 | 47 | │ └── wsgi.py 48 | └── extend_pyas2 49 | ├── apps.py 50 | ├── migrations 51 | ├── models.py 52 | ├── tests.py 53 | ├── views.py 54 | └── management 55 | └── commands 56 | └── filewatcher.py 57 | 58 | Add ``extend_pyas2`` to your ``INSTALLED_APPS`` settings, after ``pyas2``. 59 | 60 | .. code-block:: python 61 | 62 | INSTALLED_APPS = ( 63 | ... 64 | 'pyas2', 65 | 'extend_pyas2', 66 | ) 67 | 68 | 69 | An example content for the filewatcher.py may be as follows and can be run with Django's manage 70 | command: 71 | 72 | .. code-block:: console 73 | 74 | $ python manage.py filewatcher 75 | 76 | 77 | .. code-block:: python 78 | 79 | from django.core.management.base import BaseCommand, CommandError 80 | from django.core.management import call_command 81 | from django.utils.translation import ugettext as _ 82 | from pyas2.models import Organization 83 | from pyas2.models import Partner 84 | from pyas2 import settings 85 | from watchdog.observers import Observer 86 | from watchdog.observers.polling import PollingObserverVFS 87 | from watchdog.events import PatternMatchingEventHandler 88 | import time 89 | import atexit 90 | import socket 91 | import os 92 | import sys 93 | import logging 94 | 95 | logger = logging.getLogger('django') 96 | 97 | DAEMONPORT = 16388 98 | 99 | 100 | class FileWatchHandle(PatternMatchingEventHandler): 101 | """ 102 | FileWatchHandler that ignores directories. No Patterns defined by default. Any file in the 103 | directory will be sent. 104 | """ 105 | def __init__(self, tasks, dir_watch): 106 | super(FileWatchHandle, self).__init__(ignore_directories=True) 107 | self.tasks = tasks 108 | self.dir_watch = dir_watch 109 | 110 | def handle_event(self, event): 111 | self.tasks.add( 112 | (self.dir_watch['organization'], self.dir_watch['partner'], event.src_path)) 113 | logger.info(u' "%(file)s" created. Adding to Task Queue.', {'file': event.src_path}) 114 | 115 | def on_modified(self, event): 116 | self.handle_event(event) 117 | 118 | def on_created(self, event): 119 | self.handle_event(event) 120 | 121 | 122 | class WatchdogObserversManager: 123 | """ 124 | Creates and manages a list of watchdog observers as daemons. All daemons will have the same 125 | settings. By default, subdirectories are not searched. 126 | :param: force_vfs : if the underlying filesystem is a network share, OS events cannot be 127 | used reliably. Polling to be done, which is expensive. 128 | """ 129 | def __init__(self, is_daemon=True, force_vfs=False): 130 | self.observers = [] 131 | self.is_daemon = is_daemon 132 | self.force_vfs = force_vfs 133 | 134 | def add_observer(self, tasks, dir_watch): 135 | if self.force_vfs: 136 | new_observer = PollingObserverVFS(stat=os.stat, listdir=os.listdir) 137 | else: 138 | new_observer = Observer() 139 | new_observer.daemon = self.is_daemon 140 | new_observer.schedule(FileWatchHandle(tasks, dir_watch), 141 | dir_watch['path'], recursive=False) 142 | new_observer.start() 143 | self.observers.append(new_observer) 144 | 145 | def stop_all(self): 146 | for observer in self.observers: 147 | observer.stop() 148 | 149 | def join_all(self): 150 | for observer in self.observers: 151 | observer.join() 152 | 153 | 154 | class Command(BaseCommand): 155 | help = _(u'Daemon process that watches the outbox of all as2 partners and ' 156 | u'triggers sendmessage when files become available') 157 | 158 | def handle(self, *args, **options): 159 | logger.info(_(u'Starting PYAS2 send Watchdog daemon.')) 160 | engine_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 161 | try: 162 | engine_socket.bind(('127.0.0.1', DAEMONPORT)) 163 | except socket.error: 164 | engine_socket.close() 165 | raise CommandError(_(u'An instance of the send daemon is already running')) 166 | else: 167 | atexit.register(engine_socket.close) 168 | 169 | tasks = set() 170 | dir_watch_data = [] 171 | 172 | for partner in Partner.objects.all(): 173 | for org in Organization.objects.all(): 174 | outboxDir = os.path.join(settings.DATA_DIR, 175 | 'messages', 176 | partner.as2_name, 177 | 'outbox', 178 | org.as2_name) 179 | if os.path.isdir(outboxDir): 180 | dir_watch_data.append({}) 181 | dir_watch_data[-1]['path'] = outboxDir 182 | dir_watch_data[-1]['organization'] = org.as2_name 183 | dir_watch_data[-1]['partner'] = partner.as2_name 184 | 185 | if not dir_watch_data: 186 | logger.error(_(u'No partners have been configured!')) 187 | sys.exit(0) 188 | 189 | logger.info(_(u'Process existing files in the directory.')) 190 | for dir_watch in dir_watch_data: 191 | files = [f for f in os.listdir(dir_watch['path']) if 192 | os.path.isfile(os.path.join(dir_watch['path'], f))] 193 | for file in files: 194 | logger.info(u'Send as2 message "%(file)s" from "%(org)s" to "%(partner)s".', 195 | {'file': file, 196 | 'org': dir_watch['organization'], 197 | 'partner': dir_watch['partner']}) 198 | 199 | call_command('sendas2message', dir_watch['organization'], dir_watch['partner'], 200 | os.path.join(dir_watch['path'], file), delete=True) 201 | 202 | """Add WatchDog Thread Here""" 203 | logger.info(_(u'PYAS2 send Watchdog daemon started.')) 204 | active_receiving = False 205 | watchdog_file_observers = WatchdogObserversManager(is_daemon=True, force_vfs=True) 206 | for dir_watch in dir_watch_data: 207 | watchdog_file_observers.add_observer(tasks, dir_watch) 208 | try: 209 | logger.info(_(u'Watchdog awaiting tasks...')) 210 | while True: 211 | if tasks: 212 | if not active_receiving: 213 | # first request (after tasks have been fired, or startup of dirmonitor) 214 | active_receiving = True 215 | else: # active receiving events 216 | for task in tasks: 217 | logger.info( 218 | u'Send as2 message "%(file)s" from "%(org)s" to "%(partner)s".', 219 | {'file': task[2], 220 | 'org': task[0], 221 | 'partner': task[1]}) 222 | 223 | call_command('sendas2message', task[0], task[1], task[2], 224 | delete=True) 225 | tasks.clear() 226 | active_receiving = False 227 | time.sleep(2) 228 | 229 | except (Exception, KeyboardInterrupt) as msg: 230 | logger.info(u'Error in running task: "%(msg)s".', {'msg': msg}) 231 | logger.info(u'Stopping all running Watchdog threads...') 232 | watchdog_file_observers.stop_all() 233 | logger.info(u'All Watchdog threads stopped.') 234 | 235 | logger.info(u'Waiting for all Watchdog threads to finish...') 236 | watchdog_file_observers.join_all() 237 | logger.info(u'All Watchdog threads finished. Exiting...') 238 | sys.exit(0) 239 | 240 | -------------------------------------------------------------------------------- /pyas2/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from django.contrib import messages 5 | from django.shortcuts import Http404 6 | from django.shortcuts import HttpResponse 7 | from django.shortcuts import get_object_or_404 8 | from django.utils.decorators import method_decorator 9 | from django.utils.translation import gettext as _ 10 | from django.urls import reverse_lazy 11 | from django.views import View 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.views.decorators.clickjacking import xframe_options_exempt 14 | from django.views.generic import FormView 15 | from pyas2lib import Message as As2Message 16 | from pyas2lib import Mdn as As2Mdn 17 | from pyas2lib.exceptions import DuplicateDocument 18 | 19 | from pyas2.models import Mdn 20 | from pyas2.models import Message 21 | from pyas2.models import Organization 22 | from pyas2.models import Partner 23 | from pyas2.models import PrivateKey 24 | from pyas2.models import PublicCertificate 25 | from pyas2.utils import run_post_receive 26 | from pyas2.utils import run_post_send 27 | from pyas2.forms import SendAs2MessageForm 28 | 29 | logger = logging.getLogger("pyas2") 30 | 31 | 32 | @method_decorator(csrf_exempt, name="dispatch") 33 | class ReceiveAs2Message(View): 34 | """ 35 | Class receives AS2 requests from partners. 36 | Checks whether its an AS2 message or an MDN and acts accordingly. 37 | """ 38 | 39 | @staticmethod 40 | def find_message(message_id, partner_id): 41 | """Find the message using the message_id and return its pyas2 type""" 42 | message = Message.objects.filter( 43 | message_id=message_id, partner_id=partner_id.strip() 44 | ).first() 45 | if message: 46 | return message.as2message 47 | return None 48 | 49 | @staticmethod 50 | def check_message_exists(message_id, partner_id): 51 | """Check if the message already exists in the system""" 52 | return Message.objects.filter( 53 | message_id=message_id, partner_id=partner_id.strip() 54 | ).exists() 55 | 56 | @staticmethod 57 | def find_organization(org_id): 58 | """Find the org using the As2 Id and return its pyas2 type""" 59 | org = Organization.objects.filter(as2_name=org_id).first() 60 | if org: 61 | return org.as2org 62 | return None 63 | 64 | @staticmethod 65 | def find_partner(partner_id): 66 | """Find the partner using the As2 Id and return its pyas2 type""" 67 | partner = Partner.objects.filter(as2_name=partner_id).first() 68 | if partner: 69 | return partner.as2partner 70 | return None 71 | 72 | @xframe_options_exempt 73 | @csrf_exempt 74 | def post(self, request, *args, **kwargs): 75 | """Handle the post message received by the AS2 server.""" 76 | # extract the headers from the http request 77 | as2headers = "" 78 | for key in request.META: 79 | if key.startswith("HTTP") or key.startswith("CONTENT"): 80 | as2headers += ( 81 | f'{key.replace("HTTP_", "").replace("_", "-").lower()}: ' 82 | f"{request.META[key]}\n" 83 | ) 84 | 85 | # build the body along with the headers 86 | request_body = as2headers.encode() + b"\r\n" + request.body 87 | logger.debug( 88 | f'Received an HTTP POST from {request.META["REMOTE_ADDR"]} ' 89 | f"with payload :\n{request_body}" 90 | ) 91 | 92 | # First try to see if this is an MDN 93 | logger.debug("Check to see if payload is an Asynchronous MDN.") 94 | as2mdn = As2Mdn() 95 | 96 | # Parse the mdn and get the message status 97 | status, detailed_status = as2mdn.parse(request_body, self.find_message) 98 | 99 | if not detailed_status == "mdn-not-found": 100 | message = Message.objects.get( 101 | message_id=as2mdn.orig_message_id, direction="OUT" 102 | ) 103 | logger.info( 104 | f"Asynchronous MDN received for AS2 message {as2mdn.message_id} to organization " 105 | f"{message.organization.as2_name} from partner {message.partner.as2_name}" 106 | ) 107 | 108 | # Update the message status and return the response 109 | if status == "processed": 110 | message.status = "S" 111 | run_post_send(message) 112 | else: 113 | message.status = "E" 114 | message.detailed_status = ( 115 | f"Partner failed to process message: {detailed_status}" 116 | ) 117 | # Save the message and create the mdn 118 | message.save() 119 | Mdn.objects.create_from_as2mdn(as2mdn=as2mdn, message=message, status="R") 120 | 121 | return HttpResponse(_("AS2 ASYNC MDN has been received")) 122 | 123 | else: 124 | logger.debug("Payload is not an MDN parse it as an AS2 Message") 125 | as2message = As2Message() 126 | status, exception, as2mdn = as2message.parse( 127 | request_body, 128 | self.find_organization, 129 | self.find_partner, 130 | self.check_message_exists, 131 | ) 132 | 133 | logger.info( 134 | f'Received an AS2 message with id {as2message.headers.get("message-id")} for ' 135 | f'organization {as2message.headers.get("as2-to")} from ' 136 | f'partner {as2message.headers.get("as2-from")}.' 137 | ) 138 | 139 | # In case of duplicates update message id 140 | if isinstance(exception[0], DuplicateDocument): 141 | as2message.message_id += "_duplicate" 142 | 143 | # Create the Message and MDN objects 144 | message, full_fn = Message.objects.create_from_as2message( 145 | as2message=as2message, 146 | filename=as2message.payload.get_filename(), 147 | payload=as2message.content, 148 | direction="IN", 149 | status="S" if status == "processed" else "E", 150 | detailed_status=exception[1], 151 | ) 152 | 153 | # run post receive command on success 154 | if status == "processed": 155 | run_post_receive(message, full_fn) 156 | 157 | # Return the mdn in case of sync else return text message 158 | if as2mdn and as2mdn.mdn_mode == "SYNC": 159 | message.mdn = Mdn.objects.create_from_as2mdn( 160 | as2mdn=as2mdn, message=message, status="S" 161 | ) 162 | response = HttpResponse(as2mdn.content) 163 | for key, value in as2mdn.headers.items(): 164 | response[key] = value 165 | return response 166 | 167 | elif as2mdn and as2mdn.mdn_mode == "ASYNC": 168 | Mdn.objects.create_from_as2mdn( 169 | as2mdn=as2mdn, 170 | message=message, 171 | status="P", 172 | return_url=as2mdn.mdn_url, 173 | ) 174 | return HttpResponse(_("AS2 message has been received")) 175 | 176 | def get(self, request, *args, **kwargs): 177 | """Handle the GET call made to the AS2 server post endpoint.""" 178 | return HttpResponse( 179 | _("To submit an AS2 message, you must POST the message to this URL") 180 | ) 181 | 182 | def options(self, request, *args, **kwargs): 183 | """Handle the OPTIONS call made to the AS2 server post endpoint.""" 184 | response = HttpResponse() 185 | response["allow"] = ",".join(["POST", "GET"]) 186 | return response 187 | 188 | 189 | class SendAs2Message(FormView): 190 | """View for sending AS2 messages to a partner.""" 191 | 192 | # pylint: disable=W0212 193 | template_name = "pyas2/send_as2_message.html" 194 | form_class = SendAs2MessageForm 195 | success_url = reverse_lazy( 196 | f"admin:{Message._meta.app_label}_{Message._meta.model_name}_changelist" 197 | ) 198 | 199 | def get_context_data(self, **kwargs): 200 | context = super().get_context_data(**kwargs) 201 | context.update( 202 | { 203 | "opts": Partner._meta, 204 | "change": True, 205 | "is_popup": False, 206 | "save_as": False, 207 | "has_delete_permission": False, 208 | "has_add_permission": False, 209 | "has_change_permission": False, 210 | } 211 | ) 212 | return context 213 | 214 | def form_valid(self, form): 215 | # Send the file to the partner 216 | payload = form.cleaned_data["file"].read() 217 | as2message = As2Message( 218 | sender=form.cleaned_data["organization"].as2org, 219 | receiver=form.cleaned_data["partner"].as2partner, 220 | ) 221 | logger.debug( 222 | f'Building message from {form.cleaned_data["file"].name} to send to partner ' 223 | f"{as2message.receiver.as2_name} from org {as2message.sender.as2_name}." 224 | ) 225 | as2message.build( 226 | payload, 227 | filename=form.cleaned_data["file"].name, 228 | subject=form.cleaned_data["partner"].subject, 229 | content_type=form.cleaned_data["partner"].content_type, 230 | disposition_notification_to=form.cleaned_data["organization"].email_address 231 | or "no-reply@pyas2.com", 232 | ) 233 | 234 | message, _ = Message.objects.create_from_as2message( 235 | as2message=as2message, 236 | payload=payload, 237 | filename=form.cleaned_data["file"].name, 238 | direction="OUT", 239 | status="P", 240 | ) 241 | message.send_message(as2message.headers, as2message.content) 242 | if message.status in ["S", "P"]: 243 | messages.success( 244 | self.request, "Message has been successfully send to Partner." 245 | ) 246 | else: 247 | messages.error( 248 | self.request, 249 | "Message transmission failed, check Messages tab for details.", 250 | ) 251 | return super().form_valid(form) 252 | 253 | 254 | class DownloadFile(View): 255 | """A generic view for downloading files such as payload, certificates...""" 256 | 257 | def get(self, request, obj_type, obj_id, *args, **kwargs): 258 | """Return the requested file bytes as a response.""" 259 | filename = "" 260 | file_content = "" 261 | # Get the file content based 262 | if obj_type == "message_payload": 263 | obj = get_object_or_404(Message, pk=obj_id) 264 | filename = os.path.basename(obj.payload.name) 265 | file_content = obj.payload.read() 266 | 267 | elif obj_type == "mdn_payload": 268 | obj = get_object_or_404(Mdn, pk=obj_id) 269 | filename = os.path.basename(obj.payload.name) 270 | file_content = obj.payload.read() 271 | 272 | elif obj_type == "public_cert": 273 | obj = get_object_or_404(PublicCertificate, pk=obj_id) 274 | filename = obj.name 275 | file_content = obj.certificate 276 | 277 | elif obj_type == "private_key": 278 | obj = get_object_or_404(PrivateKey, pk=obj_id) 279 | filename = obj.name 280 | file_content = obj.key 281 | 282 | # Return the file contents as attachment 283 | if filename and file_content: 284 | response = HttpResponse(content_type="application/x-pem-file") 285 | disposition_type = "attachment" 286 | response["Content-Disposition"] = ( 287 | disposition_type + "; filename=" + filename 288 | ) 289 | response.write(bytes(file_content)) 290 | return response 291 | else: 292 | raise Http404() 293 | -------------------------------------------------------------------------------- /pyas2/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-05-01 06:37 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import pyas2.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="PrivateKey", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ("key", models.BinaryField()), 29 | ( 30 | "key_pass", 31 | models.CharField( 32 | max_length=100, verbose_name="Private Key Password" 33 | ), 34 | ), 35 | ], 36 | ), 37 | migrations.CreateModel( 38 | name="PublicCertificate", 39 | fields=[ 40 | ( 41 | "id", 42 | models.AutoField( 43 | auto_created=True, 44 | primary_key=True, 45 | serialize=False, 46 | verbose_name="ID", 47 | ), 48 | ), 49 | ("name", models.CharField(max_length=255)), 50 | ("certificate", models.BinaryField()), 51 | ( 52 | "certificate_ca", 53 | models.BinaryField( 54 | blank=True, null=True, verbose_name="Local CA Store" 55 | ), 56 | ), 57 | ( 58 | "verify_cert", 59 | models.BooleanField( 60 | default=True, 61 | help_text="Uncheck this option to disable certificate verification.", 62 | verbose_name="Verify Certificate", 63 | ), 64 | ), 65 | ], 66 | ), 67 | migrations.CreateModel( 68 | name="Partner", 69 | fields=[ 70 | ("name", models.CharField(max_length=100, verbose_name="Partner Name")), 71 | ( 72 | "as2_name", 73 | models.CharField( 74 | max_length=100, 75 | primary_key=True, 76 | serialize=False, 77 | verbose_name="AS2 Identifier", 78 | ), 79 | ), 80 | ( 81 | "email_address", 82 | models.EmailField(blank=True, max_length=254, null=True), 83 | ), 84 | ( 85 | "http_auth", 86 | models.BooleanField( 87 | default=False, verbose_name="Enable Authentication" 88 | ), 89 | ), 90 | ( 91 | "http_auth_user", 92 | models.CharField(blank=True, max_length=100, null=True), 93 | ), 94 | ( 95 | "http_auth_pass", 96 | models.CharField(blank=True, max_length=100, null=True), 97 | ), 98 | ("target_url", models.URLField()), 99 | ( 100 | "subject", 101 | models.CharField( 102 | default="EDI Message sent using pyas2", max_length=255 103 | ), 104 | ), 105 | ( 106 | "content_type", 107 | models.CharField( 108 | choices=[ 109 | ("application/EDI-X12", "application/EDI-X12"), 110 | ("application/EDIFACT", "application/EDIFACT"), 111 | ("application/edi-consent", "application/edi-consent"), 112 | ("application/XML", "application/XML"), 113 | ], 114 | default="application/edi-consent", 115 | max_length=100, 116 | ), 117 | ), 118 | ( 119 | "compress", 120 | models.BooleanField(default=False, verbose_name="Compress Message"), 121 | ), 122 | ( 123 | "encryption", 124 | models.CharField( 125 | blank=True, 126 | choices=[ 127 | ("tripledes_192_cbc", "3DES"), 128 | ("rc2_128_cbc", "RC2-128"), 129 | ("rc4_128_cbc", "RC4-128"), 130 | ("aes_128_cbc", "AES-128"), 131 | ("aes_192_cbc", "AES-192"), 132 | ("aes_256_cbc", "AES-256"), 133 | ], 134 | max_length=20, 135 | null=True, 136 | verbose_name="Encrypt Message", 137 | ), 138 | ), 139 | ( 140 | "signature", 141 | models.CharField( 142 | blank=True, 143 | choices=[ 144 | ("sha1", "SHA-1"), 145 | ("sha224", "SHA-224"), 146 | ("sha256", "SHA-256"), 147 | ("sha384", "SHA-384"), 148 | ("sha512", "SHA-512"), 149 | ], 150 | max_length=20, 151 | null=True, 152 | verbose_name="Sign Message", 153 | ), 154 | ), 155 | ("mdn", models.BooleanField(default=False, verbose_name="Request MDN")), 156 | ( 157 | "mdn_mode", 158 | models.CharField( 159 | blank=True, 160 | choices=[("SYNC", "Synchronous"), ("ASYNC", "Asynchronous")], 161 | max_length=20, 162 | null=True, 163 | ), 164 | ), 165 | ( 166 | "mdn_sign", 167 | models.CharField( 168 | blank=True, 169 | choices=[ 170 | ("sha1", "SHA-1"), 171 | ("sha224", "SHA-224"), 172 | ("sha256", "SHA-256"), 173 | ("sha384", "SHA-384"), 174 | ("sha512", "SHA-512"), 175 | ], 176 | max_length=20, 177 | null=True, 178 | verbose_name="Request Signed MDN", 179 | ), 180 | ), 181 | ( 182 | "confirmation_message", 183 | models.TextField( 184 | blank=True, 185 | help_text="Use this field to send a customized message in the MDN " 186 | "Confirmations for this Partner", 187 | null=True, 188 | verbose_name="Confirmation Message", 189 | ), 190 | ), 191 | ( 192 | "keep_filename", 193 | models.BooleanField( 194 | default=False, 195 | help_text="Use Original Filename to to store file on receipt, use this " 196 | "option only if you are sure partner sends unique names", 197 | verbose_name="Keep Original Filename", 198 | ), 199 | ), 200 | ( 201 | "cmd_send", 202 | models.TextField( 203 | blank=True, 204 | help_text="Command executed after successful message send, replacements " 205 | "are $filename, $sender, $recevier, $messageid and any message " 206 | "header such as $Subject", 207 | null=True, 208 | verbose_name="Command on Message Send", 209 | ), 210 | ), 211 | ( 212 | "cmd_receive", 213 | models.TextField( 214 | blank=True, 215 | help_text="Command executed after successful message receipt, replacements" 216 | " are $filename, $fullfilename, $sender, $recevier, $messageid " 217 | "and any message header such as $Subject", 218 | null=True, 219 | verbose_name="Command on Message Receipt", 220 | ), 221 | ), 222 | ( 223 | "encryption_cert", 224 | models.ForeignKey( 225 | blank=True, 226 | null=True, 227 | on_delete=django.db.models.deletion.SET_NULL, 228 | to="pyas2.PublicCertificate", 229 | ), 230 | ), 231 | ( 232 | "signature_cert", 233 | models.ForeignKey( 234 | blank=True, 235 | null=True, 236 | on_delete=django.db.models.deletion.SET_NULL, 237 | related_name="partner_s", 238 | to="pyas2.PublicCertificate", 239 | ), 240 | ), 241 | ], 242 | ), 243 | migrations.CreateModel( 244 | name="Organization", 245 | fields=[ 246 | ( 247 | "name", 248 | models.CharField(max_length=100, verbose_name="Organization Name"), 249 | ), 250 | ( 251 | "as2_name", 252 | models.CharField( 253 | max_length=100, 254 | primary_key=True, 255 | serialize=False, 256 | verbose_name="AS2 Identifier", 257 | ), 258 | ), 259 | ( 260 | "email_address", 261 | models.EmailField(blank=True, max_length=254, null=True), 262 | ), 263 | ( 264 | "confirmation_message", 265 | models.TextField( 266 | blank=True, 267 | help_text="Use this field to send a customized message in the MDN " 268 | "Confirmations for this Organization", 269 | null=True, 270 | verbose_name="Confirmation Message", 271 | ), 272 | ), 273 | ( 274 | "encryption_key", 275 | models.ForeignKey( 276 | blank=True, 277 | null=True, 278 | on_delete=django.db.models.deletion.SET_NULL, 279 | to="pyas2.PrivateKey", 280 | ), 281 | ), 282 | ( 283 | "signature_key", 284 | models.ForeignKey( 285 | blank=True, 286 | null=True, 287 | on_delete=django.db.models.deletion.SET_NULL, 288 | related_name="org_s", 289 | to="pyas2.PrivateKey", 290 | ), 291 | ), 292 | ], 293 | ), 294 | migrations.CreateModel( 295 | name="Message", 296 | fields=[ 297 | ( 298 | "id", 299 | models.AutoField( 300 | auto_created=True, 301 | primary_key=True, 302 | serialize=False, 303 | verbose_name="ID", 304 | ), 305 | ), 306 | ("message_id", models.CharField(max_length=255)), 307 | ( 308 | "direction", 309 | models.CharField( 310 | choices=[("IN", "Inbound"), ("OUT", "Outbound")], max_length=5 311 | ), 312 | ), 313 | ("timestamp", models.DateTimeField(auto_now_add=True)), 314 | ( 315 | "status", 316 | models.CharField( 317 | choices=[ 318 | ("S", "Success"), 319 | ("E", "Error"), 320 | ("W", "Warning"), 321 | ("P", "Pending"), 322 | ("R", "Retry"), 323 | ], 324 | max_length=2, 325 | ), 326 | ), 327 | ("detailed_status", models.TextField(null=True)), 328 | ( 329 | "headers", 330 | models.FileField( 331 | blank=True, null=True, upload_to=pyas2.models.get_message_store 332 | ), 333 | ), 334 | ( 335 | "payload", 336 | models.FileField( 337 | blank=True, null=True, upload_to=pyas2.models.get_message_store 338 | ), 339 | ), 340 | ("compressed", models.BooleanField(default=False)), 341 | ("encrypted", models.BooleanField(default=False)), 342 | ("signed", models.BooleanField(default=False)), 343 | ( 344 | "mdn_mode", 345 | models.CharField( 346 | choices=[("SYNC", "Synchronous"), ("ASYNC", "Asynchronous")], 347 | max_length=5, 348 | null=True, 349 | ), 350 | ), 351 | ("mic", models.CharField(max_length=100, null=True)), 352 | ("retries", models.IntegerField(null=True)), 353 | ( 354 | "organization", 355 | models.ForeignKey( 356 | null=True, 357 | on_delete=django.db.models.deletion.SET_NULL, 358 | to="pyas2.Organization", 359 | ), 360 | ), 361 | ( 362 | "partner", 363 | models.ForeignKey( 364 | null=True, 365 | on_delete=django.db.models.deletion.SET_NULL, 366 | to="pyas2.Partner", 367 | ), 368 | ), 369 | ], 370 | options={ 371 | "unique_together": {("message_id", "partner")}, 372 | }, 373 | ), 374 | migrations.CreateModel( 375 | name="Mdn", 376 | fields=[ 377 | ( 378 | "id", 379 | models.AutoField( 380 | auto_created=True, 381 | primary_key=True, 382 | serialize=False, 383 | verbose_name="ID", 384 | ), 385 | ), 386 | ("mdn_id", models.CharField(max_length=255)), 387 | ("timestamp", models.DateTimeField(auto_now_add=True)), 388 | ( 389 | "status", 390 | models.CharField( 391 | choices=[("S", "Sent"), ("R", "Received"), ("P", "Pending")], 392 | max_length=2, 393 | ), 394 | ), 395 | ("signed", models.BooleanField(default=False)), 396 | ("return_url", models.URLField(null=True)), 397 | ( 398 | "headers", 399 | models.FileField( 400 | blank=True, null=True, upload_to=pyas2.models.get_mdn_store 401 | ), 402 | ), 403 | ( 404 | "payload", 405 | models.FileField( 406 | blank=True, null=True, upload_to=pyas2.models.get_mdn_store 407 | ), 408 | ), 409 | ( 410 | "message", 411 | models.OneToOneField( 412 | on_delete=django.db.models.deletion.CASCADE, to="pyas2.Message" 413 | ), 414 | ), 415 | ], 416 | ), 417 | ] 418 | -------------------------------------------------------------------------------- /pyas2/tests/test_advanced.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | from unittest import mock 4 | 5 | from django.test import Client, override_settings 6 | from django.test import TestCase 7 | from pyas2lib import Message as As2Message 8 | from pyas2lib import Mdn as As2Mdn 9 | 10 | from pyas2 import settings 11 | from pyas2.models import Message 12 | from pyas2.models import Mdn 13 | from pyas2.models import Organization 14 | from pyas2.models import Partner 15 | from pyas2.models import PrivateKey 16 | from pyas2.models import PublicCertificate 17 | from pyas2.tests.test_basic import SendMessageMock 18 | from pyas2.tests import TEST_DIR 19 | 20 | 21 | class AdvancedTestCases(TestCase): 22 | """Test cases dealing with handling of failures and other features""" 23 | 24 | @classmethod 25 | def setUpTestData(cls): 26 | # Every test needs a client. 27 | cls.client = Client() 28 | 29 | # Load the client and server certificates 30 | with open(os.path.join(TEST_DIR, "server_private.pem"), "rb") as fp: 31 | cls.server_key = PrivateKey.objects.create(key=fp.read(), key_pass="test") 32 | 33 | with open(os.path.join(TEST_DIR, "server_public.pem"), "rb") as fp: 34 | cls.server_crt = PublicCertificate.objects.create(certificate=fp.read()) 35 | 36 | with open(os.path.join(TEST_DIR, "client_private.pem"), "rb") as fp: 37 | cls.client_key = PrivateKey.objects.create(key=fp.read(), key_pass="test") 38 | 39 | with open(os.path.join(TEST_DIR, "client_public.pem"), "rb") as fp: 40 | cls.client_crt = PublicCertificate.objects.create(certificate=fp.read()) 41 | 42 | def setUp(self): 43 | 44 | # Setup the server organization and partner 45 | Organization.objects.create( 46 | name="AS2 Server", 47 | as2_name="as2server", 48 | encryption_key=self.server_key, 49 | signature_key=self.server_key, 50 | ) 51 | self.partner = Partner.objects.create( 52 | name="AS2 Client", 53 | as2_name="as2client", 54 | target_url="http://localhost:8080/pyas2/as2receive", 55 | compress=False, 56 | mdn=False, 57 | signature_cert=self.client_crt, 58 | encryption_cert=self.client_crt, 59 | ) 60 | 61 | # Setup the client organization and partner 62 | self.organization = Organization.objects.create( 63 | name="AS2 Client", 64 | as2_name="as2client", 65 | encryption_key=self.client_key, 66 | signature_key=self.client_key, 67 | ) 68 | 69 | # Initialise the payload i.e. the file to be transmitted 70 | with open(os.path.join(TEST_DIR, "testmessage.edi"), "rb") as fp: 71 | self.payload = fp.read() 72 | 73 | @classmethod 74 | def tearDownClass(cls): 75 | # remove all files in the inbox folders 76 | inbox = os.path.join("messages", "as2server", "inbox", "as2client") 77 | try: 78 | files = os.listdir(inbox) 79 | except OSError: 80 | files = [] 81 | for the_file in files: 82 | file_path = os.path.join(inbox, the_file) 83 | if os.path.isfile(file_path): 84 | os.unlink(file_path) 85 | for message in Message.objects.all(): 86 | message.headers.delete() 87 | message.payload.delete() 88 | for mdn in Mdn.objects.all(): 89 | mdn.headers.delete() 90 | mdn.payload.delete() 91 | 92 | def test_post_send_command(self): 93 | """Test that the command after successful send gets executed.""" 94 | 95 | partner = Partner.objects.create( 96 | name="AS2 Server", 97 | as2_name="as2server", 98 | target_url="http://localhost:8080/pyas2/as2receive", 99 | signature="sha1", 100 | http_auth=True, 101 | http_auth_user="admin", 102 | http_auth_pass="password", 103 | signature_cert=self.server_crt, 104 | encryption="tripledes_192_cbc", 105 | encryption_cert=self.server_crt, 106 | mdn=True, 107 | mdn_mode="SYNC", 108 | mdn_sign="sha1", 109 | cmd_send="touch %s/$messageid.sent" % TEST_DIR, 110 | ) 111 | in_message = self.build_and_send(partner) 112 | self.assertEqual(in_message.status, "S") 113 | 114 | # Check that the command got executed 115 | touch_file = os.path.join(TEST_DIR, "%s.sent" % in_message.message_id) 116 | self.assertTrue(os.path.exists(touch_file)) 117 | os.remove(touch_file) 118 | 119 | @mock.patch("requests.post") 120 | def test_post_send_command_async(self, mock_request): 121 | """Test that the command after successful send gets executed with 122 | asynchronous MDN.""" 123 | 124 | partner = Partner.objects.create( 125 | name="AS2 Server", 126 | as2_name="as2server", 127 | target_url="http://localhost:8080/pyas2/as2receive", 128 | signature="sha1", 129 | signature_cert=self.server_crt, 130 | encryption="tripledes_192_cbc", 131 | encryption_cert=self.server_crt, 132 | mdn=True, 133 | mdn_mode="ASYNC", 134 | mdn_sign="sha1", 135 | cmd_send="touch %s/$messageid.sent" % TEST_DIR, 136 | ) 137 | in_message = self.build_and_send(partner) 138 | 139 | # Send the async mdn to the sender 140 | out_message = Message.objects.get( 141 | message_id=in_message.message_id, direction="IN" 142 | ) 143 | mock_request.side_effect = SendMessageMock(self.client) 144 | out_message.mdn.send_async_mdn() 145 | 146 | # Check that the command got executed 147 | in_message.refresh_from_db() 148 | self.assertEqual(in_message.status, "S") 149 | 150 | # Check that the command got executed 151 | touch_file = os.path.join(TEST_DIR, "%s.sent" % in_message.message_id) 152 | self.assertTrue(os.path.exists(touch_file)) 153 | os.remove(touch_file) 154 | 155 | def test_post_receive_command(self): 156 | """Test that the command after successful receive gets executed.""" 157 | # settings.DATA_DIR = TEST_DIR 158 | # add the post receive command and save it 159 | self.partner.cmd_receive = "touch %s/$filename.received" % TEST_DIR 160 | self.partner.save() 161 | 162 | # Create the client partner and send the command 163 | partner = Partner.objects.create( 164 | name="AS2 Server", 165 | as2_name="as2server", 166 | target_url="http://localhost:8080/pyas2/as2receive", 167 | signature="sha1", 168 | signature_cert=self.server_crt, 169 | encryption="tripledes_192_cbc", 170 | encryption_cert=self.server_crt, 171 | mdn=True, 172 | mdn_mode="SYNC", 173 | mdn_sign="sha1", 174 | ) 175 | in_message = self.build_and_send(partner) 176 | self.assertEqual(in_message.status, "S") 177 | 178 | # Check that the command got executed 179 | touch_file = os.path.join( 180 | TEST_DIR, "%s.msg.received" % in_message.message_id.replace("@", "") 181 | ) 182 | self.assertTrue(os.path.exists(touch_file)) 183 | os.remove(touch_file) 184 | # shutil.rmtree(os.path.join(TEST_DIR, "messages")) 185 | # settings.DATA_DIR = None 186 | 187 | def test_use_received_filename(self): 188 | """Test using the filename of the payload received while saving the file.""" 189 | 190 | # add the post receive command and save it 191 | self.partner.cmd_receive = "touch %s/$filename.received" % TEST_DIR 192 | self.partner.keep_filename = True 193 | self.partner.save() 194 | 195 | # Create the client partner and send the command 196 | partner = Partner.objects.create( 197 | name="AS2 Server", 198 | as2_name="as2server", 199 | target_url="http://localhost:8080/pyas2/as2receive", 200 | signature="sha1", 201 | signature_cert=self.server_crt, 202 | encryption="tripledes_192_cbc", 203 | encryption_cert=self.server_crt, 204 | mdn=True, 205 | mdn_mode="SYNC", 206 | mdn_sign="sha1", 207 | ) 208 | in_message = self.build_and_send(partner) 209 | self.assertEqual(in_message.status, "S") 210 | 211 | # Check that the command got executed 212 | touch_file = os.path.join(TEST_DIR, "testmessage.edi.received") 213 | self.assertTrue(os.path.exists(touch_file)) 214 | os.remove(touch_file) 215 | 216 | def test_use_received_sender_and_receiver(self): 217 | """Test using the sender and receiver as2 name while the payload received.""" 218 | 219 | # add the post receive command and save it 220 | self.partner.cmd_receive = "touch %s/$sender.to.$receiver" % TEST_DIR 221 | self.partner.keep_filename = True 222 | self.partner.save() 223 | 224 | # Create the client partner and send the command 225 | partner = Partner.objects.create( 226 | name="AS2 Server", 227 | as2_name="as2server", 228 | target_url="http://localhost:8080/pyas2/as2receive", 229 | signature="sha1", 230 | signature_cert=self.server_crt, 231 | encryption="tripledes_192_cbc", 232 | encryption_cert=self.server_crt, 233 | mdn=True, 234 | mdn_mode="SYNC", 235 | mdn_sign="sha1", 236 | ) 237 | in_message = self.build_and_send(partner) 238 | self.assertEqual(in_message.status, "S") 239 | 240 | # Check that the command got executed 241 | touch_file = os.path.join( 242 | TEST_DIR, "%s.to.%s" % (self.organization.as2_name, partner.as2_name) 243 | ) 244 | self.assertTrue(os.path.exists(touch_file)) 245 | os.remove(touch_file) 246 | 247 | @mock.patch("requests.post") 248 | def test_duplicate_error(self, mock_request): 249 | partner = Partner.objects.create( 250 | name="AS2 Server", 251 | as2_name="as2server", 252 | target_url="http://localhost:8080/pyas2/as2receive", 253 | signature="sha1", 254 | signature_cert=self.server_crt, 255 | encryption="tripledes_192_cbc", 256 | encryption_cert=self.server_crt, 257 | mdn=True, 258 | mdn_mode="SYNC", 259 | mdn_sign="sha1", 260 | ) 261 | 262 | # Send the message once 263 | as2message = As2Message( 264 | sender=self.organization.as2org, receiver=partner.as2partner 265 | ) 266 | as2message.build( 267 | self.payload, 268 | filename="testmessage.edi", 269 | subject=partner.subject, 270 | content_type=partner.content_type, 271 | ) 272 | in_message, _ = Message.objects.create_from_as2message( 273 | as2message=as2message, payload=self.payload, direction="OUT", status="P" 274 | ) 275 | 276 | mock_request.side_effect = SendMessageMock(self.client) 277 | in_message.send_message(as2message.headers, as2message.content) 278 | 279 | # Check the status of the message 280 | self.assertEqual(in_message.status, "S") 281 | out_message = Message.objects.get( 282 | message_id=in_message.message_id, direction="IN" 283 | ) 284 | self.assertEqual(out_message.status, "S") 285 | 286 | # send it again to cause duplicate error 287 | in_message.send_message(as2message.headers, as2message.content) 288 | 289 | # Make sure out message was created 290 | self.assertEqual(in_message.status, "E") 291 | out_message = Message.objects.get( 292 | message_id=in_message.message_id + "_duplicate", direction="IN" 293 | ) 294 | self.assertEqual(out_message.status, "E") 295 | 296 | def test_org_missing_error(self): 297 | # Create the client partner and send the command 298 | partner = Partner.objects.create( 299 | name="AS2 Server", 300 | as2_name="as2server2", 301 | target_url="http://localhost:8080/pyas2/as2receive", 302 | signature="sha1", 303 | signature_cert=self.server_crt, 304 | encryption="tripledes_192_cbc", 305 | encryption_cert=self.server_crt, 306 | mdn=True, 307 | mdn_mode="SYNC", 308 | mdn_sign="sha1", 309 | ) 310 | in_message = self.build_and_send(partner) 311 | self.assertEqual(in_message.status, "E") 312 | 313 | # Check the status of the received message 314 | out_message = Message.objects.get( 315 | message_id=in_message.message_id, direction="IN" 316 | ) 317 | self.assertEqual(out_message.status, "E") 318 | self.assertTrue("Unknown AS2 organization" in out_message.detailed_status) 319 | 320 | def test_partner_missing_error(self): 321 | self.organization.as2_name = "as2partner2" 322 | self.organization.save() 323 | 324 | # Create the client partner and send the command 325 | partner = Partner.objects.create( 326 | name="AS2 Server", 327 | as2_name="as2server", 328 | target_url="http://localhost:8080/pyas2/as2receive", 329 | signature="sha1", 330 | signature_cert=self.server_crt, 331 | encryption="tripledes_192_cbc", 332 | encryption_cert=self.server_crt, 333 | mdn=True, 334 | mdn_mode="SYNC", 335 | mdn_sign="sha1", 336 | ) 337 | in_message = self.build_and_send(partner) 338 | self.assertEqual(in_message.status, "E") 339 | 340 | # Check the status of the received message 341 | out_message = Message.objects.get( 342 | message_id=in_message.message_id, direction="IN" 343 | ) 344 | self.assertEqual(out_message.status, "E") 345 | self.assertTrue("Unknown AS2 partner" in out_message.detailed_status) 346 | 347 | def test_insufficient_security_error(self): 348 | self.partner.encryption = "tripledes_192_cbc" 349 | self.partner.signature = "sha1" 350 | self.partner.save() 351 | 352 | # Create the client partner and send the command 353 | partner = Partner.objects.create( 354 | name="AS2 Server", 355 | as2_name="as2server", 356 | target_url="http://localhost:8080/pyas2/as2receive", 357 | signature_cert=self.server_crt, 358 | encryption="tripledes_192_cbc", 359 | encryption_cert=self.server_crt, 360 | mdn=True, 361 | mdn_mode="SYNC", 362 | mdn_sign="sha1", 363 | ) 364 | in_message = self.build_and_send(partner) 365 | self.assertEqual(in_message.status, "E") 366 | 367 | # Check the status of the received message 368 | out_message = Message.objects.get( 369 | message_id=in_message.message_id, direction="IN" 370 | ) 371 | self.assertEqual(out_message.status, "E") 372 | self.assertTrue("signed message not found" in out_message.detailed_status) 373 | 374 | # Create the client partner and send the command 375 | partner.encryption = "" 376 | partner.save() 377 | 378 | in_message = self.build_and_send(partner) 379 | self.assertEqual(in_message.status, "E") 380 | 381 | # Check the status of the received message 382 | out_message = Message.objects.get( 383 | message_id=in_message.message_id, direction="IN" 384 | ) 385 | self.assertEqual(out_message.status, "E") 386 | self.assertTrue("encrypted message not found" in out_message.detailed_status) 387 | 388 | def test_decompression_error(self): 389 | # Create the client partner and send the command 390 | partner = Partner.objects.create( 391 | name="AS2 Server", 392 | as2_name="as2server", 393 | target_url="http://localhost:8080/pyas2/as2receive", 394 | compress=True, 395 | signature_cert=self.server_crt, 396 | mdn=True, 397 | mdn_mode="SYNC", 398 | mdn_sign="sha1", 399 | ) 400 | in_message = self.build_and_send(partner, smudge=True) 401 | self.assertEqual(in_message.status, "E") 402 | 403 | # Check the status of the received message 404 | out_message = Message.objects.get( 405 | message_id=in_message.message_id, direction="IN" 406 | ) 407 | self.assertEqual(out_message.status, "E") 408 | self.assertTrue("Decompression failed" in out_message.detailed_status) 409 | 410 | def test_encryption_error(self): 411 | # Create the client partner and send the command 412 | partner = Partner.objects.create( 413 | name="AS2 Server", 414 | as2_name="as2server", 415 | target_url="http://localhost:8080/pyas2/as2receive", 416 | encryption="tripledes_192_cbc", 417 | encryption_cert=self.client_crt, 418 | signature_cert=self.server_crt, 419 | mdn=True, 420 | mdn_mode="SYNC", 421 | mdn_sign="sha1", 422 | ) 423 | in_message = self.build_and_send(partner, smudge=False) 424 | self.assertEqual(in_message.status, "E") 425 | 426 | # Check the status of the received message 427 | out_message = Message.objects.get( 428 | message_id=in_message.message_id, direction="IN" 429 | ) 430 | self.assertEqual(out_message.status, "E") 431 | self.assertTrue("Failed to decrypt" in out_message.detailed_status) 432 | 433 | def test_signature_error(self): 434 | # Create the client partner and send the command 435 | partner = Partner.objects.create( 436 | name="AS2 Server", 437 | as2_name="as2server", 438 | target_url="http://localhost:8080/pyas2/as2receive", 439 | signature="sha1", 440 | signature_cert=self.server_crt, 441 | mdn=True, 442 | mdn_mode="SYNC", 443 | mdn_sign="sha1", 444 | ) 445 | in_message = self.build_and_send(partner, smudge=True) 446 | self.assertEqual(in_message.status, "E") 447 | 448 | # Check the status of the received message 449 | out_message = Message.objects.get( 450 | message_id=in_message.message_id, direction="IN" 451 | ) 452 | self.assertEqual(out_message.status, "E") 453 | self.assertTrue( 454 | "Failed to verify message signature" in out_message.detailed_status 455 | ) 456 | 457 | def test_missing_message_id(self): 458 | # Create the client partner and send the command 459 | partner = Partner.objects.create( 460 | name="AS2 Server", 461 | as2_name="as2server", 462 | target_url="http://localhost:8080/pyas2/as2receive", 463 | signature="sha1", 464 | signature_cert=self.server_crt, 465 | encryption="tripledes_192_cbc", 466 | encryption_cert=self.server_crt, 467 | mdn=True, 468 | mdn_mode="ASYNC", 469 | mdn_sign="sha1", 470 | ) 471 | out_message = self.build_and_send(partner) 472 | 473 | # Create MDN object without message_id 474 | in_message = As2Mdn() 475 | in_message.orig_message_id = out_message.message_id 476 | in_message.message_id = None 477 | mdn_message = Mdn.objects.create_from_as2mdn(in_message, out_message, "R") 478 | 479 | # Check that original message id was used to store mdn_id 480 | self.assertEqual(mdn_message.mdn_id, out_message.message_id) 481 | 482 | @mock.patch("requests.post") 483 | def build_and_send(self, partner, mock_request, smudge=False): 484 | # Build and send the message to server 485 | as2message = As2Message( 486 | sender=self.organization.as2org, receiver=partner.as2partner 487 | ) 488 | as2message.build( 489 | self.payload, 490 | filename="testmessage.edi", 491 | subject=partner.subject, 492 | content_type=partner.content_type, 493 | ) 494 | out_message, _ = Message.objects.create_from_as2message( 495 | as2message=as2message, payload=self.payload, direction="OUT", status="P" 496 | ) 497 | mock_request.side_effect = SendMessageMock(self.client) 498 | out_message.send_message( 499 | as2message.headers, 500 | b"xxxx" + as2message.content if smudge else as2message.content, 501 | ) 502 | 503 | return out_message 504 | 505 | 506 | @override_settings(PYAS2={"DATA_DIR": TEST_DIR}) 507 | def test_setting_data_directory(): 508 | """Test that the data directory gets set correctly.""" 509 | assert settings.DATA_DIR is None 510 | importlib.reload(settings) 511 | assert settings.DATA_DIR is TEST_DIR 512 | -------------------------------------------------------------------------------- /pyas2/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import os 4 | import posixpath 5 | import traceback 6 | from email.parser import HeaderParser 7 | from uuid import uuid4 8 | 9 | import requests 10 | from django.core.files.base import ContentFile 11 | from django.core.files.storage import default_storage 12 | from django.db import models 13 | from django.utils import timezone 14 | from django.utils.translation import gettext as _ 15 | 16 | from pyas2lib import ( 17 | Mdn as As2Mdn, 18 | Message as As2Message, 19 | Organization as As2Organization, 20 | Partner as As2Partner, 21 | ) 22 | from pyas2lib.utils import extract_certificate_info 23 | 24 | from pyas2 import settings 25 | from pyas2.utils import run_post_send 26 | 27 | logger = logging.getLogger("pyas2") 28 | 29 | 30 | class PrivateKey(models.Model): 31 | """Model for storing an Organizations Private Key.""" 32 | 33 | name = models.CharField(max_length=255) 34 | key = models.BinaryField() 35 | key_pass = models.CharField(max_length=100, verbose_name="Private Key Password") 36 | valid_from = models.DateTimeField(null=True, blank=True) 37 | valid_to = models.DateTimeField(null=True, blank=True) 38 | serial_number = models.CharField(max_length=64, null=True, blank=True) 39 | 40 | def save(self, *args, **kwargs): 41 | cert_info = extract_certificate_info(self.key) 42 | self.valid_from = cert_info["valid_from"] 43 | self.valid_to = cert_info["valid_to"] 44 | if not cert_info["serial"] is None: 45 | self.serial_number = cert_info["serial"].__str__() 46 | super().save(*args, **kwargs) 47 | 48 | def __str__(self): 49 | return str(self.name) 50 | 51 | 52 | class PublicCertificate(models.Model): 53 | """Model for storing a Partners Public Certificate.""" 54 | 55 | name = models.CharField(max_length=255) 56 | certificate = models.BinaryField() 57 | certificate_ca = models.BinaryField( 58 | verbose_name=_("Local CA Store"), null=True, blank=True 59 | ) 60 | verify_cert = models.BooleanField( 61 | verbose_name=_("Verify Certificate"), 62 | default=True, 63 | help_text=_("Uncheck this option to disable certificate verification."), 64 | ) 65 | valid_from = models.DateTimeField(null=True, blank=True) 66 | valid_to = models.DateTimeField(null=True, blank=True) 67 | serial_number = models.CharField(max_length=64, null=True, blank=True) 68 | 69 | def save(self, *args, **kwargs): 70 | cert_info = extract_certificate_info(self.certificate) 71 | self.valid_from = cert_info["valid_from"] 72 | self.valid_to = cert_info["valid_to"] 73 | if not cert_info["serial"] is None: 74 | self.serial_number = cert_info["serial"].__str__() 75 | super().save(*args, **kwargs) 76 | 77 | def __str__(self): 78 | return str(self.name) 79 | 80 | 81 | class Organization(models.Model): 82 | """Model for storing an AS2 Organization.""" 83 | 84 | name = models.CharField(verbose_name=_("Organization Name"), max_length=100) 85 | as2_name = models.CharField( 86 | verbose_name=_("AS2 Identifier"), max_length=100, primary_key=True 87 | ) 88 | email_address = models.EmailField( 89 | null=True, 90 | blank=True, 91 | help_text=_( 92 | "This email will be used for the Disposition-Notification-To " 93 | "header. If left blank, header defaults to: no-reply@pyas2.com" 94 | ), 95 | ) 96 | encryption_key = models.ForeignKey( 97 | PrivateKey, null=True, blank=True, on_delete=models.SET_NULL 98 | ) 99 | signature_key = models.ForeignKey( 100 | PrivateKey, 101 | related_name="org_s", 102 | null=True, 103 | blank=True, 104 | on_delete=models.SET_NULL, 105 | ) 106 | confirmation_message = models.TextField( 107 | verbose_name=_("Confirmation Message"), 108 | null=True, 109 | blank=True, 110 | help_text=_( 111 | "Use this field to send a customized message in the " 112 | "MDN Confirmations for this Organization" 113 | ), 114 | ) 115 | 116 | @property 117 | def as2org(self): 118 | """Returns an object of pyas2lib's Organization class""" 119 | params = {"as2_name": self.as2_name, "mdn_url": settings.MDN_URL} 120 | if self.signature_key: 121 | params["sign_key"] = bytes(self.signature_key.key) 122 | params["sign_key_pass"] = self.signature_key.key_pass 123 | 124 | if self.encryption_key: 125 | params["decrypt_key"] = bytes(self.encryption_key.key) 126 | params["decrypt_key_pass"] = self.encryption_key.key_pass 127 | 128 | if self.confirmation_message: 129 | params["mdn_confirm_text"] = self.confirmation_message 130 | 131 | return As2Organization(**params) 132 | 133 | def __str__(self): 134 | return str(self.name) 135 | 136 | 137 | class Partner(models.Model): 138 | """Model for storing an AS2 Partner.""" 139 | 140 | CONTENT_TYPE_CHOICES = ( 141 | ("application/EDI-X12", "application/EDI-X12"), 142 | ("application/EDIFACT", "application/EDIFACT"), 143 | ("application/edi-consent", "application/edi-consent"), 144 | ("application/XML", "application/XML"), 145 | ("application/octet-stream", "binary"), 146 | ) 147 | ENCRYPT_ALG_CHOICES = ( 148 | ("tripledes_192_cbc", "3DES"), 149 | ("rc2_128_cbc", "RC2-128"), 150 | ("rc4_128_cbc", "RC4-128"), 151 | ("aes_128_cbc", "AES-128"), 152 | ("aes_192_cbc", "AES-192"), 153 | ("aes_256_cbc", "AES-256"), 154 | ) 155 | SIGN_ALG_CHOICES = ( 156 | ("sha1", "SHA-1"), 157 | ("sha224", "SHA-224"), 158 | ("sha256", "SHA-256"), 159 | ("sha384", "SHA-384"), 160 | ("sha512", "SHA-512"), 161 | ) 162 | MDN_TYPE_CHOICES = ( 163 | ("SYNC", "Synchronous"), 164 | ("ASYNC", "Asynchronous"), 165 | ) 166 | 167 | name = models.CharField(verbose_name=_("Partner Name"), max_length=100) 168 | as2_name = models.CharField( 169 | verbose_name=_("AS2 Identifier"), max_length=100, primary_key=True 170 | ) 171 | email_address = models.EmailField(null=True, blank=True) 172 | 173 | http_auth = models.BooleanField( 174 | verbose_name=_("Enable Authentication"), default=False 175 | ) 176 | http_auth_user = models.CharField(max_length=100, null=True, blank=True) 177 | http_auth_pass = models.CharField(max_length=100, null=True, blank=True) 178 | https_verify_ssl = models.BooleanField( 179 | verbose_name=_("Verify SSL Certificate"), 180 | default=True, 181 | help_text=_( 182 | "Uncheck this option to disable SSL certificate verification to HTTPS." 183 | ), 184 | ) 185 | 186 | target_url = models.URLField() 187 | subject = models.CharField( 188 | max_length=255, default=_("EDI Message sent using pyas2") 189 | ) 190 | content_type = models.CharField( 191 | max_length=100, choices=CONTENT_TYPE_CHOICES, default="application/edi-consent" 192 | ) 193 | 194 | compress = models.BooleanField(verbose_name=_("Compress Message"), default=False) 195 | encryption = models.CharField( 196 | max_length=20, 197 | verbose_name=_("Encrypt Message"), 198 | choices=ENCRYPT_ALG_CHOICES, 199 | null=True, 200 | blank=True, 201 | ) 202 | encryption_cert = models.ForeignKey( 203 | PublicCertificate, null=True, blank=True, on_delete=models.SET_NULL 204 | ) 205 | signature = models.CharField( 206 | max_length=20, 207 | verbose_name=_("Sign Message"), 208 | choices=SIGN_ALG_CHOICES, 209 | null=True, 210 | blank=True, 211 | ) 212 | signature_cert = models.ForeignKey( 213 | PublicCertificate, 214 | related_name="partner_s", 215 | null=True, 216 | blank=True, 217 | on_delete=models.SET_NULL, 218 | ) 219 | 220 | mdn = models.BooleanField(verbose_name=_("Request MDN"), default=False) 221 | mdn_mode = models.CharField( 222 | max_length=20, choices=MDN_TYPE_CHOICES, null=True, blank=True 223 | ) 224 | mdn_sign = models.CharField( 225 | max_length=20, 226 | verbose_name=_("Request Signed MDN"), 227 | choices=SIGN_ALG_CHOICES, 228 | null=True, 229 | blank=True, 230 | ) 231 | 232 | confirmation_message = models.TextField( 233 | verbose_name=_("Confirmation Message"), 234 | null=True, 235 | blank=True, 236 | help_text=_( 237 | "Use this field to send a customized message in the MDN " 238 | "Confirmations for this Partner" 239 | ), 240 | ) 241 | 242 | keep_filename = models.BooleanField( 243 | verbose_name=_("Keep Original Filename"), 244 | default=False, 245 | help_text=_( 246 | "Use Original Filename to to store file on receipt, use this option " 247 | "only if you are sure partner sends unique names" 248 | ), 249 | ) 250 | cmd_send = models.TextField( 251 | verbose_name=_("Command on Message Send"), 252 | null=True, 253 | blank=True, 254 | help_text=_( 255 | "Command executed after successful message send, replacements are " 256 | "$filename, $sender, $receiver, $messageid and any message header " 257 | "such as $Subject" 258 | ), 259 | ) 260 | cmd_receive = models.TextField( 261 | verbose_name=_("Command on Message Receipt"), 262 | null=True, 263 | blank=True, 264 | help_text=_( 265 | "Command executed after successful message receipt, replacements " 266 | "are $filename, $fullfilename, $sender, $receiver, $messageid and " 267 | "any message header such as $Subject" 268 | ), 269 | ) 270 | 271 | @property 272 | def as2partner(self): 273 | """Returns an object of pyas2lib's Partner class""" 274 | params = { 275 | "as2_name": self.as2_name, 276 | "compress": self.compress, 277 | "sign": bool(self.signature), 278 | "digest_alg": self.signature, 279 | "encrypt": bool(self.encryption), 280 | "enc_alg": self.encryption, 281 | "mdn_mode": self.mdn_mode, 282 | "mdn_digest_alg": self.mdn_sign, 283 | } 284 | 285 | if self.signature_cert: 286 | params["verify_cert"] = bytes(self.signature_cert.certificate) 287 | if self.signature_cert.certificate_ca: 288 | params["verify_cert_ca"] = bytes(self.signature_cert.certificate_ca) 289 | params["validate_certs"] = self.signature_cert.verify_cert 290 | 291 | if self.encryption_cert: 292 | params["encrypt_cert"] = bytes(self.encryption_cert.certificate) 293 | if self.encryption_cert.certificate_ca: 294 | params["encrypt_cert_ca"] = bytes(self.encryption_cert.certificate_ca) 295 | params["validate_certs"] = self.encryption_cert.verify_cert 296 | 297 | if self.confirmation_message: 298 | params["mdn_confirm_text"] = self.confirmation_message 299 | 300 | return As2Partner(**params) 301 | 302 | def __str__(self): 303 | return str(self.name) 304 | 305 | 306 | class MessageManager(models.Manager): 307 | """Custom model manager for the AS2 Message model.""" 308 | 309 | def create_from_as2message( 310 | self, 311 | as2message, 312 | payload, 313 | direction, 314 | status, 315 | filename=None, 316 | detailed_status=None, 317 | ): 318 | """Create the Message from the pyas2lib's Message object.""" 319 | 320 | if direction == "IN": 321 | organization = as2message.receiver.as2_name if as2message.receiver else None 322 | partner = as2message.sender.as2_name if as2message.sender else None 323 | else: 324 | partner = as2message.receiver.as2_name if as2message.receiver else None 325 | organization = as2message.sender.as2_name if as2message.sender else None 326 | 327 | message, _ = self.update_or_create( 328 | message_id=as2message.message_id, 329 | partner_id=partner, 330 | organization_id=organization, 331 | defaults=dict( 332 | direction=direction, 333 | status=status, 334 | compressed=as2message.compressed, 335 | encrypted=as2message.encrypted, 336 | signed=as2message.signed, 337 | detailed_status=detailed_status, 338 | ), 339 | ) 340 | 341 | # Save the headers and payload to store 342 | if not filename: 343 | filename = f"{uuid4()}.msg" 344 | message.headers.save( 345 | name=f"{filename}.header", content=ContentFile(as2message.headers_str) 346 | ) 347 | message.payload.save(name=filename, content=ContentFile(payload)) 348 | 349 | # Save the payload to the inbox folder 350 | full_filename = None 351 | if direction == "IN" and status == "S": 352 | if settings.DATA_DIR: 353 | dirname = os.path.join( 354 | settings.DATA_DIR, "messages", organization, "inbox", partner 355 | ) 356 | else: 357 | dirname = os.path.join("messages", organization, "inbox", partner) 358 | if not message.partner.keep_filename or not filename: 359 | filename = f"{message.message_id}.msg" 360 | full_filename = default_storage.generate_filename( 361 | posixpath.join(dirname, filename) 362 | ) 363 | default_storage.save(name=full_filename, content=ContentFile(payload)) 364 | 365 | return message, full_filename 366 | 367 | 368 | def get_message_store(instance, filename): 369 | """Return the path for storing the message payload.""" 370 | current_date = timezone.now().strftime("%Y%m%d") 371 | if instance.direction == "OUT": 372 | target_dir = os.path.join( 373 | "messages", "__store", "payload", "sent", current_date 374 | ) 375 | else: 376 | target_dir = os.path.join( 377 | "messages", "__store", "payload", "received", current_date 378 | ) 379 | return "{0}/{1}".format(target_dir, filename) 380 | 381 | 382 | class Message(models.Model): 383 | """Model for storing an AS2 Message between an Organization and a Partner.""" 384 | 385 | DIRECTION_CHOICES = ( 386 | ("IN", _("Inbound")), 387 | ("OUT", _("Outbound")), 388 | ) 389 | STATUS_CHOICES = ( 390 | ("S", _("Success")), 391 | ("E", _("Error")), 392 | ("W", _("Warning")), 393 | ("P", _("Pending")), 394 | ("R", _("Retry")), 395 | ) 396 | MODE_CHOICES = ( 397 | ("SYNC", _("Synchronous")), 398 | ("ASYNC", _("Asynchronous")), 399 | ) 400 | 401 | message_id = models.CharField(max_length=255) 402 | direction = models.CharField(max_length=5, choices=DIRECTION_CHOICES) 403 | timestamp = models.DateTimeField(auto_now_add=True) 404 | 405 | status = models.CharField(max_length=2, choices=STATUS_CHOICES) 406 | detailed_status = models.TextField(null=True) 407 | 408 | organization = models.ForeignKey(Organization, null=True, on_delete=models.SET_NULL) 409 | partner = models.ForeignKey(Partner, null=True, on_delete=models.SET_NULL) 410 | 411 | headers = models.FileField(upload_to=get_message_store, null=True, blank=True) 412 | payload = models.FileField( 413 | upload_to=get_message_store, null=True, blank=True, max_length=4096 414 | ) 415 | 416 | compressed = models.BooleanField(default=False) 417 | encrypted = models.BooleanField(default=False) 418 | signed = models.BooleanField(default=False) 419 | 420 | mdn_mode = models.CharField(max_length=5, choices=MODE_CHOICES, null=True) 421 | mic = models.CharField(max_length=100, null=True) 422 | 423 | retries = models.IntegerField(null=True) 424 | 425 | objects = MessageManager() 426 | 427 | class Meta: 428 | """Define additional options for the Message model.""" 429 | 430 | unique_together = ("message_id", "partner") 431 | 432 | @property 433 | def as2message(self): 434 | """Returns an object of pyas2lib's Message class""" 435 | if self.direction == "IN": 436 | as2m = As2Message( 437 | sender=self.partner.as2partner, receiver=self.organization.as2org 438 | ) 439 | else: 440 | as2m = As2Message( 441 | sender=self.organization.as2org, receiver=self.partner.as2partner 442 | ) 443 | 444 | as2m.message_id = self.message_id 445 | as2m.mic = self.mic 446 | 447 | return as2m 448 | 449 | @property 450 | def status_icon(self): 451 | """Return the icon for message status""" 452 | if self.status == "S": 453 | return "admin/img/icon-yes.svg" 454 | elif self.status == "E": 455 | return "admin/img/icon-no.svg" 456 | elif self.status in ["W", "P", "R"]: 457 | return "admin/img/icon-alert.svg" 458 | else: 459 | return "admin/img/icon-unknown.svg" 460 | 461 | def send_message(self, header, payload): 462 | """Send the message to the partner""" 463 | logger.info( 464 | f'Sending message {self.message_id} from organization "{self.organization}" ' 465 | f'to partner "{self.partner}".' 466 | ) 467 | 468 | # Set up the http auth if specified in the partner profile 469 | auth = None 470 | if self.partner.http_auth: 471 | auth = (self.partner.http_auth_user, self.partner.http_auth_pass) 472 | 473 | # Send the message to the partner 474 | try: 475 | response = requests.post( 476 | self.partner.target_url, 477 | auth=auth, 478 | headers=header, 479 | data=payload, 480 | verify=self.partner.https_verify_ssl, 481 | ) 482 | response.raise_for_status() 483 | except requests.exceptions.RequestException: 484 | self.status = "R" 485 | self.detailed_status = ( 486 | f"Failed to send message, error:\n{traceback.format_exc()}" 487 | ) 488 | self.save() 489 | return 490 | 491 | # Process the MDN based on the partner profile settings 492 | if self.partner.mdn: 493 | if self.partner.mdn_mode == "ASYNC": 494 | self.status = "P" 495 | else: 496 | # Process the synchronous MDN received as response 497 | 498 | # Get the response headers, convert key to lower case 499 | # for normalization 500 | mdn_headers = dict( 501 | (k.lower().replace("_", "-"), response.headers[k]) 502 | for k in response.headers 503 | ) 504 | 505 | # create the mdn content with message-id and content-type 506 | # header and response content 507 | mdn_content = ( 508 | f'message-id: {mdn_headers.get("message-id", self.message_id)}\n' 509 | ) 510 | mdn_content += f'content-type: {mdn_headers["content-type"]}\n\n' 511 | mdn_content = mdn_content.encode("utf-8") + response.content 512 | 513 | # Parse the as2 mdn received 514 | logger.debug( 515 | f"Received MDN response for message {self.message_id} " 516 | f"with content: {mdn_content}" 517 | ) 518 | as2mdn = As2Mdn() 519 | mdn_status, mdn_detailed_status = as2mdn.parse( 520 | mdn_content, lambda x, y: self.as2message 521 | ) 522 | 523 | # Update the message status and return the response 524 | if mdn_status == "processed": 525 | self.status = "S" 526 | run_post_send(self) 527 | else: 528 | self.status = "E" 529 | self.detailed_status = ( 530 | f"Partner failed to process message: {mdn_detailed_status}" 531 | ) 532 | if mdn_detailed_status != "mdn-not-found": 533 | Mdn.objects.create_from_as2mdn( 534 | as2mdn=as2mdn, message=self, status="R" 535 | ) 536 | else: 537 | # No MDN requested mark message as success and run command 538 | self.status = "S" 539 | run_post_send(self) 540 | 541 | self.save() 542 | 543 | def __str__(self): 544 | return str(self.message_id) 545 | 546 | 547 | class MdnManager(models.Manager): 548 | """Custom model manager for the AS2 MDN model.""" 549 | 550 | def create_from_as2mdn(self, as2mdn, message, status, return_url=None): 551 | """Create the MDN from the pyas2lib's MDN object""" 552 | signed = bool(as2mdn.digest_alg) 553 | 554 | # Check for message-id in MDN. 555 | if as2mdn.message_id is None: 556 | message_id = as2mdn.orig_message_id 557 | logger.warning( 558 | f"Received MDN response without a message-id. Using original " 559 | f"message-id as ID instead: {message_id}" 560 | ) 561 | else: 562 | message_id = as2mdn.message_id 563 | 564 | mdn, _ = self.update_or_create( 565 | message=message, 566 | defaults=dict( 567 | mdn_id=message_id, 568 | status=status, 569 | signed=signed, 570 | return_url=return_url, 571 | ), 572 | ) 573 | filename = f"{uuid4()}.mdn" 574 | mdn.headers.save( 575 | name=f"{filename}.header", content=ContentFile(as2mdn.headers_str) 576 | ) 577 | mdn.payload.save(filename, content=ContentFile(as2mdn.content)) 578 | return mdn 579 | 580 | 581 | def get_mdn_store(instance, filename): 582 | """Return the path for storing the MDN payload.""" 583 | current_date = timezone.now().strftime("%Y%m%d") 584 | if instance.status == "S": 585 | target_dir = os.path.join("messages", "__store", "mdn", "sent", current_date) 586 | else: 587 | target_dir = os.path.join( 588 | "messages", "__store", "mdn", "received", current_date 589 | ) 590 | 591 | return "{0}/{1}".format(target_dir, filename) 592 | 593 | 594 | class Mdn(models.Model): 595 | """Model for storing a MDN for an AS2 Message.""" 596 | 597 | STATUS_CHOICES = ( 598 | ("S", _("Sent")), 599 | ("R", _("Received")), 600 | ("P", _("Pending")), 601 | ) 602 | 603 | mdn_id = models.CharField(max_length=255) 604 | message = models.OneToOneField(Message, on_delete=models.CASCADE) 605 | timestamp = models.DateTimeField(auto_now_add=True) 606 | status = models.CharField(max_length=2, choices=STATUS_CHOICES) 607 | 608 | signed = models.BooleanField(default=False) 609 | return_url = models.URLField(null=True) 610 | 611 | headers = models.FileField(upload_to=get_mdn_store, null=True, blank=True) 612 | payload = models.FileField( 613 | upload_to=get_mdn_store, null=True, blank=True, max_length=4096 614 | ) 615 | 616 | objects = MdnManager() 617 | 618 | def __str__(self): 619 | return str(self.mdn_id) 620 | 621 | def send_async_mdn(self): 622 | """Send the asynchronous MDN to the partner""" 623 | 624 | # convert the mdn headers to dictionary 625 | headers = HeaderParser().parsestr(self.headers.read().decode()) 626 | 627 | # Send the mdn to the partner 628 | try: 629 | response = requests.post( 630 | self.return_url, headers=dict(headers.items()), data=self.payload.read() 631 | ) 632 | response.raise_for_status() 633 | except requests.exceptions.RequestException: 634 | return 635 | 636 | # Update the status of the MDN 637 | self.status = "S" 638 | self.save() 639 | --------------------------------------------------------------------------------