permission fix
[opencloud-plugin.git] / opencloud-net / opencloud-net.py
1 #!/usr/bin/python
2
3 """
4 This program sets up dnsmasq and iptables to support the "Private-Nat"
5 and "Public" network models for OpenCloud.  It communicates with OvS
6 on the local node and Neutron to gather information about the virtual
7 interfaces instantiated by Neutron.  It uses this information to:
8
9 * add the Neutron-assigned IP address to the vif via dnsmasq
10 * set up port forwarding rules through the NAT using iptables
11
12 The iptables configuration uses a chain called 'opencloud-net' to
13 hold the port forwarding rules.  This is called from the PREROUTING
14 chain of the nat table. The chain is flushed and rebuilt every time
15 the plugin runs to avoid stale rules.  This plugin also sets up the
16 MASQ rule in the POSTROUTING chain.
17
18 NOTES: 
19 * Currently the port forwarding rules are driven from a per-node config
20   file, not from state in Neutron
21 """
22
23 # system provided modules
24 import fcntl
25 import os, string, time, socket, sys
26 from socket import inet_aton
27 import subprocess, signal
28 import json
29 from ConfigParser import ConfigParser
30 import socket, netifaces, netaddr
31
32 # Neutron modules
33 from neutronclient.v2_0 import client
34
35 plugin = "opencloud-net"
36
37 nat_net_name = "nat-net"
38 nat_net_dev = "br-nat"
39 nat_net_id = None
40
41 site_net_name = "ext-net"
42
43 site_net_dev = None
44 # Handle differences between Ubuntu 14.04, 12.04, MAAS, etc.
45 interfaces = netifaces.interfaces()
46 for dev in ['br-ex', 'em1', 'br0', 'eth0']:
47     if dev in interfaces and 2 in netifaces.ifaddresses(dev):
48         site_net_dev = dev
49         break
50
51 site_net_id = None
52
53 neutron_auth_url = None
54 neutron_username = None
55 neutron_password = None
56 neutron_tenant_name = None
57
58 # Pretty stupid right now, but should get the job done
59 def set_ip_address(dev, addr, cidr):
60     (net, bits) = cidr.split('/')
61     addrwithcidr = addr + '/' + bits
62     cmd = ["/sbin/ip", "addr", "change", addrwithcidr, "dev", dev]
63     try:
64         subprocess.call(cmd)
65     except:
66         pass
67         
68 def get_addrinfo(ifname):
69     addrs = netifaces.ifaddresses(ifname)
70     ipinfo = addrs[socket.AF_INET][0]
71     address = ipinfo['addr']
72     netmask = ipinfo['netmask']
73     cidr = netaddr.IPNetwork('%s/%s' % (address, netmask))
74     return (address, str(cidr.cidr))
75
76 # Should possibly be using python-iptables for this stuff
77 def run_iptables_cmd(args):
78     cmd = ['/sbin/iptables'] + args
79     print('%s: %s' % (plugin, ' '.join(cmd)))
80     subprocess.check_call(cmd)
81     
82 def add_iptables_rule(table, chain, args, pos = None):
83     iptargs = ['-t', table, '-C',  chain] + args
84     try:
85         run_iptables_cmd(iptargs)
86     except:
87         if pos:
88             iptargs = ['-t', table, '-I', chain, str(pos)] + args
89         else:
90             iptargs[2] = '-A'
91         try:
92             run_iptables_cmd(iptargs)
93         except:
94             print('%s: FAILED to add iptables rule' % plugin)
95
96 def reset_iptables_chain():
97     try:
98         # Flush the opencloud-nat chain
99         run_iptables_cmd(['-t', 'nat', '-F', plugin])
100     except:
101         # Probably the chain doesn't exist, try creating it
102         run_iptables_cmd(['-t', 'nat', '-N', plugin]) 
103
104     add_iptables_rule('nat', 'PREROUTING', ['-j', plugin]) 
105
106 # Nova blocks packets from external addresses by default.
107 # This is hacky but it gets around the issue.
108 def unfilter_ipaddr(dev, ipaddr):
109     add_iptables_rule(table = 'filter', 
110                       chain = 'nova-compute-sg-fallback', 
111                       args = ['-d', ipaddr, '-j', 'ACCEPT'], 
112                       pos = 1)
113     
114 # Enable iptables MASQ for a device
115 def add_iptables_masq(dev, cidr):
116     args = ['-s',  cidr, '!',  '-d',  cidr, '-j', 'MASQUERADE']
117     add_iptables_rule('nat', 'POSTROUTING', args)
118
119 def get_pidfile(dev):
120     return '/var/run/dnsmasq-%s.pid' % dev
121
122 def get_leasefile(dev):
123     return '/var/lib/dnsmasq/%s.leases' % dev
124
125 def get_hostsfile(dev):
126     return '/var/lib/dnsmasq/%s.hosts' % dev
127
128 # Check if dnsmasq already running
129 def dnsmasq_running(dev):
130     pidfile = get_pidfile(dev)
131     try:
132         pid = open(pidfile, 'r').read().strip()
133         if os.path.exists('/proc/%s' % pid):
134             return True
135     except:
136         pass
137     return False
138     
139 def dnsmasq_remove_lease(dev, ip, mac):
140     cmd = ['/usr/bin/dhcp_release', dev, ip, mac]
141     try:
142         subprocess.check_call(cmd)
143     except:
144         print('%s: dhcp_release failed' % (plugin))
145
146 def dnsmasq_sighup(dev):
147     pidfile = get_pidfile(dev)
148     try:
149         pid = open(pidfile, 'r').read().strip()
150         if os.path.exists('/proc/%s' % pid):
151             os.kill(int(pid), signal.SIGHUP)
152             print("%s: Sent SIGHUP to dnsmasq on dev %s" % (plugin, dev))
153     except:
154         print("%s: Sending SIGHUP to dnsmasq FAILED on dev %s" % (plugin, dev))
155
156 # Enable dnsmasq for this interface.
157 # It's possible that we could get by with a single instance of dnsmasq running on
158 # all devices but I haven't tried it.
159 def start_dnsmasq(dev, ipaddr, forward_dns=True, authoritative=False):
160     if not dnsmasq_running(dev):
161         # The '--dhcp-range=<IP addr>,static' argument to dnsmasq ensures that it only
162         # hands out IP addresses to clients listed in the hostsfile
163         cmd = ['/usr/sbin/dnsmasq',
164                '--strict-order',
165                '--bind-interfaces',
166                '--local=//',
167                '--domain-needed',
168                '--pid-file=%s' % get_pidfile(dev),
169                '--conf-file=',
170                '--interface=%s' % dev,
171                '--except-interface=lo',
172                '--dhcp-leasefile=%s' % get_leasefile(dev),
173                '--dhcp-hostsfile=%s' % get_hostsfile(dev),
174                '--dhcp-no-override',
175                '--dhcp-range=%s,static' % ipaddr]
176
177         if authoritative:
178             cmd.append('--dhcp-authoritative')
179
180         # Turn off forwarding DNS queries, only do DHCP
181         if forward_dns == False:
182             cmd.append('--port=0')
183
184         try:
185             print('%s: starting dnsmasq on device %s' % (plugin, dev))
186             subprocess.check_call(cmd)
187         except:
188             print('%s: FAILED to start dnsmasq for device %s' % (plugin, dev))
189             print(' '.join(cmd))
190
191 def convert_ovs_output_to_dict(out):
192     decoded = json.loads(out.strip())
193     headings = decoded['headings']
194     data = decoded['data']
195
196     records = []
197     for rec in data:
198         mydict = {}
199         for i in range(0, len(headings) - 1):
200             if not isinstance(rec[i], list):
201                 mydict[headings[i]] = rec[i]
202             else:
203                 if rec[i][0] == 'set':
204                     mydict[headings[i]] = rec[i][1]
205                 elif rec[i][0] == 'map':
206                     newdict = {}
207                     for (key, value) in rec[i][1]:
208                         newdict[key] = value
209                     mydict[headings[i]] = newdict
210                 elif rec[i][0] == 'uuid':
211                     mydict['uuid'] = rec[i][1]
212         records.append(mydict)
213
214     return records
215
216
217 # Get a list of local VM interfaces and then query Neutron to get
218 # Port records for these interfaces.
219 def get_local_neutron_ports():
220     ports = []
221
222     # Get local information for VM interfaces from OvS
223     ovs_out = subprocess.check_output(['/usr/bin/ovs-vsctl', '-f', 'json', 'find', 
224                                        'Interface', 'external_ids:iface-id!="absent"'])
225     records = convert_ovs_output_to_dict(ovs_out)
226
227     if records:
228         # Extract Neutron Port IDs from OvS records
229         port_ids = []
230         for rec in records:
231             port_ids.append(rec['external_ids']['iface-id'])
232
233         # Get the full info on these ports from Neutron
234         neutron = client.Client(username=neutron_username,
235                                 password=neutron_password,
236                                 tenant_name=neutron_tenant_name,
237                                 auth_url=neutron_auth_url)
238         ports = neutron.list_ports(id=port_ids)['ports']
239
240     return ports
241
242
243 # Generate a dhcp-hostsfile for dnsmasq.  The purpose is to make sure
244 # that the IP address assigned by Neutron appears on NAT interface.
245 def write_dnsmasq_hostsfile(dev, ports, net_id):
246     print("%s: Writing hostsfile for %s" % (plugin, dev))
247     
248     masqfile = get_hostsfile(dev)
249     masqdir = os.path.dirname(masqfile)
250     if not os.path.exists(masqdir):
251         os.makedirs(masqdir)
252        
253     # Clean up old leases in the process
254     leases = {} 
255     leasefile = get_leasefile(dev)
256     try:
257         f = open(leasefile, 'r')
258         for line in f:
259             fields = line.split()
260             try:
261                 leases[fields[2]] = fields[1]
262             except:
263                 pass
264         f.close()
265     except:
266         pass
267         
268     f = open(masqfile, 'w')
269     for port in ports:
270         if port['network_id'] == net_id:
271             mac_addr = port['mac_address']
272             ip_addr = port['fixed_ips'][0]['ip_address']
273             entry = "%s,%s\n" % (mac_addr, ip_addr)
274             f.write(entry)
275             print("%s:   %s" % (plugin, entry.rstrip()))
276
277             if ip_addr in leases and leases[ip_addr] != mac_addr:
278                 dnsmasq_remove_lease(dev, ip_addr, leases[ip_addr])
279                 print("%s: removed old lease for %s" % (plugin, ip_addr))
280     f.close()
281
282     # Send SIGHUP to dnsmasq to make it re-read hostsfile
283     dnsmasq_sighup(dev)
284
285 def add_fw_rule(protocol, fwport, ipaddr):
286     print "%s: fwd port %s/%s to %s" % (plugin, protocol, fwport, ipaddr)
287     add_iptables_rule('nat', plugin, ['-i', site_net_dev,
288                                       '-p', protocol, '--dport', str(fwport),
289                                       '-j', 'DNAT', '--to-destination', ipaddr])
290
291 # Set up iptables rules in the 'opencloud-net' chain based on
292 # the nat:forward_ports field in the Port record.
293 def set_up_port_forwarding(dev, ports):
294     if os.path.exists('/usr/local/etc/portfwd.cfg'):
295         try:
296             with open('/usr/local/etc/portfwd.cfg', 'r') as fp:
297                 for line in fp:
298                     try:
299                         (protocol, port, ipaddr) = line.strip().split()
300                         if protocol in ['tcp', 'udp']:
301                             add_fw_rule(protocol, port, ipaddr)
302                     except:
303                         pass
304         except:
305             print("%s: Could not read port forward file" % plugin)
306             pass
307
308     for port in ports:
309         if (port['network_id'] == nat_net_id) and port.get('nat:forward_ports',None):
310             for fw in port['nat:forward_ports']:
311                 ipaddr = port['fixed_ips'][0]['ip_address']
312                 protocol = fw['l4_protocol']
313                 fwport = fw['l4_port']
314
315                 #unfilter_ipaddr(dev, ipaddr)
316                 add_fw_rule(protocol, fwport, ipaddr)
317
318 def get_net_id_by_name(name):
319     neutron = client.Client(username=neutron_username,
320                             password=neutron_password,
321                             tenant_name=neutron_tenant_name,
322                             auth_url=neutron_auth_url)
323
324     net = neutron.list_networks(name=name)
325     net_id = net['networks'][0]['id']
326
327     return net_id
328
329 def get_subnet_network(net_id):
330     neutron = client.Client(username=neutron_username,
331                             password=neutron_password,
332                             tenant_name=neutron_tenant_name,
333                             auth_url=neutron_auth_url)
334
335     subnets = neutron.list_subnets(network_id=net_id)
336     
337     ipaddr = subnets['subnets'][0]['gateway_ip']
338     cidr = subnets['subnets'][0]['cidr']
339
340     return (ipaddr,cidr)
341     
342 def block_remote_dns_queries(ipaddr, cidr):
343     for proto in ['tcp', 'udp']:
344         add_iptables_rule('filter', 'INPUT',
345                             ['!', '-s', cidr, '-d', ipaddr, '-p', proto,
346                             '--dport', '53', '-j', 'DROP'])
347
348 def start():
349     global neutron_username
350     global neutron_password
351     global neutron_tenant_name
352     global neutron_auth_url
353
354     print("%s: plugin starting up..." % plugin)
355
356     parser = ConfigParser()
357     parser.read("/etc/nova/nova.conf")
358     neutron_username = parser.get("DEFAULT", "neutron_admin_username")
359     neutron_password = parser.get("DEFAULT", "neutron_admin_password")
360     neutron_tenant_name = parser.get("DEFAULT", "neutron_admin_tenant_name")
361     neutron_auth_url = parser.get("DEFAULT", "neutron_admin_auth_url")
362
363 def main(argv):
364     global nat_net_id
365     global site_net_id
366
367     lock_file = open("/var/lock/opencloud-net", "w")
368     try:
369         fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
370     except IOError, e:
371         if e.errno == errno.EAGAIN:
372             print >> sys.stderr, "Script is already running."
373             sys.exit(-1)
374
375     start()
376
377     if not nat_net_id:
378         try:
379             nat_net_id = get_net_id_by_name(nat_net_name)
380         except:
381             print("%s: no network called %s..." % (plugin, nat_net_name))
382             sys.exit(1)
383             
384     print("%s: %s id is %s..." % (plugin, nat_net_name, nat_net_id))
385
386     if not site_net_id:
387         try:
388             site_net_id = get_net_id_by_name(site_net_name)
389             print("%s: %s id is %s..." % (plugin, site_net_name, site_net_id))
390         except:
391             print("%s: no network called %s..." % (plugin, site_net_name))
392             
393     reset_iptables_chain()
394     ports = get_local_neutron_ports()
395     # print ports
396     
397     # Set IP address on br-nat if necessary
398     (nat_ip_addr, nat_cidr) = get_subnet_network(nat_net_id)
399     set_ip_address(nat_net_dev, nat_ip_addr, nat_cidr)
400
401     # Process Private-Nat networks
402     add_iptables_masq(nat_net_dev, nat_cidr)
403     write_dnsmasq_hostsfile(nat_net_dev, ports, nat_net_id)
404     set_up_port_forwarding(nat_net_dev, ports)
405     start_dnsmasq(nat_net_dev, nat_ip_addr, authoritative=True)
406
407     # Process Public networks
408     # Need iptables rule to block requests from outside...
409     if site_net_id:
410         write_dnsmasq_hostsfile(site_net_dev, ports, site_net_id)
411         (ipaddr, cidr) = get_addrinfo(site_net_dev)
412         block_remote_dns_queries(ipaddr, cidr)
413         start_dnsmasq(site_net_dev, ipaddr)
414
415 if __name__ == "__main__":
416    main(sys.argv[1:])