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:协议。
因为时间的关系,后续会增加以下功能支持:
- 目前只支持放行单一端口,后续会支持多端口和全部端口;
- 后续会支持获取A记录以外的DNS记录类型;
- 后续会增加多协议的支持。
因为这是临时编写的脚本,而且是在晚上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地址进行比对,逻辑如下:
- 遍历DNS解析记录列表,逐一在iptables检索字典中寻找匹配的key;
- 如果有匹配的key,则说明该IP不需要删除或添加,则将该key从iptables检索字典中删除;
- 如果没有匹配的key,则说明该IP需要添加到iptables中,此时将调用iptables插入规则函数;
- 遍历完DNS解析记录列表后,将iptables检索字典中的value提取出来建立一个名为rule_id_list的列表,该列表中包含需要删除的规则的编号;
- 如果此时iptables检索字典中还有值,则说明该IP不在DNS解析记录列表中,需要将其从iptables中删除。
- 使用sorted函数对列表内的数值从大到小排序;
- 遍历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群。