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