浏览代码

Merge and move branch 'master' of github.com:desec/desec-api

Peter Thomassen 8 年之前
父节点
当前提交
ca6c15b07d
共有 45 个文件被更改,包括 2009 次插入2 次删除
  1. 41 0
      api/.gitignore
  2. 25 0
      api/Dockerfile
  3. 0 0
      api/desecapi/__init__.py
  4. 93 0
      api/desecapi/authentication.py
  5. 134 0
      api/desecapi/helper/dyndns-bridge.php
  6. 2 0
      api/desecapi/helper/remote-addr.php
  7. 29 0
      api/desecapi/hooks/domain_post_create.sh
  8. 6 0
      api/desecapi/hooks/mock/domain_post_create.sh
  9. 46 0
      api/desecapi/migrations/0001_initial.py
  10. 34 0
      api/desecapi/migrations/0002_donation.py
  11. 26 0
      api/desecapi/migrations/0003_auto_20151008_1023.py
  12. 18 0
      api/desecapi/migrations/0004_remove_donation_rip.py
  13. 50 0
      api/desecapi/migrations/0005_auto_20151008_1042.py
  14. 33 0
      api/desecapi/migrations/0006_auto_20151018_1234.py
  15. 20 0
      api/desecapi/migrations/0007_domain_updated.py
  16. 0 0
      api/desecapi/migrations/__init__.py
  17. 235 0
      api/desecapi/models.py
  18. 11 0
      api/desecapi/permissions.py
  19. 17 0
      api/desecapi/serializers.py
  20. 131 0
      api/desecapi/settings.py
  21. 35 0
      api/desecapi/settings_local.py.dist
  22. 41 0
      api/desecapi/templates/emails/domain-dyndns/content.txt
  23. 1 0
      api/desecapi/templates/emails/domain-dyndns/subject.txt
  24. 47 0
      api/desecapi/templates/emails/donation/desec-attachment-jameica.txt
  25. 9 0
      api/desecapi/templates/emails/donation/desec-content.txt
  26. 1 0
      api/desecapi/templates/emails/donation/desec-subject.txt
  27. 19 0
      api/desecapi/templates/emails/donation/donor-content.txt
  28. 1 0
      api/desecapi/templates/emails/donation/donor-subject.txt
  29. 1 0
      api/desecapi/templates/emails/from.txt
  30. 0 0
      api/desecapi/templatetags/__init__.py
  31. 14 0
      api/desecapi/templatetags/sepa_extras.py
  32. 0 0
      api/desecapi/tests/__init__.py
  33. 181 0
      api/desecapi/tests/testdomains.py
  34. 50 0
      api/desecapi/tests/testdonations.py
  35. 118 0
      api/desecapi/tests/testdyndns12update.py
  36. 16 0
      api/desecapi/tests/testlogjamscanner.py
  37. 103 0
      api/desecapi/tests/utils.py
  38. 23 0
      api/desecapi/urls.py
  39. 340 0
      api/desecapi/views.py
  40. 16 0
      api/desecapi/wsgi.py
  41. 9 0
      api/entrypoint.sh
  42. 12 0
      api/manage.py
  43. 20 0
      api/requirements.txt
  44. 0 1
      desec-api
  45. 1 1
      docker-compose.yml

+ 41 - 0
api/.gitignore

@@ -0,0 +1,41 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+bin/
+build/
+develop-eggs/
+dist/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+
+# Local settings
+desecapi/settings_local.py
+
+# local database
+db.sqlite3

+ 25 - 0
api/Dockerfile

@@ -0,0 +1,25 @@
+FROM python:2.7
+
+RUN apt-get update && apt-get install -y \
+		gcc \
+		gettext \
+		mysql-client libmysqlclient-dev \
+		postgresql-client libpq-dev \
+		sqlite3 \
+	--no-install-recommends && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+RUN mkdir /usr/src/app
+WORKDIR /usr/src/app
+
+COPY requirements.txt /usr/src/app/
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . /usr/src/app
+RUN set -ex && \
+	rm -f desecapi/settings_local.py && \
+	ln -s /usr/src/app/desecapi/hooks/mock/domain_post_create.sh /usr/local/bin
+
+VOLUME /usr/src/app/desecapi/settings_local.py
+
+EXPOSE 8000
+CMD ["./entrypoint.sh"]

+ 0 - 0
api/desecapi/__init__.py


+ 93 - 0
api/desecapi/authentication.py

@@ -0,0 +1,93 @@
+from __future__ import unicode_literals
+import base64
+
+from rest_framework import exceptions, HTTP_HEADER_ENCODING
+from rest_framework.authtoken.models import Token
+from rest_framework.authentication import BaseAuthentication, get_authorization_header, authenticate
+
+
+class BasicTokenAuthentication(BaseAuthentication):
+    """
+    HTTP Basic authentication that uses username and token.
+
+    Clients should authenticate by passing the username and the token as a
+    password in the "Authorization" HTTP header, according to the HTTP
+    Basic Authentication Scheme
+
+        Authorization: Basic dXNlcm5hbWU6dG9rZW4=
+
+    For username "username" and password "token".
+    """
+
+    model = Token
+    """
+    A custom token model may be used, but must have the following properties.
+
+    * key -- The string identifying the token
+    * user -- The user to which the token belongs
+    """
+
+    def authenticate(self, request):
+        auth = get_authorization_header(request).split()
+
+        if not auth or auth[0].lower() != b'basic':
+            return None
+
+        if len(auth) == 1:
+            msg = 'Invalid basic auth token header. No credentials provided.'
+            raise exceptions.AuthenticationFailed(msg)
+        elif len(auth) > 2:
+            msg = 'Invalid basic auth token header. Basic authentication string should not contain spaces.'
+            raise exceptions.AuthenticationFailed(msg)
+
+        return self.authenticate_credentials(auth[1])
+
+    def authenticate_credentials(self, basic):
+        try:
+            user, key = base64.b64decode(basic).decode(HTTP_HEADER_ENCODING).split(':')
+            token = self.model.objects.get(key=key)
+        except self.model.DoesNotExist:
+            raise exceptions.AuthenticationFailed('Invalid basic auth token')
+
+        if not token.user.is_active:
+            raise exceptions.AuthenticationFailed('User inactive or deleted')
+
+        return token.user, token
+
+    def authenticate_header(self, request):
+        return 'Basic'
+
+
+class URLParamAuthentication(BaseAuthentication):
+    """
+    Authentication against username/password as provided in URL parameters.
+    """
+
+    model = Token
+
+    def authenticate(self, request):
+        """
+        Returns a `User` if a correct username and password have been supplied
+        using URL parameters.  Otherwise returns `None`.
+        """
+
+        if not 'username' in request.QUERY_PARAMS:
+            msg = 'No username URL parameter provided.'
+            raise exceptions.AuthenticationFailed(msg)
+        if not 'password' in request.QUERY_PARAMS:
+            msg = 'No password URL parameter provided.'
+            raise exceptions.AuthenticationFailed(msg)
+
+        return self.authenticate_credentials(request.QUERY_PARAMS['username'], request.QUERY_PARAMS['password'])
+
+    def authenticate_credentials(self, userid, key):
+
+        try:
+            token = self.model.objects.get(key=key)
+        except self.model.DoesNotExist:
+            raise exceptions.AuthenticationFailed('Invalid token')
+
+        if not token.user.is_active:
+            raise exceptions.AuthenticationFailed('User inactive or deleted')
+
+        return token.user, token

+ 134 - 0
api/desecapi/helper/dyndns-bridge.php

