def account(config_path: str, directory_path: str): dnsrobocert_config = config.load(config_path) acme = dnsrobocert_config.get("acme", {}) email = acme.get("email_account") if not email: LOGGER.warning( "Parameter acme.email_account is not set, skipping ACME registration." ) return url = config.get_acme_url(dnsrobocert_config) utils.execute( [ sys.executable, "-m", "dnsrobocert.core.certbot", "register", *_DEFAULT_FLAGS, "--config-dir", directory_path, "--work-dir", os.path.join(directory_path, "workdir"), "--logs-dir", os.path.join(directory_path, "logs"), "-m", email, "--agree-tos", "--server", url, ], check=False, )
def main(args: List[str] = None) -> int: if not args: args = sys.argv[1:] parser = argparse.ArgumentParser() parser.add_argument("-t", "--type", choices=["auth", "cleanup", "deploy"], required=True) parser.add_argument("-c", "--config", required=True) parser.add_argument("-l", "--lineage", default="None", required=False) parsed_args = parser.parse_args(args) dnsrobocert_config = config.load(parsed_args.config) if not dnsrobocert_config: print( f"Error occured while loading the configuration file, aborting the `{parsed_args.type}` hook.", file=sys.stderr, ) return 1 try: globals()[parsed_args.type](dnsrobocert_config, parsed_args.lineage) except BaseException as e: print( f"Error while executing the `{parsed_args.type}` hook:", file=sys.stderr, ) print(e, file=sys.stderr) traceback.print_exc(file=sys.stderr) return 1 return 0
def test_pfx(_autorestart, _autocmd, _fix_permissions, fake_env, fake_config): archive_path = fake_env["archive"] key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) with open(archive_path / "privkey.pem", "wb") as f: f.write( key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), )) subject = issuer = x509.Name( [x509.NameAttribute(NameOID.COMMON_NAME, u"example.com")]) cert = (x509.CertificateBuilder().subject_name(subject).issuer_name( issuer).public_key(key.public_key()).serial_number( x509.random_serial_number()).not_valid_before( datetime.datetime.utcnow()).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=10)).sign(key, hashes.SHA256(), default_backend())) with open(archive_path / "cert.pem", "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) with open(archive_path / "chain.pem", "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) hooks.deploy(config.load(fake_config), LINEAGE) assert os.path.exists(archive_path / "cert.pfx") assert os.stat(archive_path / "cert.pfx").st_size != 0
def test_fix_permissions(_autorestart, _autocmd, _pfx_export, fake_config, fake_env): archive_path = str(fake_env["archive"]) live_path = str(fake_env["live"]) probe_file = os.path.join(archive_path, "dummy.txt") probe_dir = os.path.join(archive_path, "dummy_dir") probe_live_file = os.path.join(live_path, "dummy.txt") probe_live_dir = os.path.join(live_path, "dummy_dir") open(probe_file, "w").close() os.mkdir(probe_dir) with _mock_os_chown() as chown: hooks.deploy(config.load(fake_config), LINEAGE) assert os.stat(probe_file).st_mode & 0o777 == 0o666 assert os.stat(probe_dir).st_mode & 0o777 == 0o777 assert os.stat(archive_path).st_mode & 0o777 == 0o777 if POSIX_MODE: uid = pwd.getpwnam("nobody")[2] gid = grp.getgrnam("nogroup")[2] calls = [ call(archive_path, uid, gid), call(probe_file, uid, gid), call(probe_dir, uid, gid), call(live_path, uid, gid), call(probe_live_file, uid, gid), call(probe_live_dir, uid, gid), ] assert chown.call_args_list == calls
def test_autocmd(check_call, _exists, _autorestart, _pfx_export, _fix_permissions, fake_config): hooks.deploy(config.load(fake_config), LINEAGE) call_foo = call(["docker", "exec", "foo", "echo 'Hello World!'"]) call_bar = call(["docker", "exec", "bar", "echo 'Hello World!'"]) check_call.assert_has_calls([call_foo, call_bar])
def _process_config( config_path: str, directory_path: str, runtime_config_path: str, lock: threading.Lock, ): dnsrobocert_config = config.load(config_path) if not dnsrobocert_config: return if dnsrobocert_config.get("draft"): LOGGER.info( "Configuration file is in draft mode: no action will be done.") return with open(runtime_config_path, "w") as f: f.write(yaml.dump(dnsrobocert_config)) utils.configure_certbot_workspace(dnsrobocert_config, directory_path) LOGGER.info("Registering ACME account if needed.") certbot.account(runtime_config_path, directory_path, lock) LOGGER.info("Creating missing certificates if needed (~1min for each)") certificates = dnsrobocert_config.get("certificates", {}) for certificate in certificates: try: lineage = config.get_lineage(certificate) domains = certificate["domains"] force_renew = certificate.get("force_renew", False) LOGGER.info( f"Handling the certificate for domain(s): {', '.join(domains)}" ) certbot.certonly( runtime_config_path, directory_path, lineage, lock, domains, force_renew=force_renew, ) except BaseException as error: LOGGER.error( f"An error occurred while processing certificate config `{certificate}`:\n{error}" ) LOGGER.info("Revoke and delete certificates if needed") lineages = { config.get_lineage(certificate) for certificate in certificates } for domain in os.listdir(os.path.join(directory_path, "live")): if domain != "README": domain = re.sub(r"^\*\.", "", domain) if domain not in lineages: LOGGER.info(f"Removing the certificate {domain}") certbot.revoke(runtime_config_path, directory_path, domain, lock)
def test_good_config_minimal(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: true """) parsed = config.load(str(config_path)) assert parsed
def test_bad_config_wrong_schema(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: true wrong_property: bad """) parsed = config.load(str(config_path)) assert not parsed
def certonly( config_path: str, directory_path: str, lineage: str, lock: threading.Lock, domains: Optional[List[str]] = None, force_renew: bool = False, reuse_key: bool = False, ): if not domains: return url = config.get_acme_url(config.load(config_path)) additional_params = [] if force_renew: additional_params.append("--force-renew") if reuse_key: additional_params.append("--reuse-key") for domain in domains: additional_params.append("-d") additional_params.append(domain) utils.execute( [ sys.executable, "-m", "dnsrobocert.core.certbot", "certonly", *_DEFAULT_FLAGS, "--config-dir", directory_path, "--work-dir", os.path.join(directory_path, "workdir"), "--logs-dir", os.path.join(directory_path, "logs"), "--manual", "--preferred-challenges=dns", "--manual-auth-hook", _hook_cmd("auth", config_path, lineage), "--manual-cleanup-hook", _hook_cmd("cleanup", config_path, lineage), "--expand", "--deploy-hook", _hook_cmd("deploy", config_path, lineage), "--server", url, "--cert-name", lineage, *additional_params, ], lock=lock, )
def test_bad_config_wrong_posix_mode(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: true acme: certs_permissions: files_mode: 9999 """) parsed = config.load(str(config_path)) assert not parsed
def test_environment_variable_injection(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: ${DRAFT_VALUE} acme: certs_root_path: $${NOT_PARSED} profiles: - name: one provider: ${PROVIDER} certificates: - name: test.example.com domains: [test1.example.com, test2.example.com, '${ADDITIONAL_CERT}'] profile: one """) environ = os.environ.copy() try: os.environ.update({ "DRAFT_VALUE": "true", "PROVIDER": "one", "ADDITIONAL_CERT": "test3.example.com", }) parsed = config.load(str(config_path)) finally: os.environ.clear() os.environ.update(environ) assert parsed["draft"] is True assert parsed["acme"]["certs_root_path"] == "${NOT_PARSED}" assert parsed["profiles"][0]["provider"] == "one" assert "test3.example.com" in parsed["certificates"][0]["domains"] with pytest.raises(ValueError) as raised: config.load(str(config_path)) assert ( str(raised.value) == "Error while parsing config: environment variable DRAFT_VALUE does not exist." )
def test_autorestart(check_call, _exists, _autocmd, _pfx_export, _fix_permissions, fake_config): hooks.deploy(config.load(fake_config), LINEAGE) call_container1 = call(["docker", "restart", "container1"]) call_container2 = call(["docker", "restart", "container2"]) call_service1 = call([ "docker", "service", "update", "--detach=false", "--force", "service1" ]) call_service2 = call([ "docker", "service", "update", "--detach=false", "--force", "service2" ]) check_call.assert_has_calls( [call_container1, call_container2, call_service1, call_service2])
def test_bad_config_non_existent_profile(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: true profiles: - name: one provider: one certificates: - domains: [test.example.com] profile: two """) parsed = config.load(str(config_path)) assert not parsed
def test_bad_config_duplicated_cert_name(tmp_path): config_path = tmp_path / "config.yml" with open(str(config_path), "w") as f: f.write("""\ draft: true profiles: - name: one provider: one certificates: - domains: [test.example.com] profile: one - name: test.example.com domains: [test1.example.com, test2.example.com] profile: one """) parsed = config.load(str(config_path)) assert not parsed
def renew(config_path: str, directory_path: str): dnsrobocert_config = config.load(config_path) if dnsrobocert_config: utils.execute([ sys.executable, "-m", "dnsrobocert.core.certbot", "renew", *_DEFAULT_FLAGS, "--config-dir", directory_path, "--deploy-hook", _hook_cmd("deploy", config_path), "--work-dir", os.path.join(directory_path, "workdir"), "--logs-dir", os.path.join(directory_path, "logs"), ])
def revoke(config_path: str, directory_path: str, lineage: str): url = config.get_acme_url(config.load(config_path)) utils.execute([ sys.executable, "-m", "dnsrobocert.core.certbot", "revoke", "-n", "--config-dir", directory_path, "--work-dir", os.path.join(directory_path, "workdir"), "--logs-dir", os.path.join(directory_path, "logs"), "--server", url, "--cert-path", os.path.join(directory_path, "live", lineage, "cert.pem"), ])
def test_legacy_migration(tmp_path, monkeypatch): config_path = tmp_path / "dnsrobocert" / "config.yml" legacy_config_domain_file = tmp_path / "old_config" / "domains.conf" generated_config_path = tmp_path / "dnsrobocert" / "config-generated.yml" os.mkdir(os.path.dirname(legacy_config_domain_file)) with open( os.path.join(os.path.dirname(legacy_config_domain_file), "lexicon.yml"), "w") as f: f.write("""\ ovh: auth_application_secret: SECRET additional_config: ADDITIONAL """) with open( os.path.join(os.path.dirname(legacy_config_domain_file), "lexicon_ovh.yml"), "w") as f: f.write("""\ auth_consumer_key: CONSUMER_KEY """) with open(str(legacy_config_domain_file), "w") as f: f.write("""\ test1.sub.example.com test2.sub.example.com autorestart-containers=container1,container2 autocmd-containers=container3:cmd3 arg3,container4:cmd4 arg4a arg4b *.sub.example.com sub.example.com """) monkeypatch.setenv("LEXICON_PROVIDER", "ovh") monkeypatch.setenv("LEXICON_OVH_AUTH_APPLICATION_KEY", "KEY") monkeypatch.setenv("LEXICON_OPTIONS", "--delegated=sub.example.com") monkeypatch.setenv( "LEXICON_PROVIDER_OPTIONS", "--auth-entrypoint ovh-eu --auth-application-secret=SECRET-OVERRIDE", ) monkeypatch.setenv("LEXICON_SLEEP_TIME", "60") monkeypatch.setenv("LEXICON_MAX_CHECKS", "3") monkeypatch.setenv("LEXICON_TTL", "42") monkeypatch.setenv("LETSENCRYPT_USER_MAIL", "*****@*****.**") monkeypatch.setenv("LETSENCRYPT_STAGING", "true") monkeypatch.setenv("LETSENCRYPT_ACME_V1", "true") monkeypatch.setenv("CERTS_DIRS_MODE", "0755") monkeypatch.setenv("CERTS_FILES_MODE", "0644") monkeypatch.setenv("CERTS_USER_OWNER", "nobody") monkeypatch.setenv("CERTS_GROUP_OWNER", "nogroup") monkeypatch.setenv("PFX_EXPORT", "true") monkeypatch.setenv("PFX_EXPORT_PASSPHRASE", "PASSPHRASE") monkeypatch.setenv("DEPLOY_HOOK", "./deploy.sh") with mock.patch( "dnsrobocert.core.legacy.LEGACY_CONFIGURATION_PATH", new=legacy_config_domain_file, ): legacy.migrate(config_path) assert config.load(generated_config_path) with open(generated_config_path) as f: generated_data = f.read() assert (generated_data == """\ acme: api_version: 1 certs_permissions: dirs_mode: 493 files_mode: 420 group: nogroup user: nobody email_account: [email protected] staging: true certificates: - autocmd: - cmd: cmd3 arg3 containers: - container3 - cmd: cmd4 arg4a arg4b containers: - container4 autorestart: - containers: - container1 - container2 deploy_hook: ./deploy.sh domains: - test1.sub.example.com - test2.sub.example.com name: test1.sub.example.com pfx: export: true passphrase: PASSPHRASE profile: ovh - deploy_hook: ./deploy.sh domains: - '*.sub.example.com' - sub.example.com name: sub.example.com pfx: export: true passphrase: PASSPHRASE profile: ovh profiles: - delegated_subdomain: sub.example.com max_checks: 3 name: ovh provider: ovh provider_options: additional_config: ADDITIONAL auth_application_key: KEY auth_application_secret: SECRET-OVERRIDE auth_consumer_key: CONSUMER_KEY auth_entrypoint: ovh-eu sleep_time: 60 ttl: 42 """)
def test_deploy_cli(deploy, fake_config): hooks.main(["-t", "deploy", "-c", str(fake_config), "-l", LINEAGE]) deploy.assert_called_with(config.load(fake_config), LINEAGE)