Host your own Docker HUB
How to build an controlled environment to distribute docker images based on user accounts
Docker itself, AWS (just to name the biggest docker hosts right now) and many more public / private repository servers are on the marked. But sometime there is need to host an own registry for docker images. One reason can be because we can, the other is for example to give individual pull / push rights to different images to different users and control the access also based on expiration dates.
Components and the big picture
For this setup we need several software components to work orchestrated together. Starting with the firewall to block all ports except the 443 for HTTPS, the nginx reverse proxy to terminate the SSL connection and protect the underlying services against direct access and also possible load balancing, the docker registry to host the images and at last but not least the docker token authenticator to identify users and give access to images (push and/or pull) based on their rights.
Docker introduced in the second version for the registry protocol the “Docker registry authentication scheme“. This basically transfers the access control to images to an outside system and uses the bearer token mechanism to communicate. The flow is to access an docker image is:
- Docker daemon accesses the docker registry server as usual and gets a 401 Unauthorized in return with a “WWW-Authenticate” header pointing to the authentication server the registry server trusts.
- Docker daemon contacts the authentication server with the given URL and the user identifies against the server.
- The authentication server checks the access rights based on username, password, image name and access type (pull/push) and returns a bearer token signed with the private key.
- Docker daemon accesses the docker registry again with the bearer token and the docker image request.
- Docker registry server checks the bearer token based on the authentication server public key and grants access or doesn’t.
Firewall
Ubuntu ships with a very simple firewall control script called “Uncomplicated Firewall“. The script manages the iptable configuration and lets the user configure ports with a single line. If you access the server via SSH make sure you allow ssh access before you activate the firewall. I also recommend installing fail2ban to ban script hacking.
sudo apt update
sudo apt install -y ufw fail2ban
ufw allow ssh #only necessary when you need remote access
ufw allow https
ufw allow http
ufw enable
ufw status
Nginx reverse proxy
We install Nginx also as a docker service because the update cycle is way faster compared to the software repository. The basic Nginx docker container is ready to be used and only needs the settings for http and https. Everything is handled via the https port but we also have http (port 80) open to have a redirect to https for everything with a 301 (moved permanently) return code.
FROM docker.io/nginx:latest
COPY default.conf /etc/nginx/conf.d/default.conf
COPY ssl.conf /etc/nginx/conf.d/ssl.conf
COPY cert /cert
EXPOSE 80
EXPOSE 443
This is a very simple Dockerfile to to add the ssl certificates and the http/https configuration. We could also mount the ssl and configuration in the docker-compose file and leave the images plain as it is. Both options are valid and just a flavour.
server {
listen 80;
listen [::]:80;
server_name registry.23-5.eu auth.23-5.eu;
return 301 https://$host$request_uri;
}
This is the http configuration for nginx. Accepting everything for http and returning a 301 (moved permanently) to the same server and path just with https.
SSL configuration
SSL configuration is a little bit more complicated as we also specify the ciphers and parameters for the encryption. As this topic is endless and very easy to screw up I personally relay on https://cipherli.st as a configuration source.
openssl dhparam -out dhparams.pem 4096
The recommendation is to generate own Diffie–Hellman pool bigger than 2048 bit. This process can take a very long time. We add the result file together with our keys to the cert folder.
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_dhparam /cert/dhparams.pem;
ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384";
ssl_ecdh_curve secp384r1;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 9.9.9.9 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
This configuration is based on the recommendation from cipherlist. Be aware one part of this setup is the Strict-Transport-Security with can cause a lot of long-time trouble if you mess it up. This completes the basic SSL setup.
map $upstream_http_docker_distribution_api_version $docker_distribution_api_version {
'' 'registry/2.0';
}
This mapping helps to set the right header even when Nginx removed it because of authentication. Docker registry needs this information in the http header.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name auth.23-5.eu;
ssl_certificate /cert/auth/fullchain.pem;
ssl_certificate_key /cert/auth/privkey.pem;
ssl_trusted_certificate /cert/auth/chain.pem;
location /auth {
proxy_read_timeout 90;
proxy_connect_timeout 90;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header Host $http_host;
proxy_pass http://dockerauth:5001/auth;
}
}
In this case we are running the registry and the auth server on the same virtual machine. Therefore both configurations are in the SSL.conf file. This one is for the auth server.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name registry.23-5.eu;
ssl_certificate /cert/registry/fullchain.pem;
ssl_certificate_key /cert/registry/privkey.pem;
ssl_trusted_certificate /cert/registry/chain.pem;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) {
return 404;
}
add_header 'Docker-Distribution-Api-Version' $docker_distribution_api_version always;
proxy_pass http://registry:5000;
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_read_timeout 900;
}
}
And this configuration part for the registry server itself. Important here is the client_max_body_size parameter to make sure even bigger docker images are getting through. Older docker client versions getting a 404 because they can not be handled by the docker registry.
Lets encrypt
The easiest way to get a certificate is by using let’s encrypt. There are different ways how to receive a certificate, we just use a very simple one here with the standalone call. The certbot opens a mini web server on port 80 to handle the authentication request on its own. Therefore make sure the Nginx docker is not running.
certbot certonly -d registry.23-5.eu --standalone
certbot certonly -d auth.23-5.eu --standalone
for i in registry auth client
do
cp /etc/letsencrypt/live/${i}.23-5.eu/chain.pem /root/nginx/cert/${i}/
cp /etc/letsencrypt/live/${i}.23-5.eu/fullchain.pem /root/nginx/cert/${i}/
cp /etc/letsencrypt/live/${i}.23-5.eu/privkey.pem /root/nginx/cert/${i}/
done
Do the certificate request call for the auth and the registry certificate and copy the certificate and private key to your cert folder for the docker build to pick it up. Don’t forget the dhaprams.pem file.
###Docker registry Now as the server is configured and more or less secured, let’s configure the docker registry server and auth server. Docker inc. offers a docker registry docker container which is relatively easy to hande and to configure.
- REGISTRY_AUTH=token
- REGISTRY_AUTH_TOKEN_REALM=https://auth.23-5.eu/auth
- REGISTRY_AUTH_TOKEN_SERVICE="Docker registry"
- REGISTRY_AUTH_TOKEN_ISSUER="Acme auth server"
- REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/ssl/domain.crt
The configuration is done in the docker-compose file itself. The important information is the REALM, so the docker registry can redirect the client to the auth server with the issuer and the cert bundle from the referred auth server to check the bearer token later.
Docker Token Authenticator
Docker Inc. does not provide an auth server out of the box as done with the registry itself. This is basically left for the registry provider to build their own. Luckily Cesanta stepped up and build a nice configurable auth server to be used with the registry server. docker_auth has different ways of how to store information about the user.
- Static list of users
- Google Sign-In
- Github Sign-In
- LDAP bind
- MongoDB user collection
- External Program (gets login parameters and returns 0 or 1)
In our case the way to go is the MongoDB user collection as we can control for each user individually who has access to which image and easily change it on the fly by modifying the user data in the DB itself.
server: # Server settings.
# Address to listen on.
addr: ":5001"
token:
issuer: "Acme auth server" # Must match issuer in the Registry config.
expiration: 900
certificate: "/ssl/domain.crt"
key: "/ssl/domain.key"
mongo_auth:
dial_info:
addrs: ["authdb"]
timeout: "10s"
database: "23-5"
username: "ansi"
password_file: "/config/mongopass.txt"
enabled_tls: false
collection: "users"
acl_mongo:
dial_info:
addrs: ["authdb"]
timeout: "10s"
database: "23-5"
username: "ansi"
password_file: "/config/mongopass.txt"
enabled_tls: false
collection: "acl"
cache_ttl: "10s"
This is the configuration file for the auth server. Mainly 4 parts.
-
Server
Witch port to listen on Nginx handles the TLS termination, therefore, this server has no TLS handling.
-
Token
Use the same issuer as configured in the registry server itself and provide the certificate files for signing the bearer token.
-
Mongo_auth
Where the user information is stored, the password is saved in a simple ASCII file and how to access the MongoDB. In our case, as we are behind a firewall in a docker network we don’t use TLS to access thMongoDBDB.
-
ACL_Mongo
Beside the user information, the AccessControlList (ACL) can also be stored in a MongoDB. Same configuration as the mongo_auth but there is a cache information as this information is stored in memory and refreshed every 10 seconds.
MongoDB
mongo --host localhost --username root --password example --authenticationDatabase admin
use 23-5
db.createUser({user: "ansi", pwd: "test", roles: ["readWrite"], mechanisms: ["SCRAM-SHA-1"]})
mongo --host localhost --username ansi --password test --authenticationDatabase 23-5
db.users.insert({
"username" : "waldi",
"password" : "$2y$05$hxH........Ii33Csix8hC",
"labels" : {"full-access":["test/*"],
"read-only-access":["prod/*"]
}
})
db.acl.insert([
{ "seq": 10,
"match": {"name": "${labels:full-access}"},
"actions": ["*"],
"comment": "full access"
},
{ "seq": 20,
"match": {"name": "${labels:read-only-access}"},
"actions": ["pull"],
"comment": "pull access"
}
])
The mongoDB was initialized by the docker-compose file with an admin user “root” and passwd “example”. We use this account to create a new database called “23-5” and set a new user there with username “ansi” and passwd “test”. This database stores all user and acls. The docker registry users by themselves are stored with an bencrypted password. and some labels. Bencrypt a passwd with:
sudo apt install apache2-tools
htpasswd -nB USERNAME
Beside username and password, we can also store labels of all kind to a given user. This allows us to use these labels for the ACLs again. So in our case, the ACLs defines all docker images with a given name (the name is stored in the label with read-only or full access) to access images based on their label. In our case, the user “waldi” has full access to all docker images with “test/” and only read access to everything in “prod/” but nothing else. ACLs have a seq number in which they were processed. The first patching ACL will be used.
Labels can be combined so for example:
ACL:
{
"match": { "name": "${labels:project}/${labels:group}-${labels:tier}" },
"actions": [ "push", "pull" ],
"comment": "Contrived multiple label match rule"
}
USER:
{
"username" : "busy-guy",
"password" : "$2y$05$B.x.......CbCGtjFl7S33aCUHNBxbq",
"labels" : {
"group" : [
"web",
"webdev"
],
"project" : [
"website",
"api"
],
"tier" : [
"frontend",
"backend"
]
}
}
Would give push and pull access to the docker image
website/webdev-backend
These variables can be checked for the ACL:
- ${account} the account name aka username
- ${name} the repository name “” can be used. So for example “prod/” gives access to “prod/server”
Generating bearer SSL key
In order to sign a bearer token we need a key. This can be a self signed key done with openssl:
openssl req \
-newkey rsa:4096 \
-days 365 \
-nodes -keyout domain.key \
-out domain.csr \
-subj "/C=EU/ST=Germany/L=Berlin/O=23-5/CN=auth.23-5.eu"
openssl x509 \
-signkey domain.key \
-in domain.csr \
-req -days 365 -out domain.crt
openssl req \
-x509 \
-nodes \
-days 365 \
-newkey rsa:2048 \
-keyout server.key \
-out server.pem
Docker-compose
We can configure and start the auth and registry server and nginx with one docker-compose file:
version: '3'
services:
nginx:
restart: always
build:
context: nginx
ports:
- 80:80
- 443:443
mongoclient:
image: docker.io/mongoclient/mongoclient:latest
restart: always
depends_on:
- authdb
ports:
- 3000:3000
environment:
- TZ=Europe/Berlin
- STARTUP_DELAY=1
authdb:
image: docker.io/mongo:4.1
restart: always
volumes:
- /root/auth_db:/data/db
environment:
- TZ=Europe/Berlin
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
ports:
- 27017:27017
command: --bind_ip 0.0.0.0
dockerauth:
image: docker.io/cesanta/docker_auth:1
volumes:
- /root/auth_server/config:/config:ro
- /root/auth_server/ssl:/ssl:ro
command: --v=2 --alsologtostderr /config/auth_config.yml
restart: always
environment:
- TZ=Europe/Berlin
registry:
image: docker.io/registry:2
volumes:
- /root/auth_server/ssl:/ssl:ro
- /root/docker_registry/data:/var/lib/registry
restart: always
environment:
- TZ=Europe/Berlin
- REGISTRY_AUTH=token
- REGISTRY_AUTH_TOKEN_REALM=https://auth.23-5.eu/auth
- REGISTRY_AUTH_TOKEN_SERVICE="Docker registry"
- REGISTRY_AUTH_TOKEN_ISSUER="Acme auth server"
- REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/ssl/domain.crt
I also added a mongoclient docker container to have easy access to the mongodb server. Please be aware this one is not secured by the nginx reverse proxy and is only for testing. You can also access the mongodb with command line:
docker exec -it root_authdb_1 mongo --host localhost --username root --password example --authenticationDatabase admin
The MongoDB docker is also called with a different command to give access outside of localhost. (–bind_ip 0.0.0.0)
Testing
docker-compose build
docker-compose up -d
Is starting the setup. We have a docker registry user “waldi” with this setup:
[{"username": "waldi",
"password": "$2......dKOIrAn.KxCfeEn7HhePFIO",
"labels": {"full-access": ["test", "socke*"]}
}
]
[{"seq": 10,
"match":{"name": "${labels:full-access}"},
"actions":["*"],
"comment": "full access"
},{
"seq": 20,
"match":{"name": "${labels:read-only-access}"},
"actions":["pull"],
"comment": "pull access"
}
]
So user “waldi can write and read all repositories with either “test” or anything starting with “socke“. Let’s try it.
$ docker login registry.23-5.eu
Authenticating with existing credentials...
Login Succeeded
$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
Status: Image is up to date for nginx:latest
$ docker tag nginx:latest registry.23-5.eu/test:latest
$ docker push registry.23-5.eu/test:latest
The push refers to repository [registry.23-5.eu/test]
fc4c9f8e7dac: Pushed
912ed487215b: Pushed
778790 size: 948
$ docker tag nginx:latest registry.23-5.eu/socken-test:latest
$ docker push registry.23-5.eu/socken-test:latest
The push refers to repository [registry.23-5.eu/socken-test]
fc4c9f8e7dac: Mounted from test
912ed487215b: Mounted from test
5dacd731af1b: Mounted from test
latest: digest: sha256:c10f4146f30fda9f40946bc114afeb1f4e867877c49283207a08ddbcf1778790 size: 948
It works. Now let’s test the negative part and try if the push gets refused:
$ docker tag nginx:latest registry.23-5.eu/test-socke:latest
$ docker push registry.23-5.eu/test-socke:latest
The push refers to repository [registry.23-5.eu/test-socke]
fc4c9f8e7dac: Preparing
912ed487215b: Preparing
5dacd731af1b: Preparing
denied: requested access to the resource is denied
It works! The user can be modified on the fly in the MongoDB and granted or revoked rights. There is one final test to check if the Nginx is secured: https://www.ssllabs.com/ssltest/index.html.