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 56de28207..037d84b4a 100755
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -588,6 +588,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
@@ -1345,6 +1354,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"