Merge pull request #41 from yoshikakbudto/master

add token-based auth scheme support
This commit is contained in:
andrey-pohilko
2018-02-26 22:24:03 +06:00
committed by GitHub
5 changed files with 229 additions and 47 deletions

62
.circleci/config.yml Normal file
View File

@@ -0,0 +1,62 @@
# Python CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-python/ for more details
#
version: 2
jobs:
build:
docker:
# specify the version you desire here
# use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers`
- image: circleci/python:3.6.1
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
# - image: circleci/postgres:9.4
working_directory: ~/repo
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "requirements-ci.txt" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run:
name: install dependencies
command: |
python3 -m venv venv
. venv/bin/activate
pip install -r requirements-ci.txt
- save_cache:
paths:
- ./venv
key: v1-dependencies-{{ checksum "requirements-ci.txt" }}
# run tests!
# this example uses Django's built-in test-runner
# other common Python testing frameworks include pytest and nose
# https://pytest.org
# https://nose.readthedocs.io
- run:
name: run tests
command: |
. venv/bin/activate
coverage run --include=test.py,registry.py test.py
- run:
name: generate report
command: |
. venv/bin/activate
mkdir -p /tmp/coverage
coverage html -d /tmp/coverage
mv .coverage /tmp/coverage
- store_artifacts:
path: /tmp/coverage

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
.gitignore .gitignore
.idea/ .idea/
/.vscode
*.pyc

View File

