Support OpenDNSSEC 2.1: new ods-signer protocol

The communication between ods-signer and the socket-activated process
has changed with OpenDNSSEC 2.1. Adapt ipa-ods-exporter to support also
the new protocol.

The internal database was also modified. Add a wrapper calling the
right code (table names hab=ve changed, as well as table columns).

With OpenDNSSEC the policy also needs to be explicitely loaded after
ods-enforcer-db-setup has been run, with
ods-enforcer policy import

The command ods-ksmutil notify must be replace with ods-enforce flush.

Related: https://pagure.io/freeipa/issue/8214
Reviewed-By: Alexander Bokovoy <abokovoy@redhat.com>
Reviewed-By: Christian Heimes <cheimes@redhat.com>
This commit is contained in:
Florence Blanc-Renaud 2020-03-05 15:54:40 +01:00
parent b857828180
commit 8080bf7b35
8 changed files with 266 additions and 34 deletions

View File

@ -22,7 +22,6 @@ import os
import socket import socket
import select import select
import sys import sys
import sqlite3
import traceback import traceback
import dateutil.tz import dateutil.tz
@ -42,6 +41,8 @@ from ipaserver.dnssec.abshsm import sync_pkcs11_metadata, wrappingmech_name2id
from ipaserver.dnssec.ldapkeydb import LdapKeyDB, str_hexlify from ipaserver.dnssec.ldapkeydb import LdapKeyDB, str_hexlify
from ipaserver.dnssec.localhsm import LocalHSM from ipaserver.dnssec.localhsm import LocalHSM
from ipaserver.dnssec import opendnssec
logger = logging.getLogger(os.path.basename(__file__)) logger = logging.getLogger(os.path.basename(__file__))
DAEMONNAME = 'ipa-ods-exporter' DAEMONNAME = 'ipa-ods-exporter'
@ -233,26 +234,19 @@ def get_ldap_keys(ldap, zone_dn):
def get_ods_keys(zone_name): def get_ods_keys(zone_name):
# get zone ID # get zone ID
cur = db.execute("SELECT id FROM zones WHERE LOWER(name)=LOWER(?)", rows = db.get_zone_id(zone_name)
(zone_name,))
rows = cur.fetchall()
if len(rows) != 1: if len(rows) != 1:
raise ValueError("exactly one DNS zone should exist in ODS DB") raise ValueError("exactly one DNS zone should exist in ODS DB")
zone_id = rows[0][0] zone_id = rows[0]
# get relevant keys for given zone ID: # get relevant keys for given zone ID:
# ignore keys which were generated but not used yet # ignore keys which were generated but not used yet
# key state check is using constants from # key state check is using constants from
# OpenDNSSEC's enforcer/ksm/include/ksm/ksm.h # OpenDNSSEC's enforcer/ksm/include/ksm/ksm.h
# WARNING! OpenDNSSEC version 1 and 2 are using different constants! # WARNING! OpenDNSSEC version 1 and 2 are using different constants!
cur = db.execute("SELECT kp.HSMkey_id, kp.generate, kp.algorithm, " rows = db.get_keys_for_zone(zone_id)
"dnsk.publish, dnsk.active, dnsk.retire, dnsk.dead, "
"dnsk.keytype, dnsk.state "
"FROM keypairs AS kp "
"JOIN dnsseckeys AS dnsk ON kp.id = dnsk.keypair_id "
"WHERE dnsk.zone_id = ?", (zone_id,))
keys = {} keys = {}
for row in cur: for row in rows:
key_data = sql2ldap_flags(row['keytype']) key_data = sql2ldap_flags(row['keytype'])
if key_data.get('idnsSecKeyZONE') != 'TRUE': if key_data.get('idnsSecKeyZONE') != 'TRUE':
raise ValueError("unexpected key type 0x%x" % row['keytype']) raise ValueError("unexpected key type 0x%x" % row['keytype'])
@ -483,11 +477,13 @@ def receive_systemd_command():
sys.exit(1) sys.exit(1)
logger.debug('accepting new connection') logger.debug('accepting new connection')
conn, _addr = sck.accept() conn_tmp, _addr = sck.accept()
conn = opendnssec.ODSSignerConn(conn_tmp)
logger.debug('accepted new connection %s', repr(conn)) logger.debug('accepted new connection %s', repr(conn))
# this implements cmdhandler_handle_cmd() logic # this implements cmdhandler_handle_cmd() logic
cmd = conn.recv(ODS_SE_MAXLINE).strip() cmd = conn.read_cmd()
# ODS uses an ASCII protocol, the rest of the code expects str # ODS uses an ASCII protocol, the rest of the code expects str
if six.PY3: if six.PY3:
cmd = cmd.decode('ascii') cmd = cmd.decode('ascii')
@ -548,9 +544,7 @@ def send_systemd_reply(conn, reply):
# This is necessary to let Enforcer to unlock the ODS DB. # This is necessary to let Enforcer to unlock the ODS DB.
if six.PY3: if six.PY3:
reply = reply.encode('ascii') reply = reply.encode('ascii')
conn.send(reply + b'\n') conn.send_reply_and_close(reply)
conn.shutdown(socket.SHUT_RDWR)
conn.close()
def cmd2ods_zone_name(cmd): def cmd2ods_zone_name(cmd):
# ODS stores zone name without trailing period # ODS stores zone name without trailing period
@ -566,7 +560,11 @@ def sync_zone(ldap, dns_dn, zone_name):
Key material has to be synchronized elsewhere. Key material has to be synchronized elsewhere.
Keep in mind that keys could be shared among multiple zones!""" Keep in mind that keys could be shared among multiple zones!"""
logger.debug('%s: synchronizing zone "%s"', zone_name, zone_name) logger.debug('%s: synchronizing zone "%s"', zone_name, zone_name)
ods_keys = get_ods_keys(zone_name) try:
ods_keys = get_ods_keys(zone_name)
except ValueError as e:
logger.error(str(e))
return
ods_keys_id = set(ods_keys.keys()) ods_keys_id = set(ods_keys.keys())
ldap_zone = get_ldap_zone(ldap, dns_dn, zone_name) ldap_zone = get_ldap_zone(ldap, dns_dn, zone_name)
@ -724,6 +722,14 @@ except KeyError as e:
cmd = sys.argv[1] cmd = sys.argv[1]
exitcode, msg, zone_name, cmd = parse_command(cmd) exitcode, msg, zone_name, cmd = parse_command(cmd)
if exitcode:
logger.debug("parse_command returned exitcode: %d", exitcode)
if msg:
logger.debug("parse_command returned msg: %s", msg)
if zone_name:
logger.debug("parse_command returned zone_name: %s", zone_name)
if cmd:
logger.debug("parse_command returned cmd: %s", cmd)
if exitcode is not None: if exitcode is not None:
if conn: if conn:
@ -747,9 +753,7 @@ try:
# Beware: Reply can be sent back only after DB is unlocked and closed # Beware: Reply can be sent back only after DB is unlocked and closed
# otherwise ods-enforcerd will fail. # otherwise ods-enforcerd will fail.
db = sqlite3.connect(paths.OPENDNSSEC_KASP_DB) db = opendnssec.ODSDBConnection()
db.row_factory = sqlite3.Row
db.execute('BEGIN')
if zone_name is not None: if zone_name is not None:
# only one zone should be processed # only one zone should be processed
@ -759,8 +763,8 @@ try:
cleanup_ldap_zone(ldap, dns_dn, zone_name) cleanup_ldap_zone(ldap, dns_dn, zone_name)
else: else:
# process all zones # process all zones
for zone_row in db.execute("SELECT name FROM zones"): for zone_name in db.get_zones():
sync_zone(ldap, dns_dn, zone_row['name']) sync_zone(ldap, dns_dn, zone_name)
### DNSSEC master: DNSSEC key material purging ### DNSSEC master: DNSSEC key material purging
# references to old key material were removed above in sync_zone() # references to old key material were removed above in sync_zone()

