昔我往矣

flask编写一个简易版的DnsPod

2020年08月29日

DNS是当前互联网最重要的基础设施,一般企业内也会部署自己的私有DNS服务器,在我们公司就是部署BIND9做内部域名解析,用DnsPod做外部域名解析,为了配合Nginx的运维自动化,所以对外封装了DnsPod的api接口,对内自己用flask封装了一套api。目前的过程是使用数据库记录DNS的解析记录,通过模板生成DNS的配置文件,并推送到DNS服务器,然后reload生效,过程比较复杂。

近期尝试了直接使用BIND的DLZ(Dynamically Loadable Zones)功能,BIND直接使用MySQL做后端解析记录的存储,并且使用Flask为BIND做了数据库管理后台和RESTful API。

注意:下面会从源码编译一个支持MySQL做后端存储的BIND9,但并不是所有的BIND9都支持DLZ,编译前可以检查下configure命令有没有--with-dlz-mysql 的选项 。
除MySQL外,DLZ也支持Postgres、ldap等作为后端存储。
本文内容涉及到Linux基础命令、Docker、Python等多方面的知识,比较有意思,希望能让你有所收获。。

1 BIND9的编译和配置

1.1 使用dockerfile编译Bind9

下面我使用了Dockerfile来进行编译,Dockerfile的内容如下:

FROM centos:7 as builder
WORKDIR /tmp/build
ADD https://ftp.ripe.net/mirrors/sites/ftp.isc.org/isc/bind/9.12.4-P2/bind-9.12.4-P2.tar.gz .
RUN yum -y update && \
    yum install -y mysql-devel gcc gcc-c++ make file python python-ply perl 
RUN  tar xvf bind-9.12.4-P2.tar.gz &&  cd bind-9.12.4-P2 && \
    ./configure --prefix=/data/program/bind9 --with-dlz-mysql && \
    make -j 4 && \
    make install

FROM centos:7 as runner
WORKDIR /data/program/bind9
RUN yum -y install mysql-devel
COPY --from=builder /data/program/bind9 ./
CMD ["/data/program/bind9/sbin/named", "-c", "/data/program/bind9/etc/named.conf", "-g"]

此处使用了Docker的多阶段编译,减少最后成品容器的大小。

1.2 下面开始docker容器的build过程

docker build . -t bind9-with-dlz-mysql

完成之后,会在本机生成一个 bind9-with-dlz-mysql:latest 的容器,后续我们也会将BIND9运行在该容器中。

1.3 准备MySQL

这里就不写MySQL的部署过程了,只提供创建数据库(本例中为bind9)和数据表(本例中为record)的语句。

mysql> create database bind9;
mysql> use bind9;
mysql> CREATE TABLE `record` (                                             
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,                    
   `zone` varchar(256) NOT NULL,                                     
   `host` varchar(256) NOT NULL DEFAULT '@',                         
   `type` enum('MX','CNAME','NS','SOA','A','PTR') NOT NULL,          
   `data` varchar(256) DEFAULT NULL,                                 
   `ttl` int(11) NOT NULL DEFAULT '60',                             
   `mx_priority` int(11) DEFAULT NULL,                               
   `refresh` int(11) NOT NULL DEFAULT '3600',                        
   `retry` int(11) NOT NULL DEFAULT '3600',                          
   `expire` int(11) NOT NULL DEFAULT '86400',                        
   `minimum` int(11) NOT NULL DEFAULT '3600',                        
   `serial` bigint(20) NOT NULL DEFAULT '2020082916',                
   `resp_person` varchar(64) NOT NULL DEFAULT 'sb', 
   `primary_ns` varchar(64) NOT NULL DEFAULT 'ns.xnow.me.',  
   PRIMARY KEY (`id`)                                                
 ) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;

mysql> insert INTO `record` (zone,host,type,data) values ('xnow.me','a','A','1.1.1.1');
mysql> insert INTO `record` (zone,host,type,data) values ('xnow.me','b','CNAME','a.xnow.me.');

1.4 准备BIND的配置文件named.conf

named.conf的内容如下,是从BIND的官方example中抄下来的,做了些许微调。

controls { };
options {
    directory ".";
    port 53;
    pid-file "named.pid";
    session-keyfile "session.key";
    listen-on { any; };
    listen-on-v6 { none; };
    recursion no;
};


dlz "mysql-dlz" {
    database "mysql
    {host=127.0.0.1 dbname=bind9 ssl=false port=3306 user=root pass=root}
    {select zone from record where zone = '$zone$' limit 1}
    {select ttl, type, mx_priority, case when lower(type)='txt' then concat('\"', data, '\"') when lower(type) = 'soa' then concat_ws('', data, resp_person, serial, refresh, retry, expire,minimum) else data end from record where zone = '$zone$' and host ='$record$'}";
};

和DLZ相关的配置在最后一部分,配置了MySQL的数据库信息,包括用地址、端口号、用户名和密码等。后面两行select语句是从MySQL数据库中查询DNS记录的。

