blob: 1b5e9528cd0e3f0755a521991e5b9a1b61e20dff [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 """
106 (l, r) = v.split()
107 loc = l.split('.')
108 t_type = loc[-1]
109 p = partial(lambda a, i: a[i] if len(a) > i else None, loc) # parse helper
110 method = getattr(self, 'handle_' + t_type.replace('-', ''), lambda: "Unhandled %s trap" % t_type)
111 method(p(1), p(3), p(5), p(7), r) # we expect net.0.bsc.666.bts.2.trx.1 format for trap prefix
112
113 def ctrl_SET_REPLY(self, data, _, v):
114 """
115 Debug log for replies to our commands
116 """
117 self.factory.log.debug('SET REPLY %s' % v)
118
119 def ctrl_ERROR(self, data, op_id, v):
120 """
121 We want to know if smth went wrong
122 """
123 self.factory.log.debug('CTRL ERROR [%s] %s' % (op_id, v))
124
125 def connectionMade(self):
126 """
127 Logging wrapper, calling super() is necessary not to break reconnection logic
128 """
129 self.factory.log.info("Connected to CTRL@%s:%d" % (self.factory.host, self.factory.port))
130 super(CTRL, self).connectionMade()
131
132 @defer.inlineCallbacks
133 def handle_locationstate(self, net, bsc, bts, trx, data):
134 """
135 Handle location-state TRAP: parse trap content, build CGI Request and use treq's routines to post it while setting up async handlers
136 """
137 (ts, fx, lat, lon, height, opr, adm, pol, mcc, mnc) = data.split(',')
138 tstamp = datetime.datetime.fromtimestamp(float(ts)).isoformat()
139 self.factory.log.debug('location-state@%s.%s.%s.%s (%s) [%s/%s] => %s' % (net, bsc, bts, trx, tstamp, mcc, mnc, data))
140 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) }
141 params['h'] = gen_hash(params, self.factory.secret_key)
142 d = post(self.factory.location, None, params=params)
143 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 +0100144 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 +0200145 # Ensure that we run only limited number of requests in parallel:
146 yield self.factory.semaphore.acquire()
147 yield d # we end up here only if semaphore is available which means it's ok to fire the request without exceeding the limit
148 self.factory.semaphore.release()
149
150 def handle_notificationrejectionv1(self, net, bsc, bts, trx, data):
151 """
152 Handle notification-rejection-v1 TRAP: just an example to show how more message types can be handled
153 """
154 self.factory.log.debug('notification-rejection-v1@bsc-id %s => %s' % (bsc, data))
155
156
157class TrapFactory(IPAFactory):
158 """
159 Store CGI information so TRAP handler can use it for requests
160 """
161 location = None
162 log = None
163 semaphore = None
164 client = None
165 host = None
166 port = None
167 secret_key = None
168 def __init__(self, host, port, proto, semaphore, log, location, secret_key):
169 self.host = host # for logging only,
170 self.port = port # seems to be no way to get it from ReconnectingClientFactory
171 self.log = log
172 self.semaphore = semaphore
173 self.location = location
174 self.secret_key = secret_key
175 level = self.log.getEffectiveLevel()
176 self.log.setLevel(logging.WARNING) # we do not need excessive debug from lower levels
177 super(TrapFactory, self).__init__(proto, self.log)
178 self.log.setLevel(level)
179 self.log.debug("Using IPA %s, CGI server: %s" % (Ctrl.version, self.location))
180
181
182def reloader(path, script, log, dbg1, dbg2, signum, _):
183 """
184 Signal handler: we have to use execl() because twisted's reactor is not restartable due to some bug in twisted implementation
185 """
186 log.info("Received Signal %d - restarting..." % signum)
187 if signum == signal.SIGUSR1 and dbg1 not in sys.argv and dbg2 not in sys.argv:
188 sys.argv.append(dbg1) # enforce debug
189 if signum == signal.SIGUSR2 and (dbg1 in sys.argv or dbg2 in sys.argv): # disable debug
190 if dbg1 in sys.argv:
191 sys.argv.remove(dbg1)
192 if dbg2 in sys.argv:
193 sys.argv.remove(dbg2)
194 os.execl(path, script, *sys.argv[1:])
195
196
197if __name__ == '__main__':
198 p = argparse.ArgumentParser(description='Proxy between given GCI service and Osmocom CTRL protocol.')
199 p.add_argument('-v', '--version', action='version', version=("%(prog)s v" + __version__))
200 p.add_argument('-a', '--addr-ctrl', default='localhost', help="Adress to use for CTRL interface, defaults to localhost")
201 p.add_argument('-p', '--port-ctrl', type=int, default=4250, help="Port to use for CTRL interface, defaults to 4250")
202 p.add_argument('-n', '--num-max-conn', type=int, default=5, help="Max number of concurrent HTTP requests to CGI server")
203 p.add_argument('-d', '--debug', action='store_true', help="Enable debug log")
204 p.add_argument('-o', '--output', action='store_true', help="Log to STDOUT in addition to SYSLOG")
205 p.add_argument('-l', '--location', help="Location URL of the CGI server")
206 p.add_argument('-s', '--secret-key', help="Secret key used to generate verification token")
207 p.add_argument('-c', '--config-file', help="Path Config file. Cmd line args override values in config file")
208 args = p.parse_args()
209
210 log = logging.getLogger('CTRL2CGI')
211 if args.debug:
212 log.setLevel(logging.DEBUG)
213 else:
214 log.setLevel(logging.INFO)
215 log.addHandler(logging.handlers.SysLogHandler('/dev/log'))
216 if args.output:
217 log.addHandler(logging.StreamHandler(sys.stdout))
218
219 reboot = partial(reloader, os.path.abspath(__file__), os.path.basename(__file__), log, '-d', '--debug') # keep in sync with add_argument() call above
220 signal.signal(signal.SIGHUP, reboot)
221 signal.signal(signal.SIGQUIT, reboot)
222 signal.signal(signal.SIGUSR1, reboot) # restart and enabled debug output
223 signal.signal(signal.SIGUSR2, reboot) # restart and disable debug output
224
225 location_cfgfile = None
226 secret_key_cfgfile = None
227 port_ctrl_cfgfile = None
228 addr_ctrl_cfgfile = None
229 num_max_conn_cfgfile = None
230 if args.config_file:
231 config = configparser.ConfigParser()
232 config.read(args.config_file)
233 if 'main' in config:
234 location_cfgfile = config['main'].get('location', None)
235 secret_key_cfgfile = config['main'].get('secret_key', None)
236 addr_ctrl_cfgfile = config['main'].get('addr_ctrl', None)
237 port_ctrl_cfgfile = config['main'].get('port_ctrl', None)
238 num_max_conn_cfgfile = config['main'].get('num_max_conn', None)
239 location = args.location if args.location is not None else location_cfgfile
240 secret_key = args.secret_key if args.secret_key is not None else secret_key_cfgfile
241 addr_ctrl = args.addr_ctrl if args.addr_ctrl is not None else addr_ctrl_cfgfile
242 port_ctrl = args.port_ctrl if args.port_ctrl is not None else port_ctrl_cfgfile
243 num_max_conn = args.num_max_conn if args.num_max_conn is not None else num_max_conn_cfgfile
244
245 log.info("CGI proxy %s starting with PID %d ..." % (__version__, os.getpid()))
246 reactor.connectTCP(addr_ctrl, port_ctrl, TrapFactory(addr_ctrl, port_ctrl, Trap, defer.DeferredSemaphore(num_max_conn), log, location, secret_key))
247 reactor.run()