基于ldap更新的sip directory

PS: 本文已经过脱敏处理,所有xml、ldap、ip、端口均为随机示例,若产生冲突请留言处理。cisco sip xml模板来自Cisco官方网站,若需出处请留言。代码仅作为参考。

背景

由于需要配置cisco ip电话以提供员工通讯录的功能,需要根据一个xml模板和LDAP中用户的信息(用户名以及ip电话号码)来产生相应的xml文件。需求分为两步来完成,先静态生成一份xml文件来测试,这部分通过ldap导出csv文件,再通过python来处理。第一步完成后,再通过python-ldap去ldap server中拉取用户信息以生成directory xml,最后封装进docker,通过nginx提供访问,并提供自动更新和触发更新。

由csv文件实现

先从ldap中导出csv格式的文件,部分样式如下:

1
2
3
4
5
dn
"sn=1000,user=tp.link,ou=staff,dc=example,dc=com"
"sn=1001,user=wrt.linksys,ou=staff,dc=example,dc=com"
"sn=1002,user=dd.wrt,ou=staff,dc=example,dc=com"
"sn=1003,user=wire.less,ou=staff,dc=example,dc=com"

其中的 sn 字段为ip电话的号码,user 字段为用户名,是我们需要关注和处理的字段。我们只需要提取出user和对应的sn,再使用python中的xml库即可产生xml文件。

xml模板如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<CiscoIPPhoneDirectory>
<Title>Cisco Coporate Directory</Title>
<Prompt>Select the User</Prompt>
<DirectoryEntry>
<Name>Shze Chew Lee</Name>
<Telephone>140</Telephone>
</DirectoryEntry>
<DirectoryEntry>
<Name>Sherman Scholten</Name>
<Telephone>130</Telephone>
</DirectoryEntry>
<DirectoryEntry>
<Name>Josh Bottum</Name>
<Telephone>186</Telephone>
</DirectoryEntry>
</CiscoIPPhoneDirectory>

python程序如下

python解析xml有很多库,这里使用ElementTree,用到的函数很简单,由ET.ElementTree()方法产生父节点,再由ET.SubElement()方法产生子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from xml.etree import ElementTree as ET
import sys
def InsertEntry(root,userName,teleNumber):
'''Insert entry with username and telephone number into <DirectoryEntry>.'''
entry = ET.SubElement(root,'DirectoryEntry')
name = ET.SubElement(entry,"Name")
name.text = userName
telephone = ET.SubElement(entry,"Telephone")
telephone.text = teleNumber
def csvProcess(root,filetoRead):
'''Resolve the csv file and extract the uid and number,then call the InsertEntry().'''
try:
csvFile=open(filetoRead,"rU")
except Exception,e:
print e
exit(1)
for line in csvFile:
if "sn" not in line:
continue
listOfLine=line.strip('"').split(',')
num=listOfLine[0][3:]
uid=listOfLine[1][4:]
InsertEntry(root,uid,num)
root = ET.Element('CiscoIPPhoneDirectory')
title = ET.SubElement(root, 'Title')
title.text = "Cisco Coporate Directory"
prompt = ET.SubElement(root, 'Prompt')
prompt.text = "Select the User"
csvProcess(root,sys.argv[1])
tree = ET.ElementTree(root)
output=open("output.xml","w")
tree.write(output)

使用方法: python et.py export.csv

最终产生的文件 output.xml 部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<CiscoIPPhoneDirectory>
<Title>Cisco Coporate Directory</Title>
<Prompt>Select the User</Prompt>
<DirectoryEntry>
<Name>blue.blue</Name>
<Telephone>1000</Telephone>
</DirectoryEntry>
<DirectoryEntry>
<Name>red.red</Name>
<Telephone>1001</Telephone>
</DirectoryEntry>
<DirectoryEntry>
<Name>white.white</Name>
<Telephone>1002</Telephone>
</DirectoryEntry>
<DirectoryEntry>
<Name>cray.cray</Name>
<Telephone>1003</Telephone>
</DirectoryEntry>
...
<DirectoryEntry>
<Name>black.black</Name>
<Telephone>1006</Telephone>
</DirectoryEntry>
</CiscoIPPhoneDirectory>

python-ldap

上述方法必须得手动在ldap服务器上导出csv文件再用python处理才可,接下来使用python-ldap模块直接从ldap服务器上搜索entry。