@@ -0,0 +1,134 @@
+<?php
+
+$auth = getAuthenticationDetails();
+
+if ($auth === false) {
+    header('WWW-Authenticate: Basic realm="dynDNS"');
+    header('HTTP/1.0 401 Unauthorized');
+    exit;
+}
+
+/**
+ * Determines the IPv4 address for dynDNS update.
+ */
+function getIpv4Addr() {
+    $ipv4_sources = [
+        @$_REQUEST['myip'],
+        @$_REQUEST['ip'],
+        @$_REQUEST['dnsto'],
+    ];
+
+    foreach($ipv4_sources as $ip) {
+      if ($ip) return $ip;
+    }
+
+    if (@$_SERVER['REMOTE_ADDR'] && strpos(@$_SERVER['REMOTE_ADDR'], '.') !== false) {
+        return $_SERVER['REMOTE_ADDR'];
+    }
+
+    return "";
+}
+
+/**
+ * Determines the IPv6 address for dynDNS update.
+ */
+function getIpv6Addr() {
+    $ipv6_sources = [
+        @$_REQUEST['myipv6'],
+        @$_REQUEST['ipv6'],
+    ];
+
+    foreach($ipv6_sources as $ip) {
+        if ($ip) return $ip;
+    }
+
+    if (@$_SERVER['REMOTE_ADDR'] && strpos(@$_SERVER['REMOTE_ADDR'], ':') !== false) {
+        return $_SERVER['REMOTE_ADDR'];
+    }
+
+    return "";
+}
+
+/**
+ * Determines the credentials used
+ */
+function getAuthenticationDetails() {
+    $headers = getallheaders();
+    
+    // Work around the fact that the following just produces NULL (on some machines):
+    // if(isset($headers["Authorization"])) $authorization = $headers["Authorization"];
+    foreach($headers as $key => $value) {
+        if($key == "Authorization") {
+            $authorization = $value;
+            break;
+        }
+    }
+    
+    if(isset($authorization)) {
+        $auth = explode(':', base64_decode(substr($authorization, strlen('Basic '))));
+        
+        if(isset($auth[1])) {
+            return $auth;
+        }
+    }
+    
+    if (isset($_REQUEST['username']) && isset($_REQUEST['password'])) {
+        $username = $_REQUEST['username'];
+        $password = $_REQUEST['password'];
+        
+        if(!isset($_SERVER['HTTPS']) || !strlen($_SERVER['HTTPS'])) {
+            $url = sprintf('https://%s/update?username=%s&password=%s', $_SERVER['HTTP_HOST'], urlencode($username), urlencode($password));
+            header("HTTP/1.1 301 Moved Permanently");
+            header("Location: $url");
+            exit;
+        }
+        
+        header('Strict-Transport-Security: max-age=31536000; includeSubDomains;');
+        
+        return [$username, $password];
+    }
+    
+    return false;
+}
+
+$now = new DateTime();
+
+$settings = [
+    'token' => $auth[1],
+    'domain' => $auth[0],
+    'a' => getIpv4Addr(),
+    'aaaa' => getIpv6Addr(),
+];
+
+$body = '{"arecord":"' . $settings['a'] . '","aaaarecord":"' . $settings['aaaa'] . '"}';
+
+$ch = curl_init();
+curl_setopt($ch, CURLOPT_URL, 'https://desec.io/api/domains/' . $settings['domain'] . '/');
+curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
+curl_setopt($ch, CURLOPT_HEADER, 1);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Token ' . $settings['token'], 'Content-Type: application/json', 'Content-Length: ' . strlen($body)]);
+curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+$data = curl_exec($ch);
+$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+curl_close($ch);
+
+$log  = '';
+$log .= $now->format('c') . "\n\n";
+$log .= print_r(json_decode(file_get_contents('php://input')), true) . "\n";
+$log .= print_r($_REQUEST, true) . "\n";
+$log .= print_r($auth, true) . "\n";
+$log .= print_r($settings, true) . "\n";
+$log .= print_r($data, true) . "\n";
+$log .= "\n\n\n";
+
+if ($status === 200) {
+    echo "<TITLE>success</TITLE>\n";
+    echo "return code: NOERROR\n";
+    echo "error code: NOERROR\n";
+    echo "Your hostname has been updated.\n";
+} else {
+    header('HTTP/1.1 ' . $status);
+    echo $data;
+}
+

+ 2 - 0
api/desecapi/helper/remote-addr.php

@@ -0,0 +1,2 @@
+<?php
+echo $_SERVER['REMOTE_ADDR'];

+ 29 - 0
api/desecapi/hooks/domain_post_create.sh

@@ -0,0 +1,29 @@
+#!/bin/bash
+echo -n "This is $0: "
+date
+
+if [ -z "$1" ]; then
+        exit 1
+fi
+
+ZONE=$1
+PARENT=${ZONE#*.}
+SALT=`head -c300 /dev/urandom | sha512sum | cut -b 1-16`
+
+filename=/tmp/`date -Ins`_$ZONE.log
+touch $filename
+chmod 640 $filename
+
+echo "signing $ZONE and updating serial"
+pdnsutil secure-zone $ZONE && pdnsutil set-nsec3 $ZONE "1 0 10 $SALT" && pdnsutil increase-serial $ZONE || exit 2
+
+echo "Setting DS records for $ZONE and put them in parent zone"
+DATA='{"rrsets": [ {"name": "'"$ZONE".'", "type": "DS", "ttl": 60, "changetype": "REPLACE", "records": '
+DATA+=`curl -sS -X GET -H "X-API-Key: $APITOKEN" http://127.0.0.1:8081/api/v1/servers/localhost/zones/$ZONE/cryptokeys \
+	| jq -c '[.[] | select(.active == true) | {content: .ds[]?, disabled: false}]'`
+DATA+=" } ] }"
+echo $DATA >> $filename
+curl -sSv -X PATCH --data "$DATA" -H "X-API-Key: $APITOKEN" http://127.0.0.1:8081/api/v1/servers/localhost/zones/$PARENT &>> $filename || exit 3
+
+echo -n "This was $0: "
+date

+ 6 - 0
api/desecapi/hooks/mock/domain_post_create.sh

@@ -0,0 +1,6 @@
+#!/bin/bash
+echo Mockup hook for: $0 "$@"
+
+# Send 1M zero bytes to stdout
+# (large output of this script caused problems earlier)
+dd if=/dev/zero bs=1M count=1 2>/dev/null

+ 46 - 0
api/desecapi/migrations/0001_initial.py

@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.utils.timezone
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='User',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('password', models.CharField(max_length=128, verbose_name='password')),
+                ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')),
+                ('email', models.EmailField(unique=True, max_length=255, verbose_name=b'email address')),
+                ('is_active', models.BooleanField(default=True)),
+                ('is_admin', models.BooleanField(default=False)),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='Domain',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('name', models.CharField(unique=True, max_length=255)),
+                ('arecord', models.CharField(max_length=255, blank=True)),
+                ('aaaarecord', models.CharField(max_length=1024, blank=True)),
+                ('dyn', models.BooleanField(default=False)),
+                ('owner', models.ForeignKey(related_name='domains', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ('created',),
+            },
+            bases=(models.Model,),
+        ),
+    ]

+ 34 - 0
api/desecapi/migrations/0002_donation.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Donation',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('name', models.CharField(unique=True, max_length=255)),
+                ('iban', models.CharField(max_length=34, blank=True)),
+                ('bic', models.CharField(max_length=11, blank=True)),
+                ('amount', models.DecimalField(max_digits=8, decimal_places=2)),
+                ('message', models.CharField(unique=True, max_length=255)),
+                ('due', models.DateTimeField()),
+                ('mref', models.CharField(max_length=11, blank=True)),
+                ('rip', models.CharField(max_length=39, blank=True)),
+                ('email', models.EmailField(max_length=255)),
+            ],
+            options={
+                'ordering': ('created',),
+            },
+            bases=(models.Model,),
+        ),
+    ]

+ 26 - 0
api/desecapi/migrations/0003_auto_20151008_1023.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0002_donation'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='donation',
+            name='created',
+            field=models.DateTimeField(),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='mref',
+            field=models.CharField(max_length=32, blank=True),
+            preserve_default=True,
+        ),
+    ]

+ 18 - 0
api/desecapi/migrations/0004_remove_donation_rip.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0003_auto_20151008_1023'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='donation',
+            name='rip',
+        ),
+    ]

+ 50 - 0
api/desecapi/migrations/0005_auto_20151008_1042.py

@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0004_remove_donation_rip'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='donation',
+            name='bic',
+            field=models.CharField(max_length=11),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='email',
+            field=models.EmailField(max_length=255, blank=True),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='iban',
+            field=models.CharField(max_length=34),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='message',
+            field=models.CharField(max_length=255, blank=True),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='mref',
+            field=models.CharField(max_length=32),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='name',
+            field=models.CharField(max_length=255),
+            preserve_default=True,
+        ),
+    ]

