Commit e84912fd authored by Adrien Dorsaz's avatar Adrien Dorsaz

Merge branch 'improve-gitlab-ci' into 'master'

Improve gitlab ci

See merge request !21
parents 85dba590 4ada2ec1
Pipeline #271 passed with stages
in 22 minutes and 31 seconds
/docker
/.gitlab-ci.yml
/.git
stages:
- build
- check
- unit_test
- lets_encrypt_staging
.build:
stage: build
image: docker:stable
only:
- merge_requests
- master
.check:
stage: check
image: acme-dns-tiny:buster-slim
only:
- merge_requests
- master
.unit_test:
stage: unit_test
script:
- python3-coverage run --append --source ./ -m unittest -v
tests.unit_test_acme_dns_tiny
only:
- merge_requests
- master
.lets_encrypt_staging:
stage: lets_encrypt_staging
script:
- python3-coverage run --append --source ./ -m unittest -v
tests.staging_test_acme_dns_tiny
tests.staging_test_acme_account_rollover
tests.staging_test_acme_account_deactivate
only:
- merge_requests
- master
jessie-slim:
extends: .build
script:
- docker build -t "acme-dns-tiny:jessie-slim"
-f "docker/jessie/Dockerfile" .
stretch-slim:
extends: .build
script:
- docker build -t "acme-dns-tiny:stretch-slim"
-f "docker/stretch/Dockerfile" .
buster-slim:
extends: .build
script:
- docker build -t "acme-dns-tiny:buster-slim"
-f "docker/buster/Dockerfile" .
compile:
extends: .check
script:
- python3 -m py_compile acme_dns_tiny.py tools/*.py tests/*.py
lint:
extends: .check
script:
- pylint3 acme_dns_tiny.py
- pylint3 tools/acme_account_deactivate.py
- pylint3 tools/acme_account_rollover.py
- pylint3 tests/config_factory.py
- pylint3 tests/staging_test_acme_dns_tiny.py
- pylint3 tests/unit_test_acme_dns_tiny.py
- pylint3 tests/staging_test_acme_account_deactivate.py
- pylint3 tests/staging_test_acme_account_rollover.py
pep8:
extends: .check
script:
- pycodestyle --max-line-length=100 --ignore=E401,W503 --exclude=tests .
- pycodestyle --max-line-length=100 --ignore=E722 tests
jessie-ut:
extends: .unit_test
image: acme-dns-tiny:jessie-slim
stretch-ut:
extends: .unit_test
image: acme-dns-tiny:stretch-slim
buster-ut:
extends: .unit_test
image: acme-dns-tiny:buster-slim
artifacts:
paths:
- .coverage
jessie-le-staging:
extends: .lets_encrypt_staging
image: acme-dns-tiny:jessie-slim
stretch-le-staging:
extends: .lets_encrypt_staging
image: acme-dns-tiny:stretch-slim
buster-le-staging:
extends: .lets_encrypt_staging
image: acme-dns-tiny:buster-slim
after_script:
- python3-coverage report
--include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- python3-coverage html
artifacts:
paths:
- htmlcov
The MIT License (MIT)
Copyright (c) 2015 Daniel Roesler
Copyright (c) 2016 Adrien Dorsaz
Copyright (c) 2016-2020 Adrien Dorsaz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
......
......@@ -6,13 +6,13 @@ unit_test_acme_dns_tiny_success_san:
python3 -m unittest tests.test_acme_dns_tiny.TestACMEDNSTiny.test_success_san
unit_test_acme_account_rollover:
python3 -m unittest tests.test_acme_account_rollover.TestACMEAccountRollover.test_success_account_rollover
python3 -m unittest tests.staging_test_acme_account_rollover.TestACMEAccountRollover.test_success_account_rollover
unit_test_acme_account_deactivate:
python3 -m unittest tests.test_acme_account_deactivate.TestACMEAccountDeactivate.test_success_account_deactivate
python3 -m unittest tests.staging_test_acme_account_deactivate.TestACMEAccountDeactivate.test_success_account_deactivate
unit_test_all_with_coverage:
python3-coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
python3-coverage run --source ./ -m unittest -v tests.unit_test_acme_dns_tiny tests.staging_test_acme_dns_tiny tests.staging_test_acme_account_rollover tests.staging_test_acme_account_deactivate
python3-coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
python3-coverage html
......
......@@ -67,7 +67,7 @@ above files. This user should *NOT* have access to your domain key!
This project has a very, very limited scope and codebase. The project is happy
to receive bug reports and pull requests, but please don't add any new features.
This script must stay under ~250 lines of code to ensure it can be easily
This script must stay under ~400 lines of code to ensure it can be easily
audited by anyone who wants to run it.
If you want to add features for your own setup to make things easier for you,
......
This diff is collapsed.
FROM debian:buster-slim
WORKDIR acme_dns_tiny
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-minimal python3-dnspython python3-requests \
pylint3 \
# install recommends for coverage, to include jquery
&& apt-get install -y python3-coverage pycodestyle \
&& apt-get clean
COPY . .
FROM debian:jessie-slim
WORKDIR acme_dns_tiny
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-minimal python3-dnspython python3-requests \
python3-coverage \
&& apt-get clean
COPY . .
FROM debian:jessie-slim
WORKDIR acme_dns_tiny
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-minimal python3-dnspython python3-requests \
python3-coverage \
&& apt-get clean
COPY . .
FROM debian:jessie-slim
# Minimal tools required by acme-dns-tiny CI
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-dnspython \
python3-coverage \
python3-pip \
&& apt-get clean
# Allows run python3-coverage with same command than manual install by pip
RUN update-alternatives --install \
/usr/bin/python3-coverage \
coverage \
/usr/bin/python3.4-coverage \
1
RUN ln -s /etc/alternatives/coverage /usr/bin/coverage
FROM debian:stretch-slim
# Minimal tools required by acme-dns-tiny CI
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-dnspython \
python3-coverage \
python3-configargparse \
python3-pip
# Allows run python3-coverage with same command than manual install by pip
RUN update-alternatives --install \
/usr/bin/coverage \
coverage \
/usr/bin/python3-coverage \
1
jessie:
tags:
- jessie
before_script:
- pip3 install --upgrade -r tests/requirements.txt
script:
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- coverage html
stretch:
tags:
- stretch
before_script:
- pip3 install --upgrade -r tests/requirements.txt
script:
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- coverage html
artifacts:
paths:
- htmlcov
import os, configparser
"""Create real temporary ACME dns tiny configurations to run tests with real server"""
import os
import configparser
from tempfile import NamedTemporaryFile
from subprocess import Popen
# domain with server.py running on it for testing
DOMAIN = os.getenv("GITLABCI_DOMAIN")
ACMEDIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY_V2", "https://acme-staging-v02.api.letsencrypt.org/directory")
ACMEDIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY_V2",
"https://acme-staging-v02.api.letsencrypt.org/directory")
DNSHOST = os.getenv("GITLABCI_DNSHOST")
DNSHOSTIP = os.getenv("GITLABCI_DNSHOSTIP")
DNSZONE = os.getenv("GITLABCI_DNSZONE")
......@@ -15,8 +18,9 @@ TSIGKEYVALUE = os.getenv("GITLABCI_TSIGKEYVALUE")
TSIGALGORITHM = os.getenv("GITLABCI_TSIGALGORITHM")
CONTACT = os.getenv("GITLABCI_CONTACT")
# generate simple config
def generate_config():
"""Generate basic acme-dns-tiny configuration"""
# Account key
account_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait()
......@@ -25,7 +29,7 @@ def generate_config():
domain_key = NamedTemporaryFile(delete=False)
domain_csr = NamedTemporaryFile(delete=False)
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key.name,
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait()
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait()
# acme-dns-tiny configuration
parser = configparser.ConfigParser()
......@@ -33,10 +37,9 @@ def generate_config():
parser["acmednstiny"]["AccountKeyFile"] = account_key.name
parser["acmednstiny"]["CSRFile"] = domain_csr.name
parser["acmednstiny"]["ACMEDirectory"] = ACMEDIRECTORY
if (CONTACT is not None
and CONTACT != ""):
if CONTACT:
parser["acmednstiny"]["Contacts"] = "mailto:{0}".format(CONTACT)
else:
elif "Contacts" in parser:
del parser["acmednstiny"]["Contacts"]
parser["TSIGKeyring"]["KeyName"] = TSIGKEYNAME
parser["TSIGKeyring"]["KeyValue"] = TSIGKEYVALUE
......@@ -48,60 +51,74 @@ def generate_config():
return account_key.name, domain_key.name, domain_csr.name, parser
# generate account and domain keys
def generate_acme_dns_tiny_config():
def generate_acme_dns_tiny_unit_test_config():
"""Genereate acme_dns_tiny configurations used for unit tests"""
# Configuration missing DNS section
_, domain_key, _, config = generate_config()
os.remove(domain_key)
missing_dns = NamedTemporaryFile(delete=False)
config["DNS"] = {}
with open(missing_dns.name, 'w') as configfile:
config.write(configfile)
return {"missing_dns": missing_dns.name}
def generate_acme_dns_tiny_config(): # pylint: disable=too-many-locals,too-many-statements
"""Generate acme_dns_tiny configuration with account and domain keys"""
# Simple configuration with good options
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
goodCName = NamedTemporaryFile(delete=False)
with open(goodCName.name, 'w') as configfile:
good_cname = NamedTemporaryFile(delete=False)
with open(good_cname.name, 'w') as configfile:
config.write(configfile)
# Simple configuration with good options, without contacts field
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
config.remove_option("acmednstiny", "Contacts")
goodCNameWithoutContacts = NamedTemporaryFile(delete=False)
with open(goodCNameWithoutContacts.name, 'w') as configfile:
good_cname_without_contacts = NamedTemporaryFile(delete=False)
with open(good_cname_without_contacts.name, 'w') as configfile:
config.write(configfile)
# Simple configuration without CSR in configuration (will be passed as argument)
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, cname_csr, config = generate_config()
os.remove(domain_key)
cnameCSR = domain_csr
config.remove_option("acmednstiny", "CSRFile")
goodCNameWithoutCSR = NamedTemporaryFile(delete=False)
with open(goodCNameWithoutCSR.name, 'w') as configfile:
good_cname_without_csr = NamedTemporaryFile(delete=False)
with open(good_cname_without_csr.name, 'w') as configfile:
config.write(configfile)
# Configuration with CSR containing a wildcard domain
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, domain_csr, config = generate_config()
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key,
"-subj", "/CN=*.{0}".format(DOMAIN), "-out", domain_csr]).wait()
os.remove(domain_key)
wildCName = NamedTemporaryFile(delete=False)
with open(wildCName.name, 'w') as configfile:
wild_cname = NamedTemporaryFile(delete=False)
with open(wild_cname.name, 'w') as configfile:
config.write(configfile)
# Configuration with IP as DNS Host
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
config["DNS"]["Host"] = DNSHOSTIP
dnsHostIP = NamedTemporaryFile(delete=False)
with open(dnsHostIP.name, 'w') as configfile:
dns_host_ip = NamedTemporaryFile(delete=False)
with open(dns_host_ip.name, 'w') as configfile:
config.write(configfile)
# Configuration with CSR using subject alt-name domain instead of CN (common name)
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, domain_csr, config = generate_config()
san_conf = NamedTemporaryFile(delete=False)
with open("/etc/ssl/openssl.cnf", 'r') as opensslcnf:
......@@ -109,123 +126,114 @@ def generate_acme_dns_tiny_config():
san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:www.{0}\n".format(DOMAIN).encode("utf8"))
san_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", san_conf.name,
"-out", domain_csr]).wait()
"-subj", "/", "-reqexts", "SAN", "-config", san_conf.name,
"-out", domain_csr]).wait()
os.remove(san_conf.name)
os.remove(domain_key)
goodSAN = NamedTemporaryFile(delete=False)
with open(goodSAN.name, 'w') as configfile:
good_san = NamedTemporaryFile(delete=False)
with open(good_san.name, 'w') as configfile:
config.write(configfile)
# Configuration with CSR containing a wildcard domain inside subjetcAltName
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, domain_csr, config = generate_config()
wildsan_conf = NamedTemporaryFile(delete=False)
wild_san_conf = NamedTemporaryFile(delete=False)
with open("/etc/ssl/openssl.cnf", 'r') as opensslcnf:
wildsan_conf.write(opensslcnf.read().encode("utf8"))
wildsan_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:*.{0}\n".format(DOMAIN).encode("utf8"))
wildsan_conf.seek(0)
wild_san_conf.write(opensslcnf.read().encode("utf8"))
wild_san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:*.{0}\n".format(DOMAIN).encode("utf8"))
wild_san_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", wildsan_conf.name,
"-subj", "/", "-reqexts", "SAN", "-config", wild_san_conf.name,
"-out", domain_csr]).wait()
os.remove(wildsan_conf.name)
os.remove(wild_san_conf.name)
os.remove(domain_key)
wildSAN = NamedTemporaryFile(delete=False)
with open(wildSAN.name, 'w') as configfile:
wild_san = NamedTemporaryFile(delete=False)
with open(wild_san.name, 'w') as configfile:
config.write(configfile)
# Bad configuration with weak 1024 bit account key
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
Popen(["openssl", "genrsa", "-out", account_key, "1024"]).wait()
weakKey = NamedTemporaryFile(delete=False)
with open(weakKey.name, 'w') as configfile:
weak_key = NamedTemporaryFile(delete=False)
with open(weak_key.name, 'w') as configfile:
config.write(configfile)
# Bad configuration with account key as domain key
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, domain_csr, config = generate_config()
os.remove(domain_key)
# Create a new CSR signed with the account key instead of domain key
Popen(["openssl", "req", "-new", "-sha256", "-key", account_key,
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr]).wait()
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr]).wait()
accountAsDomain = NamedTemporaryFile(delete=False)
with open(accountAsDomain.name, 'w') as configfile:
account_as_domain = NamedTemporaryFile(delete=False)
with open(account_as_domain.name, 'w') as configfile:
config.write(configfile)
# Create config parser from the good default config to generate custom configs
account_key, domain_key, domain_csr, config = generate_config();
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
invalidTSIGName = NamedTemporaryFile(delete=False)
invalid_tsig_name = NamedTemporaryFile(delete=False)
config["TSIGKeyring"]["KeyName"] = "{0}.invalid".format(TSIGKEYNAME)
with open(invalidTSIGName.name, 'w') as configfile:
config.write(configfile)
# Create config parser from the good default config to generate custom configs
account_key, domain_key, domain_csr, config = generate_config();
os.remove(domain_key)
missingDNS = NamedTemporaryFile(delete=False)
config["DNS"] = {}
with open(missingDNS.name, 'w') as configfile:
with open(invalid_tsig_name.name, 'w') as configfile:
config.write(configfile)
return {
# configs
"goodCName": goodCName.name,
"goodCNameWithoutContacts": goodCNameWithoutContacts.name,
"goodCNameWithoutCSR": goodCNameWithoutCSR.name,
"wildCName": wildCName.name,
"dnsHostIP": dnsHostIP.name,
"goodSAN": goodSAN.name,
"wildSAN": wildSAN.name,
"weakKey": weakKey.name,
"accountAsDomain": accountAsDomain.name,
"invalidTSIGName": invalidTSIGName.name,
"missingDNS": missingDNS.name,
# CName CSR file to use with goodCNameWithoutCSR as argument
"cnameCSR": domain_csr,
"good_cname": good_cname.name,
"good_cname_without_contacts": good_cname_without_contacts.name,
"good_cname_without_csr": good_cname_without_csr.name,
"wild_cname": wild_cname.name,
"dns_host_ip": dns_host_ip.name,
"good_san": good_san.name,
"wild_san": wild_san.name,
"weak_key": weak_key.name,
"account_as_domain": account_as_domain.name,
"invalid_tsig_name": invalid_tsig_name.name,
# cname CSR file to use with good_cname_without_csr as argument
"cname_csr": cname_csr,
}
# generate two account keys to roll over them
def generate_acme_account_rollover_config():
# Old account is directly created by the config generator
old_account_key, domain_key, domain_csr, config = generate_config()
"""Generate config for acme_account_rollover script"""
# Old account key is directly created by the config generator
old_account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
# New account key
new_account_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", new_account_key.name, "2048"]).wait()
rolloverAccount = NamedTemporaryFile(delete=False)
with open(rolloverAccount.name, 'w') as configfile:
rollover_account = NamedTemporaryFile(delete=False)
with open(rollover_account.name, 'w') as configfile:
config.write(configfile)
return {
# config and keys (returned to keep files on system)
"config": rolloverAccount.name,
"oldaccountkey": old_account_key,
"newaccountkey": new_account_key.name
"config": rollover_account.name,
"old_account_key": old_account_key,
"new_account_key": new_account_key.name
}
# generate an account key to delete it
def generate_acme_account_deactivate_config():
"""Generate config for acme_account_deactivate script"""
# Account key is created by the by the config generator
account_key, domain_key, domain_csr, config = generate_config()
account_key, domain_key, _, config = generate_config()
os.remove(domain_key)
deactivateAccount = NamedTemporaryFile(delete=False)
with open(deactivateAccount.name, 'w') as configfile:
deactivate_account = NamedTemporaryFile(delete=False)
with open(deactivate_account.name, 'w') as configfile:
config.write(configfile)
return {
"config": deactivateAccount.name,
"config": deactivate_account.name,
"key": account_key
}
import unittest, os, time, configparser
"""Test acme_account_deactivate script with real ACME server"""
import unittest
import os
import configparser
import acme_dns_tiny
from tests.config_factory import generate_acme_account_deactivate_config
import tools.acme_account_deactivate
ACMEDirectory = os.getenv("GITLABCI_ACMEDIRECTORY_V2", "https://acme-staging-v02.api.letsencrypt.org/directory")
ACME_DIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY_V2",
"https://acme-staging-v02.api.letsencrypt.org/directory")
class TestACMEAccountDeactivate(unittest.TestCase):
"Tests for acme_account_deactivate"
"""Tests for acme_account_deactivate."""
@classmethod
def setUpClass(self):
self.configs = generate_acme_account_deactivate_config()
def setUpClass(cls):
cls.configs = generate_acme_account_deactivate_config()
try:
acme_dns_tiny.main([self.configs['config']])
acme_dns_tiny.main([cls.configs['config']])
except ValueError as err:
if str(err).startswith("Error register"):
raise ValueError("Fail test as account has not been registered correctly: {0}".format(err))
raise ValueError("Fail test as account has not been registered correctly: {0}"
.format(err))
super(TestACMEAccountDeactivate, self).setUpClass()
super(TestACMEAccountDeactivate, cls).setUpClass()
# To clean ACME staging server and close correctly temporary files
# pylint: disable=bare-except
@classmethod
def tearDownClass(self):
def tearDownClass(cls):
# Remove temporary files
parser = configparser.ConfigParser()
parser.read(self.configs['config'])
parser.read(cls.configs['config'])
try:
os.remove(parser["acmednstiny"]["AccountKeyFile"])
except:
pass
try:
os.remove(parser["acmednstiny"]["CSRFile"])
os.remove(self.configs['config'])
except:
pass
super(TestACMEAccountDeactivate, self).tearDownClass()
try:
os.remove(cls.configs['config'])
except:
pass
super(TestACMEAccountDeactivate, cls).tearDownClass()
def test_success_account_deactivate(self):