]> git.ipfire.org Git - location/libloc.git/blame - src/python/location-importer.in
location-query: Fix typo in "aut-num"
[location/libloc.git] / src / python / location-importer.in
CommitLineData
78ff0cf2
MT
1#!/usr/bin/python3
2###############################################################################
3# #
4# libloc - A library to determine the location of someone on the Internet #
5# #
6# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
7# #
8# This library is free software; you can redistribute it and/or #
9# modify it under the terms of the GNU Lesser General Public #
10# License as published by the Free Software Foundation; either #
11# version 2.1 of the License, or (at your option) any later version. #
12# #
13# This library is distributed in the hope that it will be useful, #
14# but WITHOUT ANY WARRANTY; without even the implied warranty of #
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
16# Lesser General Public License for more details. #
17# #
18###############################################################################
19
20import argparse
6ffd06b5 21import ipaddress
78ff0cf2 22import logging
6ffd06b5
MT
23import math
24import re
78ff0cf2 25import sys
83d61c46 26import telnetlib
78ff0cf2
MT
27
28# Load our location module
29import location
29c6fa22 30import location.database
3192b66c 31import location.importer
78ff0cf2
MT
32from location.i18n import _
33
34# Initialise logging
35log = logging.getLogger("location.importer")
36log.propagate = 1
37
38class CLI(object):
39 def parse_cli(self):
40 parser = argparse.ArgumentParser(
41 description=_("Location Importer Command Line Interface"),
42 )
6ffd06b5 43 subparsers = parser.add_subparsers()
78ff0cf2
MT
44
45 # Global configuration flags
46 parser.add_argument("--debug", action="store_true",
47 help=_("Enable debug output"))
bc1f5f53
MT
48 parser.add_argument("--quiet", action="store_true",
49 help=_("Enable quiet mode"))
78ff0cf2
MT
50
51 # version
52 parser.add_argument("--version", action="version",
53 version="%(prog)s @VERSION@")
54
29c6fa22
MT
55 # Database
56 parser.add_argument("--database-host", required=True,
57 help=_("Database Hostname"), metavar=_("HOST"))
58 parser.add_argument("--database-name", required=True,
59 help=_("Database Name"), metavar=_("NAME"))
60 parser.add_argument("--database-username", required=True,
61 help=_("Database Username"), metavar=_("USERNAME"))
62 parser.add_argument("--database-password", required=True,
63 help=_("Database Password"), metavar=_("PASSWORD"))
64
0983f3dd
MT
65 # Write Database
66 write = subparsers.add_parser("write", help=_("Write database to file"))
67 write.set_defaults(func=self.handle_write)
68 write.add_argument("file", nargs=1, help=_("Database File"))
69 write.add_argument("--signing-key", nargs="?", type=open, help=_("Signing Key"))
1164d876 70 write.add_argument("--backup-signing-key", nargs="?", type=open, help=_("Backup Signing Key"))
0983f3dd
MT
71 write.add_argument("--vendor", nargs="?", help=_("Sets the vendor"))
72 write.add_argument("--description", nargs="?", help=_("Sets a description"))
73 write.add_argument("--license", nargs="?", help=_("Sets the license"))
b904896a 74 write.add_argument("--version", type=int, help=_("Database Format Version"))
0983f3dd 75
6ffd06b5
MT
76 # Update WHOIS
77 update_whois = subparsers.add_parser("update-whois", help=_("Update WHOIS Information"))
78 update_whois.set_defaults(func=self.handle_update_whois)
79
83d61c46
MT
80 # Update announcements
81 update_announcements = subparsers.add_parser("update-announcements",
82 help=_("Update BGP Annoucements"))
83 update_announcements.set_defaults(func=self.handle_update_announcements)
84 update_announcements.add_argument("server", nargs=1,
85 help=_("Route Server to connect to"), metavar=_("SERVER"))
86
d7fc3057
MT
87 # Update overrides
88 update_overrides = subparsers.add_parser("update-overrides",
89 help=_("Update overrides"),
90 )
91 update_overrides.add_argument(
92 "files", nargs="+", help=_("Files to import"),
93 )
94 update_overrides.set_defaults(func=self.handle_update_overrides)
95
78ff0cf2
MT
96 args = parser.parse_args()
97
bc1f5f53 98 # Configure logging
78ff0cf2 99 if args.debug:
f9de5e61 100 location.logger.set_level(logging.DEBUG)
bc1f5f53
MT
101 elif args.quiet:
102 location.logger.set_level(logging.WARNING)
78ff0cf2 103
6ffd06b5
MT
104 # Print usage if no action was given
105 if not "func" in args:
106 parser.print_usage()
107 sys.exit(2)
108
78ff0cf2
MT
109 return args
110
111 def run(self):
112 # Parse command line arguments
113 args = self.parse_cli()
114
29c6fa22 115 # Initialise database
6ffd06b5 116 self.db = self._setup_database(args)
29c6fa22 117
78ff0cf2 118 # Call function
6ffd06b5 119 ret = args.func(args)
78ff0cf2
MT
120
121 # Return with exit code
122 if ret:
123 sys.exit(ret)
124
125 # Otherwise just exit
126 sys.exit(0)
127
29c6fa22
MT
128 def _setup_database(self, ns):
129 """
130 Initialise the database
131 """
132 # Connect to database
133 db = location.database.Connection(
134 host=ns.database_host, database=ns.database_name,
135 user=ns.database_username, password=ns.database_password,
136 )
137
138 with db.transaction():
139 db.execute("""
83d61c46
MT
140 -- announcements
141 CREATE TABLE IF NOT EXISTS announcements(network inet, autnum bigint,
142 first_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
143 last_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP);
144 CREATE UNIQUE INDEX IF NOT EXISTS announcements_networks ON announcements(network);
145 CREATE INDEX IF NOT EXISTS announcements_family ON announcements(family(network));
146
6ffd06b5 147 -- autnums
0983f3dd 148 CREATE TABLE IF NOT EXISTS autnums(number bigint, name text NOT NULL);
6ffd06b5
MT
149 CREATE UNIQUE INDEX IF NOT EXISTS autnums_number ON autnums(number);
150
429a43d1 151 -- networks
83d61c46 152 CREATE TABLE IF NOT EXISTS networks(network inet, country text);
429a43d1 153 CREATE UNIQUE INDEX IF NOT EXISTS networks_network ON networks(network);
83d61c46 154 CREATE INDEX IF NOT EXISTS networks_search ON networks USING GIST(network inet_ops);
d7fc3057
MT
155
156 -- overrides
157 CREATE TABLE IF NOT EXISTS autnum_overrides(
158 number bigint NOT NULL,
159 name text,
bd1aa6a1 160 country text,
d7fc3057
MT
161 is_anonymous_proxy boolean DEFAULT FALSE,
162 is_satellite_provider boolean DEFAULT FALSE,
163 is_anycast boolean DEFAULT FALSE
164 );
165 CREATE UNIQUE INDEX IF NOT EXISTS autnum_overrides_number
166 ON autnum_overrides(number);
167
168 CREATE TABLE IF NOT EXISTS network_overrides(
169 network inet NOT NULL,
170 country text,
171 is_anonymous_proxy boolean DEFAULT FALSE,
172 is_satellite_provider boolean DEFAULT FALSE,
173 is_anycast boolean DEFAULT FALSE
174 );
175 CREATE UNIQUE INDEX IF NOT EXISTS network_overrides_network
176 ON network_overrides(network);
29c6fa22
MT
177 """)
178
179 return db
180
0983f3dd
MT
181 def handle_write(self, ns):
182 """
183 Compiles a database in libloc format out of what is in the database
184 """
0983f3dd 185 # Allocate a writer
1164d876 186 writer = location.Writer(ns.signing_key, ns.backup_signing_key)
0983f3dd
MT
187
188 # Set all metadata
189 if ns.vendor:
190 writer.vendor = ns.vendor
191
192 if ns.description:
193 writer.description = ns.description
194
195 if ns.license:
196 writer.license = ns.license
197
198 # Add all Autonomous Systems
199 log.info("Writing Autonomous Systems...")
200
201 # Select all ASes with a name
6e97c44b
MT
202 rows = self.db.query("""
203 SELECT
204 autnums.number AS number,
205 COALESCE(
206 (SELECT overrides.name FROM autnum_overrides overrides
207 WHERE overrides.number = autnums.number),
208 autnums.name
209 ) AS name
210 FROM autnums
211 WHERE name <> %s ORDER BY number
212 """, "")
0983f3dd
MT
213
214 for row in rows:
215 a = writer.add_as(row.number)
216 a.name = row.name
217
218 # Add all networks
219 log.info("Writing networks...")
220
221 # Select all known networks
222 rows = self.db.query("""
223 SELECT
8e8555bb 224 DISTINCT ON (announcements.network)
0983f3dd
MT
225 announcements.network AS network,
226 announcements.autnum AS autnum,
bd1aa6a1
MT
227
228 -- Country
229 COALESCE(
230 (
231 SELECT country FROM network_overrides overrides
232 WHERE announcements.network <<= overrides.network
233 ORDER BY masklen(overrides.network) DESC
234 LIMIT 1
235 ),
236 (
237 SELECT country FROM autnum_overrides overrides
238 WHERE announcements.autnum = overrides.number
239 ),
240 networks.country
241 ) AS country,
8e8555bb
MT
242
243 -- Must be part of returned values for ORDER BY clause
244 masklen(networks.network) AS sort,
0983f3dd
MT
245
246 -- Flags
1422b5d4
MT
247 COALESCE(
248 (
249 SELECT is_anonymous_proxy FROM network_overrides overrides
250 WHERE announcements.network <<= overrides.network
251 ORDER BY masklen(overrides.network) DESC
252 LIMIT 1
253 ),
254 (
255 SELECT is_anonymous_proxy FROM autnum_overrides overrides
256 WHERE announcements.autnum = overrides.number
257 )
258 ) AS is_anonymous_proxy,
259 COALESCE(
260 (
261 SELECT is_satellite_provider FROM network_overrides overrides
262 WHERE announcements.network <<= overrides.network
263 ORDER BY masklen(overrides.network) DESC
264 LIMIT 1
265 ),
266 (
267 SELECT is_satellite_provider FROM autnum_overrides overrides
268 WHERE announcements.autnum = overrides.number
269 )
270 ) AS is_satellite_provider,
271 COALESCE(
272 (
273 SELECT is_anycast FROM network_overrides overrides
274 WHERE announcements.network <<= overrides.network
275 ORDER BY masklen(overrides.network) DESC
276 LIMIT 1
277 ),
278 (
279 SELECT is_anycast FROM autnum_overrides overrides
280 WHERE announcements.autnum = overrides.number
281 )
282 ) AS is_anycast
0983f3dd 283 FROM announcements
8e8555bb
MT
284 LEFT JOIN networks ON announcements.network <<= networks.network
285 ORDER BY announcements.network, sort DESC
0983f3dd
MT
286 """)
287
288 for row in rows:
289 network = writer.add_network(row.network)
290
291 # Save AS & country
292 network.asn, network.country_code = row.autnum, row.country
293
294 # Set flags
295 if row.is_anonymous_proxy:
296 network.set_flag(location.NETWORK_FLAG_ANONYMOUS_PROXY)
297
298 if row.is_satellite_provider:
299 network.set_flag(location.NETWORK_FLAG_SATELLITE_PROVIDER)
300
301 if row.is_anycast:
302 network.set_flag(location.NETWORK_FLAG_ANYCAST)
303
304 # Write everything to file
305 log.info("Writing database to file...")
306 for file in ns.file:
307 writer.write(file)
308
6ffd06b5
MT
309 def handle_update_whois(self, ns):
310 downloader = location.importer.Downloader()
311
312 # Download all sources
0365119d
MT
313 with self.db.transaction():
314 # Create some temporary tables to store parsed data
315 self.db.execute("""
316 CREATE TEMPORARY TABLE _autnums(number integer, organization text)
317 ON COMMIT DROP;
318 CREATE UNIQUE INDEX _autnums_number ON _autnums(number);
319
320 CREATE TEMPORARY TABLE _organizations(handle text, name text)
321 ON COMMIT DROP;
322 CREATE UNIQUE INDEX _organizations_handle ON _organizations(handle);
323 """)
324
325 for source in location.importer.WHOIS_SOURCES:
6ffd06b5
MT
326 with downloader.request(source, return_blocks=True) as f:
327 for block in f:
328 self._parse_block(block)
329
0365119d
MT
330 self.db.execute("""
331 INSERT INTO autnums(number, name)
332 SELECT _autnums.number, _organizations.name FROM _autnums
333 LEFT JOIN _organizations ON _autnums.organization = _organizations.handle
334 ON CONFLICT (number) DO UPDATE SET name = excluded.name;
335 """)
336
429a43d1
MT
337 # Download all extended sources
338 for source in location.importer.EXTENDED_SOURCES:
339 with self.db.transaction():
429a43d1
MT
340 # Download data
341 with downloader.request(source) as f:
342 for line in f:
343 self._parse_line(line)
344
6ffd06b5
MT
345 def _parse_block(self, block):
346 # Get first line to find out what type of block this is
347 line = block[0]
348
6ffd06b5 349 # aut-num
429a43d1 350 if line.startswith("aut-num:"):
6ffd06b5
MT
351 return self._parse_autnum_block(block)
352
353 # organisation
354 elif line.startswith("organisation:"):
355 return self._parse_org_block(block)
356
6ffd06b5 357 def _parse_autnum_block(self, block):
6ffd06b5
MT
358 autnum = {}
359 for line in block:
360 # Split line
361 key, val = split_line(line)
362
363 if key == "aut-num":
364 m = re.match(r"^(AS|as)(\d+)", val)
365 if m:
366 autnum["asn"] = m.group(2)
367
0365119d 368 elif key == "org":
6ffd06b5
MT
369 autnum[key] = val
370
371 # Skip empty objects
372 if not autnum:
373 return
374
375 # Insert into database
0365119d
MT
376 self.db.execute("INSERT INTO _autnums(number, organization) \
377 VALUES(%s, %s) ON CONFLICT (number) DO UPDATE SET \
378 organization = excluded.organization",
379 autnum.get("asn"), autnum.get("org"),
6ffd06b5
MT
380 )
381
6ffd06b5
MT
382 def _parse_org_block(self, block):
383 org = {}
384 for line in block:
385 # Split line
386 key, val = split_line(line)
387
0365119d 388 if key in ("organisation", "org-name"):
6ffd06b5
MT
389 org[key] = val
390
391 # Skip empty objects
392 if not org:
393 return
394
0365119d
MT
395 self.db.execute("INSERT INTO _organizations(handle, name) \
396 VALUES(%s, %s) ON CONFLICT (handle) DO \
397 UPDATE SET name = excluded.name",
398 org.get("organisation"), org.get("org-name"),
6ffd06b5
MT
399 )
400
429a43d1
MT
401 def _parse_line(self, line):
402 # Skip version line
403 if line.startswith("2"):
404 return
6ffd06b5 405
429a43d1
MT
406 # Skip comments
407 if line.startswith("#"):
408 return
6ffd06b5 409
429a43d1
MT
410 try:
411 registry, country_code, type, line = line.split("|", 3)
412 except:
413 log.warning("Could not parse line: %s" % line)
414 return
6ffd06b5 415
429a43d1
MT
416 # Skip any lines that are for stats only
417 if country_code == "*":
6ffd06b5
MT
418 return
419
429a43d1
MT
420 if type in ("ipv6", "ipv4"):
421 return self._parse_ip_line(country_code, type, line)
422
429a43d1
MT
423 def _parse_ip_line(self, country, type, line):
424 try:
425 address, prefix, date, status, organization = line.split("|")
426 except ValueError:
427 organization = None
428
429 # Try parsing the line without organization
430 try:
431 address, prefix, date, status = line.split("|")
432 except ValueError:
433 log.warning("Unhandled line format: %s" % line)
434 return
435
436 # Skip anything that isn't properly assigned
437 if not status in ("assigned", "allocated"):
438 return
439
440 # Cast prefix into an integer
441 try:
442 prefix = int(prefix)
443 except:
444 log.warning("Invalid prefix: %s" % prefix)
7177031f 445 return
429a43d1
MT
446
447 # Fix prefix length for IPv4
448 if type == "ipv4":
449 prefix = 32 - int(math.log(prefix, 2))
450
451 # Try to parse the address
452 try:
453 network = ipaddress.ip_network("%s/%s" % (address, prefix), strict=False)
454 except ValueError:
455 log.warning("Invalid IP address: %s" % address)
456 return
457
87b3e102
MT
458 self.db.execute("INSERT INTO networks(network, country) \
459 VALUES(%s, %s) ON CONFLICT (network) DO \
460 UPDATE SET country = excluded.country",
461 "%s" % network, country,
6ffd06b5
MT
462 )
463
83d61c46
MT
464 def handle_update_announcements(self, ns):
465 server = ns.server[0]
466
467 # Pre-compile regular expression for routes
83d61c46
MT
468 route = re.compile(b"^\*[\s\>]i([^\s]+).+?(\d+)\si\r\n", re.MULTILINE|re.DOTALL)
469
470 with telnetlib.Telnet(server) as t:
471 # Enable debug mode
472 #if ns.debug:
473 # t.set_debuglevel(10)
474
475 # Wait for console greeting
fcd5b8b2
MT
476 greeting = t.read_until(b"> ", timeout=30)
477 if not greeting:
478 log.error("Could not get a console prompt")
479 return 1
83d61c46
MT
480
481 # Disable pagination
482 t.write(b"terminal length 0\n")
483
484 # Wait for the prompt to return
485 t.read_until(b"> ")
486
487 # Fetch the routing tables
488 with self.db.transaction():
489 for protocol in ("ipv6", "ipv4"):
490 log.info("Requesting %s routing table" % protocol)
491
492 # Request the full unicast routing table
493 t.write(b"show bgp %s unicast\n" % protocol.encode())
494
495 # Read entire header which ends with "Path"
496 t.read_until(b"Path\r\n")
497
498 while True:
499 # Try reading a full entry
500 # Those might be broken across multiple lines but ends with i
501 line = t.read_until(b"i\r\n", timeout=5)
502 if not line:
503 break
504
505 # Show line for debugging
506 #log.debug(repr(line))
507
508 # Try finding a route in here
509 m = route.match(line)
510 if m:
511 network, autnum = m.groups()
512
513 # Convert network to string
514 network = network.decode()
515
d773c1bc
MT
516 # Append /24 for IPv4 addresses
517 if not "/" in network and not ":" in network:
518 network = "%s/24" % network
519
83d61c46
MT
520 # Convert AS number to integer
521 autnum = int(autnum)
522
523 log.info("Found announcement for %s by %s" % (network, autnum))
524
525 self.db.execute("INSERT INTO announcements(network, autnum) \
526 VALUES(%s, %s) ON CONFLICT (network) DO \
527 UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
528 network, autnum,
529 )
530
531 log.info("Finished reading the %s routing table" % protocol)
532
533 # Purge anything we never want here
534 self.db.execute("""
535 -- Delete default routes
536 DELETE FROM announcements WHERE network = '::/0' OR network = '0.0.0.0/0';
537
538 -- Delete anything that is not global unicast address space
539 DELETE FROM announcements WHERE family(network) = 6 AND NOT network <<= '2000::/3';
540
1d4e4e8f
PM
541 -- DELETE "current network" address space
542 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '0.0.0.0/8';
543
cedee656
PM
544 -- DELETE local loopback address space
545 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '127.0.0.0/8';
546
1d4e4e8f 547 -- DELETE RFC 1918 address space
83d61c46
MT
548 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '10.0.0.0/8';
549 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '172.16.0.0/12';
550 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.168.0.0/16';
551
209c04b6
PM
552 -- DELETE test, benchmark and documentation address space
553 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.0.0/24';
554 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.2.0/24';
555 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.18.0.0/15';
556 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.51.100.0/24';
557 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '203.0.113.0/24';
558
559 -- DELETE CGNAT address space (RFC 6598)
560 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '100.64.0.0/10';
561
562 -- DELETE link local address space
563 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '169.254.0.0/16';
564
565 -- DELETE IPv6 to IPv4 (6to4) address space
566 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.88.99.0/24';
567
b89cee80
PM
568 -- DELETE multicast and reserved address space
569 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '224.0.0.0/4';
570 DELETE FROM announcements WHERE family(network) = 4 AND network <<= '240.0.0.0/4';
571
83d61c46
MT
572 -- Delete networks that are too small to be in the global routing table
573 DELETE FROM announcements WHERE family(network) = 6 AND masklen(network) > 48;
574 DELETE FROM announcements WHERE family(network) = 4 AND masklen(network) > 24;
575
576 -- Delete any non-public or reserved ASNs
577 DELETE FROM announcements WHERE NOT (
578 (autnum >= 1 AND autnum <= 23455)
579 OR
580 (autnum >= 23457 AND autnum <= 64495)
581 OR
582 (autnum >= 131072 AND autnum <= 4199999999)
583 );
584
585 -- Delete everything that we have not seen for 14 days
586 DELETE FROM announcements WHERE last_seen_at <= CURRENT_TIMESTAMP - INTERVAL '14 days';
587 """)
588
d7fc3057
MT
589 def handle_update_overrides(self, ns):
590 with self.db.transaction():
591 # Drop all data that we have
592 self.db.execute("""
593 TRUNCATE TABLE autnum_overrides;
594 TRUNCATE TABLE network_overrides;
595 """)
596
597 for file in ns.files:
598 log.info("Reading %s..." % file)
599
600 with open(file, "rb") as f:
601 for type, block in location.importer.read_blocks(f):
602 if type == "net":
603 network = block.get("net")
604 # Try to parse and normalise the network
605 try:
606 network = ipaddress.ip_network(network, strict=False)
607 except ValueError as e:
608 log.warning("Invalid IP network: %s: %s" % (network, e))
609 continue
610
94dfab8c
MT
611 # Prevent that we overwrite all networks
612 if network.prefixlen == 0:
613 log.warning("Skipping %s: You cannot overwrite default" % network)
614 continue
615
d7fc3057
MT
616 self.db.execute("""
617 INSERT INTO network_overrides(
618 network,
619 country,
620 is_anonymous_proxy,
621 is_satellite_provider,
622 is_anycast
56f6587a 623 ) VALUES (%s, %s, %s, %s, %s)
d7fc3057
MT
624 ON CONFLICT (network) DO NOTHING""",
625 "%s" % network,
626 block.get("country"),
627 block.get("is-anonymous-proxy") == "yes",
628 block.get("is-satellite-provider") == "yes",
629 block.get("is-anycast") == "yes",
630 )
631
f476cdfd
MT
632 elif type == "aut-num":
633 autnum = block.get("aut-num")
d7fc3057
MT
634
635 # Check if AS number begins with "AS"
636 if not autnum.startswith("AS"):
637 log.warning("Invalid AS number: %s" % autnum)
638 continue
639
640 # Strip "AS"
641 autnum = autnum[2:]
642
643 self.db.execute("""
644 INSERT INTO autnum_overrides(
645 number,
646 name,
bd1aa6a1 647 country,
d7fc3057
MT
648 is_anonymous_proxy,
649 is_satellite_provider,
650 is_anycast
bd1aa6a1 651 ) VALUES(%s, %s, %s, %s, %s, %s)
d7fc3057 652 ON CONFLICT DO NOTHING""",
bd1aa6a1
MT
653 autnum,
654 block.get("name"),
655 block.get("country"),
d7fc3057
MT
656 block.get("is-anonymous-proxy") == "yes",
657 block.get("is-satellite-provider") == "yes",
658 block.get("is-anycast") == "yes",
659 )
660
661 else:
662 log.warning("Unsupport type: %s" % type)
663
6ffd06b5
MT
664
665def split_line(line):
666 key, colon, val = line.partition(":")
667
668 # Strip any excess space
669 key = key.strip()
670 val = val.strip()
78ff0cf2 671
6ffd06b5 672 return key, val
78ff0cf2
MT
673
674def main():
675 # Run the command line interface
676 c = CLI()
677 c.run()
678
679main()