blob: bdbd5c57241c27018983d9be0a20618397e388ce [file] [log] [blame]
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +02001#!/usr/bin/python3
2# -*- mode: python-mode; py-indent-tabs-mode: nil -*-
3"""
4/*
5 * Copyright (C) 2018 sysmocom s.f.m.c. GmbH
6 *
7 * All Rights Reserved
8 *
9 * This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 3 of the License, or
12 * (at your option) any later version.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License along
20 * with this program; if not, write to the Free Software Foundation, Inc.,
21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
22 */
23"""
24
25__version__ = "0.0.1" # bump this on every non-trivial change
26
27from twisted.internet import defer, reactor
28from osmopy.twisted_ipa import CTRL, IPAFactory, __version__ as twisted_ipa_version
29from osmopy.osmo_ipa import Ctrl
30from treq import post, collect
31from functools import partial
32from distutils.version import StrictVersion as V # FIXME: use NormalizedVersion from PEP-386 when available
33import argparse, datetime, signal, sys, os, logging, logging.handlers
34import hashlib
35import json
36import configparser
37
38# we don't support older versions of TwistedIPA module
39assert V(twisted_ipa_version) > V('0.4')
40
41# keys from OpenBSC openbsc/src/libbsc/bsc_rf_ctrl.c, values CGI-specific
42oper = { 'inoperational' : 0, 'operational' : 1 }
43admin = { 'locked' : 0, 'unlocked' : 1 }
44policy = { 'off' : 0, 'on' : 1, 'grace' : 2, 'unknown' : 3 }
45
46# keys from OpenBSC openbsc/src/libbsc/bsc_vty.c
47fix = { 'invalid' : 0, 'fix2d' : 1, 'fix3d' : 1 } # CGI server treats it as boolean but expects int
48
49def patch_cgi_resp(r):
50 str = r.decode('utf-8')
51 lines = str.splitlines()
52 i = 0
53 #search for empty line, marks start of body
54 for l in lines:
55 i += 1
56 if l == '':
57 break
58 print('i="%r"' % i)
59 json_lines = lines[i:]
60 json_str = '\n'.join(json_lines)
61 return json.loads(json_str)
62
63@defer.inlineCallbacks
64def handle_reply(f, log, resp):
65 """
66 Reply handler: process raw CGI server response, function f to run for each command
67 """
68 #log.debug('HANDLE_REPLY: code=%r' % (resp.code))
69 #for key,val in resp.headers.getAllRawHeaders():
70 # log.debug('HANDLE_REPLY: key=%r val=%r' % (key, val))
71 if resp.code != 200:
72 resp_body = yield resp.text()
73 log.critical('Received HTTP response %d: %s' % (resp.code, resp_body))
74 return
75
76 parsed = yield resp.json()
77 #log.debug("RESPONSE: %r" % (parsed))
78 bsc_id = parsed.get('commands')[0].split()[0].split('.')[3] # we expect 1st command to have net.0.bsc.666.bts.2.trx.1 location prefix format
79 log.info("Received CGI response for BSC %s with %d commands, error status: %s" % (bsc_id, len(parsed.get('commands')), parsed.get('error')))
80 log.debug("BSC %s commands: %r" % (bsc_id, parsed.get('commands')))
81 for t in parsed.get('commands'): # Process commands format
82 (_, m) = Ctrl().cmd(*t.split())
83 f(m)
84
85def gen_hash(params, skey):
86 input = ''
87 for key in ['time_stamp','position_validity','admin_status','policy_status']:
88 input += str(params.get(key))
89 input += skey
90 for key in ['bsc_id','lat','lon','position_validity']:
91 input += str(params.get(key))
92 m = hashlib.md5()
93 m.update(input.encode('utf-8'))
94 res = m.hexdigest()
95 #print('HASH: \nparams="%r"\ninput="%s" \nres="%s"' %(params, input, res))
96 return res
97
98class Trap(CTRL):
99 """
100 TRAP handler (agnostic to factory's client object)
101 """
102 def ctrl_TRAP(self, data, op_id, v):
103 """
104 Parse CTRL TRAP and dispatch to appropriate handler after normalization
105 """
Maxf2199442018-11-26 14:30:00 +0100106 self.factory.log.debug('TRAP %s' % v)
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +0200107 (l, r) = v.split()
108 loc = l.split('.')
109 t_type = loc[-1]
110 p = partial(lambda a, i: a[i] if len(a) > i else None, loc) # parse helper
111 method = getattr(self, 'handle_' + t_type.replace('-', ''), lambda: "Unhandled %s trap" % t_type)
112 method(p(1), p(3), p(5), p(7), r) # we expect net.0.bsc.666.bts.2.trx.1 format for trap prefix
113
114 def ctrl_SET_REPLY(self, data, _, v):
115 """
116 Debug log for replies to our commands
117 """
118 self.factory.log.debug('SET REPLY %s' % v)
119
120 def ctrl_ERROR(self, data, op_id, v):
121 """
122 We want to know if smth went wrong
123 """
124 self.factory.log.debug('CTRL ERROR [%s] %s' % (op_id, v))
125
126 def connectionMade(self):
127 """
128 Logging wrapper, calling super() is necessary not to break reconnection logic
129 """
130 self.factory.log.info("Connected to CTRL@%s:%d" % (self.factory.host, self.factory.port))
131 super(CTRL, self).connectionMade()
132
133 @defer.inlineCallbacks
134 def handle_locationstate(self, net, bsc, bts, trx, data):
135 """
136 Handle location-state TRAP: parse trap content, build CGI Request and use treq's routines to post it while setting up async handlers
137 """
138 (ts, fx, lat, lon, height, opr, adm, pol, mcc, mnc) = data.split(',')
139 tstamp = datetime.datetime.fromtimestamp(float(ts)).isoformat()
140 self.factory.log.debug('location-state@%s.%s.%s.%s (%s) [%s/%s] => %s' % (net, bsc, bts, trx, tstamp, mcc, mnc, data))
141 params = {'bsc_id': bsc, 'lon': lon, 'lat': lat, 'position_validity': fix.get(fx, 0), 'time_stamp': tstamp, 'oper_status': oper.get(opr, 2), 'admin_status': admin.get(adm, 2), 'policy_status': policy.get(pol, 3) }
142 params['h'] = gen_hash(params, self.factory.secret_key)
143 d = post(self.factory.location, None, params=params)
144 d.addCallback(partial(handle_reply, self.transport.write, self.factory.log)) # treq's collect helper is handy to get all reply content at once using closure on ctx
Maxc16fa3c2018-11-22 18:45:21 +0100145 d.addErrback(lambda e, bsc: self.factory.log.critical("HTTP POST error %s while trying to register BSC %s on %s" % (e, bsc, self.factory.location)), bsc) # handle HTTP errors
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +0200146 # Ensure that we run only limited number of requests in parallel:
147 yield self.factory.semaphore.acquire()
148 yield d # we end up here only if semaphore is available which means it's ok to fire the request without exceeding the limit
149 self.factory.semaphore.release()
150
151 def handle_notificationrejectionv1(self, net, bsc, bts, trx, data):
152 """
153 Handle notification-rejection-v1 TRAP: just an example to show how more message types can be handled
154 """
155 self.factory.log.debug('notification-rejection-v1@bsc-id %s => %s' % (bsc, data))
156
157
158class TrapFactory(IPAFactory):
159 """
160 Store CGI information so TRAP handler can use it for requests
161 """
162 location = None
163 log = None
164 semaphore = None
165 client = None
166 host = None
167 port = None
168 secret_key = None
169 def __init__(self, host, port, proto, semaphore, log, location, secret_key):
170 self.host = host # for logging only,
171 self.port = port # seems to be no way to get it from ReconnectingClientFactory
172 self.log = log
173 self.semaphore = semaphore
174 self.location = location
175 self.secret_key = secret_key
176 level = self.log.getEffectiveLevel()
177 self.log.setLevel(logging.WARNING) # we do not need excessive debug from lower levels
178 super(TrapFactory, self).__init__(proto, self.log)
179 self.log.setLevel(level)
180 self.log.debug("Using IPA %s, CGI server: %s" % (Ctrl.version, self.location))
181
182
183def reloader(path, script, log, dbg1, dbg2, signum, _):
184 """
185 Signal handler: we have to use execl() because twisted's reactor is not restartable due to some bug in twisted implementation
186 """
187 log.info("Received Signal %d - restarting..." % signum)
188 if signum == signal.SIGUSR1 and dbg1 not in sys.argv and dbg2 not in sys.argv:
189 sys.argv.append(dbg1) # enforce debug
190 if signum == signal.SIGUSR2 and (dbg1 in sys.argv or dbg2 in sys.argv): # disable debug
191 if dbg1 in sys.argv:
192 sys.argv.remove(dbg1)
193 if dbg2 in sys.argv:
194 sys.argv.remove(dbg2)
195 os.execl(path, script, *sys.argv[1:])
196
197
198if __name__ == '__main__':
199 p = argparse.ArgumentParser(description='Proxy between given GCI service and Osmocom CTRL protocol.')
200 p.add_argument('-v', '--version', action='version', version=("%(prog)s v" + __version__))
201 p.add_argument('-a', '--addr-ctrl', default='localhost', help="Adress to use for CTRL interface, defaults to localhost")
202 p.add_argument('-p', '--port-ctrl', type=int, default=4250, help="Port to use for CTRL interface, defaults to 4250")
203 p.add_argument('-n', '--num-max-conn', type=int, default=5, help="Max number of concurrent HTTP requests to CGI server")
204 p.add_argument('-d', '--debug', action='store_true', help="Enable debug log")
205 p.add_argument('-o', '--output', action='store_true', help="Log to STDOUT in addition to SYSLOG")
206 p.add_argument('-l', '--location', help="Location URL of the CGI server")
207 p.add_argument('-s', '--secret-key', help="Secret key used to generate verification token")
208 p.add_argument('-c', '--config-file', help="Path Config file. Cmd line args override values in config file")
209 args = p.parse_args()
210
211 log = logging.getLogger('CTRL2CGI')
212 if args.debug:
213 log.setLevel(logging.DEBUG)
214 else:
215 log.setLevel(logging.INFO)
216 log.addHandler(logging.handlers.SysLogHandler('/dev/log'))
217 if args.output:
218 log.addHandler(logging.StreamHandler(sys.stdout))
219
220 reboot = partial(reloader, os.path.abspath(__file__), os.path.basename(__file__), log, '-d', '--debug') # keep in sync with add_argument() call above
221 signal.signal(signal.SIGHUP, reboot)
222 signal.signal(signal.SIGQUIT, reboot)
223 signal.signal(signal.SIGUSR1, reboot) # restart and enabled debug output
224 signal.signal(signal.SIGUSR2, reboot) # restart and disable debug output
225
226 location_cfgfile = None
227 secret_key_cfgfile = None
228 port_ctrl_cfgfile = None
229 addr_ctrl_cfgfile = None
230 num_max_conn_cfgfile = None
231 if args.config_file:
232 config = configparser.ConfigParser()
233 config.read(args.config_file)
234 if 'main' in config:
235 location_cfgfile = config['main'].get('location', None)
236 secret_key_cfgfile = config['main'].get('secret_key', None)
237 addr_ctrl_cfgfile = config['main'].get('addr_ctrl', None)
238 port_ctrl_cfgfile = config['main'].get('port_ctrl', None)
239 num_max_conn_cfgfile = config['main'].get('num_max_conn', None)
240 location = args.location if args.location is not None else location_cfgfile
241 secret_key = args.secret_key if args.secret_key is not None else secret_key_cfgfile
242 addr_ctrl = args.addr_ctrl if args.addr_ctrl is not None else addr_ctrl_cfgfile
243 port_ctrl = args.port_ctrl if args.port_ctrl is not None else port_ctrl_cfgfile
244 num_max_conn = args.num_max_conn if args.num_max_conn is not None else num_max_conn_cfgfile
245
246 log.info("CGI proxy %s starting with PID %d ..." % (__version__, os.getpid()))
247 reactor.connectTCP(addr_ctrl, port_ctrl, TrapFactory(addr_ctrl, port_ctrl, Trap, defer.DeferredSemaphore(num_max_conn), log, location, secret_key))
248 reactor.run()