Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mail notification #823

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Password Expiration Notice</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
background-color: #007bff;
color: #ffffff;
padding: 10px 0;
text-align: center;
}
.content {
padding: 20px;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
margin-top: 20px;
background-color: #007bff;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
}
.footer {
margin-top: 20px;
font-size: 12px;
color: #888888;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Expiration Notice</h1>
</div>
<div class="content">
<p>Dear $user,</p>
<p>Your password is expiring in <strong>$days days</strong>. To ensure the security of your account, please change your password before it expires.</p>
<a href="$portal" class="button">Change Password</a>
</div>
<div class="footer">
<p>If you have any questions, please contact the administrator.</p>
</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[Unit]
Description=Domain user password notification

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/notify-user-domain
10 changes: 10 additions & 0 deletions core/imageroot/etc/systemd/system/user-domain-notification.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[Unit]
Description=Domain user password notification trigger

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=3600

[Install]
WantedBy=timers.target
37 changes: 33 additions & 4 deletions core/imageroot/usr/local/agent/pypkg/agent/ldapclient/ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
# along with NethServer. If not, see COPYING.
#

from datetime import timedelta
import datetime
import ldap3
from .exceptions import LdapclientEntryNotFound
from .base import LdapclientBase
Expand All @@ -31,6 +33,16 @@ def _get_dn_attributes(self, dn, lfilter='(objectClass=*)', attributes=[ldap3.AL
return entry['attributes']

raise LdapclientEntryNotFound()

def get_max_pwd_age(self):
response = self.ldapconn.search(self.base_dn, '(objectClass=domainDNS)', attributes=['maxPwdAge'])
if response[0]:
result = [entry for entry in response[2] if entry['type'] == 'searchResEntry']
else:
return None
if not result:
return None
return result[0]['attributes']['maxPwdAge']

def get_group(self, group):
# Escape group string to build the filter assertion:
Expand Down Expand Up @@ -121,12 +133,15 @@ def get_user_entry(self, user, lextra_attributes=[]):

raise LdapclientEntryNotFound()

def list_users(self):
def list_users(self, extra_info=False):
attributes = ['displayName', 'sAMAccountName', 'userAccountControl']
if extra_info:
attributes += ['whenCreated', 'pwdLastSet', 'mail']
user_entry_generator = self.ldapconn.extend.standard.paged_search(
search_base = self.base_dn,
search_filter = f'(&(objectClass=user)(objectCategory=person){self._get_users_search_filter_clause()})',
search_scope = ldap3.SUBTREE,
attributes = ['displayName', 'sAMAccountName', 'userAccountControl'],
attributes = attributes,
paged_size = 900,
generator=True,
)
Expand All @@ -135,9 +150,23 @@ def list_users(self):
for entry in user_entry_generator:
if entry['type'] != 'searchResEntry':
continue # ignore referrals
users.append({
user = {
"user": entry['attributes']['sAMAccountName'],
"display_name": entry['attributes'].get('displayName') or "",
"locked": bool(entry['attributes']['userAccountControl'] & 0x2), # ACCOUNTDISABLE
})
}
if extra_info:
pwd_changed_time = entry['attributes'].get('pwdLastSet', entry['attributes'].get('whenCreated', None))
if self.get_max_pwd_age().total_seconds() >= 86400000000000:
# Password aging is disabled
user['expired'] = False
user['password_expiration'] = -1
else:
expiry_date = pwd_changed_time + timedelta(seconds=self.get_max_pwd_age().total_seconds())
user['expired'] = datetime.datetime.now(datetime.timezone.utc) > expiry_date
user['password_expiration'] = int(expiry_date.timestamp())
user["mail"] = entry['attributes'].get('mail') if entry['attributes'].get('mail') else ""

users.append(user)

return users
38 changes: 34 additions & 4 deletions core/imageroot/usr/local/agent/pypkg/agent/ldapclient/rfc2307.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,24 @@
# along with NethServer. If not, see COPYING.
#

from datetime import timedelta
import datetime
import ldap3
from .exceptions import LdapclientEntryNotFound
from .base import LdapclientBase

class LdapclientRfc2307(LdapclientBase):

def get_pwd_max_age(self):
response = self.ldapconn.search(f'cn=default,ou=PPolicy,{self.base_dn}', '(objectClass=pwdPolicy)', attributes=['pwdMaxAge'])[2]
result = [entry for entry in response if entry['type'] == 'searchResEntry']
if not result:
return None
pwd_max_age_str = result[0]['attributes']['pwdMaxAge']
if not pwd_max_age_str:
return None
return int(pwd_max_age_str)

def get_group(self, group):
# Escape group string to build the filter assertion:
escgroup = ldap3.utils.conv.escape_filter_chars(group)
Expand Down Expand Up @@ -111,18 +123,36 @@ def get_user_entry(self, user, lextra_attributes=[]):

raise LdapclientEntryNotFound()

def list_users(self):
def list_users(self, extra_info=False):
attributes = ['displayName', 'uid'] + self.filter_schema_attributes(['pwdAccountLockedTime'])
if extra_info:
attributes += ['mail', 'pwdChangedTime', 'createTimestamp']
response = self.ldapconn.search(self.base_dn, f'(&(objectClass=posixAccount)(objectClass=inetOrgPerson){self._get_users_search_filter_clause()})',
attributes=['displayName', 'uid'] + self.filter_schema_attributes(['pwdAccountLockedTime']),
attributes=attributes,
)[2]

users = []
max_pwd_age = self.get_pwd_max_age()
for entry in response:
if entry['type'] != 'searchResEntry':
continue # ignore referrals
users.append({
user = {
"user": entry['attributes']['uid'][0],
"display_name": entry['attributes'].get('displayName') or "",
"locked": entry['attributes'].get('pwdAccountLockedTime', []) != [],
})
}

if extra_info:
pwd_changed_time = entry['attributes'].get('pwdChangedTime', entry['attributes'].get('createTimestamp', None))
if pwd_changed_time and max_pwd_age:
expiry_date = pwd_changed_time + timedelta(seconds=max_pwd_age)
user["expired"] = datetime.datetime.now(datetime.timezone.utc) > expiry_date
user["password_expiration"] = int(expiry_date.timestamp())
else:
expiry_date = ""
user["expired"] = False
user["password_expiration"] = -1
user["mail"] = entry['attributes'].get('mail')[0] if len(entry['attributes'].get('mail', [])) > 0 else ""
users.append(user)

return users
78 changes: 78 additions & 0 deletions core/imageroot/usr/local/bin/ns8-sendmail
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/local/agent/pyenv/bin/python3

#
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

# This script reads a mail from stdin and sends it using Python's smtplib
# It takes as arguments the recipient

import ssl
import sys
import agent
import smtplib
import argparse
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

# Accept the following arguments
# argv[1] to argv[n] = recipient
# -s = subject
# -f = from

# Parse the arguments
parser = argparse.ArgumentParser(description='Send an email using cluster notification settings.')
parser.add_argument('recipients', metavar='R', type=str, nargs='*', help='Email recipients')
parser.add_argument('-s', '--subject', type=str, default='', help='Email subject')
parser.add_argument('-f', '--from', dest='from_addr', type=str, default=f'no-reply@{agent.get_hostname()}', help='From address')

args = parser.parse_args()

# Check if recipients list is empty
if not args.recipients:
print("Error: At least one recipient is required.", file=sys.stderr)
sys.exit(1)

# Read mail body from stdin
body = sys.stdin.read()

# Read SMTP cluser configuration
rdb = agent.redis_connect(use_replica=True)
smtp_config = agent.get_smarthost_settings(rdb)
if not smtp_config['enabled']:
print("Error: Smarthost is not enabled.", file=sys.stderr)
sys.exit(1)

# Create a SSL context
ctx = ssl.create_default_context()
if not smtp_config['tls_verify']:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

# Setup connection based on encrypt_smtp value.
# Possible values: none, starttls, tls
if smtp_config['encrypt_smtp'] == 'tls':
smtp = smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port'], context=ctx)
elif smtp_config['encrypt_smtp'] == 'starttls':
smtp = smtplib.SMTP(smtp_config['host'], smtp_config['port'])
smtp.starttls(context=ctx)
else:
smtp = smtplib.SMTP(smtp_config['host'], smtp_config['port'])

# Authenticate if needed
if smtp_config['username'] and smtp_config['password']:
smtp.login(smtp_config['username'], smtp_config['password'])

# Create a multipart message and set headers
message = MIMEMultipart()
message["From"] = args.from_addr
message["To"] = ','.join(args.recipients)
message["Subject"] = args.subject

# Attach the HTML part
message.attach(MIMEText(body, "html"))

# Send the message via the configured SMTP server
smtp.sendmail(args.from_addr, ','.join(args.recipients), message.as_string())
smtp.quit()
Loading