diff --git a/docs/vault-approle.md b/docs/vault-approle.md index ab2d9fe..18f39a1 100644 --- a/docs/vault-approle.md +++ b/docs/vault-approle.md @@ -1,6 +1,6 @@ # Automatic renewal of a client certificate on Ubuntu with Vault AppRole -This guide shows how an unattended Ubuntu server gets a client certificate from a remote Vault server with the AppRole authentication method, and keeps it fresh. A systemd timer starts a renewal service, the service renews the certificate before it expires, and after a successful renewal it runs a configurable Bash hook that signals the application to reread the certificate or restarts it. +This guide shows how an unattended Ubuntu server gets a client certificate from a remote Vault server with the AppRole authentication method, and keeps it fresh. A systemd timer starts a renewal service, the service renews the certificate before it expires, and after a successful renewal it runs a configurable Bash hook that signals the application to reread the certificate or restarts it. The server talks to Vault either with the Vault CLI (section 2) or with Python 3 through the Vault HTTP API (section 2a). ## Prerequisites @@ -260,6 +260,43 @@ sudo systemctl daemon-reload sudo systemctl enable --now vault-cert-renew.timer ``` +## 2a. Alternative: use the Vault HTTP API on the server + +Role: **Server administrator** + +The server does not need the Vault CLI. Python 3 handles the HTTP calls and the JSON answers of the Vault API with its standard library only, and `openssl` creates the key and the CSR. Python 3 is `python3` 3.12 on Ubuntu 24.04 and 3.14 on Ubuntu 26.04. The configuration file, the hook and the systemd units from section 2 stay the same. Only three parts of section 2 are replaced: the installation of the Vault CLI, the unwrapping of the secret ID and the renewal script. + +### Install the tools + +Use these commands instead of the installation of the Vault CLI. They do nothing when the packages are already installed: + +```bash +sudo apt update +sudo apt install -y python3 openssl +``` + +### Install the renewal script + +The renewal script is `vault_cert_renew.py` in the repository. Install it at the path that the systemd service uses, `/usr/local/sbin/vault-cert-renew`, so the service stays unchanged: + +```bash +sudo install -m 0755 vault_cert_renew.py /usr/local/sbin/vault-cert-renew +``` + +It reads the same settings from the configuration file, and `VAULT_ADDR` is required. With the `--unwrap` option it also stores the secret ID from a response wrapping token, so the Vault CLI is not needed for that either. + +The script has the same behavior as the CLI version: it exits without contacting Vault while the certificate is valid, replaces the files only when the new certificate matches the new key, and runs the hook after a successful renewal. The Vault token stays in memory and the batch token expires on its own. A failed Vault call prints the status code and the answer of Vault to the journal. + +### Store the credentials + +Create the directories and the credential files as in section 2 and write the role ID the same way. Then store the secret ID with the script. It asks for the wrapping token without echo, so the token does not appear in the process list or in the shell history. The configuration file is not read in this step, so pass the address of Vault: + +```bash +sudo VAULT_ADDR=https://vault.example.com:8200 /usr/local/sbin/vault-cert-renew --unwrap +``` + +The wrapping token can be unwrapped only once, as with the CLI. The script writes the secret ID to `/etc/vault-cert/secret_id` with the access limited to root. + ## 3. Verify and operate Role: **Server administrator** diff --git a/vault_cert_renew.py b/vault_cert_renew.py new file mode 100755 index 0000000..65a94a9 --- /dev/null +++ b/vault_cert_renew.py @@ -0,0 +1,139 @@ +#!/usr/bin/python3 +import argparse +import getpass +import json +import os +import ssl +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request + +parser = argparse.ArgumentParser(description="Renew the client certificate from Vault with AppRole.") +parser.add_argument( + "--unwrap", + action="store_true", + help="read a response wrapping token and store the unwrapped secret ID", +) +args = parser.parse_args() + + +def setting(name, default=None): + value = os.environ.get(name, default) + if value is None: + sys.exit(f"{name} is not set") + return value + + +vault_addr = setting("VAULT_ADDR").rstrip("/") +context = ssl.create_default_context(cafile=os.environ.get("VAULT_CACERT") or None) + + +def api(path, body=None, token=None): + headers = {"Content-Type": "application/json"} + if token: + headers["X-Vault-Token"] = token + request = urllib.request.Request( + f"{vault_addr}/v1/{path}", + data=json.dumps(body or {}).encode(), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, context=context) as response: + return json.load(response) + except urllib.error.HTTPError as error: + sys.exit(f"Vault returned {error.code} for {path}: {error.read().decode()}") + + +role_id_file = setting("ROLE_ID_FILE", "/etc/vault-cert/role_id") +secret_id_file = setting("SECRET_ID_FILE", "/etc/vault-cert/secret_id") + +# Store the secret ID that is wrapped in a single use response wrapping token. +if args.unwrap: + wrapping_token = getpass.getpass("Wrapping token: ") + secret_id = api("sys/wrapping/unwrap", token=wrapping_token)["data"]["secret_id"] + descriptor = os.open(secret_id_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w") as file: + file.write(secret_id) + print(f"The secret ID was written to {secret_id_file}") + sys.exit(0) + +cert_dir = setting("CERT_DIR", "/var/lib/vault-cert") +common_name = setting("CERT_CN") +sign_path = setting("SIGN_PATH", "pki_int/sign/wif-client") +renew_before_days = int(setting("RENEW_BEFORE_DAYS", "30")) +cert_ttl = setting("CERT_TTL", "8760h") +hook = os.environ.get("HOOK", "") + +cert = os.path.join(cert_dir, "client_cert.pem") +key = os.path.join(cert_dir, "client_key.pem") + +# Do nothing while the certificate is valid for longer than the renewal window. +if os.path.isfile(cert) and os.path.isfile(key): + valid = subprocess.run( + ["openssl", "x509", "-checkend", str(renew_before_days * 86400), "-noout", "-in", cert], + capture_output=True, + ) + if valid.returncode == 0: + print(f"The certificate is valid for more than {renew_before_days} days, nothing to do") + sys.exit(0) + +os.makedirs(cert_dir, mode=0o750, exist_ok=True) +with tempfile.TemporaryDirectory(dir=cert_dir, prefix=".renew.") as workdir: + new_key = os.path.join(workdir, "client_key.pem") + new_cert = os.path.join(workdir, "client_cert.pem") + csr_file = os.path.join(workdir, "client.csr") + + print("Logging in to Vault with AppRole") + with open(role_id_file) as file: + role_id = file.read().strip() + with open(secret_id_file) as file: + secret_id = file.read().strip() + token = api("auth/approle/login", {"role_id": role_id, "secret_id": secret_id})["auth"]["client_token"] + + print(f"Generating the key and the CSR for {common_name}") + subprocess.run( + ["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:4096", "-out", new_key], + check=True, + ) + subprocess.run( + ["openssl", "req", "-new", "-key", new_key, "-subj", f"/CN={common_name}", "-out", csr_file], + check=True, + ) + + print("Signing the CSR") + with open(csr_file) as file: + csr = file.read() + signed = api(sign_path, {"csr": csr, "common_name": common_name, "ttl": cert_ttl}, token) + with open(new_cert, "w") as file: + file.write(signed["data"]["certificate"].rstrip("\n") + "\n") + + # Replace the files only when the new certificate matches the new key. + cert_public = subprocess.run( + ["openssl", "x509", "-in", new_cert, "-noout", "-pubkey"], + capture_output=True, text=True, check=True, + ).stdout + key_public = subprocess.run( + ["openssl", "pkey", "-in", new_key, "-pubout"], + capture_output=True, text=True, check=True, + ).stdout + if cert_public != key_public: + sys.exit("The issued certificate does not match the generated key") + + os.chmod(new_key, 0o600) + os.chmod(new_cert, 0o644) + os.replace(new_key, key) + os.replace(new_cert, cert) + +end_date = subprocess.run( + ["openssl", "x509", "-in", cert, "-noout", "-enddate"], + capture_output=True, text=True, check=True, +).stdout.strip().split("=", 1)[1] +print(f"The certificate was renewed, it expires on {end_date}") + +if hook: + print(f"Running the hook {hook}") + subprocess.run([hook], env={**os.environ, "CERT_FILE": cert, "KEY_FILE": key}, check=True)