@@ -1,11 +1,15 @@
#!/usr/bin/env python #!/usr/bin/env python
import requests import requests
from requests.auth import HTTPBasicAuth import ast
from requests.packages.urllib3.exceptions import InsecureRequestWarning from requests.packages.urllib3.exceptions import InsecureRequestWarning
import json import json
import pprint
import base64
import re import re
import sys
import argparse import argparse
import www_authenticate
from datetime import timedelta, datetime as dt from datetime import timedelta, datetime as dt
# this is a registry manipulator, can do following: # this is a registry manipulator, can do following:
@@ -35,21 +39,77 @@ from datetime import timedelta, datetime as dt
# number of image versions to keep # number of image versions to keep
CONST_KEEP_LAST_VERSIONS = 10 CONST_KEEP_LAST_VERSIONS = 10
# print debug messages
DEBUG = False
# this class is created for testing # this class is created for testing
class Requests: class Requests:
def request(self, method, url, **kwargs): def request(self, method, url, **kwargs):
return requests.request(method, url, **kwargs) return requests.request(method, url, **kwargs)
def bearer_request(self, method, url, auth, **kwargs):
global DEBUG
if DEBUG: print("[debug][funcname]: bearer_request()")
if DEBUG:
print('[debug][registry][request]: {0} {1}'.format(method, url))
if 'Authorization' in kwargs['headers']:
print('[debug][registry][request]: Authorization header:')
token_parsed = kwargs['headers']['Authorization'].split('.')
pprint.pprint(ast.literal_eval(decode_base64(token_parsed[0])))
pprint.pprint(ast.literal_eval(decode_base64(token_parsed[1])))
res = requests.request(method, url, **kwargs)
if str(res.status_code)[0] == '2':
if DEBUG: print("[debug][registry] accepted")
return (res, kwargs['headers']['Authorization'])
if res.status_code == 401:
if DEBUG: print("[debug][registry] Access denied. Refreshing token...")
oauth = www_authenticate.parse(res.headers['Www-Authenticate'])
if DEBUG:
print('[debug][auth][answer] Auth header:')
pprint.pprint(oauth['bearer'])
# print('[info] retreiving bearer token for {0}'.format(oauth['bearer']['scope']))
request_url = '{0}?service={1}&scope={2}'.format(oauth['bearer']['realm'],
oauth['bearer']['service'],
oauth['bearer']['scope'])
if DEBUG:
print('[debug][auth][request] Refreshing auth token: POST {0}'.format(request_url))
try_oauth = requests.post(request_url, auth=auth, **kwargs)
try:
token = ast.literal_eval(try_oauth._content)['token']
except SyntaxError:
print('\n\n[ERROR] couldnt accure token: {0}'.format(try_oauth._content))
sys.exit(1)
if DEBUG:
print('[debug][auth] token issued: ')
token_parsed=token.split('.')
pprint.pprint(ast.literal_eval(decode_base64(token_parsed[0])))
pprint.pprint(ast.literal_eval(decode_base64(token_parsed[1])))
kwargs['headers']['Authorization'] = 'Bearer {0}'.format(token)
else:
return (res, kwargs['headers']['Authorization'])
res = requests.request(method, url, **kwargs)
return (res, kwargs['headers']['Authorization'])
def natural_keys(text): def natural_keys(text):
''' """
alist.sort(key=natural_keys) sorts in human order alist.sort(key=natural_keys) sorts in human order
http://nedbatchelder.com/blog/200712/human_sorting.html http://nedbatchelder.com/blog/200712/human_sorting.html
(See Toothy's implementation in the comments) (See Toothy's implementation in the comments)
''' """
def __atoi(text): def __atoi(text):
return int(text) if text.isdigit() else text return int(text) if text.isdigit() else text
@@ -57,6 +117,42 @@ def natural_keys(text):
return [__atoi(c) for c in re.split('(\d+)', text)] return [__atoi(c) for c in re.split('(\d+)', text)]
def decode_base64(data):
"""Decode base64, padding being optional.
:param data: Base64 data as an ASCII byte string
:returns: The decoded byte string.
"""
data = data.replace('Bearer ','')
# print('[debug] base64 string to decode:\n{0}'.format(data))
missing_padding = len(data) % 4
if missing_padding != 0:
data += b'='* (4 - missing_padding)
return base64.decodestring(data)
def get_auth_schemes(r,path):
""" Returns list of auth schemes(lowcased) if www-authenticate: header exists
returns None if no header found
- www-authenticate: basic
- www-authenticate: bearer
"""
if DEBUG: print("[debug][funcname]: get_auth_schemes()")
try_oauth = requests.head('{0}{1}'.format(r.hostname,path))
if 'Www-Authenticate' in try_oauth.headers:
oauth = www_authenticate.parse(try_oauth.headers['Www-Authenticate'])
if DEBUG:
print('[debug][docker] Auth schemes found:{0}'.format([m for m in oauth]))
return [m.lower() for m in oauth]
else:
if DEBUG:
print('[debug][docker] No Auth schemes found')
return []
# class to manipulate registry # class to manipulate registry
class Registry: class Registry:
@@ -67,15 +163,16 @@ class Registry:
def __init__(self): def __init__(self):
self.username = None self.username = None
self.password = None self.password = None
self.auth_schemes = []
self.hostname = None self.hostname = None
self.no_validate_ssl = False self.no_validate_ssl = False
self.http = None self.http = None
self.last_error = None self.last_error = None
def parse_login(self, login): def parse_login(self, login):
if login != None: if login is not None:
if not ':' in login: if ':' not in login:
self.last_error = "Please provide -l in the form USER:PASSWORD" self.last_error = "Please provide -l in the form USER:PASSWORD"
return (None, None) return (None, None)
@@ -87,12 +184,13 @@ class Registry:
return (None, None) return (None, None)
@staticmethod @staticmethod
def create(host, login, no_validate_ssl): def create(host, login, no_validate_ssl):
r = Registry() r = Registry()
(r.username, r.password) = r.parse_login(login) (r.username, r.password) = r.parse_login(login)
if r.last_error != None: if r.last_error is not None:
print(r.last_error) print(r.last_error)
exit(1) exit(1)
@@ -101,14 +199,22 @@ class Registry:
r.http = Requests() r.http = Requests()
return r return r
def send(self, path, method="GET"): def send(self, path, method="GET"):
# try: if 'bearer' in self.auth_schemes:
result = self.http.request( (result, self.HEADERS['Authorization']) = self.http.bearer_request(
method, "{0}{1}".format(self.hostname, path), method, "{0}{1}".format(self.hostname, path),
headers=self.HEADERS, auth=(('', '') if self.username in ["", None]
auth=(None if self.username == "" else (self.username, self.password)),
else (self.username, self.password)), headers=self.HEADERS,
verify=not self.no_validate_ssl) verify=not self.no_validate_ssl)
else:
result = self.http.request(
method, "{0}{1}".format(self.hostname, path),
headers=self.HEADERS,
auth=(None if self.username == ""
else (self.username, self.password)),
verify=not self.no_validate_ssl)
# except Exception as error: # except Exception as error:
# print("cannot connect to {0}\nerror {1}".format( # print("cannot connect to {0}\nerror {1}".format(
@@ -124,14 +230,14 @@ class Registry:
def list_images(self): def list_images(self):
result = self.send('/v2/_catalog?n=10000') result = self.send('/v2/_catalog?n=10000')
if result == None: if result is None:
return [] return []
return json.loads(result.text)['repositories'] return json.loads(result.text)['repositories']
def list_tags(self, image_name): def list_tags(self, image_name):
result = self.send("/v2/{0}/tags/list".format(image_name)) result = self.send("/v2/{0}/tags/list".format(image_name))
if result == None: if result is None:
return [] return []
try: try:
@@ -140,7 +246,7 @@ class Registry:
self.last_error = "list_tags: invalid json response" self.last_error = "list_tags: invalid json response"
return [] return []
if tags_list != None: if tags_list is not None:
tags_list.sort(key=natural_keys) tags_list.sort(key=natural_keys)
return tags_list return tags_list
@@ -156,7 +262,7 @@ class Registry:
image_headers = self.send("/v2/{0}/manifests/{1}".format( image_headers = self.send("/v2/{0}/manifests/{1}".format(
image_name, tag), method="HEAD") image_name, tag), method="HEAD")
if image_headers == None: if image_headers is None:
print(" tag digest not found: {0}".format(self.last_error)) print(" tag digest not found: {0}".format(self.last_error))
return None return None
@@ -176,13 +282,13 @@ class Registry:
tag_digest, tag)) tag_digest, tag))
return True return True
if tag_digest == None: if tag_digest is None:
return False return False
delete_result = self.send("/v2/{0}/manifests/{1}".format( delete_result = self.send("/v2/{0}/manifests/{1}".format(
image_name, tag_digest), method="DELETE") image_name, tag_digest), method="DELETE")
if delete_result == None: if delete_result is None:
print("failed, error: {0}".format(self.last_error)) print("failed, error: {0}".format(self.last_error))
return False return False
@@ -191,29 +297,12 @@ class Registry:
print("done") print("done")
return True return True
# this function is not used and thus not tested
# def delete_tag_layer(self, image_name, layer_digest, dry_run):
# if dry_run:
# print('would delete layer {0}'.format(layer_digest))
# return False
#
# print('deleting layer {0}'.format(layer_digest),)
#
# delete_result = self.send('/v2/{0}/blobs/{1}'.format(
# image_name, layer_digest), method='DELETE')
#
# if delete_result == None:
# print("failed, error: {0}".format(self.last_error))
# return False
#
# print("done")
# return True
def list_tag_layers(self, image_name, tag): def list_tag_layers(self, image_name, tag):
layers_result = self.send("/v2/{0}/manifests/{1}".format( layers_result = self.send("/v2/{0}/manifests/{1}".format(
image_name, tag)) image_name, tag))
if layers_result == None: if layers_result is None:
print("error {0}".format(self.last_error)) print("error {0}".format(self.last_error))
return [] return []
@@ -229,7 +318,7 @@ class Registry:
config_result = self.send( config_result = self.send(
"/v2/{0}/manifests/{1}".format(image_name, tag)) "/v2/{0}/manifests/{1}".format(image_name, tag))
if config_result == None: if config_result is None:
print(" tag digest not found: {0}".format(self.last_error)) print(" tag digest not found: {0}".format(self.last_error))
return [] return []
@@ -246,12 +335,21 @@ class Registry:
container_header = {"Accept": "{0}".format( container_header = {"Accept": "{0}".format(
image_config['mediaType'])} image_config['mediaType'])}
response = self.http.request("GET", "{0}{1}".format(self.hostname, "/v2/{0}/blobs/{1}".format( if 'bearer' in self.auth_schemes:
image_name, image_config['digest'])), container_header['Authorization'] = self.HEADERS['Authorization']
headers=container_header, (response, self.HEADERS['Authorization']) = self.http.bearer_request("GET", "{0}{1}".format(self.hostname, "/v2/{0}/blobs/{1}".format(
auth=(None if self.username == "" image_name, image_config['digest'])),
else (self.username, self.password)), auth=(('', '') if self.username in ["", None]
verify=not self.no_validate_ssl) else (self.username, self.password)),
headers=container_header,
verify=not self.no_validate_ssl)
else:
response = self.http.request("GET", "{0}{1}".format(self.hostname, "/v2/{0}/blobs/{1}".format(
image_name, image_config['digest'])),
headers=container_header,
auth=(None if self.username == ""
else (self.username, self.password)),
verify=not self.no_validate_ssl)
if str(response.status_code)[0] == '2': if str(response.status_code)[0] == '2':
self.last_error = None self.last_error = None
@@ -311,6 +409,13 @@ for more detail on garbage collection read here:
nargs='?', nargs='?',
metavar='N') metavar='N')
parser.add_argument(
'--debug',
help=('Turn debug output'),
action='store_const',
default=False,
const=True)
parser.add_argument( parser.add_argument(
'--dry-run', '--dry-run',
help=('If used in combination with --delete,' help=('If used in combination with --delete,'
@@ -467,17 +572,23 @@ def delete_tags_by_age(registry, image_name, dry_run, hours, tags_to_keep):
def main_loop(args): def main_loop(args):
global DEBUG
DEBUG = True if args.debug else False
keep_last_versions = int(args.num) keep_last_versions = int(args.num)
if args.no_validate_ssl: if args.no_validate_ssl:
requests.packages.urllib3.disable_warnings(InsecureRequestWarning) requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
registry = Registry.create(args.host, args.login, args.no_validate_ssl) registry = Registry.create(args.host, args.login, args.no_validate_ssl)
registry.auth_schemes = get_auth_schemes(registry,'/v2/_catalog')
if args.delete: if args.delete:
print("Will delete all but {0} last tags".format(keep_last_versions)) print("Will delete all but {0} last tags".format(keep_last_versions))
if args.image != None: if args.image is not None:
image_list = args.image image_list = args.image
else: else:
image_list = registry.list_images() image_list = registry.list_images()

View File

@@ -3,3 +3,4 @@ chardet==3.0.4
idna==2.5 idna==2.5
requests==2.18.3 requests==2.18.3
urllib3==1.22 urllib3==1.22
www-authenticate==0.9.2

View File

@@ -1,3 +1,9 @@
mock mock
coverage coverage
certifi
chardet
idna
requests
urllib3
www-authenticate