+ 33 - 0
api/desecapi/migrations/0006_auto_20151018_1234.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import desecapi.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0005_auto_20151008_1042'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='donation',
+            name='created',
+            field=models.DateTimeField(default=desecapi.models.get_default_value_created),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='due',
+            field=models.DateTimeField(default=desecapi.models.get_default_value_due),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='donation',
+            name='mref',
+            field=models.CharField(default=desecapi.models.get_default_value_mref, max_length=32),
+            preserve_default=True,
+        ),
+    ]

+ 20 - 0
api/desecapi/migrations/0007_domain_updated.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0006_auto_20151018_1234'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='domain',
+            name='updated',
+            field=models.DateTimeField(null=True),
+            preserve_default=True,
+        ),
+    ]

+ 0 - 0
api/desecapi/migrations/__init__.py


+ 235 - 0
api/desecapi/models.py

@@ -0,0 +1,235 @@
+from django.conf import settings
+from django.db import models
+from django.contrib.auth.models import (
+    BaseUserManager, AbstractBaseUser
+)
+from django.utils import timezone
+import requests
+import json
+import subprocess
+import os
+import datetime, time
+
+class MyUserManager(BaseUserManager):
+    def create_user(self, email, password=None):
+        """
+        Creates and saves a User with the given email, date of
+        birth and password.
+        """
+        if not email:
+            raise ValueError('Users must have an email address')
+
+        user = self.model(
+            email=self.normalize_email(email),
+        )
+
+        user.set_password(password)
+        user.save(using=self._db)
+        return user
+
+    def create_superuser(self, email, password):
+        """
+        Creates and saves a superuser with the given email, date of
+        birth and password.
+        """
+        user = self.create_user(email,
+                                password=password
+        )
+        user.is_admin = True
+        user.save(using=self._db)
+        return user
+
+
+class User(AbstractBaseUser):
+    email = models.EmailField(
+        verbose_name='email address',
+        max_length=255,
+        unique=True,
+    )
+    is_active = models.BooleanField(default=True)
+    is_admin = models.BooleanField(default=False)
+
+    objects = MyUserManager()
+
+    USERNAME_FIELD = 'email'
+    REQUIRED_FIELDS = []
+
+    def get_full_name(self):
+        return self.email
+
+    def get_short_name(self):
+        return self.email
+
+    def __str__(self):
+        return self.email
+
+    def has_perm(self, perm, obj=None):
+        "Does the user have a specific permission?"
+        # Simplest possible answer: Yes, always
+        return True
+
+    def has_module_perms(self, app_label):
+        "Does the user have permissions to view the app `app_label`?"
+        # Simplest possible answer: Yes, always
+        return True
+
+    @property
+    def is_staff(self):
+        "Is the user a member of staff?"
+        # Simplest possible answer: All admins are staff
+        return self.is_admin
+
+
+class Domain(models.Model):
+    created = models.DateTimeField(auto_now_add=True)
+    updated = models.DateTimeField(null=True)
+    name = models.CharField(max_length=255, unique=True)
+    arecord = models.CharField(max_length=255, blank=True)
+    aaaarecord = models.CharField(max_length=1024, blank=True)
+    dyn = models.BooleanField(default=False)
+    owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='domains')
+
+    headers = {
+        'User-Agent': 'desecapi',
+        'X-API-Key': settings.POWERDNS_API_TOKEN,
+    }
+
+    def save(self, *args, **kwargs):
+        if self.id is None:
+            self.pdnsCreate()
+            if self.arecord or self.aaaarecord:
+                self.pdnsUpdate()
+        else:
+            orig = Domain.objects.get(id=self.id)
+            if self.arecord != orig.arecord or self.aaaarecord != orig.aaaarecord:
+                self.pdnsUpdate()
+        self.updated = timezone.now()
+        super(Domain, self).save(*args, **kwargs) # Call the "real" save() method.
+
+    def pdnsCreate(self):
+        payload = {
+            "name": self.name + ".",
+            "kind": "master",
+            "masters": [],
+            "nameservers": [
+                "ns1.desec.io.",
+                "ns2.desec.io."
+            ]
+        }
+        r = requests.post(settings.POWERDNS_API + '/zones', data=json.dumps(payload), headers=self.headers)
+        if r.status_code < 200 or r.status_code >= 300:
+            raise Exception(r)
+
+        self.postCreateHook()
+
+    def pdnsUpdate(self):
+        if self.arecord:
+            a = \
+                {
+                    "records": [
+                            {
+                                "type": "A",
+                                "name": self.name + ".",
+                                "disabled": False,
+                                "content": self.arecord,
+                            }
+                        ],
+                    "ttl": 60,
+                    "changetype": "REPLACE",
+                    "type": "A",
+                    "name": self.name + ".",
+                }
+        else:
+            a = \
+                {
+                    "changetype": "DELETE",
+                    "type": "A",
+                    "name": self.name + "."
+                }
+
+        if self.aaaarecord:
+            aaaa = \
+                {
+                    "records": [
+                            {
+                                "type": "AAAA",
+                                "name": self.name + ".",
+                                "disabled": False,
+                                "content": self.aaaarecord,
+                            }
+                        ],
+                    "ttl": 60,
+                    "changetype": "REPLACE",
+                    "type": "AAAA",
+                    "name": self.name + ".",
+                }
+        else:
+            aaaa = \
+                {
+                    "changetype": "DELETE",
+                    "type": "AAAA",
+                    "name": self.name + "."
+                }
+
+        payload = { "rrsets": [a, aaaa] }
+        r = requests.patch(settings.POWERDNS_API + '/zones/' + self.name, data=json.dumps(payload), headers=self.headers)
+        if r.status_code < 200 or r.status_code >= 300:
+            raise Exception(r)
+
+    def hook(self, cmd):
+        if not self.name:
+            raise Exception
+
+        env = os.environ.copy()
+        env['APITOKEN'] = settings.POWERDNS_API_TOKEN
+
+        cmd = [cmd, self.name.lower()]
+        p_hook = subprocess.Popen(cmd,
+                                  stdin=subprocess.PIPE,
+                                  stdout=subprocess.PIPE,
+                                  stderr=subprocess.PIPE,
+                                  env=env)
+        stdout, stderr = p_hook.communicate()
+
+        if not p_hook.returncode == 0:
+            raise Exception((stdout, stderr))
+
+        return
+
+    def postCreateHook(self):
+        self.hook(cmd='domain_post_create.sh')
+
+    class Meta:
+        ordering = ('created',)
+
+
+def get_default_value_created():
+    return timezone.now()
+
+def get_default_value_due():
+    return timezone.now() + datetime.timedelta(days=7)
+
+def get_default_value_mref():
+    return "ONDON" + str((timezone.now() - timezone.datetime(1970,1,1,tzinfo=timezone.utc)).total_seconds())
+
+
+class Donation(models.Model):
+
+    created = models.DateTimeField(default=get_default_value_created)
+    name = models.CharField(max_length=255)
+    iban = models.CharField(max_length=34)
+    bic = models.CharField(max_length=11)
+    amount = models.DecimalField(max_digits=8,decimal_places=2)
+    message = models.CharField(max_length=255, blank=True)
+    due = models.DateTimeField(default=get_default_value_due)
+    mref = models.CharField(max_length=32,default=get_default_value_mref)
+    email = models.EmailField(max_length=255, blank=True)
+
+
+    def save(self, *args, **kwargs):
+        self.iban = self.iban[:6] + "xxx" # do NOT save account details
+        super(Donation, self).save(*args, **kwargs) # Call the "real" save() method.
+
+
+    class Meta:
+        ordering = ('created',)

+ 11 - 0
api/desecapi/permissions.py

@@ -0,0 +1,11 @@
+from rest_framework import permissions
+
+
+class IsOwner(permissions.BasePermission):
+    """
+    Custom permission to only allow owners of an object to view or edit it.
+    """
+
+    def has_object_permission(self, request, view, obj):
+        return obj.owner == request.user
+

+ 17 - 0
api/desecapi/serializers.py

@@ -0,0 +1,17 @@
+from rest_framework import serializers
+from models import Domain, Donation
+
+
+class DomainSerializer(serializers.ModelSerializer):
+    owner = serializers.Field(source='owner.email')
+    name = serializers.RegexField(regex=r'^[A-Za-z0-9\.\-]+$')
+
+    class Meta:
+        model = Domain
+        fields = ('id', 'name', 'owner', 'arecord', 'aaaarecord', 'dyn')
+
+class DonationSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = Donation
+        fields = ('name', 'iban', 'bic', 'amount', 'message', 'email')

