Query Active Directory LDAP for alumni (#120)
continuous-integration/drone/push Build is passing Details

Closes #116.

UWLDAP has program information for current students, so we should continue using it by default.
If the sn attribute (last name) is missing from the entry, then we query ADLDAP instead.

Reviewed-on: #120
This commit is contained in:
Max Erenberg 2024-02-01 23:57:53 -05:00
parent bd1da799c6
commit a4a4ef089c
28 changed files with 353 additions and 43 deletions

17
.drone/adldap_data.ldif Normal file
View File

@ -0,0 +1,17 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

View File

@ -31,13 +31,13 @@ IMAGE__setup_ldap() {
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/rfc2307bis.schema /etc/ldap/schema/
cp .drone/csc.schema /etc/ldap/schema/
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \

View File

@ -186,3 +186,28 @@ objectClass: group
objectClass: posixGroup
cn: office1
gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

10
.drone/mock_ad.schema Normal file
View File

@ -0,0 +1,10 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

View File

@ -7,6 +7,7 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid
@ -40,6 +41,11 @@ access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access
access to *
by dn="cn=ceod,dc=csclub,dc=internal" write

View File

@ -106,3 +106,18 @@ objectClass: person
objectClass: top
uid: exec3
mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

View File

@ -1,5 +1,4 @@
import os
import socket
import sys
from zope import component
@ -9,6 +8,7 @@ from .krb_check import krb_check
from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services():
@ -19,8 +19,7 @@ def register_services():
if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG']
else:
# This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
if is_in_development():
config_file = './tests/ceo_dev.ini'
else:
config_file = '/etc/csc/ceo.ini'

View File

@ -1,10 +1,9 @@
import socket
from typing import List, Tuple, Dict
import click
import requests
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler
@ -53,6 +52,6 @@ def handle_sync_response(resp: requests.Response):
def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'):
if not is_in_development():
click.echo('This command may only be called during development.')
raise Abort()

View File

@ -0,0 +1,18 @@
import typing
from typing import Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.ADLDAPRecord import ADLDAPRecord
class IADLDAPService(Interface):
"""Represents the AD LDAP database."""
def get_user(username: str) -> Optional['ADLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
"""

View File

@ -1,20 +1,23 @@
from typing import List, Union
import typing
from typing import List, Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.UWLDAPRecord import UWLDAPRecord
class IUWLDAPService(Interface):
"""Represents the UW LDAP database."""
def get_user(username: str):
def get_user(username: str) -> Optional['UWLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
"""
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
"""
Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in

View File

@ -1,3 +1,4 @@
from .IADLDAPService import IADLDAPService
from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService

View File

@ -0,0 +1,57 @@
import ldap3
from .UWLDAPRecord import UWLDAPRecord
class ADLDAPRecord:
"""Represents a record from the AD LDAP."""
# These are just the ones in which we're interested
ldap_attributes = [
'sAMAccountName',
'mail',
'description',
'sn',
'givenName',
]
def __init__(
self,
sam_account_name: str,
mail: str,
description: str,
sn: str,
given_name: str,
):
self.sam_account_name = sam_account_name
self.mail = mail
self.description = description
self.sn = sn
self.given_name = given_name
@staticmethod
def deserialize_from_ldap(entry: ldap3.Entry):
"""
Deserializes a dict returned from LDAP into an
ADLDAPRecord.
"""
return ADLDAPRecord(
sam_account_name=entry.sAMAccountName.value,
mail=entry.mail.value,
description=entry.description.value,
sn=entry.sn.value,
given_name=entry.givenName.value,
)
def to_uwldap_record(self) -> UWLDAPRecord:
"""Converts this AD LDAP record into a UW LDAP record."""
return UWLDAPRecord(
uid=self.sam_account_name,
mail_local_addresses=[self.mail],
program=None,
# The description attribute has the format "Lastname, Firstname"
cn=' '.join(self.description.split(', ')[::-1]),
# Alumni's last names are prepended with an asterisk
sn=(self.sn[1:] if self.sn[0] == '*' else self.sn),
given_name=self.given_name
)

View File

@ -1,4 +1,6 @@
from .ADLDAPRecord import ADLDAPRecord
from .Config import Config
from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService
from .Term import Term
from .UWLDAPRecord import UWLDAPRecord

View File

@ -1,6 +1,7 @@
import datetime
import re
from dataclasses import dataclass
import socket
# TODO: disallow underscores. Will break many tests with usernames that include _
VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$")
@ -70,3 +71,8 @@ def validate_username(username: str) -> UsernameValidationResult:
if not VALID_USERNAME_RE.fullmatch(username):
return UsernameValidationResult(False, 'Username is invalid')
return UsernameValidationResult(True)
def is_in_development() -> bool:
"""This is a hack to determine if we're in the dev env or not"""
return socket.getfqdn().endswith('.csclub.internal')

View File

@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
IContainerRegistryService, IClubWebHostingService
IContainerRegistryService, IClubWebHostingService, IADLDAPService
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService, CloudStackService, \
CloudResourceManager, KubernetesService, VHostManager, \
ContainerRegistryService, ClubWebHostingService
ContainerRegistryService, ClubWebHostingService, ADLDAPService
from ceod.db import MySQLService, PostgreSQLService
@ -36,6 +36,15 @@ def create_app(flask_config={}):
from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
# Only offer mailman API if this host is running Mailman
if hostname == cfg.get('ceod_mailman_host'):
from ceod.api import mailman
@ -53,15 +62,6 @@ def create_app(flask_config={}):
from ceod.api import webhosting
app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
register_error_handlers(app)
@app.route('/ping')
@ -112,9 +112,13 @@ def register_services(app):
mail_srv = MailService()
component.provideUtility(mail_srv, IMailService)
# UWLDAPService
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
# UWLDAPService, ADLDAPService
if hostname == cfg.get('ceod_admin_host'):
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
adldap_srv = ADLDAPService()
component.provideUtility(adldap_srv, IADLDAPService)
# ClubWebHostingService
if hostname == cfg.get('ceod_webhosting_host'):

View File

@ -3,15 +3,25 @@ from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, ILDAPService
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
from ceo_common.logger_factory import logger_factory
bp = Blueprint('uwldap', __name__)
logger = logger_factory(__name__)
@bp.route('/<username>')
def get_user(username: str):
uwldap_srv = component.getUtility(IUWLDAPService)
record = uwldap_srv.get_user(username)
if record is not None and not record.sn:
# Alumni are missing a lot of information in UWLDAP.
# Try AD LDAP instead
logger.debug('Querying %s from AD LDAP', username)
adldap_srv = component.getUtility(IADLDAPService)
ad_record = adldap_srv.get_user(username)
if ad_record is not None:
record = ad_record.to_uwldap_record()
if record is None:
return {
'error': 'user not found',

View File

@ -0,0 +1,57 @@
from typing import Optional
import dns.resolver
import ldap3
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IADLDAPService, IConfig
from ceo_common.logger_factory import logger_factory
from ceo_common.model import ADLDAPRecord
from ceo_common.utils import is_in_development
logger = logger_factory(__name__)
@implementer(IADLDAPService)
class ADLDAPService:
def __init__(self):
cfg = component.getUtility(IConfig)
if is_in_development():
self.adldap_dns_srv_name = None
self.adldap_server_url = cfg.get('adldap_server_url')
else:
self.adldap_dns_srv_name = cfg.get('adldap_dns_srv_name')
# Perform the actual DNS query later so that we don't delay startup
self.adldap_server_url = None
self.adldap_base = cfg.get('adldap_base')
def _get_server_url(self) -> str:
assert self.adldap_dns_srv_name is not None
answers = dns.resolver.resolve(self.adldap_dns_srv_name, 'SRV')
target = answers[0].target.to_text()
# Strip the trailing '.'
target = target[:-1]
logger.debug('Using AD LDAP server %s', target)
# ldaps doesn't seem to work
return 'ldap://' + target
def _get_conn(self) -> ldap3.Connection:
if self.adldap_server_url is None:
self.adldap_server_url = self._get_server_url()
# When get_info=ldap3.SCHEMA (the default), ldap3 tries to search
# for schema information in 'CN=Schema,CN=Configuration,DC=ds,DC=uwaterloo,DC=ca',
# which doesn't exist so a LDAPNoSuchObjectResult exception is raised.
# To avoid this, we tell ldap3 not to look up any server info.
server = ldap3.Server(self.adldap_server_url, get_info=ldap3.NONE)
return ldap3.Connection(
server, auto_bind=True, read_only=True, raise_exceptions=True)
def get_user(self, username: str) -> Optional[ADLDAPRecord]:
conn = self._get_conn()
conn.search(
self.adldap_base, f'(sAMAccountName={username})',
attributes=ADLDAPRecord.ldap_attributes, size_limit=1)
if not conn.entries:
return None
return ADLDAPRecord.deserialize_from_ldap(conn.entries[0])

View File

@ -1,11 +1,11 @@
from typing import Union, List
from typing import List, Optional
import ldap3
from zope import component
from zope.interface import implementer
from .UWLDAPRecord import UWLDAPRecord
from ceo_common.interfaces import IUWLDAPService, IConfig
from ceo_common.model import UWLDAPRecord
@implementer(IUWLDAPService)
@ -20,7 +20,7 @@ class UWLDAPService:
self.uwldap_server_url, auto_bind=True, read_only=True,
raise_exceptions=True)
def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
def get_user(self, username: str) -> Optional[UWLDAPRecord]:
conn = self._get_conn()
conn.search(
self.uwldap_base, f'(uid={username})',
@ -29,7 +29,7 @@ class UWLDAPService:
return None
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]:
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
programs = [None] * len(usernames)
user_indices = {uid: i for i, uid in enumerate(usernames)}

View File

@ -1,3 +1,4 @@
from .ADLDAPService import ADLDAPService
from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService
from .KerberosService import KerberosService
@ -5,7 +6,6 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
from .User import User
from .Group import Group
from .UWLDAPService import UWLDAPService
from .UWLDAPRecord import UWLDAPRecord
from .FileService import FileService
from .SudoRole import SudoRole
from .MailService import MailService

View File

@ -1,15 +1,16 @@
version: "3.6"
x-common: &common
image: ceo-generic:bullseye
volumes:
- ceo-venv:/app/venv:ro
- ./.drone:/app/.drone:ro
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro
- ceo-venv:/app/venv:ro
- ./ceo:/app/ceo:ro
- ./ceo_common:/app/ceo_common:ro
- ./ceod:/app/ceod:ro
- ./tests:/app/tests:ro
# for flake8
- ./setup.cfg:/app/setup.cfg:ro
security_opt:
- label:disable
working_dir: /app
@ -18,6 +19,7 @@ x-common: &common
x-ceod-common: &ceod-common
<<: *common
image: ceo-generic:bullseye
environment:
FLASK_APP: ceod.api
FLASK_DEBUG: "true"

View File

@ -30,6 +30,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
server_url = ldaps://uwldap.uwaterloo.ca
base = dc=uwaterloo,dc=ca
[adldap]
dns_srv_name = _ldap._tcp.teaching.ds.uwaterloo.ca
base = dc=teaching,dc=ds,dc=uwaterloo,dc=ca
[members]
min_id = 20001
max_id = 29999

View File

@ -1,5 +1,6 @@
click==8.1.6
cryptography==41.0.2
dnspython==2.5.0
Flask==2.3.2
gssapi==1.8.2
gunicorn==21.2.0

View File

@ -35,6 +35,7 @@ if ! $DOCKER volume exists ceo-venv; then
$DOCKER volume create ceo-venv
$DOCKER run \
--rm \
--security-opt label=disable \
-w /app/venv \
-v ceo-venv:/app/venv:z \
-v ./requirements.txt:/tmp/requirements.txt:ro \

View File

@ -50,3 +50,19 @@ def test_updateprograms(
# make sure that the user was changed
status, data = client.get(f'/api/members/{uwldap_user.uid}')
assert data['program'] == 'New Program'
def test_get_alumni(client):
uid = 'alumni1'
status, data = client.get(f'/api/uwldap/{uid}')
assert status == 200
expected = {
"cn": 'Alumni One',
"given_name": 'Alumni',
"sn": 'One',
# This should be the email address from AD LDAP
"mail_local_addresses": [f'{uid}@alumni.uwaterloo.internal'],
# Program attribute should be missing
"uid": uid,
}
assert data == expected

View File

@ -27,6 +27,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal
base = ou=UWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=ADLDAP,dc=csclub,dc=internal
[members]
min_id = 20001
max_id = 29999

View File

@ -26,6 +26,10 @@ sudo_base = ou=TestSUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal
base = ou=TestUWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=TestADLDAP,dc=csclub,dc=internal
[members]
# 20000 is ctdalek, 20001 is office1
min_id = 20002

View File

@ -30,15 +30,16 @@ from .utils import ( # noqa: F401
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService
from ceo_common.model import Config, HTTPClient, Term
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService, \
IADLDAPService
from ceo_common.model import Config, HTTPClient, Term, UWLDAPRecord
import ceo_common.utils
from ceod.api import create_app
from ceod.db import MySQLService, PostgreSQLService
from ceod.model import KerberosService, LDAPService, FileService, User, \
MailmanService, Group, UWLDAPService, UWLDAPRecord, MailService, \
MailmanService, Group, UWLDAPService, MailService, \
CloudStackService, KubernetesService, VHostManager, CloudResourceManager, \
ContainerRegistryService, ClubWebHostingService
ContainerRegistryService, ClubWebHostingService, ADLDAPService
from .MockSMTPServer import MockSMTPServer
from .MockMailmanServer import MockMailmanServer
from .MockCloudStackServer import MockCloudStackServer
@ -309,6 +310,17 @@ def uwldap_srv(cfg, ldap_conn):
'givenName': 'Calum',
},
)
conn.add(
f'uid=alumni1,{base_dn}',
['inetLocalMailRecipient', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'mailLocalAddress': 'alumni1@uwaterloo.internal',
'ou': 'Alumni',
'cn': 'Alumni',
'sn': 'One',
'givenName': 'Alumni',
},
)
_uwldap_srv = UWLDAPService()
component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv
@ -316,6 +328,31 @@ def uwldap_srv(cfg, ldap_conn):
delete_subtree(conn, base_dn)
@pytest.fixture(scope='session')
def adldap_srv(cfg, ldap_conn):
conn = ldap_conn
base_dn = cfg.get('adldap_base')
delete_subtree(conn, base_dn)
conn.add(base_dn, 'organizationalUnit')
conn.add(
f'cn=alumni1,{base_dn}',
['mockADUser', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'description': 'One, Alumni',
'givenName': 'Alumni',
'sn': '*One',
'cn': 'alumni1',
'sAMAccountName': 'alumni1',
'displayName': 'alumni1',
'mail': 'alumni1@alumni.uwaterloo.internal',
},
)
_adldap_srv = ADLDAPService()
component.getGlobalSiteManager().registerUtility(_adldap_srv, IADLDAPService)
yield _adldap_srv
delete_subtree(conn, base_dn)
@pytest.fixture(scope='session')
def webhosting_srv():
with tempfile.TemporaryDirectory() as tmpdir:
@ -376,14 +413,25 @@ def postgresql_srv(cfg):
return psql_srv
def delete_dir_contents(dir: str):
if os.path.isdir(dir):
for item in os.listdir(dir):
path = os.path.join(dir, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.unlink(path)
@pytest.fixture(scope='session')
def vhost_dir_setup(cfg):
state_dir = '/run/ceod'
if os.path.isdir(state_dir):
shutil.rmtree(state_dir)
os.makedirs(state_dir)
# Don't delete the directory itself because the non-test ceod process
# is using it
delete_dir_contents(state_dir)
os.makedirs(state_dir, exist_ok=True)
yield
shutil.rmtree(state_dir)
delete_dir_contents(state_dir)
@pytest.fixture(scope='session')
@ -422,6 +470,7 @@ def app(
file_srv,
mailman_srv,
uwldap_srv,
adldap_srv,
mail_srv,
mysql_srv,
postgresql_srv,