1.5 启动和测试

启动容器,根据dockerfile中的定义,会在前台输出请求日志

docker run --rm -v $PWD/named.conf:/data/program/bind9/etc/named.conf --name=named9 --net=host bind9-with-dlz-mysql:latest

新开一个终端窗口,使用 host 命令(也可以使用nslookup或者dig)进行测试。上文中,我们在创建表结构之后,注入了一条A记录和一条CNAME记录,这里请求试试看。

host -t A a.xnow.me 127.0.0.1   
host -t A b.xnow.me 127.0.0.1

2. 使用Flask编写Bind9的后台和API

这里主要使用了2个模块:

  • flask-admin ,生成数据库的管理后台
  • flask-restplus, 生成api和 api swagger文档

首先安装下python的依赖,此处使用的是Python3.7

pip3 install flask flask-restplus flask-admin mysql flask-sqlalchemy sqlalchemy-serializer 

2.1 Python代码

app.py代码内容如下:

import enum

from flask import Flask,request, jsonify, abort
from flask_admin import Admin, BaseView, expose
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy_serializer import SerializerMixin
from flask_restplus import Api, Resource, fields


app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = "mysql://root:root@localhost:3306/bind9?charset=utf8"
app.config['SECRET_KEY'] = "123456"
api = Api(app, version='1.0', title="Bind9 API")

admin = Admin(app, name="域名管理系统")
db = SQLAlchemy(app)

class RecordTypeEnum(enum.Enum):
    A = 1
    CNAME = 2
    NS = 3
    SOA = 4
    PTR = 5
    MX = 6
    TXT = 7
    AAAA = 8


class Record(db.Model, SerializerMixin):  # 表数据结构
    __tablename = "record"
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    zone = db.Column(db.String(256), nullable=False)
    host = db.Column(db.String(256), nullable=False)
    type = db.Column(db.Enum(RecordTypeEnum), nullable=False)
    data = db.Column(db.String(256), nullable=False)
    ttl = db.Column(db.Integer, nullable=False, default=600)
    mx_priority = db.Column(db.Integer)
    refresh = db.Column(db.Integer, nullable=False, default=3600)
    retry = db.Column(db.Integer, nullable=False, default=3600)
    expire = db.Column(db.Integer, nullable=False, default=86400)
    minimum = db.Column(db.Integer, nullable=False, default=3600)
    serial = db.Column(db.Integer, nullable=False, default=2020082916)
    resp_person = db.Column(db.String(256))
    primary_ns = db.Column(db.String(256))


record = api.model('Record', {
    'id': fields.Integer(readonly=True),
    'zone': fields.String(required=True),
    'host': fields.String(required=True),
    'type': fields.String(required=True),
    'data': fields.String(required=True),
    'ttl': fields.Integer(default=60),
    'resp_person': fields.String(required=True),
    'primary_ns': fields.String(required=True),
})


class RecordView(Resource):   # 创建api
    @api.param("id", "record id")
    def get(self):
        id = request.args.get("id")
        return Record.query.filter_by(id=id).first().to_dict() or abort(404)

    @api.doc("add a dns record")
    @api.expect(record)
    @api.marshal_with(record, 201)
    def post(self):
        rcd = Record(**api.payload)
        db.session.add(rcd)
        db.session.commit()
        return rcd, 201


admin.add_view(ModelView(Record, db.session))
api.add_resource(RecordView, '/api/record')

app.run(debug=True)

执行:

export FLASK_APP=app.py
flask run

2.2 使用

http://localhost:5000/admin # 这里是数据库的后台管理系统
http://localhost:5000/api/record # 这是管理解析记录的API,代码中只支持了 POSTGET两个方法。
http://localhost:5000/ # 这里是生成的swagger文档

基本具备了DnsPod的web管理功能和api管理功能。当然只是很简陋的版本,如果要用在生产环境中,还需要不断的完善。

3. DLZ的优缺点

使用这种模式的优点很明显:灵活、优雅。但是缺点也有,每次请求DNS解析都要查询数据库,可能存在如下的风险:

  • 生产环境的MySQL数据库肯定是分布式主从部署,DNS请求查询数据库会导致解析的网络延迟增加(TCP三次握手)。
  • MySQL的可靠性要求往往低于DNS,MySQL迁移过程中导致的偶尔请求中断可能会导致部分DNS解析失败。
  • DNS的QPS高峰可能达到数万,对MySQL造成一定的并发压力。
  • 如果MySQL主从切换依赖域名解析,可能导致MySQL主故障后,依赖DNS,而DNS因为主数据库故障而无法解析,导致的死锁问题。

但是在某些场景中,这个方案是十分有用的,比如存在缓存DNS服务的情况下,主MySQL或者主DNS临时无法解析不会导致客户端的请求失败。所以,我个人认为,在企业局域网中,这个方案不一定最佳,但是在广域网上,这个方案是十分优秀的。

当前暂无评论 »

添加新评论 »