chiark / gitweb /
keys/tripe-keys.in: Add an option to control the bulk transform.
[tripe] / keys / tripe-keys.in
CommitLineData
060ca767 1#! @PYTHON@
fd42a1e5
MW
2### -*-python-*-
3###
4### Key management and distribution
5###
6### (c) 2006 Straylight/Edgeware
7###
8
9###----- Licensing notice ---------------------------------------------------
10###
11### This file is part of Trivial IP Encryption (TrIPE).
12###
13### TrIPE is free software; you can redistribute it and/or modify
14### it under the terms of the GNU General Public License as published by
15### the Free Software Foundation; either version 2 of the License, or
16### (at your option) any later version.
17###
18### TrIPE is distributed in the hope that it will be useful,
19### but WITHOUT ANY WARRANTY; without even the implied warranty of
20### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21### GNU General Public License for more details.
22###
23### You should have received a copy of the GNU General Public License
24### along with TrIPE; if not, write to the Free Software Foundation,
25### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27###--------------------------------------------------------------------------
28### External dependencies.
060ca767 29
30import catacomb as C
31import os as OS
32import sys as SYS
c55f0b7c 33import re as RX
060ca767 34import getopt as O
c77687d5 35import shutil as SH
c2f28e4b 36import time as T
c77687d5 37import filecmp as FC
060ca767 38from cStringIO import StringIO
39from errno import *
40from stat import *
41
fd42a1e5 42###--------------------------------------------------------------------------
060ca767 43### Useful regular expressions
44
fd42a1e5 45## Match a comment or blank line.
c77687d5 46rx_comment = RX.compile(r'^\s*(#|$)')
fd42a1e5
MW
47
48## Match a KEY = VALUE assignment.
c77687d5 49rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
fd42a1e5
MW
50
51## Match a ${KEY} substitution.
c77687d5 52rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
fd42a1e5
MW
53
54## Match a @TAG@ substitution.
c77687d5 55rx_atsubst = RX.compile(r'@([-\w]+)@')
fd42a1e5
MW
56
57## Match a single non-alphanumeric character.
c77687d5 58rx_nonalpha = RX.compile(r'\W')
fd42a1e5
MW
59
60## Match the literal string "<SEQ>".
c77687d5 61rx_seq = RX.compile(r'\<SEQ\>')
060ca767 62
6005ef9b
MW
63## Match a shell metacharacter.
64rx_shmeta = RX.compile('[\\s`!"#$&*()\\[\\];\'|<>?\\\\]')
65
66## Match a character which needs escaping in a shell double-quoted string.
67rx_shquote = RX.compile(r'["`$\\]')
68
fd42a1e5
MW
69###--------------------------------------------------------------------------
70### Utility functions.
060ca767 71
fd42a1e5 72## Exceptions.
060ca767 73class SubprocessError (Exception): pass
74class VerifyError (Exception): pass
75
fd42a1e5 76## Program name and identification.
060ca767 77quis = OS.path.basename(SYS.argv[0])
78PACKAGE = "@PACKAGE@"
79VERSION = "@VERSION@"
80
81def moan(msg):
fd42a1e5 82 """Report MSG to standard error."""
060ca767 83 SYS.stderr.write('%s: %s\n' % (quis, msg))
84
85def die(msg, rc = 1):
fd42a1e5 86 """Report MSG to standard error, and exit with code RC."""
060ca767 87 moan(msg)
88 SYS.exit(rc)
89
90def subst(s, rx, map):
fd42a1e5
MW
91 """
92 Substitute values into a string.
93
94 Repeatedly match RX (a compiled regular expression) against the string S.
95 For each match, extract group 1, and use it as a key to index the MAP;
96 replace the match by the result. Finally, return the fully-substituted
97 string.
98 """
060ca767 99 out = StringIO()
100 i = 0
101 for m in rx.finditer(s):
102 out.write(s[i:m.start()] + map[m.group(1)])
103 i = m.end()
104 out.write(s[i:])
105 return out.getvalue()
106
6005ef9b
MW
107def shell_quotify(arg):
108 """
109 Quotify ARG to keep the shell happy.
110
111 This isn't actually used for invoking commands, just for presentation
112 purposes; but correctness is still nice.
113 """
114 if not rx_shmeta.search(arg):
115 return arg
116 elif arg.find("'") == -1:
117 return "'%s'" % arg
118 else:
119 return '"%s"' % rx_shquote.sub(lambda m: '\\' + m.group(0), arg)
120
060ca767 121def rmtree(path):
fd42a1e5 122 """Delete the directory tree given by PATH."""
060ca767 123 try:
c77687d5 124 st = OS.lstat(path)
060ca767 125 except OSError, err:
126 if err.errno == ENOENT:
127 return
128 raise
129 if not S_ISDIR(st.st_mode):
130 OS.unlink(path)
131 else:
132 cwd = OS.getcwd()
133 try:
134 OS.chdir(path)
135 for i in OS.listdir('.'):
136 rmtree(i)
137 finally:
138 OS.chdir(cwd)
139 OS.rmdir(path)
140
141def zap(file):
fd42a1e5 142 """Delete the named FILE if it exists; otherwise do nothing."""
060ca767 143 try:
144 OS.unlink(file)
145 except OSError, err:
146 if err.errno == ENOENT: return
147 raise
148
149def run(args):
fd42a1e5
MW
150 """
151 Run a subprocess whose arguments are given by the string ARGS.
152
153 The ARGS are split at word boundaries, and then subjected to configuration
154 variable substitution (see conf_subst). Individual argument elements
155 beginning with `!' are split again into multiple arguments at word
156 boundaries.
157 """
060ca767 158 args = map(conf_subst, args.split())
159 nargs = []
160 for a in args:
161 if len(a) > 0 and a[0] != '!':
162 nargs += [a]
163 else:
164 nargs += a[1:].split()
165 args = nargs
6005ef9b 166 print '+ %s' % ' '.join([shell_quotify(arg) for arg in args])
8cae2567 167 SYS.stdout.flush()
060ca767 168 rc = OS.spawnvp(OS.P_WAIT, args[0], args)
169 if rc != 0:
170 raise SubprocessError, rc
171
172def hexhyphens(bytes):
fd42a1e5
MW
173 """
174 Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
175 """
060ca767 176 out = StringIO()
177 for i in xrange(0, len(bytes)):
178 if i > 0 and i % 4 == 0: out.write('-')
179 out.write('%02x' % ord(bytes[i]))
180 return out.getvalue()
181
182def fingerprint(kf, ktag):
fd42a1e5
MW
183 """
184 Compute the fingerprint of a key, using the user's selected hash.
185
186 KF is the name of a keyfile; KTAG is the tag of the key.
187 """
060ca767 188 h = C.gchashes[conf['fingerprint-hash']]()
189 k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
190 return h.done()
191
fd42a1e5
MW
192###--------------------------------------------------------------------------
193### The configuration file.
060ca767 194
fd42a1e5 195## Exceptions.
060ca767 196class ConfigFileError (Exception): pass
fd42a1e5
MW
197
198## The configuration dictionary.
060ca767 199conf = {}
200
fd42a1e5
MW
201def conf_subst(s):
202 """
203 Apply configuration substitutions to S.
204
205 That is, for each ${KEY} in S, replace it with the current value of the
206 configuration variable KEY.
207 """
208 return subst(s, rx_dollarsubst, conf)
060ca767 209
060ca767 210def conf_read(f):
fd42a1e5
MW
211 """
212 Read the file F and insert assignments into the configuration dictionary.
213 """
060ca767 214 lno = 0
215 for line in file(f):
216 lno += 1
c77687d5 217 if rx_comment.match(line): continue
060ca767 218 if line[-1] == '\n': line = line[:-1]
c77687d5 219 match = rx_keyval.match(line)
060ca767 220 if not match:
221 raise ConfigFileError, "%s:%d: bad line `%s'" % (f, lno, line)
222 k, v = match.groups()
223 conf[k] = conf_subst(v)
224
060ca767 225def conf_defaults():
fd42a1e5
MW
226 """
227 Apply defaults to the configuration dictionary.
228
229 Fill in all the interesting configuration variables based on the existing
230 contents, as described in the manual.
231 """
c77687d5 232 for k, v in [('repos-base', 'tripe-keys.tar.gz'),
233 ('sig-base', 'tripe-keys.sig-<SEQ>'),
234 ('repos-url', '${base-url}${repos-base}'),
235 ('sig-url', '${base-url}${sig-base}'),
236 ('sig-file', '${base-dir}${sig-base}'),
237 ('repos-file', '${base-dir}${repos-base}'),
060ca767 238 ('conf-file', '${base-dir}tripe-keys.conf'),
b14ccd2f 239 ('upload-hook', ': run upload hook'),
060ca767 240 ('kx', 'dh'),
256bc8d0
MW
241 ('kx-genalg', lambda: {'dh': 'dh',
242 'ec': 'ec'}[conf['kx']]),
243 ('kx-param-genalg', lambda: {'dh': 'dh-param',
244 'ec': 'ec-param'}[conf['kx']]),
ca3aaaeb 245 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
060ca767 246 'ec': '-Cnist-p256'}[conf['kx']]),
67bb121f 247 ('kx-attrs', ''),
060ca767 248 ('kx-expire', 'now + 1 year'),
c2f28e4b 249 ('kx-warn-days', '28'),
39bcd193 250 ('bulk', 'iiv'),
ca3aaaeb 251 ('cipher', 'rijndael-cbc'),
060ca767 252 ('hash', 'sha256'),
7858dfa0 253 ('master-keygen-flags', '-l'),
67bb121f 254 ('master-attrs', ''),
060ca767 255 ('mgf', '${hash}-mgf'),
256 ('mac', lambda: '%s-hmac/%d' %
257 (conf['hash'],
258 C.gchashes[conf['hash']].hashsz * 4)),
259 ('sig', lambda: {'dh': 'dsa', 'ec': 'ecdsa'}[conf['kx']]),
260 ('sig-fresh', 'always'),
261 ('sig-genalg', lambda: {'kcdsa': 'dh',
262 'dsa': 'dsa',
263 'rsapkcs1': 'rsa',
264 'rsapss': 'rsa',
265 'ecdsa': 'ec',
06a174df
MW
266 'eckcdsa': 'ec',
267 'ed25519': 'ed25519',
268 'ed448': 'ed448'}[conf['sig']]),
ca3aaaeb
MW
269 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
270 'dsa': '-b3072 -B256',
060ca767 271 'ec': '-Cnist-p256',
06a174df
MW
272 'rsa': '-b3072',
273 'ed25519': '',
274 'ed448': ''}[conf['sig-genalg']]),
060ca767 275 ('sig-hash', '${hash}'),
276 ('sig-expire', 'forever'),
277 ('fingerprint-hash', '${hash}')]:
278 try:
279 if k in conf: continue
280 if type(v) == str:
281 conf[k] = conf_subst(v)
282 else:
283 conf[k] = v()
284 except KeyError, exc:
285 if len(exc.args) == 0: raise
286 conf[k] = '<missing-var %s>' % exc.args[0]
287
fd42a1e5
MW
288###--------------------------------------------------------------------------
289### Key-management utilities.
290
291def master_keys():
292 """
293 Iterate over the master keys.
294 """
295 if not OS.path.exists('master'):
296 return
297 for k in C.KeyFile('master').itervalues():
298 if (k.type != 'tripe-keys-master' or
299 k.expiredp or
300 not k.tag.startswith('master-')):
301 continue #??
302 yield k
303
304def master_sequence(k):
305 """
306 Return the sequence number of the given master key as an integer.
307
308 No checking is done that K is really a master key.
309 """
310 return int(k.tag[7:])
311
312def max_master_sequence():
313 """
314 Find the master key with the highest sequence number and return this
315 sequence number.
316 """
317 seq = -1
318 for k in master_keys():
319 q = master_sequence(k)
320 if q > seq: seq = q
321 return seq
322
323def seqsubst(x, q):
324 """
325 Return the value of the configuration variable X, with <SEQ> replaced by
326 the value Q.
327 """
328 return rx_seq.sub(str(q), conf[x])
329
330###--------------------------------------------------------------------------
331### Commands: help [COMMAND...]
060ca767 332
333def version(fp = SYS.stdout):
334 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
335
336def usage(fp):
337 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
338
339def cmd_help(args):
340 if len(args) == 0:
341 version(SYS.stdout)
342 print
343 usage(SYS.stdout)
344 print """
345Key management utility for TrIPE.
346
347Options supported:
348
e04c2d50
MW
349-h, --help Show this help message.
350-v, --version Show the version number.
351-u, --usage Show pointlessly short usage string.
060ca767 352
353Subcommands available:
354"""
355 args = commands.keys()
356 args.sort()
357 for c in args:
db76b51b
MW
358 try: func, min, max, help = commands[c]
359 except KeyError: die("unknown command `%s'" % c)
360 print '%s%s%s' % (c, help and ' ', help)
060ca767 361
fd42a1e5
MW
362###--------------------------------------------------------------------------
363### Commands: newmaster
c77687d5 364
365def cmd_newmaster(args):
366 seq = max_master_sequence() + 1
060ca767 367 run('''key -kmaster add
368 -a${sig-genalg} !${sig-param}
7858dfa0 369 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
67bb121f 370 sig=${sig} hash=${sig-hash} !${master-attrs}''' % seq)
c77687d5 371 run('key -kmaster extract -f-secret repos/master.pub')
060ca767 372
fd42a1e5
MW
373###--------------------------------------------------------------------------
374### Commands: setup
375
c77687d5 376def cmd_setup(args):
377 OS.mkdir('repos')
060ca767 378 run('''key -krepos/param add
256bc8d0 379 -a${kx-param-genalg} !${kx-param}
fc5f4823 380 -eforever -tparam tripe-param
67bb121f 381 kx-group=${kx} mgf=${mgf} mac=${mac}
39bcd193 382 bulk=${bulk} cipher=${cipher} hash=${hash} ${kx-attrs}''')
c77687d5 383 cmd_newmaster(args)
060ca767 384
fd42a1e5
MW
385###--------------------------------------------------------------------------
386### Commands: upload
387
060ca767 388def cmd_upload(args):
389
390 ## Sanitize the repository directory
391 umask = OS.umask(0); OS.umask(umask)
392 mode = 0666 & ~umask
393 for f in OS.listdir('repos'):
394 ff = OS.path.join('repos', f)
c77687d5 395 if (f.startswith('master') or f.startswith('peer-')) \
396 and f.endswith('.old'):
060ca767 397 OS.unlink(ff)
398 continue
c77687d5 399 OS.chmod(ff, mode)
400
401 rmtree('tmp')
402 OS.mkdir('tmp')
403 OS.symlink('../repos', 'tmp/repos')
404 cwd = OS.getcwd()
405 try:
406
407 ## Build the configuration file
408 seq = max_master_sequence()
409 v = {'MASTER-SEQUENCE': str(seq),
410 'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
411 'master-%d' % seq))}
412 fin = file('tripe-keys.master')
413 fout = file('tmp/tripe-keys.conf', 'w')
414 for line in fin:
415 fout.write(subst(line, rx_atsubst, v))
416 fin.close(); fout.close()
417 SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
418 commit = [conf['repos-file'], conf['conf-file']]
419
420 ## Make and sign the repository archive
421 OS.chdir('tmp')
422 run('tar chozf ${repos-file}.new .')
423 OS.chdir(cwd)
424 for k in master_keys():
425 seq = master_sequence(k)
426 sigfile = seqsubst('sig-file', seq)
427 run('''catsign -kmaster sign -abdC -kmaster-%d
428 -o%s.new ${repos-file}.new''' % (seq, sigfile))
429 commit.append(sigfile)
430
431 ## Commit the changes
432 for base in commit:
433 new = '%s.new' % base
434 OS.rename(new, base)
838e5ce7
MW
435
436 ## Remove files in the base-dir which don't correspond to ones we just
437 ## committed
438 allow = {}
439 basedir = conf['base-dir']
440 bdl = len(basedir)
441 for base in commit:
442 if base.startswith(basedir): allow[base[bdl:]] = 1
443 for found in OS.listdir(basedir):
444 if found not in allow: OS.remove(OS.path.join(basedir, found))
c77687d5 445 finally:
446 OS.chdir(cwd)
e04c2d50 447 rmtree('tmp')
b14ccd2f 448 run('sh -c ${upload-hook}')
060ca767 449
fd42a1e5
MW
450###--------------------------------------------------------------------------
451### Commands: rebuild
452
453def cmd_rebuild(args):
454 zap('keyring.pub')
455 for i in OS.listdir('repos'):
456 if i.startswith('peer-') and i.endswith('.pub'):
457 run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
458
459###--------------------------------------------------------------------------
460### Commands: update
461
060ca767 462def cmd_update(args):
463 cwd = OS.getcwd()
464 rmtree('tmp')
465 try:
466
467 ## Fetch a new distribution
468 OS.mkdir('tmp')
469 OS.chdir('tmp')
c77687d5 470 seq = int(conf['master-sequence'])
162fcf48
MW
471 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
472 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
060ca767 473 run('tar xfz tripe-keys.tar.gz')
474
475 ## Verify the signature
c77687d5 476 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
477 got = fingerprint('repos/master.pub', 'master-%d' % seq)
060ca767 478 if want != got: raise VerifyError
c77687d5 479 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
480 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
060ca767 481
482 ## OK: update our copy
483 OS.chdir(cwd)
484 if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
485 OS.rename('tmp/repos', 'repos')
f56dbbc4 486 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
c77687d5 487 moan('configuration file changed: recommend running another update')
488 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
060ca767 489 rmtree('repos.old')
490
491 finally:
492 OS.chdir(cwd)
493 rmtree('tmp')
494 cmd_rebuild(args)
495
fd42a1e5
MW
496###--------------------------------------------------------------------------
497### Commands: generate TAG
060ca767 498
499def cmd_generate(args):
500 tag, = args
501 keyring_pub = 'peer-%s.pub' % tag
502 zap('keyring'); zap(keyring_pub)
503 run('key -kkeyring merge repos/param')
256bc8d0 504 run('key -kkeyring add -a${kx-genalg} -pparam -e${kx-expire} -t%s tripe' %
c77687d5 505 tag)
ca6eb20c 506 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
060ca767 507
fd42a1e5
MW
508###--------------------------------------------------------------------------
509### Commands: clean
510
060ca767 511def cmd_clean(args):
512 rmtree('repos')
513 rmtree('tmp')
c77687d5 514 for i in OS.listdir('.'):
515 r = i
516 if r.endswith('.old'): r = r[:-4]
517 if (r == 'master' or r == 'param' or
518 r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
519 zap(i)
060ca767 520
c2f28e4b
MW
521###--------------------------------------------------------------------------
522### Commands: check
523
24285984 524def check_key(k):
c2f28e4b
MW
525 now = T.time()
526 thresh = int(conf['kx-warn-days']) * 86400
24285984
MW
527 if k.exptime == C.KEXP_FOREVER: return None
528 elif k.exptime == C.KEXP_EXPIRE: left = -1
529 else: left = k.exptime - now
530 if left < 0:
531 return "key `%s' HAS EXPIRED" % k.tag
532 elif left < thresh:
533 if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
534 else: n, u, uu = left // 3600, 'hour', 'hours'
535 return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
536 else:
537 return None
538
539def cmd_check(args):
540 if OS.path.exists('keyring.pub'):
541 for k in C.KeyFile('keyring.pub').itervalues():
542 whinge = check_key(k)
543 if whinge is not None: print whinge
544 if OS.path.exists('master'):
545 whinges = []
546 for k in C.KeyFile('master').itervalues():
547 whinge = check_key(k)
548 if whinge is None: break
549 whinges.append(whinge)
550 else:
551 for whinge in whinges: print whinge
c2f28e4b 552
65faf8df
MW
553###--------------------------------------------------------------------------
554### Commands: mtu
555
39bcd193
MW
556def mac_tagsz():
557 macname = conf['mac']
558 index = macname.rindex('/')
559 if index == -1: tagsz = C.gcmacs[macname].tagsz
560 else: tagsz = int(macname[index + 1:])/8
561 return tagsz
562
65faf8df
MW
563def cmd_mtu(args):
564 mtu, = (lambda mtu = '1500': (mtu,))(*args)
565 mtu = int(mtu)
566
65faf8df
MW
567 mtu -= 20 # Minimum IP header
568 mtu -= 8 # UDP header
569 mtu -= 1 # TrIPE packet type octet
39bcd193
MW
570
571 bulk = conf['bulk']
572
573 if bulk == 'v0':
574 blksz = C.gcciphers[conf['cipher']].blksz
575 mtu -= mac_tagsz() # MAC tag
576 mtu -= 4 # Sequence number
577 mtu -= blksz # Initialization vector
578
579 elif bulk == 'iiv':
580 mtu -= mac_tagsz() # MAC tag
581 mtu -= 4 # Sequence number
582
583 else:
584 die("Unknown bulk transform `%s'" % bulk)
65faf8df
MW
585
586 print mtu
587
fd42a1e5
MW
588###--------------------------------------------------------------------------
589### Main driver.
060ca767 590
060ca767 591commands = {'help': (cmd_help, 0, 1, ''),
c77687d5 592 'newmaster': (cmd_newmaster, 0, 0, ''),
060ca767 593 'setup': (cmd_setup, 0, 0, ''),
594 'upload': (cmd_upload, 0, 0, ''),
595 'update': (cmd_update, 0, 0, ''),
596 'clean': (cmd_clean, 0, 0, ''),
65faf8df 597 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
c2f28e4b 598 'check': (cmd_check, 0, 0, ''),
060ca767 599 'generate': (cmd_generate, 1, 1, 'TAG'),
600 'rebuild': (cmd_rebuild, 0, 0, '')}
601
602def init():
fd42a1e5
MW
603 """
604 Load the appropriate configuration file and set up the configuration
605 dictionary.
606 """
060ca767 607 for f in ['tripe-keys.master', 'tripe-keys.conf']:
608 if OS.path.exists(f):
609 conf_read(f)
610 break
611 conf_defaults()
fd42a1e5 612
060ca767 613def main(argv):
fd42a1e5
MW
614 """
615 Main program: parse options and dispatch to appropriate command handler.
616 """
060ca767 617 try:
618 opts, args = O.getopt(argv[1:], 'hvu',
619 ['help', 'version', 'usage'])
620 except O.GetoptError, exc:
621 moan(exc)
622 usage(SYS.stderr)
623 SYS.exit(1)
624 for o, v in opts:
625 if o in ('-h', '--help'):
626 cmd_help([])
627 SYS.exit(0)
628 elif o in ('-v', '--version'):
629 version(SYS.stdout)
630 SYS.exit(0)
631 elif o in ('-u', '--usage'):
632 usage(SYS.stdout)
633 SYS.exit(0)
634 if len(argv) < 2:
635 cmd_help([])
636 else:
637 c = argv[1]
db76b51b
MW
638 try: func, min, max, help = commands[c]
639 except KeyError: die("unknown command `%s'" % c)
060ca767 640 args = argv[2:]
db76b51b
MW
641 if len(args) < min or (max is not None and len(args) > max):
642 SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
643 SYS.exit(1)
060ca767 644 func(args)
645
fd42a1e5
MW
646###----- That's all, folks --------------------------------------------------
647
648if __name__ == '__main__':
649 init()
650 main(SYS.argv)