0x01 前言

为了实现高可用与降低服务器费用以及其他需求,我通过DDNS的形式将家中宽带的公网IP解析到某个域名,DDNS的脚本请参考以下文章:

这样的服务搭建方式有一个问题,从VPS发起的请求访问我家里时,因为来源IP是固定的,我可以在家中的pfsense设定防火墙白名单以放行相关IP;但从我家里发起请求访问VPS,因为家庭宽带的公网IP会变化,因此设定固定的iptables规则是不可行的。

因此我撰写了这个脚本,实现在VPS上自动删除与添加iptables规则的功能。

0x02 准备

首先需要确认iptables服务处于开启的状态:

# 检查iptables状态
[root@py-t1 ~]# systemctl status iptables.service 
● iptables.service - IPv4 firewall with iptables
   Loaded: loaded (/usr/lib/systemd/system/iptables.service; disabled; vendor preset: disabled)
   Active: active (exited) since Sat 2018-08-11 10:09:34 CST; 4s ago
  Process: 26847 ExecStop=/usr/libexec/iptables/iptables.init stop (code=exited, status=0/SUCCESS)
  Process: 26907 ExecStart=/usr/libexec/iptables/iptables.init start (code=exited, status=0/SUCCESS)
 Main PID: 26907 (code=exited, status=0/SUCCESS)

Aug 11 10:09:34 py-t1 systemd[1]: Starting IPv4 firewall with iptables...
Aug 11 10:09:34 py-t1 iptables.init[26907]: iptables: Applying firewall rules: [  OK  ]
Aug 11 10:09:34 py-t1 systemd[1]: Started IPv4 firewall with iptables.

在最小化安装的centos7中,默认是没有安装iptables并以firewalld代替的,但我习惯用iptables,所以请根据以下文章进行配置:

为了模拟测试,我选择使用以下两个域名模拟DDNS域名解析的IP发生变化的情况:

  • qq.com
  • aliyun.com

这里需要注意下,因为这两个域名都需要配置MX记录,所以不会使用CNAME而必须用A记录指向服务器的IP。因此,我的脚本目前也只支持A记录,其他更多的记录类型会在后期根据需求的情况进行更新。

以上两个域名的解析在我这边的响应如下:

# qq.com的DNS响应
[root@py-t1 ~]# dig qq.com

; <<>> DiG 9.9.4-RedHat-9.9.4-61.el7 <<>> qq.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 19754
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1480
;; QUESTION SECTION:
;qq.com.				IN	A

;; ANSWER SECTION:
qq.com.			1800	IN	A	58.60.9.21
qq.com.			1800	IN	A	180.163.26.39
qq.com.			1800	IN	A	59.37.96.63

;; Query time: 3 msec
;; SERVER: 10.1.1.1#53(10.1.1.1)
;; WHEN: Sat Aug 11 10:15:04 CST 2018
;; MSG SIZE  rcvd: 83

# aliyun.com的DNS响应
[root@py-t1 ~]# dig aliyun.com

; <<>> DiG 9.9.4-RedHat-9.9.4-61.el7 <<>> aliyun.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 28734
;; flags: qr rd ra; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1480
;; QUESTION SECTION:
;aliyun.com.			IN	A

;; ANSWER SECTION:
aliyun.com.		1800	IN	A	140.205.172.20
aliyun.com.		1800	IN	A	140.205.32.13
aliyun.com.		1800	IN	A	140.205.172.21
aliyun.com.		1800	IN	A	140.205.34.12
aliyun.com.		1800	IN	A	140.205.230.3

;; Query time: 6 msec
;; SERVER: 10.1.1.1#53(10.1.1.1)
;; WHEN: Sat Aug 11 10:15:30 CST 2018
;; MSG SIZE  rcvd: 119

需要注意的是,这两个域名都存在多个A记录并指向不同的IP,这是为了实现故障转移功能所设定的。

而在家庭环境中,有可能会拥有电信和联通甚至更多运营商的线路,毕竟现在都流行办理手机套餐送宽带,因此在这个脚本中会将所有A记录的IP地址添加到iptables白名单中。

最后需要准备python3的运行环境,然后安装dnspython包:

[root@py-t1 ~]# pip3 install dnspython

0x02 脚本

0x02.1 配置文件

需要修改的配置文件内容如下:

{
  "domain": "ngx.hk",
  "port": "10050",
  "record": "a",
  "proto": "tcp"
}
  • domain:DDNS域名;
  • port:白名单端口;
  • record:仅支持a记录;
  • proto:协议。

因为时间的关系,后续会增加以下功能支持:

  1. 目前只支持放行单一端口,后续会支持多端口和全部端口;
  2. 后续会支持获取A记录以外的DNS记录类型;
  3. 后续会增加多协议的支持。

因为这是临时编写的脚本,而且是在晚上11点前后开始写的,因为时间关系,我只实现我需要的基础功能,后续会完善脚本,而下面是脚本读取与解析的函数:

# 读取配置文件
def read_config():
    config_content = open(sys.path[0] + '/config.json')
    try:
        config_json = json.loads(config_content.read())
    except Exception as e:
        print('config json error, load failed! msg: \n' + str(e))
        sys.exit(1)
    else:
        config_content.close()
        return config_json

修改完配置文件后建议先测试一遍,确认可以正常读取后再继续,后期会加上debug模式,以便测试。

该模块最终会输出字典类型的内容。

0x02.2 检索iptables

为了方便检索,我特意在规则中增加特定的注释:DDNS iptables rule。那么只需要执行以下语句即可获取所有相关的规则:

[root@py-t1 ~]# iptables -L INPUT -n --line-numbers | grep "DDNS iptables rule"
5    ACCEPT     tcp  --  58.60.9.21           0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */
6    ACCEPT     tcp  --  180.163.26.39        0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */
7    ACCEPT     tcp  --  59.37.96.63          0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */

该函数的代码如下:

# 从iptables INPUT链中检索包含 "DDNS iptables rule" 备注的规则
def get_info_from_chain():
    shell_get_rules = 'iptables -L INPUT -n --line-numbers | grep "DDNS iptables rule"'
    output_dict = dict()

    run_shell = subprocess.Popen(shell_get_rules, shell=True, stdout=subprocess.PIPE)
    shell_output = run_shell.communicate()[0]

    # 判断status code,若无相关规则,则返还status code 1,此时直接返还空字典
    if run_shell.returncode == 1:
        return output_dict
    # 判断status code,若有相关规则,则返还status code 0,此时返还经过处理的字典
    elif run_shell.returncode == 0:
        output_list = shell_output.decode().split('\n')
        for i in output_list:
            if len(i) == 0:
                pass
            else:
                i_list = ' '.join(i.split()).split()
                output_dict[i_list[4]] = i_list[0]
        return output_dict

    # 若返还的status code不是0或1,则抛出异常
    else:
        raise subprocess.CalledProcessError(run_shell.returncode, shell_get_rules, output=shell_output)

在这里我使用subprocess调用shell命令,如果iptables中存在相关规则,则会返还status code 0,而后进行字符串处理;如果返还status code 1,则代表iptables中不存在相关的规则,而后返回空字典;如果是其他status code则拉起错误。

在有内容返还的时候需要做一些字符上的处理,subprocess返还的是一串字节,因此需要decode:

b'5    ACCEPT     tcp  --  58.60.9.21           0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */\n6    ACCEPT     tcp  --  180.163.26.39        0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */\n7    ACCEPT     tcp  --  59.37.96.63          0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */\n'

首先以换行符为分界点建立列表,然后删除多余的空格并以空格建立一个新的列表:

['5', 'ACCEPT', 'tcp', '--', '58.60.9.21', '0.0.0.0/0', 'tcp', 'dpt:10050', '/*', 'DDNS', 'iptables', 'rule', '*/']
['6', 'ACCEPT', 'tcp', '--', '180.163.26.39', '0.0.0.0/0', 'tcp', 'dpt:10050', '/*', 'DDNS', 'iptables', 'rule', '*/']
['7', 'ACCEPT', 'tcp', '--', '59.37.96.63', '0.0.0.0/0', 'tcp', 'dpt:10050', '/*', 'DDNS', 'iptables', 'rule', '*/']

在这里我只需要规则的编号和来源IP,也就是列表中的第0和第4个值,然后将他们配对写入到字典中:

{'180.163.26.39': '6', '59.37.96.63': '7', '58.60.9.21': '5'}

0x02.3 iptables插入规则

以下是插入规则的函数:

# 往INPUT链插入规则
def append_rule_to_chain(src_ip, dst_port, proto):
    shell_append_rule = 'iptables -A INPUT -p ' + str(proto) + ' --dport ' + str(dst_port) + ' -s ' + str(
        src_ip) + ' -j ACCEPT -m comment --comment "DDNS iptables rule"'

    run_shell = subprocess.Popen(shell_append_rule, shell=True, stdout=subprocess.PIPE)
    shell_output = run_shell.communicate()[0]

    if run_shell.returncode == 0 or 1:
        pass
    else:
        raise subprocess.CalledProcessError(run_shell.returncode, shell_append_rule, output=shell_output)

目前只支持单一协议与端口的放行,所以函数比较简单,同样的需要判断status code,而iptables完整的语句如下:

iptables -A INPUT -p tcp --dport 10050 -s 10.10.1.1 -j ACCEPT -m comment --comment "DDNS iptables rule"

0x02.4 iptables删除规则

以下是删除规则的函数:

# 从INPUT链中删除规则
def del_rule_from_chain(rule_id):
    shell_del_rule = 'iptables -D INPUT ' + str(rule_id)

    run_shell = subprocess.Popen(shell_del_rule, shell=True, stdout=subprocess.PIPE)
    shell_output = run_shell.communicate()[0]

    if run_shell.returncode == 0 or 1:
        pass
    else:
        raise subprocess.CalledProcessError(run_shell.returncode, shell_del_rule, output=shell_output)

规则的删除只需要知道规则的编号即可,所以需要往函数中传递该编号,而iptables完整的语句如下:

iptables -D INPUT 1

0x02.5 DDNS域名的IP

以下是该函数的代码:

# 从DNS服务器获取特定解析类型的IP地址
def get_dns_record(ddns_domain, ddns_record):
    output_list = []
    dns_resolver = resolver.Resolver()
    dns_answers = dns_resolver.query(ddns_domain, ddns_record)
    if len(dns_answers) is not 0:
        for rdata in dns_answers:
            output_list.append(str(rdata))
    return output_list

在这里使用一个外部的软件包,他会返还一个列表形式的内容,但以上函数没考虑到空列表的情况,这个会在后续完善。

0x03 运行

if __name__ == '__main__':
    config_dict = read_config()  # 读取配置文件
    dns_list = get_dns_record(config_dict['domain'], config_dict['record'])  # 获取IP地址列表
    dns_list_need_remove = []  # 建立中间列表
    chain_dict = get_info_from_chain()  # 获取现有规则

    '''
    1. 遍历DNS解析记录:遍历故障转移环境中多个IP地址
    2. 逐一比对iptables中的记录,若比对成功,则从chain_dict字典中删除key,否则调用append_rule_to_chain函数
    '''
    for j in dns_list:
        if j in chain_dict.keys():
            del chain_dict[j]
        else:
            append_rule_to_chain(j, config_dict['port'], config_dict['proto'])

    # 读取chain_dict字典中所有value,也就是iptables规则的编号
    rule_id_list = list(chain_dict.values())

    # 如果rule_id_list列表中的value数量大于1,则调用sorted排序,降序
    if len(rule_id_list) > 1:
        rule_id_list = sorted(rule_id_list, reverse=True)

    # 遍历排序后的rule_id_list列表,调用del_rule_from_chain参数逐一删除失效的INPUT规则
    for i in rule_id_list:
        del_rule_from_chain(i)

最后是运行在操作iptables前需要对解析记录与iptables中的IP地址进行比对,逻辑如下:

  1. 遍历DNS解析记录列表,逐一在iptables检索字典中寻找匹配的key;
  2. 如果有匹配的key,则说明该IP不需要删除或添加,则将该key从iptables检索字典中删除;
  3. 如果没有匹配的key,则说明该IP需要添加到iptables中,此时将调用iptables插入规则函数;
  4. 遍历完DNS解析记录列表后,将iptables检索字典中的value提取出来建立一个名为rule_id_list的列表,该列表中包含需要删除的规则的编号;
    • 如果此时iptables检索字典中还有值,则说明该IP不在DNS解析记录列表中,需要将其从iptables中删除。
  5. 使用sorted函数对列表内的数值从大到小排序;
  6. 遍历rule_id_list列表,条用del_rule_from_chain函数将其从iptables中删除。

这里有一个需要注意的点,iptables是这样的:

[root@py-t1 ~]# iptables -L INPUT -vn --line-numbers 
Chain INPUT (policy ACCEPT 49 packets, 10671 bytes)
num   pkts bytes target     prot opt in     out     source               destination         
1     1770 1440K ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            state RELATED,ESTABLISHED
2        6   504 ACCEPT     icmp --  *      *       0.0.0.0/0            0.0.0.0/0           
3        0     0 ACCEPT     all  --  lo     *       0.0.0.0/0            0.0.0.0/0           
4        1    64 ACCEPT     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            state NEW tcp dpt:22
5        0     0 ACCEPT     tcp  --  *      *       58.60.9.21           0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */
6        0     0 ACCEPT     tcp  --  *      *       180.163.26.39        0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */
7        0     0 ACCEPT     tcp  --  *      *       59.37.96.63          0.0.0.0/0            tcp dpt:10050 /* DDNS iptables rule */

第一列为规则的编码,以上面的内容为例,最后3行为脚本自动添加的规则,如果需要自动删除,那么一定要从编码大的往编码小的删除。

举个反例:当我删除地5条规则之后,下面的规则编码会发生变化,因为规则会上移,也就是原来的规则7变成规则6,而规则6遍成规则5。

为了避免这种情况,我调用sorted对列表从大到小排序,在遍历的时候也会遵循从大到小的顺序逐一删除不需要的iptables规则。

0x04 结语

功能比较单一,我主要是用于放行zabbix相关的端口,但我还有其他需求,所以该脚本会更新得比较频繁,该脚放置在我的私有gitlab中:

如果有其他需求或建议,可以通过邮件与我联系,也欢迎加入QQ群。