+ 131 - 0
api/desecapi/settings.py

@@ -0,0 +1,131 @@
+"""
+Django settings for desecapi project.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.7/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.7/ref/settings/
+"""
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+import os
+
+BASE_DIR = os.path.dirname(os.path.dirname(__file__))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'lk*42u45uy9dg8l+o9$x56&%_@+85u+20#m82+f8r#w_*cj483'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = False
+
+TEMPLATE_DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = (
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'rest_framework',
+    'rest_framework.authtoken',
+    'djoser',
+    'desecapi',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+)
+
+ROOT_URLCONF = 'desecapi.urls'
+
+WSGI_APPLICATION = 'desecapi.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
+
+DATABASES = {
+    'default': {
+        # 'ENGINE': 'django.db.backends.sqlite3',
+        # 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+        'ENGINE': 'django.db.backends.mysql',
+        'NAME': '',
+        'USER': '',
+        'PASSWORD': '',
+        'HOST': '127.0.0.1',
+        'CHARSET': 'utf8',
+        'COLLATION': 'utf8_general_ci',
+        'TEST': {
+            'CHARSET': 'utf8',
+            'COLLATION': 'utf8_general_ci',
+        }
+    },
+
+}
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.7/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.7/howto/static-files/
+STATIC_URL = '/api/static/'
+
+REST_FRAMEWORK = {
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'rest_framework.authentication.TokenAuthentication',
+    ),
+}
+
+# user management configuration
+DJOSER = {
+    'DOMAIN': 'desec.io',
+    'SITE_NAME': 'deSEC',
+    'PASSWORD_RESET_CONFIRM_URL': '#/password/reset/confirm/{uid}/{token}',
+    'ACTIVATION_URL': '#/activate/{uid}/{token}',
+    'LOGIN_AFTER_ACTIVATION': True,
+    'SEND_ACTIVATION_EMAIL': False,
+}
+
+# How to send mail
+EMAIL_HOST = ''
+EMAIL_HOST_USER = ''
+EMAIL_HOST_PASSWORD = ''
+EMAIL_PORT = ''
+DEFAULT_FROM_EMAIL = 'deSEC.io <support@desec.io>'
+
+# use our own user model
+AUTH_USER_MODEL = 'desecapi.User'
+
+# Import local settings
+try:
+    from settings_local import *
+except ImportError, exp:
+    pass

+ 35 - 0
api/desecapi/settings_local.py.dist

@@ -0,0 +1,35 @@
+import os
+
+BASE_DIR = os.path.dirname(os.path.dirname(__file__))
+
+SECRET_KEY = ''
+EMAIL_HOST = ''
+EMAIL_HOST_USER = ''
+EMAIL_HOST_PASSWORD = ''
+EMAIL_PORT = ''
+DEBUG = True
+ALLOWED_HOSTS = []
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+        #'ENGINE': 'django.db.backends.mysql',
+        #'NAME': '',
+        #'USER': '',
+        #'PASSWORD': '',
+        #'HOST': '',
+        #'CHARSET': 'utf8',
+        #'COLLATION': 'utf8_general_ci',
+        #'TEST': {
+        #    'CHARSET': 'utf8',
+        #    'COLLATION': 'utf8_general_ci',
+        #}
+    }
+}
+POWERDNS_API = 'http://localhost:8081/servers/localhost' # no trailing slash, please!
+POWERDNS_API_TOKEN = 'MyAuthToken'
+ADMINS = (('Nils', 'nils@desec.io'), )
+
+SEPA = {
+    'CREDITOR_ID': '',
+}

+ 41 - 0
api/desecapi/templates/emails/domain-dyndns/content.txt

@@ -0,0 +1,41 @@
+Hi there,
+
+And welcome to the deSEC dynDNS service! I'm Nils, CTO of deSEC.
+If you have any questions or concerns, please do not hestitate
+to contact me.
+
+To get started using your new dynDNS domain {{ domain }},
+please configure your device (or any other dynDNS client) to use
+the following credentials:
+
+  url:      {{ url }}
+  username: {{ username }}
+  password: {{ password }}
+
+Alternatively, you can update your dynDNS IP record by visiting 
+this page:
+https://update.dedyn.io/update?username={{ username }}&password={{ password }}
+If your router does not support dynDNS, you might want to 
+bookmark this URL.
+
+You can use our website to see if everything works as expected:
+https://desec.io/#!/en/tools/dyndns-check?domain={{ domain }}
+
+We know there is always room for improvement, so please shoot me
+an email if we can do anything better.
+
+Thanks for using deSEC, we hope you do enjoy your DNSSEC-enabled
+dynDNS.
+
+Cheers,
+Nils
+
+--
+deSEC GbR
+Maybachufer 9
+12047 Berlin
+Germany
+
+phone: +49-30-47384344
+
+Vertreten durch: Jan Binger, Peter Thomassen, Nils Wisiol

+ 1 - 0
api/desecapi/templates/emails/domain-dyndns/subject.txt

@@ -0,0 +1 @@
+Welcome to deSEC, {{ domain }}!