View File

@ -298,6 +298,36 @@ class BaseTaskNamespace:
cmd = [paths.ODS_ENFORCER_DB_SETUP] cmd = [paths.ODS_ENFORCER_DB_SETUP]
return ipautil.run(cmd, stdin="y", runas=constants.ODS_USER) return ipautil.run(cmd, stdin="y", runas=constants.ODS_USER)
def run_ods_notify(self, **kwargs):
"""Notify ods-enforcerd to reload its conf."""
if paths.ODS_KSMUTIL is not None and os.path.exists(paths.ODS_KSMUTIL):
# OpenDNSSEC 1.4
cmd = [paths.ODS_KSMUTIL, 'notify']
else:
# OpenDNSSEC 2.x
cmd = [paths.ODS_ENFORCER, 'flush']
# run commands as ODS user
if os.geteuid() == 0:
kwargs['runas'] = constants.ODS_USER
return ipautil.run(cmd, **kwargs)
def run_ods_policy_import(self, **kwargs):
"""Run OpenDNSSEC manager command to import policy."""
# This step is needed with OpenDNSSEC 2.1 only
if paths.ODS_KSMUTIL is not None and os.path.exists(paths.ODS_KSMUTIL):
# OpenDNSSEC 1.4
return
# OpenDNSSEC 2.x
cmd = [paths.ODS_ENFORCER, 'policy', 'import']
# run commands as ODS user
if os.geteuid() == 0:
kwargs['runas'] = constants.ODS_USER
ipautil.run(cmd, **kwargs)
def run_ods_manager(self, params, **kwargs): def run_ods_manager(self, params, **kwargs):
"""Run OpenDNSSEC manager command (ksmutil, enforcer) """Run OpenDNSSEC manager command (ksmutil, enforcer)