先来看一下最终的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#2016-3-21 Ver 0.0.1
#2016-3-22 ver 0.0.2
#Maintained by tyr.chen
#Prereq:
#This program rely on the python-ldap module and this module rely on openldap dev lib.
#To install this module,you have to excute the following command.
#- sudo apt-get install libsasl2-dev python-dev libldap2-dev libssl-dev
#- sudo pip install python-ldap
#Description:
#Usage: python xml_by_ldap.py output-file-name [control_port]
#The control port is optional,when given this parameters,this program will listen on control_port permanently,
#when receive the string 'update',run the main funcation.
#The output-file-name parameters must be delivied.
import sys,ldap,socket
from xml.etree import ElementTree as ET
LDAP_HOST='ldap.example.com'
LDAP_DN='sn=administrator,dc=example,dc=com'
LDAP_SECRET='password'
try:
OUTPUT_FILE=sys.argv[1]
except IndexError:
print 'Usage:python %s output-file-name [control_port]' % (sys.argv[0])
exit(1)
class MyException(Exception):
def __init__(self, err):
Exception.__init__(self)
self.errno = err
def pull_user_and_sn(ldap_host):
try:
# Connect and authentication.
con = ldap.initialize('ldap://'+ldap_host)
con.simple_bind_s(LDAP_DN,LDAP_SECRET)
con.protocol_version = ldap.VERSION3
except ldap.LDAPError,e:
print e
raise MyException(1)
baseDN= 'ou=staff,dc=example,dc=com'
searchScope = ldap.SCOPE_SUBTREE
retrieveAttributes = None
# IPphone number start with 6.
searchFilter = 'sn=1*'
try:
# Search the entry with filter 'sn=1*' in the baseDN.
result_raw = con.search_s(baseDN,searchScope,searchFilter,retrieveAttributes)
# The LDAP search operation typically requires five parameters:
# - The base DN, which indicates where in the directory information tree the search should start.
# - The scope, which indicates how deeply the search should delve into the directory information tree.
# - The search filter, which indicates which entries should be considered matches.
# - The attribute list, which indicates which attributes of a matching record should be returned.
# - A flag indicating whether attribute values should be returned (the Attrs Only flag).
res=[]
for i in range(len(result_raw)):
# Extract the first two segment,cn and uid.
sn_and_user = result_raw[i][0].split(',')[:2]
# Filter useless data in the field of cn & uid.
if 'sn' in sn_and_user[0] and 'user' in sn_and_user[1]:
res.append(sn_and_user)
return res
except ldap.LDAPError,e:
print e
raise MyException(2)
def InsertEntry(root,userName,teleNumber):
''' Insert uid and phone number entry into root node.'''
entry = ET.SubElement(root,'DirectoryEntry')
name = ET.SubElement(entry,'Name')
name.text = userName
telephone = ET.SubElement(entry,'Telephone')
telephone.text = teleNumber
def main():
''' Main function.'''
root = ET.Element('CiscoIPPhoneDirectory')
title = ET.SubElement(root, 'Title')
title.text = 'Cisco Coporate Directory'
prompt = ET.SubElement(root, 'Prompt')
prompt.text = 'Select the User'
try :
res=pull_user_and_sn(LDAP_HOST)
except MyException:
return 1
for i in range(len(res)):
#InsertEntry(rootnode,userName,teleNumber)
InsertEntry(root,res[i][1][4:],res[i][0][3:])
tree = ET.ElementTree(root)
try:
output=open(OUTPUT_FILE,'w')
except IOError,e:
print e
return 2
tree.write(output)
# tree.write(sys.stdout)
print 'Done.\nOuput file: '+OUTPUT_FILE
return 0
def listen_control(port):
'''Simple tcp socket ,expecting on 'update' string to call main().Can work with nc.'''
try :
s=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0',port))
s.listen(5)
except socket.error,e:
print e
exit(5)
while True:
cs,ca = s.accept()
print 'Connected from: ',ca,
rev = cs.recv(512)
if rev.strip() == 'update':
print 'update signal.'
if main() ==0:
cs.send('[update success]\n')
else:
cs.send('[update failure]\n')
else:
print 'interruption.'
cs.send('[wrong instruction]\n')
cs.close()
exit(6)
if __name__=='__main__':
if len(sys.argv) == 2:
main()
if len(sys.argv) == 3:
listen_control(int(sys.argv[2]))

pull_user_and_sn()

定义了pull_user_and_sn()函数,这部分用到了ldap模块,为了安装python-ldap我们需要执行以下命令:

1
2
$ sudo apt-get install libsasl2-dev python-dev libldap2-dev libssl-dev
$ sudo pip install python-ldap

在这个函数中,首先进行连接的建立和绑定,而后用search_s(baseDN,searchScope,searchFilter,retrieveAttributes)这个函数来对baseDN进行搜索,其中的参数设置如下:

