From 98bb4e94fdc6e683bcc59bb58377c504d172800f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Cami?= Date: Tue, 5 May 2020 15:59:11 +0200 Subject: [PATCH] IPA-EPN: First version. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPN stands for Expiring Password Notification. It is a standalone tool designed to build a list of users whose password would expire in the near future, and either display the list in a machine-readable format, or send email notifications to these users. EPN provides command-line options to display the list of affected users. This provides data introspection and helps understand how many emails would be sent for a given day, or a given date range. The command-line options can also be used by a monitoring system to alert whenever a number of emails over the SMTP quota would be sent. EPN is meant to be launched once a day from an IPA client (preferred) or replica from a systemd timer. EPN does not keep state. The list of affected users is built at runtime but never kept. TLS/STARTTLS SMTP code is untested and unlikely to work as-is. Parts of code contributed by Rob Crittenden. Ideas and feedback contributed by Christian Heimes and Michal Polovka. Fixes: https://pagure.io/freeipa/issue/3687 Signed-off-by: François Cami Signed-off-by: Rob Crittenden Reviewed-By: Michal Polovka Reviewed-By: Christian Heimes Reviewed-By: Michal Polovka Reviewed-By: Christian Heimes --- client/Makefile.am | 8 +- client/ipa-epn.in | 25 ++ client/man/Makefile.am | 4 +- client/man/epn.conf.5 | 76 ++++ client/man/ipa-epn.1 | 119 ++++++ freeipa.spec.in | 19 + init/systemd/Makefile.am | 8 +- init/systemd/ipa-epn.service.in | 9 + init/systemd/ipa-epn.timer.in | 9 + ipaclient/install/ipa_epn.py | 717 ++++++++++++++++++++++++++++++++ ipaplatform/base/paths.py | 1 + 11 files changed, 990 insertions(+), 5 deletions(-) create mode 100644 client/ipa-epn.in create mode 100644 client/man/epn.conf.5 create mode 100644 client/man/ipa-epn.1 create mode 100644 init/systemd/ipa-epn.service.in create mode 100644 init/systemd/ipa-epn.timer.in create mode 100644 ipaclient/install/ipa_epn.py diff --git a/client/Makefile.am b/client/Makefile.am index 3d17432a6..858a9369e 100644 --- a/client/Makefile.am +++ b/client/Makefile.am @@ -44,6 +44,7 @@ sbin_SCRIPTS = \ ipa-client-automount \ ipa-client-install \ ipa-client-samba \ + ipa-epn \ $(NULL) ipa_getkeytab_SOURCES = \ @@ -91,10 +92,12 @@ ipa_join_LDADD = \ $(NULL) SUBDIRS = \ - share \ + share \ man \ - sysconfig \ + sysconfig \ $(NULL) +# init + noinst_HEADERS = \ ipa-client-common.h @@ -104,6 +107,7 @@ EXTRA_DIST = \ ipa-client-automount.in \ ipa-client-install.in \ ipa-client-samba.in \ + ipa-epn.in \ $(NULL) install-data-hook: diff --git a/client/ipa-epn.in b/client/ipa-epn.in new file mode 100644 index 000000000..59ad1f267 --- /dev/null +++ b/client/ipa-epn.in @@ -0,0 +1,25 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2020 FreeIPA Contributors see COPYING for license +# + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""This tool prepares then sends email notifications to users + whose passwords are expiring in the near future. +""" + +from ipaclient.install.ipa_epn import EPN + +EPN.run_cli() diff --git a/client/man/Makefile.am b/client/man/Makefile.am index 685922746..e30679f03 100644 --- a/client/man/Makefile.am +++ b/client/man/Makefile.am @@ -10,7 +10,9 @@ dist_man1_MANS = \ ipa-client-samba.1 \ ipa-certupdate.1 \ ipa-join.1 \ + ipa-epn.1 \ ipa.1 dist_man5_MANS = \ - default.conf.5 + default.conf.5 \ + epn.conf.5 diff --git a/client/man/epn.conf.5 b/client/man/epn.conf.5 new file mode 100644 index 000000000..753728ceb --- /dev/null +++ b/client/man/epn.conf.5 @@ -0,0 +1,76 @@ +.\" A man page for epn.conf +.\" Copyright (C) 2020 Red Hat, Inc. +.\" +.\" This program is free software; you can redistribute it and/or modify +.\" it under the terms of the GNU General Public License as published by +.\" the Free Software Foundation, either version 3 of the License, or +.\" (at your option) any later version. +.\" +.\" This program is distributed in the hope that it will be useful, but +.\" WITHOUT ANY WARRANTY; without even the implied warranty of +.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +.\" General Public License for more details. +.\" +.\" You should have received a copy of the GNU General Public License +.\" along with this program. If not, see . +.\" +.\" Author: Rob Crittenden +.\" +.TH "epn.conf" "5" "Apr 28 2020" "FreeIPA" "FreeIPA Manual Pages" +.SH "NAME" +epn.conf \- Expiring Password Notification configuration file +.SH "SYNOPSIS" +/etc/ipa/epn.conf +.SH "DESCRIPTION" +The \fIepn.conf \fRconfiguration file is used to set the options for the ipa-epn tool to notify users of upcoming password expiration. + +.SH "SYNTAX" +The configuration options are not case sensitive. The values may be case sensitive, depending on the option. + +Blank lines are ignored. +Lines beginning with # are comments and are ignored. + +Valid lines consist of an option name, an equals sign and a value. Spaces surrounding equals sign are ignored. An option terminates at the end of a line. + +Values should not be quoted, the quotes will not be stripped. + +.RS L + # Wrong \- don't include quotes + verbose = "True" + + # Right \- Properly formatted options + verbose = True + verbose=True +.RE + +Options must appear in the section named [global]. There are no other sections defined or used currently. + +Options may be defined that are not used by IPA. Be careful of misspellings, they will not be rejected. +.SH "OPTIONS" +.TP +.B smtp_server\fR +Specifies the SMTP server to use. The default is localhost. +.TP +.B smtp_port +Specifies the SMTP port. The default is 25. +.TP +.B smtp_user +Specifies the id of the user to authenticate with the SMTP server. Default None. +.TP +.B smtp_password +Specifies the password for the authorized user. Default None. +.TP +.B smtp_timeout +Specifies the number of seconds to wait for SMTP to respond. Default 60. +.TP +.B smtp_security +Specifies the type of secure connection to make. Options are: none, starttls and ssl. The default is none. +.TP +.B notify_ttls +This is the list of days before a password expiration when ipa-epn shoould notify a user that their password will soon require a reset. If this value is not specified then the default list will be used: 28, 14, 7, 3, 1. +.SH "FILES" +.TP +.I /etc/ipa/epn.conf +Configuration file +.SH "SEE ALSO" +.BR ipa-epn (1) diff --git a/client/man/ipa-epn.1 b/client/man/ipa-epn.1 new file mode 100644 index 000000000..18c8d7821 --- /dev/null +++ b/client/man/ipa-epn.1 @@ -0,0 +1,119 @@ +.\" A man page for ipa-epn +.\" Copyright (C) 2020 Red Hat, Inc. +.\" +.\" This program is free software; you can redistribute it and/or modify +.\" it under the terms of the GNU General Public License as published by +.\" the Free Software Foundation, either version 3 of the License, or +.\" (at your option) any later version. +.\" +.\" This program is distributed in the hope that it will be useful, but +.\" WITHOUT ANY WARRANTY; without even the implied warranty of +.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +.\" General Public License for more details. +.\" +.\" You should have received a copy of the GNU General Public License +.\" along with this program. If not, see . +.\" +.\" +.TH "ipa-epn" "1" "Apr 24 2020" "FreeIPA" "FreeIPA Manual Pages" +.SH "NAME" +ipa\-epn \- Send expiring password nofications +.SH "SYNOPSIS" +ipa\-epn \[options\] + +.SH "DESCRIPTION" +ipa\-epn provides a method to warn users via email that their IPA account password is about to expire. + +It can be used in dry\-run mode which is recommmended during setup. The output is always JSON in this case. + +It can also be launched daily by its systemd timer. +In this case it will parse its configuration file epn.conf(5) and send an email to users whose passwords are expiring within the defined future date ranges. + +See the OPTIONS section below and the epn.conf(5) man page on how to configure the tool. + +.SH "OPTIONS" +.TP +\fB\-\-to-nbdays\fR \fI\fR +The \-\-to\-nbdays CLI option can be used to determine the number of notifications that would be sent in a given timeframe. + +If \fB\-\-from\-nbdays\fR is not specified, ipa\-epn will look within a 24\-hour long time range in days. + +if \fB\-\-from\-nbdays\fR is specified, the date range starts at \fB\-\-from\-nbdays\fR days in the future and ends at \fB\-\-to\-nbdays\fR in the future. + +Together, these two CLI options can be used to determine how many emails would be sent in a specific time in the future. + +The \fB\-\-to\-nbdays\fR CLI option implies \fB\-\-dry\-run\fR. +.TP +\fB\-\-from\-nbdays\fR \fI\fR +See \fB\-\-to\-nbdays\fR for an explanation. This option must be used in conjonction with \fB\-\-to\-nbdays\fR. +.TP +\fB\-\-dry\-run\fR +The \fB\-\-dry\-run\fR CLI option is intented to test ipa\-epn's configuration. + +For instance, if notify_ttls is set to 21, 14, 3, \fB\-\-dry-run\fR would display the list of users whose passwords would expire in 21, 14, and 3 days in the future. + + +.SH "EXAMPLES" +.nf + # date + Sun 12 Apr 2020 06:23:08 AM CEST + # ipa\-epn \-\-dry\-run + [ + { + "uid": "user5", + "cn": "user 5", + "krbpasswordexpiration": "2020\-04\-17 15:51:53", + "mail": "['user5@ipa.test']" + } + ] + The IPA\-EPN command was successful + + # ipa\-epn \-\-to\-nbdays 6 \-\-dry-run + [ + { + "uid": "user5", + "cn": "user 5", + "krbpasswordexpiration": "2020\-04\-17 15:51:53", + "mail": "['user5@ipa.test']" + } + ] + The IPA\-EPN command was successful + + # ipa\-epn \-\-from-nbdays 2 \-\-to-nbdays 6 \-\-dry\-run + [ + { + "uid": "user5", + "cn": "user 5", + "krbpasswordexpiration": "2020\-04\-17 15:51:53", + "mail": "['user5@ipa.test']" + } + ] + The IPA\-EPN command was successful + + # ipa\-epn \-\-from\-nbdays 8 \-\-to\-nbdays 12 \-\-dry\-run + [ + { + "uid": "user3", + "cn": "user 5", + "krbpasswordexpiration": "2020\-04\-21 00:00:08", + "mail": "['user3@ipa.test']" + } + ] + The IPA\-EPN command was successful + + +.SH "EXIT STATUS" +The exit status is 0 on success, nonzero on error. + +.SH "SEE ALSO" + RFE: https://pagure.io/freeipa/issue/3687 + Design document: https://github.com/freeipa/freeipa/blob/master/doc/designs/expiring-password-notification.md + + +.SH "KNOWN BUGS" + None yet. + +.SH "REPORTING BUGS AND ENHANCEMENT IDEAS" +.nf + Please make sure first the issue is not already reported by searching at https://pagure.io/freeipa/issues. If it is not, file a new issue at https://pagure.io/freeipa/new_issue. + diff --git a/freeipa.spec.in b/freeipa.spec.in index 411bb43c4..c4dcfd01d 100755 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -583,6 +583,15 @@ Requires: cifs-utils This package provides command-line tools to deploy Samba domain member on the machine enrolled into a FreeIPA environment +%package client-epn +Summary: Tools to configure Expiring Password Notification in IPA +Group: System Environment/Base +Requires: %{name}-client = %{version}-%{release} + +%description client-epn +This package provides a service to collect and send expiring password +notifications via email (SMTP). + %package -n python3-ipaclient Summary: Python libraries used by IPA client Group: System Environment/Libraries @@ -1337,6 +1346,16 @@ fi %{_sbindir}/ipa-client-samba %{_mandir}/man1/ipa-client-samba.1* + +%files client-epn +%doc README.md Contributors.txt +%license COPYING +%{_sbindir}/ipa-epn +%{_mandir}/man1/ipa-epn.1* +%{_mandir}/man5/epn.conf.5* +%attr(644,root,root) %{_unitdir}/ipa-epn.service +%attr(644,root,root) %{_unitdir}/ipa-epn.timer + %files -n python3-ipaclient %doc README.md Contributors.txt %license COPYING diff --git a/init/systemd/Makefile.am b/init/systemd/Makefile.am index 945f6ac22..5053dbff6 100644 --- a/init/systemd/Makefile.am +++ b/init/systemd/Makefile.am @@ -4,11 +4,15 @@ AUTOMAKE_OPTIONS = 1.7 dist_noinst_DATA = \ ipa-custodia.service.in \ - ipa.service.in + ipa.service.in \ + ipa-epn.service.in \ + ipa-epn.timer.in systemdsystemunit_DATA = \ ipa-custodia.service \ - ipa.service + ipa.service \ + ipa-epn.service \ + ipa-epn.timer CLEANFILES = $(systemdsystemunit_DATA) diff --git a/init/systemd/ipa-epn.service.in b/init/systemd/ipa-epn.service.in new file mode 100644 index 000000000..260ce5c87 --- /dev/null +++ b/init/systemd/ipa-epn.service.in @@ -0,0 +1,9 @@ +[Unit] +Description=Execute IPA Expiring Password Notification (EPN) + +[Service] +Type=simple +ExecStart=@sbindir@/ipa-epn + +[Install] +WantedBy=multi-user.target diff --git a/init/systemd/ipa-epn.timer.in b/init/systemd/ipa-epn.timer.in new file mode 100644 index 000000000..dffda6991 --- /dev/null +++ b/init/systemd/ipa-epn.timer.in @@ -0,0 +1,9 @@ +[Unit] +Description=Execute IPA Expiring Password Notification (EPN) every day at 1AM + +[Timer] +OnCalendar=*-*-* 01:00:00 +Unit=ipa-epn.service + +[Install] +WantedBy=multi-user.target diff --git a/ipaclient/install/ipa_epn.py b/ipaclient/install/ipa_epn.py new file mode 100644 index 000000000..a7446c7b1 --- /dev/null +++ b/ipaclient/install/ipa_epn.py @@ -0,0 +1,717 @@ +# +# Copyright (C) 2020 FreeIPA Contributors see COPYING for license +# + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""This tool prepares then sends email notifications to users + whose passwords are expiring in the near future. +""" + + +from __future__ import absolute_import, print_function + +import ast +import grp +import json +import os +import pwd +import logging +import smtplib + +from collections import deque +from datetime import datetime, timedelta +from email.utils import formataddr, formatdate +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.header import Header + +from ipaclient.install.client import is_ipa_client_installed +from ipaplatform.paths import paths +from ipalib import api, errors +from ipalib.install import sysrestore +from ipapython import admintool, ipaldap +from ipapython.dn import DN + + +EPN_CONF = "/etc/ipa/epn.conf" +EPN_CONFIG = { + "smtp_server": "localhost", + "smtp_port": 25, + "smtp_user": None, + "smtp_password": None, + "smtp_timeout": 60, + "smtp_security": "none", + "smtp_admin": "root@localhost", + "notify_ttls": "28,14,7,3,1", +} + +logger = logging.getLogger(__name__) + + +def drop_privileges(new_username="daemon", new_groupname="daemon"): + """Drop privileges, defaults to daemon:daemon. + """ + try: + if os.getuid() != 0: + return + + os.setgroups([]) + os.setgid(pwd.getpwnam(new_username).pw_uid) + os.setuid(grp.getgrnam(new_groupname).gr_gid) + + if os.getuid() == 0: + raise Exception() + + logger.debug( + "Dropped privileges to user=%s, group=%s", + new_username, + new_groupname, + ) + + except Exception as e: + logger.error( + "Failed to drop privileges to %s, %s: %s", + new_username, + new_groupname, + e, + ) + + +class EPNUserList: + """Maintains a list of users whose passwords are expiring. + Provides add(), check(), pop(), and json_print(). + From the outside, the list is considered always sorted: + * displaying the list results in a sorted JSON representation thereof + * pop() returns the "most urgent" item from the list. + Internal implementation notes: + * Uses a deque instead of a list for efficiency reasons + * all add()-style methods MUST set _sorted to False. + * all print() and pop-like methods MUST call _sort() first. + """ + + def __init__(self): + self._sorted = False + self._expiring_password_user_dq = deque() + + def __bool__(self): + """If it quacks like a container... + """ + return bool(self._expiring_password_user_dq) + + def __len__(self): + """Return len(self).""" + return len(self._expiring_password_user_dq) + + def add(self, entry): + """Parses and appends an LDAP user entry with the uid, cn, + krbpasswordexpiration and mail attributes. + """ + try: + self._sorted = False + self._expiring_password_user_dq.append( + dict( + uid=str(entry["uid"].pop(0)), + cn=str(entry["cn"].pop(0)), + krbpasswordexpiration=str( + entry["krbpasswordexpiration"].pop(0) + ), + mail=str(entry["mail"]), + ) + ) + except IndexError as e: + logger.info("IPA-EPN: Could not parse entry: %s", e) + + def pop(self): + """Returns the "most urgent" user to notify. + In fact: popleft() + """ + self._sort() + try: + return self._expiring_password_user_dq.popleft() + except IndexError: + return False + + def check(self): + self.json_print(really_print=False) + + def json_print(self, really_print=True): + """Dump self._expiring_password_user_dq to JSON. + Check that the result can be re-rencoded to UTF-8. + If really_print, print the result. + """ + try: + self._sort() + temp_str = json.dumps( + list(self._expiring_password_user_dq), + indent=4, + ensure_ascii=False, + ) + temp_str.encode("utf8") + if really_print: + print(temp_str) + except Exception as e: + logger.error("IPA-EPN: unexpected error: %s", e) + + def _sort(self): + if not self._sorted: + if isinstance(self._expiring_password_user_dq, deque): + self._expiring_password_user_dq = deque( + sorted( + self._expiring_password_user_dq, + key=lambda item: item["krbpasswordexpiration"], + ) + ) + self._sorted = True + + +class EPN(admintool.AdminTool): + command_name = "IPA-EPN" + log_file_name = paths.IPAEPN_LOG + + usage = "%prog [options]" + description = "Expiring Password Notifications (EPN)" + + def __init__(self, options, args): + super(EPN, self).__init__(options, args) + self._conn = None + self._expiring_password_user_list = EPNUserList() + self._ldap_data = [] + self._date_ranges = [] + self._mailer = None + + @classmethod + def add_options(cls, parser): + super(EPN, cls).add_options(parser, debug_option=True) + parser.add_option( + "--from-nbdays", + dest="from_nbdays", + action="store", + default=None, + help="minimal number of days", + ) + parser.add_option( + "--to-nbdays", + dest="to_nbdays", + action="store", + default=None, + help="maximal number of days", + ) + parser.add_option( + "--dry-run", + dest="dry_run", + action="store_true", + default=False, + help="Dry run mode. JSON ouput only.", + ) + + def validate_options(self): + super(EPN, self).validate_options(needs_root=True) + if self.options.to_nbdays: + self.options.dry_run = True + if self.options.from_nbdays and not self.options.to_nbdays: + self.option_parser.error( + "You cannot specify --from-nbdays without --to-nbdays" + ) + + def setup_logging(self, log_file_mode="a"): + super(EPN, self).setup_logging(log_file_mode="a") + + def run(self): + super(EPN, self).run() + + fstore = sysrestore.FileStore(paths.IPA_CLIENT_SYSRESTORE) + if not is_ipa_client_installed(fstore): + logger.error("IPA client is not configured on this system.") + raise admintool.ScriptError() + + self._get_krb5_ticket() + self._read_configuration() + self._validate_configuration() + self._parse_configuration() + self._get_connection() + drop_privileges() + if self.options.to_nbdays: + self._build_cli_date_ranges() + for date_range in self._date_ranges: + self._fetch_data_from_ldap(date_range) + self._parse_ldap_data() + if self.options.dry_run: + self._pretty_print_data() + else: + self._mailer = MailUserAgent( + security_protocol=api.env.smtp_security, + smtp_hostname=api.env.smtp_server, + smtp_port=api.env.smtp_port, + smtp_timeout=api.env.smtp_timeout, + smtp_username=api.env.smtp_user, + smtp_password=api.env.smtp_password, + x_mailer=self.command_name, + ) + self._send_emails() + + def _get_date_range_from_nbdays(self, nbdays_end, nbdays_start=None): + """Detects current time and returns a date range, given a number + of days in the future. + If only nbdays_end is specified, the range is 1d long. + """ + now = datetime.utcnow() + today_at_midnight = datetime.combine(now, datetime.min.time()) + range_end = today_at_midnight + timedelta(days=nbdays_end) + if nbdays_start is not None: + range_start = today_at_midnight + timedelta(days=nbdays_start) + else: + range_start = range_end - timedelta(days=1) + + logger.debug( + "IPA-EPN: Current date: %s \n" + "IPA-EPN: Date & time, today at midnight: %s \n" + "IPA-EPN: Date range start: %s \n" + "IPA-EPN: Date range end: %s \n", + now, + today_at_midnight, + range_start, + range_end, + ) + return (range_start, range_end) + + def _datetime_to_generalized_time(self, dt): + """Convert datetime to LDAP_GENERALIZED_TIME_FORMAT + Note: Consider moving into ipalib. + """ + dt = dt.timetuple() + generalized_time_str = str(dt.tm_year) + "".join( + "0" * (2 - len(str(item))) + str(item) + for item in ( + dt.tm_mon, + dt.tm_mday, + dt.tm_hour, + dt.tm_min, + dt.tm_sec, + ) + ) + return generalized_time_str + "Z" + + def _get_krb5_ticket(self): + """Setup the environment to obtain a krb5 ticket for us using the + system keytab. + Uses CCACHE = MEMORY (limited to the current process). + """ + os.environ.setdefault("KRB5_CLIENT_KTNAME", "/etc/krb5.keytab") + os.environ["KRB5CCNAME"] = "MEMORY:" + + def _read_configuration(self): + """Merge in the EPN configuration from /etc/ipa/epn.conf""" + base_config = dict( + context="epn", confdir=paths.ETC_IPA, in_server=False, + ) + api.bootstrap(**base_config) + api.env._merge(**EPN_CONFIG) + + if not api.isdone("finalize"): + api.finalize() + + def _validate_configuration(self): + """Examine the user-provided configuration. + """ + if api.env.smtp_security.lower() not in ("none", "starttls", "ssl"): + raise RuntimeError( + "smtp_security must be one of: " "none, starttls or ssl" + ) + if api.env.smtp_user is not None and api.env.smtp_password is None: + raise RuntimeError("smtp_user set and smtp_password is not") + if api.env.notify_ttls is None: + raise RuntimeError("notify_ttls must be set in %s" % EPN_CONF) + try: + [int(k) for k in str(api.env.notify_ttls).split(',')] + except ValueError as e: + raise RuntimeError('Failed to parse notify_ttls: \'%s\': %s' % + (api.env.notify_ttls, e)) + + def _parse_configuration(self): + """ + """ + daylist = [int(k) for k in str(api.env.notify_ttls).split(',')] + daylist.sort() + + for day in daylist: + self._date_ranges.append( + self._get_date_range_from_nbdays( + nbdays_start=None, nbdays_end=day + 1 + ) + ) + + def _get_connection(self): + """Create a connection to LDAP and bind to it. + """ + if self._conn is not None: + return self._conn + + try: + # LDAPI + self._conn = ipaldap.LDAPClient.from_realm(api.env.realm) + self._conn.external_bind() + except Exception: + try: + # LDAP + GSSAPI + self._conn = ipaldap.LDAPClient.from_hostname_secure( + api.env.server + ) + self._conn.gssapi_bind() + except Exception as e: + logger.error( + "Unable to bind to LDAP server %s: %s", + self._conn.ldap_uri, + e, + ) + + return self._conn + + def _fetch_data_from_ldap(self, date_range): + """Run a LDAP query to fetch a list of user entries whose passwords + would expire in the near future. Store in self._ldap_data. + """ + + if self._conn is None: + logger.error( + "IPA-EPN: Connection to LDAP not established. Exiting." + ) + + search_base = DN(api.env.container_user, api.env.basedn) + attrs_list = ["uid", "krbpasswordexpiration", "mail", "cn"] + + search_filter = ( + "(&(!(nsaccountlock=TRUE)) \ + (krbpasswordexpiration<=%s) \ + (krbpasswordexpiration>=%s))" + % ( + self._datetime_to_generalized_time(date_range[1]), + self._datetime_to_generalized_time(date_range[0]), + ) + ) + + try: + self._ldap_data = self._conn.get_entries( + search_base, + filter=search_filter, + attrs_list=attrs_list, + scope=self._conn.SCOPE_SUBTREE, + ) + except errors.EmptyResult: + logger.debug("Empty Result.") + finally: + logger.debug("%d entries found", len(self._ldap_data)) + + def _parse_ldap_data(self): + """Fill out self._expiring_password_user_list from data from ldap. + """ + if self._ldap_data: + for entry in self._ldap_data: + self._expiring_password_user_list.add(entry) + # Validate json. + try: + self._pretty_print_data(really_print=False) + except Exception as e: + logger.error("IPA-EPN: Could not create JSON: %s", e) + finally: + self._ldap_data = [] + + def _pretty_print_data(self, really_print=True): + """Dump self._expiring_password_user_list to JSON. + """ + self._expiring_password_user_list.json_print( + really_print=really_print + ) + + def _send_emails(self): + if self._mailer is None: + logger.error("IPA-EPN: mailer was not configured.") + return + else: + while self._expiring_password_user_list: + entry = self._expiring_password_user_list.pop() + self._mailer.send_message( + mail_subject="Your password is expiring.", + mail_body=os.linesep.join( + [ + "Hi %s, Your password will expire on %s." + % (entry["cn"], entry["krbpasswordexpiration"]), + "Please change it as soon as possible.", + ] + ), + subscribers=ast.literal_eval(entry["mail"]), + ) + now = datetime.utcnow() + expdate = datetime.strptime( + entry["krbpasswordexpiration"], + '%Y-%m-%d %H:%M:%S') + logger.debug( + "Notified %s (%s). Password expiring in %d days at %s.", + entry["mail"], entry["uid"], (expdate - now).days, + expdate) + self._mailer.cleanup() + + def _build_cli_date_ranges(self): + """When self.options.to_nbdays is set, override the date ranges read + from the configuration file and build the date ranges from the CLI + options. + """ + self._date_ranges = [] + logger.debug("IPA-EPN: Ignoring configuration file ranges.") + if self.options.from_nbdays is not None: + self._date_ranges.append( + self._get_date_range_from_nbdays( + nbdays_start=int(self.options.from_nbdays), + nbdays_end=int(self.options.to_nbdays), + ) + ) + elif self.options.to_nbdays is not None: + self._date_ranges.append( + self._get_date_range_from_nbdays( + nbdays_start=None, nbdays_end=int(self.options.to_nbdays) + ) + ) + + +class MTAClient: + """MTA Client class. Originally done for EPN. + """ + + def __init__( + self, + security_protocol="none", + smtp_hostname="localhost", + smtp_port=25, + smtp_timeout=60, + smtp_username=None, + smtp_password=None, + ): + # We only support "none" (cleartext) for now. + # Future values: "ssl", "starttls" + self._security_protocol = security_protocol + self._smtp_hostname = smtp_hostname + self._smtp_port = smtp_port + self._smtp_timeout = smtp_timeout + self._username = smtp_username + self._password = smtp_password + + # This should not be touched + self._conn = None + + if ( + self._security_protocol == "none" + and "localhost" not in self._smtp_hostname + ): + logger.error( + "IPA-EPN: using cleartext for non-localhost SMTPd " + "is not supported." + ) + + self._connect() + + def cleanup(self): + self._disconnect() + + def send_message(self, message_str=None, subscribers=None): + try: + result = self._conn.sendmail( + api.env.smtp_admin, subscribers, message_str, + ) + except Exception as e: + logger.info("IPA-EPN: Failed to send mail: %s", e) + finally: + if result: + for key in result: + logger.info( + "IPA-EPN: Failed to send mail to '%s': %s %s", + key, + result[key][0], + result[key][1], + ) + logger.info( + "IPA-EPN: Failed to send mail to at least one recipient" + ) + + def _connect(self): + try: + if self._security_protocol == "none": + self._conn = smtplib.SMTP( + host=self._smtp_hostname, + port=self._smtp_port, + timeout=self._smtp_timeout, + ) + else: + self._conn = smtplib.SMTP_SSL( + host=self._smtp_hostname, + port=self._smtp_port, + timeout=self._smtp_timeout, + ) + except smtplib.SMTPException as e: + logger.error( + "IPA-EPN: Unable to connect to %s:%s: %s", + self._smtp_hostname, + self._smtp_port, + e, + ) + + try: + self._conn.ehlo() + except smtplib.SMTPException as e: + logger.error( + "IPA-EPN: EHLO failed for host %s:%s: %s", + self._smtp_hostname, + self._smtp_port, + e, + ) + + if ( + self._conn.has_extn("STARTTLS") + and self._security_protocol == "starttls" + ): + try: + self._conn.starttls() + self._conn.ehlo() + except smtplib.SMTPException as e: + logger.error( + "IPA-EPN: Unable to create an encrypted session to " + "%s:%s: %s", + self._smtp_hostname, + self._smtp_port, + e, + ) + + if self._username and self._password: + if self._conn.has_extn("AUTH"): + try: + self._conn.login(self._username, self._password) + if self._security_protocol == "none": + logger.info( + "IPA-EPN: Username and Password " + "were sent in the clear." + ) + except smtplib.SMTPAuthenticationError: + logger.error( + "IPA-EPN: Authentication to %s:%s failed, " + "please check your username and/or password:", + self._smtp_hostname, + self._smtp_port, + ) + except smtplib.SMTPException as e: + logger.error( + "IPA-EPN: SMTP Error at %s:%s:%s", + self._smtp_hostname, + self._smtp_port, + e, + ) + else: + err_str = ( + "IPA-EPN: Server at %s:%s " + "does not support authentication.", + self._smtp_hostname, + self._smtp_port, + ) + logger.error(err_str) + + def _disconnect(self): + self._conn.quit() + + +class MailUserAgent: + """The MUA class for EPN. + """ + + def __init__( + self, + security_protocol="none", + smtp_hostname="localhost", + smtp_port=25, + smtp_timeout=60, + smtp_username=None, + smtp_password=None, + x_mailer=None, + ): + + self._x_mailer = x_mailer + self._subject = None + self._body = None + self._subscribers = None + self._subtype = None + + # Let it be plain or html? + self._subtype = "plain" + # UTF-8 only for now + self._charset = "utf-8" + + self._msg = None + self._message_str = None + + self._mta_client = MTAClient( + security_protocol=security_protocol, + smtp_hostname=smtp_hostname, + smtp_port=smtp_port, + smtp_timeout=smtp_timeout, + smtp_username=smtp_username, + smtp_password=smtp_password, + ) + + def cleanup(self): + self._mta_client.cleanup() + + def send_message( + self, mail_subject=None, mail_body=None, subscribers=None + ): + """Given mail_subject, mail_body, and subscribers, composes + the message and sends it. + """ + if None in [mail_subject, mail_body, subscribers]: + logger.error("IPA-EPN: Tried to send an empty message.") + return False + self._compose_message( + mail_subject=mail_subject, + mail_body=mail_body, + subscribers=subscribers, + ) + self._mta_client.send_message( + message_str=self._message_str, subscribers=subscribers + ) + return True + + def _compose_message( + self, mail_subject=None, mail_body=None, subscribers=None + ): + """The composer creates a MIME multipart message. + """ + + self._subject = mail_subject + self._body = mail_body + self._subscribers = subscribers + + self._msg = MIMEMultipart(_charset=self._charset) + self._msg["From"] = formataddr( + ("IPA-EPN", "noreply@%s" % socket.getfqdn()) + ) + self._msg["To"] = ", ".join(self._subscribers) + self._msg["Date"] = formatdate(localtime=True) + self._msg["Subject"] = Header(self._subject, self._charset) + self._msg.preamble = "Multipart message" + if "X-Mailer" not in self._msg and self._x_mailer: + self._msg.add_header("X-Mailer", self._x_mailer) + self._msg.attach( + MIMEText( + self._body + "\n\n", + _subtype=self._subtype, + _charset=self._charset, + ) + ) + self._message_str = self._msg.as_string() diff --git a/ipaplatform/base/paths.py b/ipaplatform/base/paths.py index 6723c1c53..cc3d4a578 100644 --- a/ipaplatform/base/paths.py +++ b/ipaplatform/base/paths.py @@ -342,6 +342,7 @@ class BasePathNamespace: IPASERVER_UNINSTALL_LOG = "/var/log/ipaserver-uninstall.log" IPAUPGRADE_LOG = "/var/log/ipaupgrade.log" IPATRUSTENABLEAGENT_LOG = "/var/log/ipatrust-enable-agent.log" + IPAEPN_LOG = "/var/log/ipaepn.log" KADMIND_LOG = "/var/log/kadmind.log" KRB5KDC_LOG = "/var/log/krb5kdc.log" MESSAGES = "/var/log/messages"