View File

@ -0,0 +1,45 @@
#
# Copyright (C) 2020 FreeIPA Contributors see COPYING for license
#
import socket
from ipaserver.dnssec._odsbase import AbstractODSDBConnection
from ipaserver.dnssec._odsbase import AbstractODSSignerConn
from ipaserver.dnssec._odsbase import ODS_SE_MAXLINE
class ODSDBConnection(AbstractODSDBConnection):
def get_zones(self):
cur = self._db.execute("SELECT name from zones")
rows = cur.fetchall()
return [row['name'] for row in rows]
def get_zone_id(self, zone_name):
cur = self._db.execute(
"SELECT id FROM zones WHERE LOWER(name)=LOWER(?)",
(zone_name,))
rows = cur.fetchall()
return [row[0] for row in rows]
def get_keys_for_zone(self, zone_id):
cur = self._db.execute(
"SELECT kp.HSMkey_id, kp.generate, kp.algorithm, "
"dnsk.publish, dnsk.active, dnsk.retire, dnsk.dead, "
"dnsk.keytype, dnsk.state "
"FROM keypairs AS kp "
"JOIN dnsseckeys AS dnsk ON kp.id = dnsk.keypair_id "
"WHERE dnsk.zone_id = ?", (zone_id,))
for row in cur:
yield row
class ODSSignerConn(AbstractODSSignerConn):
def read_cmd(self):
cmd = self._conn.recv(ODS_SE_MAXLINE).strip()
return cmd
def send_reply_and_close(self, reply):
self._conn.send(reply + b'\n')
self._conn.shutdown(socket.SHUT_RDWR)
self._conn.close()

View File

@ -0,0 +1,67 @@
#
# Copyright (C) 2020 FreeIPA Contributors see COPYING for license
#
from datetime import datetime
from ipaserver.dnssec._odsbase import AbstractODSDBConnection
from ipaserver.dnssec._odsbase import AbstractODSSignerConn
from ipaserver.dnssec._odsbase import ODS_SE_MAXLINE
CLIENT_OPC_STDOUT = 0
CLIENT_OPC_EXIT = 4
class ODSDBConnection(AbstractODSDBConnection):
def get_zones(self):
cur = self._db.execute("SELECT name from zone")
rows = cur.fetchall()
return [row['name'] for row in rows]
def get_zone_id(self, zone_name):
cur = self._db.execute(
"SELECT id FROM zone WHERE LOWER(name)=LOWER(?)",
(zone_name,))
rows = cur.fetchall()
return [row[0] for row in rows]
def get_keys_for_zone(self, zone_id):
cur = self._db.execute(
"SELECT hsmk.locator, hsmk.inception, hsmk.algorithm, "
"hsmk.keyType, hsmk.state "
"FROM hsmKey AS hsmk "
"JOIN keyData AS kd ON hsmk.id = kd.hsmKeyId "
"WHERE kd.zoneId = ?", (zone_id,))
for row in cur:
key = dict()
key['HSMkey_id'] = row['locator']
key['generate'] = str(datetime.fromtimestamp(row['inception']))
key['algorithm'] = row['algorithm']
key['publish'] = key['generate']
key['active'] = None
key['retire'] = None
key['dead'] = None
if row['keyType'] == 2:
key['keytype'] = 256
elif row['keyType'] == 1:
key['keytype'] = 257
key['state'] = row['state']
yield key
class ODSSignerConn(AbstractODSSignerConn):
def read_cmd(self):
msg = self._conn.recv(ODS_SE_MAXLINE)
_opc = int(msg[0])
msglen = int(msg[1]) << 8 + int(msg[2])
cmd = msg[3:msglen - 1].strip()
return cmd
def send_reply_and_close(self, reply):
prefix = bytearray([CLIENT_OPC_STDOUT, len(reply) >> 8,
len(reply) & 255])
self._conn.sendall(prefix + reply)
# 2nd message: CLIENT_OPC_EXIT, then len, msg len, exit code
prefix = bytearray([CLIENT_OPC_EXIT, 0, 1, 0])
self._conn.sendall(prefix)
self._conn.close()

View File

