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