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 select
import sys
import sqlite3
import traceback
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.localhsm import LocalHSM
from ipaserver.dnssec import opendnssec
logger = logging.getLogger(os.path.basename(__file__))
DAEMONNAME = 'ipa-ods-exporter'
@ -233,26 +234,19 @@ def get_ldap_keys(ldap, zone_dn):
def get_ods_keys(zone_name):
# get zone ID
cur = db.execute("SELECT id FROM zones WHERE LOWER(name)=LOWER(?)",
(zone_name,))
rows = cur.fetchall()
rows = db.get_zone_id(zone_name)
if len(rows) != 1:
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:
# ignore keys which were generated but not used yet
# key state check is using constants from
# OpenDNSSEC's enforcer/ksm/include/ksm/ksm.h
# WARNING! OpenDNSSEC version 1 and 2 are using different constants!
cur = 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,))
rows = db.get_keys_for_zone(zone_id)
keys = {}
for row in cur:
for row in rows:
key_data = sql2ldap_flags(row['keytype'])
if key_data.get('idnsSecKeyZONE') != 'TRUE':
raise ValueError("unexpected key type 0x%x" % row['keytype'])
@ -483,11 +477,13 @@ def receive_systemd_command():
sys.exit(1)
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))
# 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
if six.PY3:
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.
if six.PY3:
reply = reply.encode('ascii')
conn.send(reply + b'\n')
conn.shutdown(socket.SHUT_RDWR)
conn.close()
conn.send_reply_and_close(reply)
def cmd2ods_zone_name(cmd):
# 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.
Keep in mind that keys could be shared among multiple zones!"""
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())
ldap_zone = get_ldap_zone(ldap, dns_dn, zone_name)
@ -724,6 +722,14 @@ except KeyError as e:
cmd = sys.argv[1]
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 conn:
@ -747,9 +753,7 @@ try:
# Beware: Reply can be sent back only after DB is unlocked and closed
# otherwise ods-enforcerd will fail.
db = sqlite3.connect(paths.OPENDNSSEC_KASP_DB)
db.row_factory = sqlite3.Row
db.execute('BEGIN')
db = opendnssec.ODSDBConnection()
if zone_name is not None:
# only one zone should be processed
@ -759,8 +763,8 @@ try:
cleanup_ldap_zone(ldap, dns_dn, zone_name)
else:
# process all zones
for zone_row in db.execute("SELECT name FROM zones"):
sync_zone(ldap, dns_dn, zone_row['name'])
for zone_name in db.get_zones():
sync_zone(ldap, dns_dn, zone_name)
### DNSSEC master: DNSSEC key material purging
# 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]
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):
"""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):
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]
output = self.ksmutil(cmd)
logger.info('%s', output)
self.notify_enforcer()
output = None
try:
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):
# 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
if name == dns.name.empty:
name = dns.name.root
cmd = ['zone', 'delete', '--zone', str(name)]
output = self.ksmutil(cmd)
logger.info('%s', output)
self.notify_enforcer()
self.cleanup_signer(name)
output = None
try:
output = self.ksmutil(cmd)
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):
cmd = ['notify']
output = self.ksmutil(cmd)
logger.info('%s', output)
result = tasks.run_ods_notify(capture_output=True)
logger.info('%s', result.output)
def cleanup_signer(self, 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):
self.restart() # needed to reload conf files
tasks.run_ods_policy_import()
def uninstall(self):
if not self.is_configured():