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]