views.py 8.86 KB
Newer Older
CAMPION Sebastien's avatar
CAMPION Sebastien committed
1 2 3 4 5
import base64
import logging

import config.env
from django.http import JsonResponse, HttpResponse
6
import django.utils
7
from django.views.decorators.csrf import csrf_exempt
BAIRE Anthony's avatar
BAIRE Anthony committed
8
from main.models import User, Runner, Token, Webapp, WebappVersion
9
from main.helpers import is_allowed_ip_admin
CAMPION Sebastien's avatar
CAMPION Sebastien committed
10

BAIRE Anthony's avatar
BAIRE Anthony committed
11
from .tokens import JwtToken
CAMPION Sebastien's avatar
CAMPION Sebastien committed
12 13 14

log = logging.getLogger('jwt')

BAIRE Anthony's avatar
BAIRE Anthony committed
15 16 17
# tokens below this size will automatically be rejected (to prevent any
# misconfiguration)
MIN_TOKEN_SIZE = 32
CAMPION Sebastien's avatar
CAMPION Sebastien committed
18

19 20 21 22 23 24 25 26 27 28 29 30

# on startup read the controller token from ALLGO_CONTROLLER_TOKEN_PATH
def _read_controller_token():
    path = config.env.ALLGO_CONTROLLER_TOKEN_PATH
    try:
        with open(path) as fp:
            token = fp.read().strip()
        return token
    except OSError as e:
        log.warning("failed to get the controller token at %r (%s)", path, e)
CONTROLLER_TOKEN = _read_controller_token()

31
@csrf_exempt
32 33
def pre_pushpull(request, action):
    """pre-hook for pushing/pulling image manifests
34

35
    This endpoint is called by allgo.aio before pushing/pulling an image to the
36
    registry.
37

38 39
    it returns a 200 response with the WebappVersion.id in the body if
    successful
40
    """
41

42 43 44 45
    if request.META.get("HTTP_X_ORIGIN") != "aio":
        # this endpoint is only usable by allgo.aio
        return HttpResponse(status=404)
    if request.method != "POST":
46 47
        return HttpResponse(status=405)

48 49
    repo = request.GET["repo"]
    tag  = request.GET["tag"]
50
    description = request.GET["description"]
51 52

    try:
53 54 55
        # find the relevant webapp
        webapp = Webapp.objects.get(docker_name=repo)
    except Webapp.DoesNotExist:
56 57
        return JsonResponse({"errors": [
            {"code": "NAME_INVALID", "message": "unknown repository"}]}, status=404)
58

59 60 61 62 63
    if action == "pull":
        # find the id of the WebappVersion to be pulled
        version = WebappVersion.objects.filter(webapp=webapp, number=tag,
                state=WebappVersion.READY).order_by("-id").first()
        if version is None:
64 65
            return JsonResponse({"errors": [
                {"code": "TAG_INVALID", "message": "unknown tag"}]}, status=404)
66 67 68

    elif action == "push":
        # create a new WebappVersion entry in state USER
69
        version = WebappVersion(
70
                webapp=webapp, number=tag, state=WebappVersion.USER,
71
                published=True, description=description)
72
        version.save()
73

74 75 76 77 78 79 80 81
    else:
        return HttpResponse(status=500)

    log.info("%s docker image %s:%s -> id%d",
            action, webapp.docker_name, tag, version.id)

    # return the version id of the image being pushed/pulled
    return HttpResponse(str(version.id))
82

83 84 85
@csrf_exempt
def post_push(request):
    """post-push hook for image manifests
86

87 88 89 90 91 92 93 94 95
    This endpoint is called by allgo.aio after pushing an image to the
    registry, but before the result is forwarded to the client.

    It is responsible of updating the database with the new webapp version.
    """
    if request.META.get("HTTP_X_ORIGIN") != "aio":
        # this endpoint is only usable by allgo.aio
        return HttpResponse(status=404)
    if request.method != "POST":
96 97
        return HttpResponse(status=405)

98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    version_id = int(request.GET["version_id"])
    success = int(request.GET["success"])

    if not success:
        # push failed
        # -> remove the version
        WebappVersion.objects.filter(id=version_id,
                state=WebappVersion.USER).delete()
    else:
        # Switch the version state to READY
        # (see PushManager._process() in controller.py for more details about the
        # process)

        # version being pushed
        version_query = WebappVersion.objects.filter(id=version_id)
        version = version_query.get()

        # query and lock candidate versions to be READY
        versions = list(version_query.union(WebappVersion.objects.filter(
            webapp=version.webapp, number=version.number,
            state=WebappVersion.READY)).select_for_update())

120
        # set the latest one to READY and the others to DELETED
121
        latest_id = max(v.id for v in versions)
122 123
        for ver in versions:
            old = ver.get_state_display()
124 125 126 127 128
            if ver.id == latest_id:
                ver.state = WebappVersion.READY
            else:
                ver.state = WebappVersion.DELETED
                ver.deleted_at = django.utils.timezone.now()
129 130 131
            new = ver.get_state_display()
            log.info("version id %d: %s -> %s", ver.id, old, new)
            ver.save()
132 133

    return HttpResponse(status=204)
134 135 136

@csrf_exempt
def registry_notfound(request):
137
    """Default endpoint for all registry urls
138 139 140 141 142 143 144

    should never be served (if the reverse-proxy is well configured)
    """
    return JsonResponse({"error":
        "registry not found (this is very likely a reverse-proxy config issue)"},
        status=404)

