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
This diff is collapsed.
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):
""" Test success account key deactivate """
with self.assertLogs(level='INFO') as accountdeactivatelog:
tools.acme_account_deactivate.main(["--account-key", self.configs['key'],
"--acme-directory", ACMEDirectory])
self.assertIn("INFO:acme_account_deactivate:Account key deactivated !",
accountdeactivatelog.output)
"--acme-directory", ACME_DIRECTORY])
self.assertIn("INFO:acme_account_deactivate:The account has been deactivated.",
accountdeactivatelog.output)
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
unittest.main()
import unittest, os, time, configparser
"""Test acme_account_rollover script with real ACME server"""
import unittest
import os
import configparser
import acme_dns_tiny
from tests.config_factory import generate_acme_account_rollover_config
from tools.acme_account_deactivate import account_deactivate
import tools.acme_account_rollover
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 TestACMEAccountRollover(unittest.TestCase):
"Tests for acme_account_rollover"
"""Tests for acme_account_rollover."""
@classmethod
def setUpClass(self):
self.configs = generate_acme_account_rollover_config()
acme_dns_tiny.main([self.configs['config']])
super(TestACMEAccountRollover, self).setUpClass()
def setUpClass(cls):
cls.configs = generate_acme_account_rollover_config()
acme_dns_tiny.main([cls.configs['config']])
super(TestACMEAccountRollover, cls).setUpClass()
# To clean ACME staging server and close correctly temporary files
# pylint: disable=bare-except
@classmethod
def tearDownClass(self):
# deactivate account key registration at end of tests
# (we assume the key has been roll oved)
account_deactivate(self.configs["newaccountkey"], ACMEDirectory)
def tearDownClass(cls):
# Remove temporary files
parser = configparser.ConfigParser()
parser.read(self.configs['config'])
parser.read(cls.configs['config'])
try:
# deactivate account key registration at end of tests
# (we assume the key has been rolled over)
account_deactivate(cls.configs["new_account_key"], ACME_DIRECTORY)
except:
pass
try:
os.remove(parser["acmednstiny"]["AccountKeyFile"])
except:
pass
try:
os.remove(parser["acmednstiny"]["CSRFile"])
os.remove(self.configs["newaccountkey"])
os.remove(self.configs['config'])
except:
pass
super(TestACMEAccountRollover, self).tearDownClass()
try:
os.remove(cls.configs["new_account_key"])
except:
pass
try:
os.remove(cls.configs['config'])
except:
pass
super(TestACMEAccountRollover, cls).tearDownClass()
def test_success_account_rollover(self):
""" Test success account key rollover """
""" Test success account key rollover."""
with self.assertLogs(level='INFO') as accountrolloverlog:
tools.acme_account_rollover.main(["--current", self.configs['oldaccountkey'],
"--new", self.configs['newaccountkey'],
"--acme-directory", ACMEDirectory])
self.assertIn("INFO:acme_account_rollover:Account keys rolled over !",
accountrolloverlog.output)
tools.acme_account_rollover.main(["--current", self.configs['old_account_key'],
"--new", self.configs['new_account_key'],
"--acme-directory", ACME_DIRECTORY])
self.assertIn("INFO:acme_account_rollover:Keys rolled over.", accountrolloverlog.output)
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
unittest.main()
"""Unit tests for the acme_dns_tiny script"""
import unittest
import sys
import os
import configparser
import dns.version
import acme_dns_tiny
from tests.config_factory import generate_acme_dns_tiny_unit_test_config
class TestACMEDNSTiny(unittest.TestCase):
"Tests for acme_dns_tiny.get_crt()"
@classmethod
def setUpClass(cls):
print("Init acme_dns_tiny with python modules:")
print(" - python: {0}".format(sys.version))
print(" - dns python: {0}".format(dns.version.version))
cls.configs = generate_acme_dns_tiny_unit_test_config()
sys.stdout.flush()
super(TestACMEDNSTiny, cls).setUpClass()
# Close correctly temporary files
@classmethod
def tearDownClass(cls):
# close temp files correctly
for conffile in cls.configs:
parser = configparser.ConfigParser()
parser.read(cls.configs[conffile])
os.remove(parser["acmednstiny"]["AccountKeyFile"])
os.remove(parser["acmednstiny"]["CSRFile"])
os.remove(cls.configs[conffile])
super(TestACMEDNSTiny, cls).tearDownClass()
def test_failure_notcompleted_configuration(self):
""" Configuration file have to be completed """
self.assertRaisesRegex(ValueError, r"Some required settings are missing.",
acme_dns_tiny.main, [self.configs['missing_dns'], "--verbose"])
if __name__ == "__main__": # pragma: no cover
unittest.main()
#!/usr/bin/env python3
import sys, argparse, subprocess, json, base64, binascii, re, copy, logging, requests
"""Tiny script to deactivate account on an ACME server."""
import sys
import argparse
import subprocess
import json
import base64
import binascii
import re
import copy
import logging
import requests
LOGGER = logging.getLogger("acme_account_deactivate")
LOGGER.addHandler(logging.StreamHandler())
def _b64(text):
"""Encodes text as base64 as specified in ACME RFC."""
return base64.urlsafe_b64encode(text).decode("utf8").rstrip("=")
def _openssl(command, options, communicate=None):
"""Run openssl command line and raise IOError on non-zero return."""
openssl = subprocess.Popen(["openssl", command] + options, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = openssl.communicate(communicate)
if openssl.returncode != 0:
raise IOError("OpenSSL Error: {0}".format(err))
return out
def account_deactivate(accountkeypath, acme_directory, log=LOGGER):
def _b64(b):
""""Encodes string as base64 as specified in ACME RFC """
return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
def _openssl(command, options, communicate=None):
"""Run openssl command line and raise IOError on non-zero return."""
openssl = subprocess.Popen(["openssl", command] + options,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = openssl.communicate(communicate)
if openssl.returncode != 0:
raise IOError("OpenSSL Error: {0}".format(err))
return out
"""Deactivate an ACME account."""
def _send_signed_request(url, payload):
"""Sends signed requests to ACME server."""
nonlocal jws_nonce
if payload == "": # on POST-as-GET, final payload has to be just empty string
nonlocal nonce
if payload == "": # on POST-as-GET, final payload has to be just empty string
payload64 = ""
else:
payload64 = _b64(json.dumps(payload).encode("utf8"))
protected = copy.deepcopy(jws_header)
protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
protected = copy.deepcopy(private_acme_signature)
protected["nonce"] = nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
protected["url"] = url
if url == acme_config["newAccount"]:
del protected["kid"]
if "kid" in protected:
del protected["kid"]
else:
del protected["jwk"]
protected64 = _b64(json.dumps(protected).encode("utf8"))
signature = _openssl("dgst", ["-sha256", "-sign", accountkeypath],
"{0}.{1}".format(protected64, payload64).encode("utf8"))
jose = {
"protected": protected64, "payload": payload64,"signature": _b64(signature)
"protected": protected64, "payload": payload64, "signature": _b64(signature)
}
joseheaders = {
'User-Agent': adtheaders.get('User-Agent'),
'Content-Type': 'application/jose+json'
}
try:
response = requests.post(url, json=jose, headers=joseheaders)
except requests.exceptions.RequestException as error:
response = error.response
finally:
jws_nonce = response.headers['Replay-Nonce']
try:
return response, response.json()
except ValueError as error:
return response, json.dumps({})
nonce = response.headers['Replay-Nonce']
try:
return response, response.json()
except ValueError: # if body is empty or not JSON formatted
return response, json.dumps({})
# main code
adtheaders = {'User-Agent': 'acme-dns-tiny/2.1'}
joseheaders = copy.deepcopy(adtheaders)
joseheaders['Content-Type'] = 'application/jose+json'
adtheaders = {'User-Agent': 'acme-dns-tiny/2.2'}
nonce = None
log.info("Fetch informations from the ACME directory.")
directory = requests.get(acme_directory, headers=adtheaders)
acme_config = directory.json()
acme_config = requests.get(acme_directory, headers=adtheaders).json()
log.info("Parsing account key.")
log.info("Get private signature from account key.")
accountkey = _openssl("rsa", ["-in", accountkeypath, "-noout", "-text"])
pub_hex, pub_exp = re.search(
r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
accountkey.decode("utf8"), re.MULTILINE | re.DOTALL).groups()
pub_exp = "{0:x}".format(int(pub_exp))
pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
jws_header = {
# That signature is used to authenticate with the ACME server, it needs to be safely kept
private_acme_signature = {
"alg": "RS256",
"jwk": {
"e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
"kty": "RSA",
"n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
},
"kid": None,
}
jws_nonce = None
log.info("Ask CA provider account url.")
account_request = {}
account_request["onlyReturnExisting"] = True
http_response, result = _send_signed_request(acme_config["newAccount"], account_request)
log.info("Ask to the ACME server the account identifier to complete the private signature.")
http_response, result = _send_signed_request(acme_config["newAccount"],
{"onlyReturnExisting": True})
if http_response.status_code == 200:
jws_header["kid"] = http_response.headers['Location']
private_acme_signature["kid"] = http_response.headers['Location']
else:
raise ValueError("Error looking or account URL: {0} {1}".format(http_response.status_code, result))
raise ValueError("Error looking or account URL: {0} {1}"
.format(http_response.status_code, result))
log.info("Deactivating account...")
http_response, result = _send_signed_request(jws_header["kid"], {"status": "deactivated"})
log.info("Deactivating the account.")
http_response, result = _send_signed_request(private_acme_signature["kid"],
{"status": "deactivated"})
if http_response.status_code == 200:
log.info("Account key deactivated !")
log.info("The account has been deactivated.")
else:
raise ValueError("Error while deactivating the account key: {0} {1}".format(http_response.status_code, result))
raise ValueError("Error while deactivating the account key: {0} {1}"
.format(http_response.status_code, result))
def main(argv):
"""Parse arguments and deactivate account."""
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Tiny ACME client to deactivate ACME account",
description="Tiny ACME script to deactivate an ACME account",
epilog="""This script permanently *deactivates* an ACME account.
You should revoke your certificates *before* using this script,
as the server won't accept any further request with this account.