1
2
3
4
baseDN = 'ou=staff,dc=example,dc=com' # 从这个记录开始搜索
searchScope = ldap.SCOPE_SUBTREE # 搜索baseDN及其子树
retrieveAttributes = None # 取出搜索结果的过滤条件
searchFilter = 'sn=1*' # 搜索的过滤条件

其它在程序中已做注释。

InsertEntry()

在root节点中插入子节点,即Name和Telephone。

listen_control)

一个简单的tcp套接字,接受tcp连接,并在收到特定string时调用main()函数。在之前自定义了一个异常类,用于main()运行中出现的任何异常都能被捕获并返回非0值,并发送update failure给触发更新的client。

对接收的参数进行判断,如果没给端口则直接调用main()进行update后退出,否则调用listen_control()并阻塞在listen状态,并持续接受连接。

使用docker封装

封装进docker主要是为了方便部署、管理和迁移。将上述程序和nginx封装进docker,以提供xml文件的http访问。并提供一种自动更新方式和两种命令更新方式。

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# xml service for cisco ip phone.
#
# VERSION 0.0.1
FROM ubuntu:14.04
MAINTAINER Tyr Chen <tyr.chen@tyr.gift>
RUN sed 's@archive\.ubuntu\.com@mirrors.ustc.edu.cn@' -i /etc/apt/sources.list
RUN apt-get update && \
apt-get install -y --no-install-recommends libsasl2-dev python-dev libldap2-dev libssl-dev python2.7 python-pip nginx supervisor gcc
RUN apt-get clean
RUN pip install python-ldap
COPY xml_by_ldap.py /xml_by_ldap.py
COPY init.sh /init.sh
EXPOSE 80:80 8000:8000
ENV SERVER_NAME 'localhost'
CMD ["start"]
ENTRYPOINT ["/init.sh"]

大致解释一下。$SERVER_NAME这个环境变量是nginx配置文件中监听的server name。当docker run不加任何指令时,CMD会作为ENTRYPOINT的参数,即运行的是 ‘/init.sh start’。
init.sh的内容如下:

init.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/usr/bin/env bash
update(){
python /xml_by_ldap.py /usr/share/nginx/html/directory.xml
if [ "$?" -ne "0" ];then
echo "Ldap pull failed."
else
echo 'Ldap pull success.'
fi
}
start(){
# 修改nginx配置文件
cat <<EOF > /etc/nginx/sites-enabled/default
server {
listen 80 default_server;
root /usr/share/nginx/html;
index directory.xml;
server_name $SERVER_NAME;
}
EOF
cat /etc/nginx/sites-enabled/default
/usr/sbin/nginx && echo 'nginx started.'
ps -ef
rm /etc/supervisor/conf.d/supervisord.conf
cat <<EOF >/etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
EOF
cat <<EOF >> /etc/supervisor/conf.d/supervisord.conf
[program:xml_generator]
command=python /xml_by_ldap.py /usr/share/nginx/html/directory.xml 8000
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
EOF
cat <<EOF >> /etc/supervisor/conf.d/supervisord.conf
[program:sleep_3600]
command=bash -c "while true;do python /xml_by_ldap.py /usr/share/nginx/html/directory.xml;sleep 3600;done"
autorestart=true
EOF
cat /etc/supervisor/conf.d/supervisord.conf
update
/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
}
case "$1" in
start)
start
;;
update)
update
;;
*)
exit 1
;;
esac

主要由update()和start()两个函数构成。update即调用xml_by_ldap来一次性执行该文件,更新一次后结束。start中,由supervisor来管理进程,启动xml_by_ldap的监听模式,以及每隔3600s自动更新一次。

RUN & UPDATE

run
docker run -d --restart=always -p 80:80 -p 8000:8000 -e SERVER_NAME='server.name' --name xml tyr/sip-xml

80为nginx监听端口,根目录即为xml_by_ldap产生的directory.xml。

SERVER_NAME为nginx配置文件中的server name,不指定即为localhost.

xml_by_ldap.py 控制端口为2000,接受tcp连接,唯一有效指令为update

update

container启动时会自动update一次,往后每隔3600s自动更新一次,此外,有两种手动update方法:

docker exec sip-xml /init.sh update
或者
echo update | nc 192.168.1.1 2000

return value

nc控制时有三种返回值:

[update failure] 发送update,但是server pull ldap失败

[wrong instruction] 指令错误,应发送update

[update success] 更新成功

XML

nginx 的server name由SERVER_NAME环境变量设置,默认为localhost.XML directory默认url即为http://[docker host ip]

如果您觉得这篇文章对您有帮助,不妨支持我一下!