feat: Integración de Siguldry PKCS#11 y spooler de firmas
* Implementación del rol de Ansible 'siguldry' para CentOS Stream 10 * Migración del plugin de firmas de Koji Hub a un spooler asíncrono * Modificación de roles para utilizar empaquetamiento RPM nativo de siguldry-pkcs11 * Documentación de decisiones (ADRs) y roadmap en docs/technical/ * Limpieza de binarios locales pesados de PKCS#11 en Ansible Co-developed-by: Gemini AI <renich+gemini@woralelandia.com> Signed-off-by: Rénich Bon Ćirić <renich@woralelandia.com>
This commit is contained in:
parent
d1203ba555
commit
89d6f66be8
32 changed files with 1453 additions and 321 deletions
241
roles/koji_hub/files/koji-sign-spooler
Normal file
241
roles/koji_hub/files/koji-sign-spooler
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import glob
|
||||
import logging
|
||||
import configparser
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('koji-sign-spooler')
|
||||
|
||||
SPOOL_DIR = '/var/spool/koji-sign/queue'
|
||||
FAILED_DIR = '/var/spool/koji-sign/failed'
|
||||
|
||||
def get_siguldry_key_info():
|
||||
"""
|
||||
Retrieves the public key and key ID/fingerprint from Siguldry.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env['SIGULDRY_SOCKET'] = '/run/siguldry-client-proxy/siguldry-client-proxy.socket'
|
||||
|
||||
# Get key info
|
||||
res = subprocess.run(
|
||||
['siguldry-client', '--credentials-directory', '/etc/credstore', '-c', '/etc/siguldry/client.toml', 'key', 'get', 'mxos-signing-key', '--certificates'],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
if res.returncode != 0:
|
||||
logger.error(f"Failed to get key info from Siguldry: {res.stderr}")
|
||||
return None, None
|
||||
|
||||
output = res.stdout
|
||||
start_marker = "-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
||||
end_marker = "-----END PGP PUBLIC KEY BLOCK-----"
|
||||
|
||||
start_idx = output.find(start_marker)
|
||||
end_idx = output.find(end_marker)
|
||||
if start_idx == -1 or end_idx == -1:
|
||||
logger.error("Could not find PGP public key block in Siguldry client output.")
|
||||
return None, None
|
||||
|
||||
pubkey = output[start_idx:end_idx + len(end_marker)]
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.pub', delete=False) as f:
|
||||
f.write(pubkey)
|
||||
tmp_name = f.name
|
||||
|
||||
try:
|
||||
gpg_res = subprocess.run(
|
||||
['gpg', '--show-keys', '--with-colons', tmp_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
if gpg_res.returncode != 0:
|
||||
logger.error(f"Failed to read GPG key ID: {gpg_res.stderr}")
|
||||
return None, None
|
||||
|
||||
key_id = None
|
||||
fingerprint = None
|
||||
for line in gpg_res.stdout.splitlines():
|
||||
parts = line.split(':')
|
||||
if parts[0] == 'pub':
|
||||
key_id = parts[4][-8:]
|
||||
elif parts[0] == 'fpr':
|
||||
fingerprint = parts[9]
|
||||
break
|
||||
|
||||
sig_key_id = fingerprint if fingerprint else key_id
|
||||
return pubkey, sig_key_id
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_queue():
|
||||
if not os.path.exists(SPOOL_DIR):
|
||||
return
|
||||
|
||||
# Import kojihub dynamically so that it loads after service starts
|
||||
import kojihub
|
||||
import koji
|
||||
|
||||
# Read configuration file to get KojiDir
|
||||
config = configparser.ConfigParser()
|
||||
config_files = ['/etc/koji-hub/hub.conf'] + sorted(glob.glob('/etc/koji-hub/hub.conf.d/*.conf'))
|
||||
config.read(config_files)
|
||||
|
||||
koji_dir = config.get('hub', 'KojiDir', fallback='/mnt/koji')
|
||||
|
||||
# Get list of files in spool directory
|
||||
files = sorted(os.listdir(SPOOL_DIR))
|
||||
if not files:
|
||||
return
|
||||
|
||||
# Connect to database
|
||||
try:
|
||||
kojihub.db.connect()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Koji Hub DB: {e}")
|
||||
return
|
||||
|
||||
for filename in files:
|
||||
file_path = os.path.join(SPOOL_DIR, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
build_id = int(filename)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid build ID in spool file name: {filename}")
|
||||
os.rename(file_path, os.path.join(FAILED_DIR, filename))
|
||||
continue
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
build_nvr = f.read().strip()
|
||||
|
||||
logger.info(f"Processing build {build_id} ({build_nvr}) for signing")
|
||||
|
||||
# Retry loop to wait for the completeBuild transaction to commit
|
||||
rpms = []
|
||||
for attempt in range(5):
|
||||
try:
|
||||
rpms = kojihub.list_rpms(buildID=build_id)
|
||||
if rpms:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Error querying RPMs (attempt {attempt+1}): {e}")
|
||||
logger.info(f"RPMs not found yet for build {build_id}, waiting 1 second... (attempt {attempt+1}/5)")
|
||||
time.sleep(1)
|
||||
|
||||
if not rpms:
|
||||
logger.error(f"No RPMs found for build {build_id} after retries. Moving spool file to failed.")
|
||||
os.makedirs(FAILED_DIR, exist_ok=True)
|
||||
os.rename(file_path, os.path.join(FAILED_DIR, filename))
|
||||
continue
|
||||
|
||||
# Get Siguldry key information
|
||||
pubkey, sig_key_id = get_siguldry_key_info()
|
||||
if not sig_key_id:
|
||||
logger.error("Failed to retrieve Siguldry key ID. Skipping build signing.")
|
||||
os.makedirs(FAILED_DIR, exist_ok=True)
|
||||
os.rename(file_path, os.path.join(FAILED_DIR, filename))
|
||||
continue
|
||||
|
||||
# Write public key to koji topdir so it can be served
|
||||
pubkey_path = os.path.join(koji_dir, 'gpg_key.pub')
|
||||
try:
|
||||
with open(pubkey_path, 'w') as f:
|
||||
f.write(pubkey)
|
||||
logger.info(f"Wrote public GPG key to {pubkey_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write public key to {pubkey_path}: {e}")
|
||||
|
||||
success = True
|
||||
for rpm in rpms:
|
||||
rpm_id = rpm['id']
|
||||
rpm_nvr = rpm['nvr']
|
||||
rpm_path = rpm['path']
|
||||
full_rpm_path = os.path.join(koji_dir, rpm_path)
|
||||
|
||||
try:
|
||||
logger.info(f"Signing RPM {rpm_nvr} via rpmsign and Siguldry PKCS#11")
|
||||
env = os.environ.copy()
|
||||
env['PKCS11_MODULE_PATH'] = '/usr/lib64/libsiguldry_pkcs11.so'
|
||||
env['SIGULDRY_SOCKET'] = '/run/siguldry-client-proxy/siguldry-client-proxy.socket'
|
||||
|
||||
# Sign using rpmsign command with Sequoia backend macro definitions
|
||||
cmd = [
|
||||
'rpmsign',
|
||||
'--define', '_openpgp_sign sq',
|
||||
'--define', f'_openpgp_sign_id {sig_key_id}',
|
||||
'--addsign', full_rpm_path
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"rpmsign failed for {rpm_nvr}: stdout: {result.stdout}, stderr: {result.stderr}")
|
||||
success = False
|
||||
else:
|
||||
logger.info(f"Successfully signed RPM {rpm_nvr} on disk. Extracting signature header.")
|
||||
sig_data = koji.rip_rpm_sighdr(full_rpm_path)
|
||||
kojihub.add_rpm_sig(rpm_id, sig_data, sigkey=sig_key_id)
|
||||
logger.info(f"Successfully imported signature for {rpm_nvr} with key {sig_key_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error signing RPM {rpm_nvr}: {e}")
|
||||
success = False
|
||||
|
||||
if success:
|
||||
try:
|
||||
kojihub.context.cnx.commit()
|
||||
logger.info(f"All signatures committed for build {build_id} ({build_nvr}).")
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to commit signatures to database: {e}")
|
||||
try:
|
||||
kojihub.context.cnx.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.error(f"Failed to sign some/all RPMs for build {build_id}. Keeping spool file for retry.")
|
||||
break
|
||||
|
||||
try:
|
||||
kojihub.context.cnx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def main():
|
||||
logger.info("Koji Sign Spooler Daemon started")
|
||||
os.makedirs(SPOOL_DIR, exist_ok=True)
|
||||
os.makedirs(FAILED_DIR, exist_ok=True)
|
||||
|
||||
while True:
|
||||
try:
|
||||
process_queue()
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled exception in spooler loop: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in a new issue