CAMPION Sebastien's avatar
CAMPION Sebastien committed
145 146

def jwt_auth(request):
147 148 149
    """JWT auth endpoint used by the docker registry

    Spec: https://docs.docker.com/registry/spec/auth/jwt/
CAMPION Sebastien's avatar
CAMPION Sebastien committed
150

BAIRE Anthony's avatar
add doc  
BAIRE Anthony committed
151 152 153 154 155
    The HTTP request is expected to include an Authorization header using the
    Basic authentication method

    The client may provide either:
    - the email+password of an allgo user
156
    - a runner token, in which case the username is the arbitrary value "token"
BAIRE Anthony's avatar
add doc  
BAIRE Anthony committed
157 158
      and the password is the token value

CAMPION Sebastien's avatar
CAMPION Sebastien committed
159 160 161
    :param request:
    :return:
    """
162 163 164 165

    #
    # Identify the actor making the request (either a User or a Runner)
    #
CAMPION Sebastien's avatar
CAMPION Sebastien committed
166 167
    auth_header = request.META.get('HTTP_AUTHORIZATION', '')
    if not auth_header:
168 169 170
        log.info("Token request without http authorisation %s %s %s",
                request.META['HTTP_USER_AGENT'],
                request.META['REMOTE_ADDR'], request.META['QUERY_STRING'])
CAMPION Sebastien's avatar
CAMPION Sebastien committed
171 172
        return HttpResponse(status=401)
    token_type, credentials = auth_header.split(' ')
173 174 175 176 177
    if token_type != "Basic":
        log.info("Token request with unknown http authentication method %s %s %r",
                request.META['HTTP_USER_AGENT'],
                request.META['REMOTE_ADDR'], token_type)
        return HttpResponse(status=401)
178
    username, password = base64.b64decode(credentials).decode('utf-8').split(':', 1)
179
    #log.debug('HTTP_AUTHORIZATION %s username %s', auth_header, username)
180
    if username == "token":
BAIRE Anthony's avatar
BAIRE Anthony committed
181 182 183
        if len(password) < MIN_TOKEN_SIZE:
            log.info("provided token is too short")
            return HttpResponse(status=401)
184 185 186
        if password == CONTROLLER_TOKEN:
            actor = "CONTROLLER"
        else:
BAIRE Anthony's avatar
BAIRE Anthony committed
187 188
            actor = Token.authenticate(password)
            if actor is None:
189
                return HttpResponse(status=401)
CAMPION Sebastien's avatar
CAMPION Sebastien committed
190
    else:
BAIRE Anthony's avatar
BAIRE Anthony committed
191 192 193 194 195 196 197 198 199 200 201 202 203
# FIXME: user authentication is disabled for the moment because we do not work with users
# authenticated via allauth
        return HttpResponse(status=401)
#
#        try:
#            actor = User.objects.get(email=username)
#        except User.DoesNotExist:
#            log.warning("Token request but user doest not exist")
#            return HttpResponse(status=401)
#        password_valid = actor.check_password(password)
#        if token_type != 'Basic' or not password_valid:
#            log.info("Token request but user password mismatch")
#            return HttpResponse(status=401)
CAMPION Sebastien's avatar
CAMPION Sebastien committed
204

205 206 207 208 209 210 211
    #
    # Evaluate the allowed actions
    #
    try:
        resource_type, repository, requested_actions = request.GET.get('scope', "::").split(":")
    except ValueError:
        return JsonResponse({'error': 'Invalid scope parameter'}, status=400)
CAMPION Sebastien's avatar
CAMPION Sebastien committed
212

BAIRE Anthony's avatar
BAIRE Anthony committed
213
    allowed_actions = set()
214
    if resource_type == "repository":
215
        if actor == "CONTROLLER":
216
            if is_allowed_ip_admin(get_client_ip(request)):
BAIRE Anthony's avatar
BAIRE Anthony committed
217
                allowed_actions.update(("pull", "push"))
CAMPION Sebastien's avatar
CAMPION Sebastien committed
218
        else:
219 220 221 222 223
            try:
                webapp = Webapp.objects.get(docker_name = repository)
            except Webapp.DoesNotExist:
                pass
            else:
BAIRE Anthony's avatar
BAIRE Anthony committed
224 225 226 227 228 229 230 231
                if "push" in requested_actions and webapp.is_pushable_by(actor):
                    allowed_actions.add("push")
                    if "pull" in requested_actions:
                        # NOTE: the official docker client requests both push & pull rights for
                        # pushing (and it is unable to push without the pull permission)
                        # (anyway the nginx config prevents pulling blobs for the moment)
                        allowed_actions.add("pull")

232 233
                if "pull" in requested_actions and webapp.is_pullable_by(actor,
                        client_ip = get_client_ip(request)):
BAIRE Anthony's avatar
BAIRE Anthony committed
234
                    allowed_actions.add("pull")
235 236 237 238 239 240

    #
    # Generate the token
    #
    service = request.GET['service']
    log.info("Token authorized for %s on %s actions %s", actor, repository, allowed_actions)
BAIRE Anthony's avatar
BAIRE Anthony committed
241
    token = JwtToken(service, resource_type, repository, list(allowed_actions))
242 243

    return JsonResponse({'token': token.encode_token()})
CAMPION Sebastien's avatar
CAMPION Sebastien committed
244 245 246 247 248 249 250 251 252


def get_client_ip(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