This is a simple token authentication service for a private Docker registry written in python. It is based on the Distribution Registry v2 Bearer token specification.
It is supposed to be run behind a proxy such as nginx which handles SSL. Later in this document, I will provide an example configuration for nginx.
This little service has the following capabilities:
- generate JWT tokens for the Docker registry
- restrict access to the registry to authenticated users
- select whether a user can push or pull images by a simple json configuration file (config/users.json)
- whitelist/blacklist repositories for public access (pull access only) by a simple json configuration file (config/repositories.json)
The service is supposed to be running in combination with a Docker registry. Therefore the following requirements are necessary:
- Docker including docker compose
- A reverse proxy which handles SSL and forwards requests to the registry and the token service
In the next chapters example files will be created which are required to successfully run the service. This includes the needed certificates, the configuration files and the docker-compose.yml file.
As JWT tokens are used for authentication, the service needs a private key and a certificate. The following commands generate a new ECDSA key and a self-signed certificate:
mkdir certs
openssl ecparam -genkey -name prime256v1 -noout -out certs/RootCA.key
openssl req -x509 -nodes -new -sha256 -days 1024 -key certs/RootCA.key -out certs/RootCA.crt -subj "/CN=localhost/O=company/C=US"
The subdirectory config contains example configuration files. Copy and rename them with the same name but without the .example
suffix:
cp config/config.py.example config/config.py
cp config/users.json.example config/users.json
cp config/repositories.json.example config/repositories.json
cp config/uwsgi.ini.example config/uwsgi.ini
Edit those files accordingly. If you set backlist
to true
in repositories.json, the repositories listed in the repositories
array will not be accessible without authentication. If you set blacklist
to false
, it will work as a whitelist and only the repositories listed in the repositories
array will be accessible without authentication.
You cannot give only push
access. If you want to give users push
access you always have to put pull
as well.
The passwords for the users in users.json are hashed with bcrypt and can be generated with the following command:
python3 -c 'import bcrypt; print(bcrypt.hashpw(b"password", bcrypt.gensalt()).decode("utf-8"))'
Alternatively, you can use the htpasswd command from the httpd docker image if you don't want to use python:
docker run --rm -it httpd htpasswd -nbB username password
This service only supports bcrypt hashed passwords!
In case you wonder: The passwords in the example are foo
and bar
.
In this example the token service and the registry are using unix sockets to communicate:
- the token service listens on /var/www/upstream-sockets/registry-auth-service.sock
- the registry listens on /var/www/upstream-sockets/docker-registry.sock
Since the reverse proxy is accessing the registry and the token service through the sockets, the user and group of the sockets must be the same as the user and group of the reverse proxy. In this example, the user and group are www-data. Create the directory and set the permissions:
mkdir -p /var/www/upstream-sockets
chown -R www-data:www-data /var/www/upstream-sockets
Since in my example the reverse proxy is not running inside a container, the directory is created in a place where it actually can be accessed by the reverse proxy.
The services create the socket files when they are started. This means that the services must run using the correct user. For the token service, the user and group are set in the uwsgi.ini file. For the registry, the user and group are set in the docker-compose.yml file which is described in the next chapter.
Both services use certificates to sign and verify the tokens. Those certificates must be accessible by the services as well. So change the permissions of the certificates:
chown -R www-data:www-data certs
The docker-compose.yml file starts the token service and the registry. Here is an example fitting the previous configuration:
version: '3'
services:
registry:
image: registry:2
user: "33:33" # set the user to the same as used by the reverse proxy
environment:
REGISTRY_AUTH: token
REGISTRY_AUTH_TOKEN_REALM: https://example.org/v2/token
REGISTRY_AUTH_TOKEN_SERVICE: Authentication
REGISTRY_AUTH_TOKEN_ISSUER: example issuer # match the issuer in the config file
REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE: /mnt/local/certs/RootCA.crt
REGISTRY_HTTP_SECRET: iru7cBDFI4CgqTmjz4n0Z+YkQHQOAxEX # generate your own (eg. with 'openssl rand -base64 24')
REGISTRY_HTTP_ADDR: /mnt/local/socket/docker-registry.sock
REGISTRY_HTTP_NET: unix
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /registry-data
volumes:
- ./certs/RootCA.crt:/mnt/local/certs/RootCA.crt
- /var/www/upstream-sockets:/mnt/local/socket
- ./registry-data:/registry-data
restart: always
auth-server:
build:
context: .
volumes:
- /var/www/upstream-sockets:/app/socket # Mount socket from host to container
- ./config:/app/config # Mount config files from host to container
- ./certs/RootCA.key:/app/certs/RootCA.key # Mount RootCA from host to container
restart: always
The token service expects the config files to be in the config directory of the service. The path to the certificate is set within the config.py file.
You can of course use any reverse proxy you like. In this example, nginx is used as a reverse proxy to handle SSL and forward requests to the registry and the token service. Here is the relevant part of the configuration:
upstream docker-registry {
server unix:///var/www/upstream-sockets/docker-registry.sock;
}
upstream docker-auth {
server unix:///var/www/upstream-sockets/registry-auth-service.sock;
}
map $upstream_http_docker_distribution_api_version $docker_distribution_api_version {
'' 'registry/2.0';
}
server {
// general stuff
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.org;
// SSL stuff
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_dhparam /path/to/ssl-dhparams.pem;
location /v2/ {
# disable any limits to avoid HTTP 413 for large image uploads
client_max_body_size 0;
# required to avoid HTTP 411: see Issue #1486 (https://github.com/moby/moby/issues/1486)
chunked_transfer_encoding on;
# Do not allow connections from docker 1.5 and earlier
# docker pre-1.6.0 did not properly set the user agent on ping, catch "Go *" user agents
if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) {
return 404;
}
## If $docker_distribution_api_version is empty, the header is not added.
## See the map directive above where this variable is defined.
add_header 'Docker-Distribution-Api-Version' $docker_distribution_api_version always;
proxy_pass http://docker-registry;
proxy_set_header Host $http_host; # required for docker client's sake
proxy_set_header X-Real-IP $remote_addr; # pass on real client's IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_read_timeout 900;
}
location /v2/token {
uwsgi_pass docker-auth;
include uwsgi_params;
}
}
It is important that you have two upstreams, one for the registry and one for the token service. They must match the environment variables of the docker-compose.yml and the configuration of the token service.
After the configuration is done, the service can be started with the following command:
docker-compose up -d
The docker registry can be accessed like any other registry. Here are some examples:
Login:
docker login -u bar -p bar https://example.org
Create an example image:
docker pull alpine
docker tag alpine example.org/some-image:latest
docker push example.org/some-image:latest
Pull the image:
docker pull example.org/some-image:latest
You can use the registry API to get information about the available repositories as well. Here is an example:
TOKEN=$(curl -s -X GET https://example.org/v2/token\?account\=anon\&service\=Authentication\&scope\=registry:catalog:\* | jq -r '.token')
curl -H "Authorization: Bearer $TOKEN" https://example.org/v2/_catalog
The output should look like this:
{"repositories":["some-image"]}
Now to list the tags of the repository... of course you need another token for another scope:
TOKEN=$(curl -s -X GET https://example.org/v2/token\?account\=anon\&service\=Authentication\&scope\=repository:some-image:pull | jq -r '.token')
curl -H "Authorization: Bearer $TOKEN" https://example.org/v2/some-image/tags/list
This will print:
{"name":"some-image","tags":["latest"]}