svc/conntrack.in: Gather address hacking functions into a new section.
[tripe] / svc / conntrack.in
CommitLineData
2ec90437
MW
1#! @PYTHON@
2### -*-python-*-
3###
4### Service for automatically tracking network connection status
5###
6### (c) 2010 Straylight/Edgeware
7###
8
9###----- Licensing notice ---------------------------------------------------
10###
11### This file is part of Trivial IP Encryption (TrIPE).
12###
11ad66c2
MW
13### TrIPE is free software: you can redistribute it and/or modify it under
14### the terms of the GNU General Public License as published by the Free
15### Software Foundation; either version 3 of the License, or (at your
16### option) any later version.
2ec90437 17###
11ad66c2
MW
18### TrIPE is distributed in the hope that it will be useful, but WITHOUT
19### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
20### FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21### for more details.
2ec90437
MW
22###
23### You should have received a copy of the GNU General Public License
11ad66c2 24### along with TrIPE. If not, see <https://www.gnu.org/licenses/>.
2ec90437
MW
25
26VERSION = '@VERSION@'
27
28###--------------------------------------------------------------------------
29### External dependencies.
30
31from ConfigParser import RawConfigParser
32from optparse import OptionParser
33import os as OS
34import sys as SYS
35import socket as S
36import mLib as M
37import tripe as T
38import dbus as D
39for i in ['mainloop', 'mainloop.glib']:
40 __import__('dbus.%s' % i)
a69f4417
MW
41try: from gi.repository import GLib as G
42except ImportError: import gobject as G
2ec90437
MW
43from struct import pack, unpack
44
45SM = T.svcmgr
46##__import__('rmcr').__debug = True
47
48###--------------------------------------------------------------------------
49### Utilities.
50
51class struct (object):
52 """A simple container object."""
53 def __init__(me, **kw):
54 me.__dict__.update(kw)
55
56def toposort(cmp, things):
57 """
58 Generate the THINGS in an order consistent with a given partial order.
59
60 The function CMP(X, Y) should return true if X must precede Y, and false if
61 it doesn't care. If X and Y are equal then it should return false.
62
63 The THINGS may be any finite iterable; it is converted to a list
64 internally.
65 """
66
67 ## Make sure we can index the THINGS, and prepare an ordering table.
68 ## What's going on? The THINGS might not have a helpful equality
69 ## predicate, so it's easier to work with indices. The ordering table will
70 ## remember which THINGS (by index) are considered greater than other
71 ## things.
72 things = list(things)
73 n = len(things)
74 order = [{} for i in xrange(n)]
75 rorder = [{} for i in xrange(n)]
76 for i in xrange(n):
77 for j in xrange(n):
78 if i != j and cmp(things[i], things[j]):
79 order[j][i] = True
80 rorder[i][j] = True
81
82 ## Now we can do the sort.
83 out = []
84 while True:
85 done = True
86 for i in xrange(n):
87 if order[i] is not None:
88 done = False
89 if len(order[i]) == 0:
90 for j in rorder[i]:
91 del order[j][i]
92 yield things[i]
93 order[i] = None
94 if done:
95 break
96
6e8bbeeb
MW
97###--------------------------------------------------------------------------
98### Address manipulation.
99
11ab0da6
MW
100def parse_address(addrstr):
101 return unpack('>L', S.inet_aton(addrstr))[0]
102
6e8bbeeb
MW
103def straddr(a): return a is None and '#<none>' or S.inet_ntoa(pack('>L', a))
104def strmask(m):
105 for i in xrange(33):
106 if m == 0xffffffff ^ ((1 << (32 - i)) - 1): return str(i)
107 return straddr(m)
108
2ec90437
MW
109###--------------------------------------------------------------------------
110### Parse the configuration file.
111
112## Hmm. Should I try to integrate this with the peers database? It's not a
113## good fit; it'd need special hacks in tripe-newpeers. And the use case for
114## this service are largely going to be satellite notes, I don't think
115## scalability's going to be a problem.
116
117class Config (object):
118 """
119 Represents a configuration file.
120
121 The most interesting thing is probably the `groups' slot, which stores a
122 list of pairs (NAME, PATTERNS); the NAME is a string, and the PATTERNS a
123 list of (TAG, PEER, ADDR, MASK) triples. The implication is that there
124 should be precisely one peer with a name matching NAME-*, and that it
125 should be NAME-TAG, where (TAG, PEER, ADDR, MASK) is the first triple such
126 that the host's primary IP address (if PEER is None -- or the IP address it
127 would use for communicating with PEER) is within the network defined by
128 ADDR/MASK.
129 """
130
131 def __init__(me, file):
132 """
133 Construct a new Config object, reading the given FILE.
134 """
135 me._file = file
136 me._fwatch = M.FWatch(file)
137 me._update()
138
139 def check(me):
140 """
141 See whether the configuration file has been updated.
142 """
143 if me._fwatch.update():
144 me._update()
145
146 def _update(me):
147 """
148 Internal function to update the configuration from the underlying file.
149 """
150
151 ## Read the configuration. We have no need of the fancy substitutions,
152 ## so turn them all off.
153 cp = RawConfigParser()
154 cp.read(me._file)
2d4998c4 155 if T._debug: print '# reread config'
2ec90437
MW
156
157 ## Save the test address. Make sure it's vaguely sensible. The default
158 ## is probably good for most cases, in fact, since that address isn't
159 ## actually in use. Note that we never send packets to the test address;
160 ## we just use it to discover routing information.
161 if cp.has_option('DEFAULT', 'test-addr'):
162 testaddr = cp.get('DEFAULT', 'test-addr')
163 S.inet_aton(testaddr)
164 else:
165 testaddr = '1.2.3.4'
166
167 ## Scan the configuration file and build the groups structure.
168 groups = []
169 for sec in cp.sections():
170 pats = []
171 for tag in cp.options(sec):
172 spec = cp.get(sec, tag).split()
173
174 ## Parse the entry into peer and network.
175 if len(spec) == 1:
176 peer = None
177 net = spec[0]
178 else:
179 peer, net = spec
180
181 ## Syntax of a net is ADDRESS/MASK, where ADDRESS is a dotted-quad,
182 ## and MASK is either a dotted-quad or a single integer N indicating
183 ## a mask with N leading ones followed by trailing zeroes.
184 slash = net.index('/')
11ab0da6 185 addr = parse_address(net[:slash])
508294ea 186 if net[slash + 1:].isdigit():
2ec90437
MW
187 n = int(net[slash + 1:], 10)
188 mask = (1 << 32) - (1 << 32 - n)
508294ea 189 else:
11ab0da6 190 mask = parse_address(net[slash + 1:])
2ec90437
MW
191 pats.append((tag, peer, addr & mask, mask))
192
193 ## Annoyingly, RawConfigParser doesn't preserve the order of options.
194 ## In order to make things vaguely sane, we topologically sort the
195 ## patterns so that more specific patterns are checked first.
196 pats = list(toposort(lambda (t, p, a, m), (tt, pp, aa, mm): \
197 (p and not pp) or \
198 (p == pp and m == (m | mm) and aa == (a & mm)),
199 pats))
200 groups.append((sec, pats))
201
202 ## Done.
203 me.testaddr = testaddr
204 me.groups = groups
205
206### This will be a configuration file.
207CF = None
208
2d4998c4
MW
209def cmd_showconfig():
210 T.svcinfo('test-addr=%s' % CF.testaddr)
211def cmd_showgroups():
212 for sec, pats in CF.groups:
213 T.svcinfo(sec)
214def cmd_showgroup(g):
215 for s, p in CF.groups:
216 if s == g:
217 pats = p
218 break
219 else:
171206b5 220 raise T.TripeJobError('unknown-group', g)
2d4998c4
MW
221 for t, p, a, m in pats:
222 T.svcinfo('peer', t,
223 'target', p or '(default)',
224 'net', '%s/%s' % (straddr(a), strmask(m)))
225
2ec90437
MW
226###--------------------------------------------------------------------------
227### Responding to a network up/down event.
228
229def localaddr(peer):
230 """
231 Return the local IP address used for talking to PEER.
232 """
233 sk = S.socket(S.AF_INET, S.SOCK_DGRAM)
234 try:
235 try:
236 sk.connect((peer, 1))
237 addr, _ = sk.getsockname()
11ab0da6 238 addr = parse_address(addr)
2ec90437
MW
239 return addr
240 except S.error:
241 return None
242 finally:
243 sk.close()
244
245_kick = T.Queue()
f5393555
MW
246_delay = None
247
248def cancel_delay():
249 global _delay
250 if _delay is not None:
251 if T._debug: print '# cancel delayed kick'
252 G.source_remove(_delay)
253 _delay = None
4f6b41b9
MW
254
255def netupdown(upness, reason):
256 """
257 Add or kill peers according to whether the network is up or down.
258
259 UPNESS is true if the network is up, or false if it's down.
260 """
261
262 _kick.put((upness, reason))
263
f5393555
MW
264def delay_netupdown(upness, reason):
265 global _delay
266 cancel_delay()
267 def _func():
268 global _delay
269 if T._debug: print '# delayed %s: %s' % (upness, reason)
270 _delay = None
271 netupdown(upness, reason)
272 return False
273 if T._debug: print '# delaying %s: %s' % (upness, reason)
274 _delay = G.timeout_add(2000, _func)
275
2ec90437
MW
276def kickpeers():
277 while True:
278 upness, reason = _kick.get()
2d4998c4
MW
279 if T._debug: print '# kickpeers %s: %s' % (upness, reason)
280 select = []
f5393555 281 cancel_delay()
2ec90437
MW
282
283 ## Make sure the configuration file is up-to-date. Don't worry if we
284 ## can't do anything useful.
285 try:
286 CF.check()
287 except Exception, exc:
288 SM.warn('conntrack', 'config-file-error',
289 exc.__class__.__name__, str(exc))
290
291 ## Find the current list of peers.
292 peers = SM.list()
293
294 ## Work out the primary IP address.
295 if upness:
296 addr = localaddr(CF.testaddr)
297 if addr is None:
298 upness = False
b10a8c3d
MW
299 else:
300 addr = None
2d4998c4
MW
301 if not T._debug: pass
302 elif addr: print '# local address = %s' % straddr(addr)
303 else: print '# offline'
2ec90437
MW
304
305 ## Now decide what to do.
306 changes = []
307 for g, pp in CF.groups:
2d4998c4 308 if T._debug: print '# check group %s' % g
2ec90437
MW
309
310 ## Find out which peer in the group ought to be active.
b10a8c3d
MW
311 ip = None
312 map = {}
313 want = None
314 for t, p, a, m in pp:
315 if p is None or not upness:
316 ipq = addr
317 else:
318 ipq = localaddr(p)
2d4998c4
MW
319 if T._debug:
320 info = 'peer=%s; target=%s; net=%s/%s; local=%s' % (
321 t, p or '(default)', straddr(a), strmask(m), straddr(ipq))
b10a8c3d
MW
322 if upness and ip is None and \
323 ipq is not None and (ipq & m) == a:
2d4998c4 324 if T._debug: print '# %s: SELECTED' % info
b10a8c3d 325 map[t] = 'up'
2d4998c4 326 select.append('%s=%s' % (g, t))
f2bdb96e
MW
327 if t == 'down' or t.startswith('down/'):
328 want = None
329 else:
330 want = t
b10a8c3d
MW
331 ip = ipq
332 else:
333 map[t] = 'down'
2d4998c4 334 if T._debug: print '# %s: skipped' % info
2ec90437
MW
335
336 ## Shut down the wrong ones.
337 found = False
2d4998c4 338 if T._debug: print '# peer-map = %r' % map
2ec90437 339 for p in peers:
b10a8c3d
MW
340 what = map.get(p, 'leave')
341 if what == 'up':
2ec90437 342 found = True
2d4998c4 343 if T._debug: print '# peer %s: already up' % p
b10a8c3d 344 elif what == 'down':
cf2e4ea6
MW
345 def _(p = p):
346 try:
347 SM.kill(p)
348 except T.TripeError, exc:
349 if exc.args[0] == 'unknown-peer':
350 ## Inherently racy; don't worry about this.
351 pass
352 else:
353 raise
2d4998c4 354 if T._debug: print '# peer %s: bring down' % p
cf2e4ea6 355 changes.append(_)
2ec90437
MW
356
357 ## Start the right one if necessary.
7b7e3c74 358 if want is not None and not found:
cf2e4ea6
MW
359 def _(want = want):
360 try:
8d1d183e 361 list(SM.svcsubmit('connect', 'active', want))
cf2e4ea6
MW
362 except T.TripeError, exc:
363 SM.warn('conntrack', 'connect-failed', want, *exc.args)
2d4998c4 364 if T._debug: print '# peer %s: bring up' % want
cf2e4ea6 365 changes.append(_)
2ec90437
MW
366
367 ## Commit the changes.
368 if changes:
2d4998c4 369 SM.notify('conntrack', upness and 'up' or 'down', *select + reason)
2ec90437
MW
370 for c in changes: c()
371
2ec90437
MW
372###--------------------------------------------------------------------------
373### NetworkManager monitor.
374
498d9f42
MW
375DBPROPS_IFACE = 'org.freedesktop.DBus.Properties'
376
2ec90437
MW
377NM_NAME = 'org.freedesktop.NetworkManager'
378NM_PATH = '/org/freedesktop/NetworkManager'
379NM_IFACE = NM_NAME
380NMCA_IFACE = NM_NAME + '.Connection.Active'
381
2079efa1
MW
382NM_STATE_CONNECTED = 3 #obsolete
383NM_STATE_CONNECTED_LOCAL = 50
384NM_STATE_CONNECTED_SITE = 60
385NM_STATE_CONNECTED_GLOBAL = 70
386NM_CONNSTATES = set([NM_STATE_CONNECTED,
387 NM_STATE_CONNECTED_LOCAL,
388 NM_STATE_CONNECTED_SITE,
389 NM_STATE_CONNECTED_GLOBAL])
2ec90437
MW
390
391class NetworkManagerMonitor (object):
392 """
393 Watch NetworkManager signals for changes in network state.
394 """
395
396 ## Strategy. There are two kinds of interesting state transitions for us.
397 ## The first one is the global are-we-connected state, which we'll use to
398 ## toggle network upness on a global level. The second is which connection
399 ## has the default route, which we'll use to tweak which peer in the peer
400 ## group is active. The former is most easily tracked using the signal
401 ## org.freedesktop.NetworkManager.StateChanged; for the latter, we track
402 ## org.freedesktop.NetworkManager.Connection.Active.PropertiesChanged and
403 ## look for when a new connection gains the default route.
404
405 def attach(me, bus):
406 try:
407 nm = bus.get_object(NM_NAME, NM_PATH)
498d9f42 408 state = nm.Get(NM_IFACE, 'State', dbus_interface = DBPROPS_IFACE)
2079efa1 409 if state in NM_CONNSTATES:
2ec90437
MW
410 netupdown(True, ['nm', 'initially-connected'])
411 else:
412 netupdown(False, ['nm', 'initially-disconnected'])
bd9bd714
MW
413 except D.DBusException, e:
414 if T._debug: print '# exception attaching to network-manager: %s' % e
2079efa1
MW
415 bus.add_signal_receiver(me._nm_state, 'StateChanged',
416 NM_IFACE, NM_NAME, NM_PATH)
417 bus.add_signal_receiver(me._nm_connchange, 'PropertiesChanged',
418 NMCA_IFACE, NM_NAME, None)
2ec90437
MW
419
420 def _nm_state(me, state):
2079efa1 421 if state in NM_CONNSTATES:
f5393555 422 delay_netupdown(True, ['nm', 'connected'])
2ec90437 423 else:
f5393555 424 delay_netupdown(False, ['nm', 'disconnected'])
2ec90437
MW
425
426 def _nm_connchange(me, props):
f5393555
MW
427 if props.get('Default', False) or props.get('Default6', False):
428 delay_netupdown(True, ['nm', 'default-connection-change'])
2ec90437 429
a95eb44a
MW
430##--------------------------------------------------------------------------
431### Connman monitor.
432
433CM_NAME = 'net.connman'
434CM_PATH = '/'
435CM_IFACE = 'net.connman.Manager'
436
437class ConnManMonitor (object):
438 """
439 Watch ConnMan signls for changes in network state.
440 """
441
442 ## Strategy. Everything seems to be usefully encoded in the `State'
443 ## property. If it's `offline', `idle' or `ready' then we don't expect a
444 ## network connection. During handover from one network to another, the
445 ## property passes through `ready' to `online'.
446
447 def attach(me, bus):
448 try:
449 cm = bus.get_object(CM_NAME, CM_PATH)
450 props = cm.GetProperties(dbus_interface = CM_IFACE)
451 state = props['State']
452 netupdown(state == 'online', ['connman', 'initially-%s' % state])
bd9bd714
MW
453 except D.DBusException, e:
454 if T._debug: print '# exception attaching to connman: %s' % e
a95eb44a
MW
455 bus.add_signal_receiver(me._cm_state, 'PropertyChanged',
456 CM_IFACE, CM_NAME, CM_PATH)
457
458 def _cm_state(me, prop, value):
459 if prop != 'State': return
f5393555 460 delay_netupdown(value == 'online', ['connman', value])
a95eb44a 461
2ec90437
MW
462###--------------------------------------------------------------------------
463### Maemo monitor.
464
465ICD_NAME = 'com.nokia.icd'
466ICD_PATH = '/com/nokia/icd'
467ICD_IFACE = ICD_NAME
468
469class MaemoICdMonitor (object):
470 """
471 Watch ICd signals for changes in network state.
472 """
473
474 ## Strategy. ICd only handles one connection at a time in steady state,
475 ## though when switching between connections, it tries to bring the new one
476 ## up before shutting down the old one. This makes life a bit easier than
477 ## it is with NetworkManager. On the other hand, the notifications are
478 ## relative to particular connections only, and the indicator that the old
479 ## connection is down (`IDLE') comes /after/ the new one comes up
480 ## (`CONNECTED'), so we have to remember which one is active.
481
482 def attach(me, bus):
483 try:
484 icd = bus.get_object(ICD_NAME, ICD_PATH)
485 try:
486 iap = icd.get_ipinfo(dbus_interface = ICD_IFACE)[0]
487 me._iap = iap
488 netupdown(True, ['icd', 'initially-connected', iap])
489 except D.DBusException:
490 me._iap = None
491 netupdown(False, ['icd', 'initially-disconnected'])
bd9bd714
MW
492 except D.DBusException, e:
493 if T._debug: print '# exception attaching to icd: %s' % e
2ec90437
MW
494 me._iap = None
495 bus.add_signal_receiver(me._icd_state, 'status_changed', ICD_IFACE,
496 ICD_NAME, ICD_PATH)
497
498 def _icd_state(me, iap, ty, state, hunoz):
499 if state == 'CONNECTED':
500 me._iap = iap
f5393555 501 delay_netupdown(True, ['icd', 'connected', iap])
2ec90437
MW
502 elif state == 'IDLE' and iap == me._iap:
503 me._iap = None
f5393555 504 delay_netupdown(False, ['icd', 'idle'])
2ec90437
MW
505
506###--------------------------------------------------------------------------
507### D-Bus connection tracking.
508
509class DBusMonitor (object):
510 """
511 Maintains a connection to the system D-Bus, and watches for signals.
512
513 If the connection is initially down, or drops for some reason, we retry
514 periodically (every five seconds at the moment). If the connection
515 resurfaces, we reattach the monitors.
516 """
517
518 def __init__(me):
519 """
520 Initialise the object and try to establish a connection to the bus.
521 """
522 me._mons = []
523 me._loop = D.mainloop.glib.DBusGMainLoop()
7bfa1e06 524 me._state = 'startup'
2ec90437
MW
525 me._reconnect()
526
527 def addmon(me, mon):
528 """
529 Add a monitor object to watch for signals.
530
531 MON.attach(BUS) is called, with BUS being the connection to the system
532 bus. MON should query its service's current status and watch for
533 relevant signals.
534 """
535 me._mons.append(mon)
536 if me._bus is not None:
537 mon.attach(me._bus)
538
16650038 539 def _reconnect(me, hunoz = None):
2ec90437
MW
540 """
541 Start connecting to the bus.
542
543 If we fail the first time, retry periodically.
544 """
7bfa1e06
MW
545 if me._state == 'startup':
546 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'startup')
547 elif me._state == 'connected':
548 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'lost')
549 else:
550 T.aside(SM.notify, 'conntrack', 'dbus-connection',
551 'state=%s' % me._state)
552 me._state == 'reconnecting'
2ec90437
MW
553 me._bus = None
554 if me._try_connect():
555 G.timeout_add_seconds(5, me._try_connect)
556
557 def _try_connect(me):
558 """
559 Actually make a connection attempt.
560
561 If we succeed, attach the monitors.
562 """
563 try:
7bfa1e06
MW
564 addr = OS.getenv('TRIPE_CONNTRACK_BUS')
565 if addr == 'SESSION':
566 bus = D.SessionBus(mainloop = me._loop, private = True)
567 elif addr is not None:
568 bus = D.bus.BusConnection(addr, mainloop = me._loop)
569 else:
570 bus = D.SystemBus(mainloop = me._loop, private = True)
571 for m in me._mons:
572 m.attach(bus)
573 except D.DBusException, e:
2ec90437
MW
574 return True
575 me._bus = bus
7bfa1e06 576 me._state = 'connected'
2ec90437 577 bus.call_on_disconnection(me._reconnect)
7bfa1e06 578 T.aside(SM.notify, 'conntrack', 'dbus-connection', 'connected')
2ec90437
MW
579 return False
580
581###--------------------------------------------------------------------------
582### TrIPE service.
583
584class GIOWatcher (object):
585 """
586 Monitor I/O events using glib.
587 """
588 def __init__(me, conn, mc = G.main_context_default()):
589 me._conn = conn
590 me._watch = None
591 me._mc = mc
592 def connected(me, sock):
593 me._watch = G.io_add_watch(sock, G.IO_IN,
594 lambda *hunoz: me._conn.receive())
595 def disconnected(me):
596 G.source_remove(me._watch)
597 me._watch = None
598 def iterate(me):
599 me._mc.iteration(True)
600
601SM.iowatch = GIOWatcher(SM)
602
603def init():
604 """
605 Service initialization.
606
607 Add the D-Bus monitor here, because we might send commands off immediately,
608 and we want to make sure the server connection is up.
609 """
29807d89 610 global DBM
22b47552 611 T.Coroutine(kickpeers, name = 'kickpeers').switch()
29807d89
MW
612 DBM = DBusMonitor()
613 DBM.addmon(NetworkManagerMonitor())
a95eb44a 614 DBM.addmon(ConnManMonitor())
29807d89 615 DBM.addmon(MaemoICdMonitor())
f5393555
MW
616 G.timeout_add_seconds(30, lambda: (_delay is not None or
617 netupdown(True, ['interval-timer']) or
618 True))
2ec90437
MW
619
620def parse_options():
621 """
622 Parse the command-line options.
623
624 Automatically changes directory to the requested configdir, and turns on
625 debugging. Returns the options object.
626 """
627 op = OptionParser(usage = '%prog [-a FILE] [-d DIR]',
628 version = '%%prog %s' % VERSION)
629
630 op.add_option('-a', '--admin-socket',
631 metavar = 'FILE', dest = 'tripesock', default = T.tripesock,
632 help = 'Select socket to connect to [default %default]')
633 op.add_option('-d', '--directory',
634 metavar = 'DIR', dest = 'dir', default = T.configdir,
635 help = 'Select current diretory [default %default]')
636 op.add_option('-c', '--config',
637 metavar = 'FILE', dest = 'conf', default = 'conntrack.conf',
638 help = 'Select configuration [default %default]')
639 op.add_option('--daemon', dest = 'daemon',
640 default = False, action = 'store_true',
641 help = 'Become a daemon after successful initialization')
642 op.add_option('--debug', dest = 'debug',
643 default = False, action = 'store_true',
644 help = 'Emit debugging trace information')
645 op.add_option('--startup', dest = 'startup',
646 default = False, action = 'store_true',
647 help = 'Being called as part of the server startup')
648
649 opts, args = op.parse_args()
650 if args: op.error('no arguments permitted')
651 OS.chdir(opts.dir)
652 T._debug = opts.debug
653 return opts
654
655## Service table, for running manually.
656def cmd_updown(upness):
657 return lambda *args: T.defer(netupdown, upness, ['manual'] + list(args))
658service_info = [('conntrack', VERSION, {
659 'up': (0, None, '', cmd_updown(True)),
2d4998c4
MW
660 'down': (0, None, '', cmd_updown(False)),
661 'show-config': (0, 0, '', cmd_showconfig),
662 'show-groups': (0, 0, '', cmd_showgroups),
663 'show-group': (1, 1, 'GROUP', cmd_showgroup)
2ec90437
MW
664})]
665
666if __name__ == '__main__':
667 opts = parse_options()
668 CF = Config(opts.conf)
669 T.runservices(opts.tripesock, service_info,
670 init = init, daemon = opts.daemon)
671
672###----- That's all, folks --------------------------------------------------