+ 47 - 0
api/desecapi/templates/emails/donation/desec-attachment-jameica.txt

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<objects>
+  <object type="de.willuhn.jameica.hbci.server.SepaLastschriftImpl" id="2">
+    {# Ende-zu-Ende ID #}
+    <endtoendid type="java.lang.String"></endtoendid>
+
+    {# Unterschriftsdatum der Referenz #}
+    <sigdate type="java.sql.Date">{{ donation.created | date:"d.m.Y H:i:s" }}</sigdate>
+
+    {# mandatsreferenz #}
+    <mandateid type="java.lang.String">{{ donation.mref }}</mandateid>
+
+    {# Gläubiger-ID #}
+    <creditorid type="java.lang.String">DE84ZZZ00001034223</creditorid>
+
+    {% load sepa_extras %}
+    {# Name des Kontos, das belastet wird [sic!] #}
+    <empfaenger_name type="java.lang.String">{{ donation.name | clean }}</empfaenger_name>
+
+    {# IBAN des Kontos, das belastet wird [sic!] #}
+    <empfaenger_konto type="java.lang.String">{{ donation.iban | clean }}</empfaenger_konto>
+
+    {# Noch nicht ausgeführt #}
+    <ausgefuehrt type="java.lang.Integer">0</ausgefuehrt>
+
+    {# Basis-Lastschrift #}
+    <sepatype type="java.lang.String">CORE</sepatype>
+
+    {# Betrag #}
+    <betrag type="java.lang.Double">{{ donation.amount }}</betrag>
+
+    {# Einzugstermin #}
+    <targetdate type="java.sql.Date">{{ donation.due | date:"d.m.Y H:i:s" }}</targetdate>
+        
+    {# deSEC Spende #}
+    <zweck type="java.lang.String">deSEC Spende/Donation authorized at {{ donation.created | date:"Y-m-d H:i:s" }} UTC</zweck>
+
+    {# Einmal-Lastschrift #}
+    <sequencetype type="java.lang.String">OOFF</sequencetype>
+
+    {# Empfänger BIC #}
+    <empfaenger_bic type="java.lang.String">{{ donation.bic | clean }}</empfaenger_bic>
+
+    {# Jameica-Konto ID #}
+    <konto_id type="java.lang.Integer">1</konto_id>
+  </object>
+</objects>

+ 9 - 0
api/desecapi/templates/emails/donation/desec-content.txt

@@ -0,0 +1,9 @@
+Woha,
+
+{{ donation.name }} <{{ donation.email }}> hat {{ donation.amount }}€ gespendet.
+
+Nachricht:
+{{ donation.message | default:"(keine)" }}
+
+Schöne Grüße
+API-Server

+ 1 - 0
api/desecapi/templates/emails/donation/desec-subject.txt

@@ -0,0 +1 @@
+Spende: {{ donation.amount }}€

+ 19 - 0
api/desecapi/templates/emails/donation/donor-content.txt

@@ -0,0 +1,19 @@
+Dear supporter,
+
+We hereby confirm your donation to deSEC. We would like to THANK YOU for
+your support. If you have any questions concerning your donation, how
+we use your money, or if we can do anything else for you: Please do not
+hestitate to contact us at input@desec.io or reply to this email.
+
+We will debit {{ donation.amount }} € from your account within the next
+two weeks. Your mandate reference number is {{ donation.mref }}; our
+creditor identifier is {{ creditoridentifier }}.
+
+Please note that the payment is handled by "enit", which may be the name 
+appearing on your bank statement.
+
+Again, thank you so much.
+
+--
+Nils Wisiol
+deSEC

+ 1 - 0
api/desecapi/templates/emails/donation/donor-subject.txt

@@ -0,0 +1 @@
+Thank you for your deSEC donation! <3

+ 1 - 0
api/desecapi/templates/emails/from.txt

@@ -0,0 +1 @@
+nils@desec.io

+ 0 - 0
api/desecapi/templatetags/__init__.py


+ 14 - 0
api/desecapi/templatetags/sepa_extras.py

@@ -0,0 +1,14 @@
+from django import template
+import unicodedata
+import re
+
+register = template.Library()
+
+def clean(value):
+    """Replaces non-ascii characters with their closest ascii
+       representation and then removes everything but [A-Za-z0-9 ]"""
+    normalized = unicodedata.normalize('NFKD', value)
+    cleaned = re.sub(r'[^A-Za-z0-9 ]','',normalized)
+    return cleaned
+
+register.filter('clean', clean)

+ 0 - 0
api/desecapi/tests/__init__.py


+ 181 - 0
api/desecapi/tests/testdomains.py

@@ -0,0 +1,181 @@
+from django.core.urlresolvers import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from utils import utils
+from django.db import transaction
+from desecapi.models import Domain
+from django.core import mail
+import httpretty
+from django.conf import settings
+
+
+class UnauthenticatedDomainTests(APITestCase):
+    def testExpectUnauthorizedOnGet(self):
+        url = reverse('domain-list')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnPost(self):
+        url = reverse('domain-list')
+        response = self.client.post(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnPut(self):
+        url = reverse('domain-detail', args=(1,))
+        response = self.client.put(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnDelete(self):
+        url = reverse('domain-detail', args=(1,))
+        response = self.client.delete(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+
+class AuthenticatedDomainTests(APITestCase):
+    def setUp(self):
+        if not hasattr(self, 'owner'):
+            self.owner = utils.createUser()
+            self.ownedDomains = [utils.createDomain(self.owner), utils.createDomain(self.owner)]
+            self.otherDomains = [utils.createDomain(), utils.createDomain()]
+            self.token = utils.createToken(user=self.owner)
+            transaction.commit()
+            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+    def testExpectOnlyOwnedDomains(self):
+        url = reverse('domain-list')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 2)
+        self.assertEqual(response.data[0]['name'], self.ownedDomains[0].name)
+        self.assertEqual(response.data[1]['name'], self.ownedDomains[1].name)
+
+    def testCanDeleteOwnedDomain(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCantDeleteOtherDomains(self):
+        url = reverse('domain-detail', args=(self.otherDomains[1].pk,))
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanGetOwnedDomains(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['name'], self.ownedDomains[1].name)
+
+    def testCantGetOtherDomains(self):
+        url = reverse('domain-detail', args=(self.otherDomains[1].pk,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanPutOwnedDomain(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.get(url)
+        newname = utils.generateDomainname()
+        response.data['name'] = newname
+        response = self.client.put(url, response.data)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['name'], newname)
+
+    def testCantPutOtherDomains(self):
+        url = reverse('domain-detail', args=(self.otherDomains[1].pk,))
+        response = self.client.put(url, {})
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanPostDomains(self):
+        url = reverse('domain-list')
+        data = {'name': utils.generateDomainname()}
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(response.data['dyn'], False)
+
+    def testCanPostDynDomains(self):
+        url = reverse('domain-list')
+        data = {'name': utils.generateDomainname(), 'dyn': True}
+        response = self.client.post(url, data)
+        email = str(mail.outbox[0].message())
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertTrue(data['name'] in email)
+        self.assertTrue(self.token in email)
+        self.assertEqual(response.data['dyn'], True)
+
+    def testCanUpdateARecord(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.get(url)
+        response.data['arecord'] = '10.13.3.7'
+        response = self.client.put(url, response.data)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['arecord'], '10.13.3.7')
+
+    def testCanUpdateAAAARecord(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.get(url)
+        response.data['aaaarecord'] = 'fe80::a11:10ff:fee0:ff77'
+        response = self.client.put(url, response.data)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['aaaarecord'], 'fe80::a11:10ff:fee0:ff77')
+
+    def testPostingCausesPdnsAPICall(self):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.POST, settings.POWERDNS_API + '/zones')
+
+        url = reverse('domain-list')
+        data = {'name': utils.generateDomainname(), 'dyn': True}
+        response = self.client.post(url, data)
+
+        self.assertTrue(data['name'] in httpretty.last_request().body)
+        self.assertTrue('ns1.desec.io' in httpretty.last_request().body)
+
+    def testUpdateingCausesPdnsAPICall(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        response = self.client.get(url)
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + response.data['name'])
+
+        response.data['arecord'] = '10.13.3.7'
+        response = self.client.put(url, response.data)
+
+        self.assertTrue('10.13.3.7' in httpretty.last_request().body)
+
+    def testDomainDetailURL(self):
+        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
+        urlByName = reverse('domain-detail/byName', args=(self.ownedDomains[1].name,))
+
+        self.assertTrue(("/%d" % self.ownedDomains[1].pk) in url)
+        self.assertTrue("/" + self.ownedDomains[1].name in urlByName)
+
+    def testCantUseInvalidCharactersInDomainName(self):
+        outboxlen = len(mail.outbox)
+        invalidnames = [
+            'with space.dedyn.io',
+            'another space.de',
+            ' spaceatthebeginning.com',
+            'percentage%sign.com',
+            '%percentagesign.dedyn.io',
+            'slash/desec.io',
+            '/slashatthebeginning.dedyn.io',
+            '\\backslashatthebeginning.dedyn.io',
+            'backslash\\inthemiddle.at',
+            '@atsign.com',
+            'at@sign.com',
+        ]
+
+        url = reverse('domain-list')
+        for domainname in invalidnames:
+            data = {'name': domainname, 'dyn': True}
+            response = self.client.post(url, data)
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+            self.assertEqual(len(mail.outbox), outboxlen)

+ 50 - 0
api/desecapi/tests/testdonations.py

@@ -0,0 +1,50 @@
+# coding: utf-8
+from django.core.urlresolvers import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from utils import utils
+from django.db import transaction
+from desecapi.models import Domain
+from django.core import mail
+import httpretty
+from django.conf import settings
+
+
+class UnsuccessfulDonationTests(APITestCase):
+    def testExpectUnauthorizedOnGet(self):
+        url = reverse('donation')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def testExpectUnauthorizedOnPut(self):
+        url = reverse('donation')
+        response = self.client.put(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def testExpectUnauthorizedOnDelete(self):
+        url = reverse('donation')
+        response = self.client.delete(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+
+class SuccessfulDonationTests(APITestCase):
+    def testCanPostDonations(self):
+        url = reverse('donation')
+        data = \
+            {
+                'name': u'KÖmplißier你好ter Vornamö',
+                'iban': 'DE89370400440532013000',
+                'bic': 'BYLADEM1SWU',
+                'amount': 123.45,
+                'message': u'hi there, thank you. Also, some random special chars: ß, ä, é, µ, 我爱你',
+                'email': 'email@example.com',
+            }
+        response = self.client.post(url, data)
+        email_internal = str(mail.outbox[0].message())
+        direct_debit = str(mail.outbox[0].attachments[0][1])
+        email_external = str(mail.outbox[1].message())
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(len(mail.outbox), 2)
+        self.assertEqual(response.data['iban'], 'DE8937xxx')
+        self.assertTrue('KOmpliierter Vornamo' in direct_debit)
+        self.assertTrue(data['iban'] in email_internal)

+ 118 - 0
api/desecapi/tests/testdyndns12update.py

@@ -0,0 +1,118 @@
+from django.core.urlresolvers import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from utils import utils
+from django.db import transaction
+import base64
+
+
+class DynDNS12UpdateTest(APITestCase):
+    owner = None
+    token = None
+    username = None
+    password = None
+
+    def setUp(self):
+        self.owner = utils.createUser()
+        self.token = utils.createToken(user=self.owner)
+        transaction.commit()
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+        url = reverse('domain-list')
+        data = {'name': utils.generateDynDomainname(), 'dyn': True}
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['dyn'], True)
+
+        self.username = response.data['name']
+        self.password = self.token
+        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode(self.username + ':' + self.password))
+
+    def assertIP(self, ipv4=None, ipv6=None):
+        old_credentials = self.client._credentials['HTTP_AUTHORIZATION']
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.password)
+        url = reverse('domain-detail/byName', args=(self.username,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        if ipv4 is not None:
+            self.assertEqual(response.data['arecord'], ipv4)
+        if ipv6 is not None:
+            self.assertEqual(response.data['aaaarecord'], ipv6)
+        self.client.credentials(HTTP_AUTHORIZATION=old_credentials)
+
+    def testDynDNS1UpdateDDClientSuccess(self):
+        # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'action': 'edit',
+                                       'started': 1,
+                                       'hostname': 'YES',
+                                       'host_id': self.username,
+                                       'myip': '10.1.2.3'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='10.1.2.3')
+
+    def testDynDNS1UpdateDDClientIPv6Success(self):
+        # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'action': 'edit',
+                                       'started': 1,
+                                       'hostname': 'YES',
+                                       'host_id': self.username,
+                                       'myipv6': '::1337'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv6='::1337')
+
+    def testDynDNS2UpdateDDClientIPv4Success(self):
+        #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'system': 'dyndns',
+                                       'hostname': self.username,
+                                       'myip': '10.2.3.4'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='10.2.3.4')
+
+    def testDynDNS2UpdateDDClientIPv6Success(self):
+        #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'system': 'dyndns',
+                                       'hostname': self.username,
+                                       'myipv6': '::1338'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv6='::1338')
+
+    def testFritzBoxIPv6(self):
+        #/
+        url = reverse('dyndns12update')
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='127.0.0.1')
+
+    def testManualIPv6(self):
+        #/update?username=foobar.dedyn.io&password=secret
+        self.client.credentials(HTTP_AUTHORIZATION='')
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'username': self.username,
+                                       'password': self.token,
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='127.0.0.1')

+ 16 - 0
api/desecapi/tests/testlogjamscanner.py

@@ -0,0 +1,16 @@
+from django.core.urlresolvers import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from utils import utils
+from django.db import transaction
+from desecapi.models import Domain
+from django.core import mail
+import httpretty
+from django.conf import settings
+
+
+class LogjamScannerTest(APITestCase):
+    def testBasicSubprocess(self):
+        url = reverse('scan-logjam')
+        response = self.client.get(url, {'host':'google.com', 'port':'443', 'starttls':'none'}, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)

+ 103 - 0
api/desecapi/tests/utils.py

@@ -0,0 +1,103 @@
+import random
+import string
+
+from rest_framework.authtoken.models import Token
+from desecapi.models import Domain, User
+
+
+class utils(object):
+    @classmethod
+    def generateRandomString(cls, size=6, chars=string.ascii_lowercase + string.digits):
+        return ''.join(random.choice(chars) for _ in range(size))
+
+    @classmethod
+    def generateUsername(cls):
+        return cls.generateRandomString() + '@desec.io'
+
+    @classmethod
+    def generateDomainname(cls):
+        return random.choice(string.ascii_lowercase) + cls.generateRandomString() + '.de'
+
+    @classmethod
+    def generateDynDomainname(cls):
+        return random.choice(string.ascii_lowercase) + cls.generateRandomString() + '.dedyn.io'
+
+    """
+    Creates a new user and saves it to the database.
+    The user object is returned.
+    """
+
+    @classmethod
+    def createUser(cls, username=None):
+        if username is None:
+            username = cls.generateUsername()
+        user = User(email=username)
+        user.plainPassword = cls.generateRandomString(size=12)
+        user.set_password(user.plainPassword)
+        user.save()
+        return user
+
+    """
+    Creates a new domain and saves it to the database.
+    The domain object is returned.
+    """
+
+    @classmethod
+    def createDomain(cls, owner=None, port=80):
+        if owner is None:
+            owner = cls.createUser(username=None)
+        domain = Domain(name=cls.generateDomainname(), owner=owner)
+        domain.save()
+        return domain
+
+    @classmethod
+    def createToken(cls, user):
+        token = Token.objects.create(user=user)
+        token.save();
+        return token.key;
+
+    """
+    Returns a certificate for (www.)desec.io, signed by startssl.com,
+    valid until 2015-11-15, serial number 0x1454C4 = 1332420 (base 10).
+    SHA1 fingerprint is 8D:2E:F1:35:05:08:78:D3:FD:09:30:8A:A4:9C:D6:90:3E:04:8F:56
+    SHA256 fingerprint is 8E:F3:F2:83:36:1C:F8:EC:8D:ED:4E:B8:05:82:4F:06:7D:47:86:05:B2:79:97:AB:FE:A7:64:60:4C:62:9D:6D
+    """
+    @classmethod
+    def getDeSecCertificate(self):
+        cert = ('-----BEGIN CERTIFICATE-----\n'
+                'MIIGLzCCBRegAwIBAgIDFFTEMA0GCSqGSIb3DQEBCwUAMIGMMQswCQYDVQQGEwJJ\n'
+                'TDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0\n'
+                'YWwgQ2VydGlmaWNhdGUgU2lnbmluZzE4MDYGA1UEAxMvU3RhcnRDb20gQ2xhc3Mg\n'
+                'MSBQcmltYXJ5IEludGVybWVkaWF0ZSBTZXJ2ZXIgQ0EwHhcNMTQxMTEzMjAzNDI1\n'
+                'WhcNMTUxMTE1MDUwMzU2WjBIMQswCQYDVQQGEwJVUzEVMBMGA1UEAxMMd3d3LmRl\n'
+                'c2VjLmlvMSIwIAYJKoZIhvcNAQkBFhNwb3N0bWFzdGVyQGRlc2VjLmlvMIIBIjAN\n'
+                'BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+h4uKeIMvr0jpGc8DP55q3b2vWa\n'
+                'wNFeneQgVyeO+b4MDRduOrlOrsid7da/qJxxTbbyI94npjWvu5GpayIK3xC3qpUm\n'
+                'uVSo2CmMlqpWo62cURZe9NK8eXUmEjbStOtgFIZDOADHxe0RgEr+i7AWLQvIPgHi\n'
+                '8P1N2zd5ujBfrBMd8sXsATXeBc4Ft4wMNOLotpL9uUxnMJiMUHFU+TeYcl2g+n0S\n'
+                'DfNCVq6e0Bs5uIbrPr+RJMkHMVDBaEwC6X83bIRARTh+YwhI1ARThyR7/vBnx/9a\n'
+                '/YD4B2SxomBDAx7iRF6XZ8QjHhl8Xo5bkPAa22BcRIukh4ByAaO0a9lMewIDAQAB\n'
+                'o4IC2zCCAtcwCQYDVR0TBAIwADALBgNVHQ8EBAMCA6gwEwYDVR0lBAwwCgYIKwYB\n'
+                'BQUHAwEwHQYDVR0OBBYEFBV7yHCr6j2KD+E4LIOcBMYuFMx1MB8GA1UdIwQYMBaA\n'
+                'FOtCNNCYsKuf9BtrCPfMZC7vDixFMCEGA1UdEQQaMBiCDHd3dy5kZXNlYy5pb4II\n'
+                'ZGVzZWMuaW8wggFWBgNVHSAEggFNMIIBSTAIBgZngQwBAgEwggE7BgsrBgEEAYG1\n'
+                'NwECAzCCASowLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cuc3RhcnRzc2wuY29tL3Bv\n'
+                'bGljeS5wZGYwgfcGCCsGAQUFBwICMIHqMCcWIFN0YXJ0Q29tIENlcnRpZmljYXRp\n'
+                'b24gQXV0aG9yaXR5MAMCAQEagb5UaGlzIGNlcnRpZmljYXRlIHdhcyBpc3N1ZWQg\n'
+                'YWNjb3JkaW5nIHRvIHRoZSBDbGFzcyAxIFZhbGlkYXRpb24gcmVxdWlyZW1lbnRz\n'
+                'IG9mIHRoZSBTdGFydENvbSBDQSBwb2xpY3ksIHJlbGlhbmNlIG9ubHkgZm9yIHRo\n'
+                'ZSBpbnRlbmRlZCBwdXJwb3NlIGluIGNvbXBsaWFuY2Ugb2YgdGhlIHJlbHlpbmcg\n'
+                'cGFydHkgb2JsaWdhdGlvbnMuMDUGA1UdHwQuMCwwKqAooCaGJGh0dHA6Ly9jcmwu\n'
+                'c3RhcnRzc2wuY29tL2NydDEtY3JsLmNybDCBjgYIKwYBBQUHAQEEgYEwfzA5Bggr\n'
+                'BgEFBQcwAYYtaHR0cDovL29jc3Auc3RhcnRzc2wuY29tL3N1Yi9jbGFzczEvc2Vy\n'
+                'dmVyL2NhMEIGCCsGAQUFBzAChjZodHRwOi8vYWlhLnN0YXJ0c3NsLmNvbS9jZXJ0\n'
+                'cy9zdWIuY2xhc3MxLnNlcnZlci5jYS5jcnQwIwYDVR0SBBwwGoYYaHR0cDovL3d3\n'
+                'dy5zdGFydHNzbC5jb20vMA0GCSqGSIb3DQEBCwUAA4IBAQBSI82kiD0St0MnhQok\n'
+                'NOTvYrF7kyMVEaVoJC08VocwBejaDVRUhazv1YBYy7WwdoQ+oYYZB37Vaa83xF3B\n'
+                'aY59NR4UN8cPFjevt/Z9DDuslN1pWaBu/W+W2qn2t3suRuT+l4n+zEo9SwIBhn0x\n'
+                'TRTDoj+kfvx+1CYIcagRMvB5TBUWs61OtFaYCp410axzZBo97P9DMsRqw0maFYGv\n'
+                's93Bi+fJGHndo+E4Qei3MRadDZKjQnvErsmrFzlVSqHcPwWtUqSCVF5BXP9YsRZn\n'
+                'hvehPEY+gPmclXFMi1FY3Z1gdhN4B1DjXfhlmKxC3GrM7CoKFjOutWWwZOIZGKdL\n'
+                'g7Vp\n'
+                '-----END CERTIFICATE-----\n')
+        return cert.__str__()

+ 23 - 0
api/desecapi/urls.py

@@ -0,0 +1,23 @@
+from django.conf.urls import patterns, include, url
+from django.contrib import admin
+from views import *
+from rest_framework.urlpatterns import format_suffix_patterns
+
+apiurls = [
+    url(r'^$', Root.as_view(), name='root'),
+    url(r'^domains/$', DomainList.as_view(), name='domain-list'),
+    url(r'^domains/(?P<pk>[0-9]+)/$', DomainDetail.as_view(), name='domain-detail'),
+    url(r'^domains/(?P<name>[a-zA-Z\.\-0-9]+)/$', DomainDetailByName.as_view(), name='domain-detail/byName'),
+    url(r'^dns$', DnsQuery.as_view(), name='dns-query'),
+    url(r'^scan/logjam$', ScanLogjam.as_view(), name='scan-logjam'),
+    url(r'^dyndns/update$', DynDNS12Update.as_view(), name='dyndns12update'),
+    url(r'^donation/', DonationList.as_view(), name='donation'),
+]
+
+apiurls = format_suffix_patterns(apiurls)
+
+urlpatterns = patterns('',
+                       url(r'^admin/', include(admin.site.urls)),
+                       url(r'^api/auth/', include('djoser.urls')),
+                       url(r'^api/', include(apiurls)),
+)

+ 340 - 0
api/desecapi/views.py

@@ -0,0 +1,340 @@
+from __future__ import unicode_literals
+from django.core.mail import EmailMessage
+from models import Domain
+from serializers import DomainSerializer, DonationSerializer
+from rest_framework import generics
+from permissions import IsOwner
+from rest_framework import permissions
+from django.http import Http404
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.reverse import reverse
+from rest_framework.authentication import TokenAuthentication, get_authorization_header
+from rest_framework.renderers import StaticHTMLRenderer
+from dns import resolver
+import subprocess
+import re
+from django.template.loader import get_template
+from django.template import Context
+from authentication import BasicTokenAuthentication, URLParamAuthentication
+import base64
+from desecapi import settings
+
+class DomainList(generics.ListCreateAPIView):
+    serializer_class = DomainSerializer
+    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+
+    def get_queryset(self):
+        return Domain.objects.filter(owner=self.request.user.pk)
+
+    def pre_save(self, obj):
+        obj.owner = self.request.user
+
+    def post_save(self, obj, created=False):
+        def sendDynDnsEmail(domain):
+            content_tmpl = get_template('emails/domain-dyndns/content.txt')
+            subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
+            from_tmpl = get_template('emails/from.txt')
+            context = Context({
+                'domain': domain.name,
+                'url': 'https://update.dedyn.io/',
+                'username': domain.name,
+                'password': self.request.auth.key
+            })
+            email = EmailMessage(subject_tmpl.render(context),
+                                 content_tmpl.render(context),
+                                 from_tmpl.render(context),
+                                 [self.request.user.email])
+            email.send()
+
+        if created and obj.dyn:
+            sendDynDnsEmail(obj)
+
+
+class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
+    serializer_class = DomainSerializer
+    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+
+    def get_queryset(self):
+        return Domain.objects.filter(owner=self.request.user.pk)
+
+    def pre_save(self, obj):
+        # Set the owner of this domain to the current user (important for new domains)
+        obj.owner = self.request.user
+
+    def put(self, request, pk, format=None):
+        # Don't accept PUT requests for non-existent or non-owned domains.
+        domain = Domain.objects.filter(owner=self.request.user.pk, pk=pk)
+        if len(domain) is 0:
+            raise Http404
+        return super(DomainDetail, self).put(request, pk, format)
+
+
+class DomainDetailByName(DomainDetail):
+    lookup_field = 'name'
+
+
+class Root(APIView):
+    def get(self, request, format=None):
+        if self.request.user and self.request.user.is_authenticated():
+            return Response({
+                'domains': reverse('domain-list'),
+                'user': reverse('user'),
+                'logout:': reverse('logout'),
+            })
+        else:
+            return Response({
+                'login': reverse('login'),
+                'register': reverse('register'),
+            })
+
+class DnsQuery(APIView):
+    def get(self, request, format=None):
+        desecio = resolver.Resolver()
+
+        if not 'domain' in request.GET:
+            return Response(status=400)
+
+        domain = str(request.GET['domain'])
+
+        def getRecords(domain, type):
+            records = []
+            try:
+                for ip in desecio.query(domain, type):
+                    records.append(str(ip))
+            except resolver.NoAnswer:
+                return []
+            except resolver.NoNameservers:
+                return []
+            except resolver.NXDOMAIN:
+                return []
+            return records
+
+        # find currently active NS records
+        nsrecords = getRecords(domain, 'NS')
+
+        # find desec.io nameserver IP address with standard nameserver
+        ips = desecio.query('ns2.desec.io')
+        desecio.nameservers = []
+        for ip in ips:
+            desecio.nameservers.append(str(ip))
+
+        # query desec.io nameserver for A and AAAA records
+        arecords = getRecords(domain, 'A')
+        aaaarecords = getRecords(domain, 'AAAA')
+
+        return Response({
+            'domain': domain,
+            'ns': nsrecords,
+            'a': arecords,
+            'aaaa': aaaarecords,
+            '_nameserver': desecio.nameservers
+        })
+
+class ScanLogjam(APIView):
+    def get(self, request, format=None):
+        # retrieve address to connect to
+        addr = str(request.GET['host']) + ':' + str(int(request.GET['port']))
+        starttls = str(request.GET['starttls'])
+
+        def getOpenSSLOutput(cipher, connect, starttls=None, openssl='openssl-1.0.2a'):
+            if starttls not in ['smtp', 'pop3', 'imap', 'ftp', 'xmpp']:
+                starttls = None
+
+            if starttls:
+                starttlsparams = ['-starttls', starttls]
+            else:
+                starttlsparams = []
+
+            if cipher:
+                cipherparams = ['-cipher', cipher]
+            else:
+                cipherparams = []
+
+            cmd = [
+                      openssl,
+                      's_client',
+                      '-connect',
+                      connect
+                  ] + starttlsparams + cipherparams
+            p_openssl = subprocess.Popen(cmd,
+                                         stdin=subprocess.PIPE,
+                                         stdout=subprocess.PIPE,
+                                         stderr=subprocess.PIPE)
+            stdout, stderr = p_openssl.communicate()
+
+            return (stdout, stderr)
+
+        # check if there is an SSL-enabled host
+        output = getOpenSSLOutput(None, addr, openssl='openssl')
+        if (not re.search('SSL-Session:', output[0])):
+            raise Http404('Can\'t connect via SSL/TLS')
+
+        # find DH size
+        dhsize = None
+        output = getOpenSSLOutput('EDH', addr, starttls)
+        res = re.search('Server Temp Key: DH, ([0-9]+) bits', output[0])
+        if res:
+            dhsize = int(res.group(1))
+        else:
+            if (re.search('handshake failure:', output[1])):
+                # server does not accept EDH connections, or no connections at all
+                pass
+            else:
+                raise Http404('Failed to determine DH key size.')
+
+        # check EXP cipher suits
+        exp = True
+        output = getOpenSSLOutput('EXP', addr, starttls)
+        res = re.search('handshake failure:', output[1])
+        if res:
+            exp = False
+        else:
+            if (re.search('SSL-Session:', output[0])):
+                # connection was established
+                exp = True
+            else:
+                raise Exception('Failed to check for EXP cipher suits.')
+
+        return Response({
+            'openssl': {
+                'addr': addr,
+                'logjam': {
+                    'dhsize': dhsize,
+                    'expcipher': exp
+                },
+                'version': 'openssl-1.0.2a',
+            }
+        })
+
+
+class DynDNS12Update(APIView):
+    authentication_classes = (TokenAuthentication, BasicTokenAuthentication, URLParamAuthentication,)
+    renderer_classes = [StaticHTMLRenderer]
+
+    def findDomain(self, request):
+
+        def findDomainname(request):
+            # 1. hostname parameter
+            if 'hostname' in request.QUERY_PARAMS and request.QUERY_PARAMS['hostname'] != 'YES':
+                return request.QUERY_PARAMS['hostname']
+
+            # 2. host_id parameter
+            if 'host_id' in request.QUERY_PARAMS:
+                return request.QUERY_PARAMS['host_id']
+
+            # 3. http basic auth username
+            try:
+                return base64.b64decode(get_authorization_header(request).split(' ')[1]).split(':')[0]
+            except:
+                pass
+
+            # 4. username parameter
+            if 'username' in request.QUERY_PARAMS:
+                return request.QUERY_PARAMS['username']
+
+            # 5. only domain associated with this user account
+            if len(request.user.domains.all()) == 1:
+                return request.user.domains[0].name
+
+            return None
+
+        domainname = findDomainname(request)
+        domain = None
+
+        # load and check permissions
+        try:
+            domain = Domain.objects.filter(owner=self.request.user.pk, name=domainname).all()[0]
+        except:
+            pass
+
+        return domain
+
+    def findIP(self, request, params, version=4):
+        if version == 4:
+            lookfor = '.'
+        elif version == 6:
+            lookfor = ':'
+        else:
+            raise Exception
+
+        # Check URL parameters
+        for p in params:
+            if p in request.QUERY_PARAMS and lookfor in request.QUERY_PARAMS[p]:
+                return request.QUERY_PARAMS[p]
+
+        # Check remote IP address
+        client_ip = self.get_client_ip(request)
+        if lookfor in client_ip:
+            return client_ip
+
+        # give up
+        return ''
+
+    def get_client_ip(self, request):
+        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+        if x_forwarded_for:
+            ip = x_forwarded_for.split(',')[0]
+        else:
+            ip = request.META.get('REMOTE_ADDR')
+        return ip
+
+    def findIPv4(self, request):
+        return self.findIP(request, ['myip', 'myipv4', 'ip'])
+
+    def findIPv6(self, request):
+        return self.findIP(request, ['myipv6', 'ipv6', 'myip', 'ip'], version=6)
+
+    def get(self, request, format=None):
+        domain = self.findDomain(request)
+
+        if domain is None:
+            raise Http404
+
+        domain.arecord = self.findIPv4(request)
+        domain.aaaarecord = self.findIPv6(request)
+        domain.save()
+
+        return Response('good')
+
+class DonationList(generics.CreateAPIView):
+    serializer_class = DonationSerializer
+
+    def pre_save(self, obj):
+        def sendDonationEmails(donation):
+            context = Context({
+                'donation': donation,
+                'creditoridentifier': settings.SEPA['CREDITOR_ID'],
+            })
+
+            # internal desec notification
+            content_tmpl = get_template('emails/donation/desec-content.txt')
+            subject_tmpl = get_template('emails/donation/desec-subject.txt')
+            attachment_tmpl = get_template('emails/donation/desec-attachment-jameica.txt')
+            from_tmpl = get_template('emails/from.txt')
+            email = EmailMessage(subject_tmpl.render(context),
+                                 content_tmpl.render(context),
+                                 from_tmpl.render(context),
+                                 ['donation@desec.io'],
+                                 attachments=[
+                                     ('jameica-directdebit.xml',
+                                      attachment_tmpl.render(context),
+                                      'text/xml')
+                                 ])
+            email.send()
+
+            # donor notification
+            if donation.email:
+                content_tmpl = get_template('emails/donation/donor-content.txt')
+                subject_tmpl = get_template('emails/donation/donor-subject.txt')
+                test = content_tmpl.render(context)
+                email = EmailMessage(subject_tmpl.render(context),
+                                     content_tmpl.render(context),
+                                     from_tmpl.render(context),
+                                     [donation.email])
+                email.send()
+
+
+        # send emails
+        sendDonationEmails(obj)
+

+ 16 - 0
api/desecapi/wsgi.py

@@ -0,0 +1,16 @@
+"""
+WSGI config for desecapi project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/
+"""
+
+import os
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "desecapi.settings")
+
+from django.core.wsgi import get_wsgi_application
+
+application = get_wsgi_application()

+ 9 - 0
api/entrypoint.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+host=db; port=3306; n=120; i=0; while ! (echo > /dev/tcp/$host/$port) 2> /dev/null; do [[ $i -eq $n ]] && >&2 echo "$host:$port not up after $n seconds, exiting" && exit 1; echo "waiting for $host:$port to come up"; sleep 1; i=$((i+1)); done
+host=ns; port=8081; n=120; i=0; while ! (echo > /dev/tcp/$host/$port) 2> /dev/null; do [[ $i -eq $n ]] && >&2 echo "$host:$port not up after $n seconds, exiting" && exit 1; echo "waiting for $host:$port to come up"; sleep 1; i=$((i+1)); done
+
+python manage.py migrate
+
+echo Finished migrations, starting API server ...
+python manage.py runserver 0.0.0.0:8000

+ 12 - 0
api/manage.py

@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+import sys
+
+import os
+
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "desecapi.settings")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)

+ 20 - 0
api/requirements.txt

@@ -0,0 +1,20 @@
+Django==1.7.11
+MySQL-python==1.2.5
+Pygments==2.0.1
+argparse==1.2.1
+cffi>=1.0.2
+coverage==3.7.1
+cryptography>=0.9
+djangorestframework==2.4.4
+djoser==0.1.0
+dnspython==1.12.0
+enum34==1.0.4
+httpretty==0.8.10
+idna==2.0
+ipaddress==1.0.7
+pyOpenSSL==0.15.1
+pyasn1==0.1.7
+pycparser==2.13
+requests==2.7.0
+six==1.9.0
+wsgiref==0.1.2

+ 0 - 1
desec-api

@@ -1 +0,0 @@
-../desec-api/desec-api

+ 1 - 1
docker-compose.yml

@@ -19,7 +19,7 @@ db:
    - MYSQL_ROOT_PASSWORD=test123
    - MYSQL_ROOT_PASSWORD=test123
 
 
 api:
 api:
-  build: desec-api
+  build: api
   links:
   links:
    - db
    - db
    - ns
    - ns