@ -0,0 +1,52 @@
#
# Copyright (C) 2020 FreeIPA Contributors see COPYING for license
#
import six
import abc
import sqlite3
from ipaplatform.paths import paths
ODS_SE_MAXLINE = 1024 # from ODS common/config.h
@six.add_metaclass(abc.ABCMeta)
class AbstractODSDBConnection():
"""Abstract class representing the Connection to ODS database."""
def __init__(self):
"""Creates a connection to the kasp database."""
self._db = sqlite3.connect(paths.OPENDNSSEC_KASP_DB)
self._db.row_factory = sqlite3.Row
self._db.execute('BEGIN')
@abc.abstractmethod
def get_zones(self):
"""Returns a list of zone names."""
@abc.abstractmethod
def get_zone_id(self, zone_name):
"""Returns a list of zone ids for the given zone_name."""
@abc.abstractmethod
def get_keys_for_zone(self, zone_id):
"""Returns a list of keys for the given zone_id."""
def close(self):
"""Closes the connection to the kasp database."""
self._db.close()
@six.add_metaclass(abc.ABCMeta)
class AbstractODSSignerConn():
"""Abstract class representing the Connection to ods-signer."""
def __init__(self, conn):
"""Initializes the object with a socket conn."""
self._conn = conn
@abc.abstractmethod
def read_cmd(self):
"""Reads the next command on the connection."""
@abc.abstractmethod
def send_reply_and_close(self, reply):
"""Sends the reply on the connection."""

View File

@ -159,27 +159,48 @@ class ODSMgr:
def add_ods_zone(self, uuid, name): def add_ods_zone(self, uuid, name):
zone_path = '%s%s' % (ENTRYUUID_PREFIX, uuid) zone_path = '%s%s' % (ENTRYUUID_PREFIX, uuid)
if name != dns.name.root:
name = name.relativize(dns.name.root)
cmd = ['zone', 'add', '--zone', str(name), '--input', zone_path] cmd = ['zone', 'add', '--zone', str(name), '--input', zone_path]
output = self.ksmutil(cmd) output = None
logger.info('%s', output) try:
self.notify_enforcer() output = self.ksmutil(cmd)
except ipautil.CalledProcessError as e:
# Zone already exists in HSM
if e.returncode == 1 \
and str(e.output).endswith(str(name) + ' already exists!'):
# Just return
return
if output is not None:
logger.info('%s', output)
self.notify_enforcer()
def del_ods_zone(self, name): def del_ods_zone(self, name):
# ods-ksmutil blows up if zone name has period at the end # ods-ksmutil blows up if zone name has period at the end
name = name.relativize(dns.name.root) if name != dns.name.root:
name = name.relativize(dns.name.root)
# detect if name is root zone # detect if name is root zone
if name == dns.name.empty: if name == dns.name.empty:
name = dns.name.root name = dns.name.root
cmd = ['zone', 'delete', '--zone', str(name)] cmd = ['zone', 'delete', '--zone', str(name)]
output = self.ksmutil(cmd) output = None
logger.info('%s', output) try:
self.notify_enforcer() output = self.ksmutil(cmd)
self.cleanup_signer(name) except ipautil.CalledProcessError as e:
# Zone already doesn't exist in HSM
if e.returncode == 1 \
and str(e.output).endswith(str(name) + ' not found!'):
# Just cleanup signer, no need to notify enforcer
self.cleanup_signer(name)
return
if output is not None:
logger.info('%s', output)
self.notify_enforcer()
self.cleanup_signer(name)
def notify_enforcer(self): def notify_enforcer(self):
cmd = ['notify'] result = tasks.run_ods_notify(capture_output=True)
output = self.ksmutil(cmd) logger.info('%s', result.output)
logger.info('%s', output)
def cleanup_signer(self, zone_name): def cleanup_signer(self, zone_name):
cmd = ['ods-signer', 'ldap-cleanup', str(zone_name)] cmd = ['ods-signer', 'ldap-cleanup', str(zone_name)]

View File

@ -0,0 +1,12 @@
#
# Copyright (C) 2020 FreeIPA Contributors see COPYING for license
#
import os
from ipaplatform.paths import paths
# pylint: disable=unused-import
if paths.ODS_KSMUTIL is not None and os.path.exists(paths.ODS_KSMUTIL):
from ._ods14 import ODSDBConnection, ODSSignerConn
else:
from ._ods21 import ODSDBConnection, ODSSignerConn

View File

@ -314,6 +314,7 @@ class OpenDNSSECInstance(service.Service):
def __start(self): def __start(self):
self.restart() # needed to reload conf files self.restart() # needed to reload conf files
tasks.run_ods_policy_import()
def uninstall(self): def uninstall(self):
if not self.is_configured(): if not self.is_configured():