"""Inert NITRO responses. All mutations affect bounded per-source dictionaries only.""" import copy import datetime import json import os import pathlib import secrets import urllib.parse from http.cookies import SimpleCookie, CookieError RESOURCES = json.loads(pathlib.Path(__file__).with_name('nitro-resources.json').read_text()) CONFIG = { 'nsversion': [dict(version=os.environ.get('HONEYPOT_VERSION', 'NetScaler NS14.1: Build 29.63.nc'), mode=0)], 'nshardware': [dict(hwdescription='NetScaler Virtual Appliance', serialno='VMPE00000001')], 'nshostname': [dict(hostname='adc-gateway')], 'nsconfig': [dict(ipaddress='198.51.100.10', netmask='255.255.255.0', changed=False, configchanged=False)], 'nsip': [dict(ipaddress='198.51.100.10', netmask='255.255.255.0', type='NSIP', state='ENABLED', gui='SECUREONLY'), dict(ipaddress='198.51.100.11', netmask='255.255.255.0', type='SNIP', state='ENABLED')], 'nsfeature': [dict(lb=True, cs=True, ssl=True, sslvpn=True, aaa=True, rewrite=True, responder=True)], 'nsmode': [dict(fr=True, l3=True, usnip=True, pmtud=True)], 'nslicense': [dict(licensetype='Premium', modelid='1000', ssl=True, sslvpn=True)], 'nsparam': [dict(timezone='GMT', cookieversion='0', maxconn=0)], 'hanode': [dict(id=0, ipaddress='198.51.100.10', state='UP', hastatus='YES', masterstate='Primary')], 'interface': [dict(id='1/1', state='ENABLED', curstate='UP', mtu=1500, mac='00:50:56:01:02:03')], 'vlan': [dict(id=1, aliasname='DEFAULT')], 'route': [dict(network='0.0.0.0', netmask='0.0.0.0', gateway='198.51.100.1', distance=1)], 'dnsnameserver': [dict(ip='198.51.100.53', state='ENABLED')], 'server': [dict(name='app01', ipaddress='198.51.100.20', state='ENABLED')], 'service': [dict(name='svc_app_https', servername='app01', ip='198.51.100.20', port=443, servicetype='SSL', state='ENABLED', curstate='UP')], 'servicegroup': [dict(servicegroupname='sg_apps', servicetype='SSL', state='ENABLED')], 'lbvserver': [dict(name='lb_apps', ipv46='198.51.100.12', port=443, servicetype='SSL', lbmethod='LEASTCONNECTION', state='ENABLED', curstate='UP')], 'csvserver': [dict(name='cs_apps', ipv46='198.51.100.12', port=443, servicetype='SSL', state='ENABLED')], 'vpnvserver': [dict(name='vpn_gateway', ipv46='198.51.100.10', port=443, servicetype='SSL', state='ENABLED', curstate='UP', authentication='ON')], 'vpnsessionaction': [dict(name='vpn_default', defaultauthorizationaction='ALLOW', clientlessvpnmode='OFF')], 'vpnsessionpolicy': [dict(name='vpn_default_policy', rule='true', action='vpn_default')], 'vpnportaltheme': [dict(name='RfWebUI', basetheme='RfWebUI')], 'authenticationvserver': [dict(name='aaa_gateway', servicetype='SSL', authenticationdomain='corp.invalid', state='ENABLED')], 'authenticationldapaction': [dict(name='ldap_corp', serverip='198.51.100.30', serverport=636, ldapbase='dc=corp,dc=invalid', sectype='SSL')], 'authenticationldappolicy': [dict(name='ldap_policy', rule='ns_true', reqaction='ldap_corp')], 'systemuser': [dict(username='nsroot', timeout=900)], 'systemgroup': [dict(groupname='administrators', timeout=900)], 'sslcertkey': [dict(certkey='gateway_cert', cert='/nsconfig/ssl/gateway.crt', key='/nsconfig/ssl/gateway.key', status='Valid')], 'sslvserver': [dict(vservername='vpn_gateway', ssl3='DISABLED', tls1='DISABLED', tls12='ENABLED', tls13='ENABLED')], 'lbvserver_service_binding': [dict(name='lb_apps', servicename='svc_app_https', weight=1)], 'vpnvserver_vpnsessionpolicy_binding': [dict(name='vpn_gateway', policy='vpn_default_policy', priority=100)], 'vpnvserver_sslcertkey_binding': [dict(name='vpn_gateway', certkeyname='gateway_cert')], 'servicegroup_servicegroupmember_binding': [dict(servicegroupname='sg_apps', ip='198.51.100.20', port=443, weight=1)], 'systemuser_systemgroup_binding': [dict(username='nsroot', groupname='administrators')], 'auditnslogparams': [dict(serverip='198.51.100.40', serverport=3020, loglevel=['ERROR', 'WARNING'])], 'snmpmib': [dict(contact='Network Operations', location='Datacenter', name='adc-gateway')], 'ntpserver': [dict(serverip='198.51.100.123', preferred='YES')], 'systemfile': [], 'nsrunningconfig': [dict(response='set ns hostName adc-gateway\nadd server app01 198.51.100.20\n')], 'nssavedconfig': [dict(response='set ns hostName adc-gateway\nadd server app01 198.51.100.20\n')], } KEYS = ('name', 'servicegroupname', 'username', 'groupname', 'certkey', 'vservername', 'ipaddress', 'ip', 'id', 'filename') def reply(status=200, errorcode=0, message='Done', **data): return status, dict(errorcode=errorcode, message=message, severity='ERROR' if errorcode else 'NONE', **data), [] def identity(row): return next((str(row[k]) for k in KEYS if k in row), '') def respond(state, method, url, body, headers): parts = url.path.strip('/').split('/', 4) if len(parts) < 4 or parts[:2] != ['nitro', 'v1'] or parts[2] not in RESOURCES: return reply(404, 258, 'Invalid resource type') kind, resource = parts[2:4] name = urllib.parse.unquote(parts[4]) if len(parts) > 4 else '' try:query = dict(urllib.parse.parse_qsl(url.query, max_num_fields=128)) except ValueError:return reply(400, 258, 'Invalid argument') if not hasattr(state, 'nitro'): state.nitro = dict(token=None, data={}) fake = state.nitro payload = {} if method in ('POST', 'PUT', 'PATCH') and body: try: # Large systemfile bodies are captured separately; never retain their bytes in fake config. payload = json.loads(body) if len(body) <= 65536 else ({'systemfile': {}} if resource == 'systemfile' else None) if not isinstance(payload, dict):raise ValueError() except (ValueError, RecursionError): return reply(400, 258, 'Invalid argument') if resource == 'login' and kind == 'config' and method == 'POST': login = payload.get('login', {}) if not isinstance(login, dict) or not login.get('username') or not login.get('password'): return reply(400, 258, 'Invalid argument') fake['token'] = '##' + secrets.token_hex(16).upper() status, result, extra = reply(201, sessionid=fake['token'], login=[dict(sessionid=fake['token'])]) extra.append(('Set-Cookie', 'NITRO_AUTH_TOKEN='+fake['token']+'; Path=/nitro/v1; Secure; HttpOnly')) return status, result, extra cookie = SimpleCookie() try:cookie.load(headers.get('Cookie', '')) except CookieError:pass token = cookie.get('NITRO_AUTH_TOKEN') auth = bool(token and fake['token'] and token.value == fake['token']) # These credentials authenticate only this invented appliance, never an external system. auth |= bool(headers.get('X-NITRO-USER') and headers.get('X-NITRO-PASS')) auth |= headers.get('Authorization', '').startswith('Basic ') if not auth:return reply(401, 444, 'Authentication required') if resource == 'logout' and method == 'POST': fake['token'] = None status, result, extra = reply(201) extra.append(('Set-Cookie', 'NITRO_AUTH_TOKEN=; Path=/nitro/v1; Max-Age=0')) return status, result, extra if resource not in RESOURCES[kind] and resource not in CONFIG: return reply(404, 258, 'Invalid resource type') rows = copy.deepcopy(fake['data'].get(resource, CONFIG.get(resource, []))) if resource.endswith('_binding') and not rows: root = resource[:-8] for item in CONFIG.get(root, []): bindings = {key:copy.deepcopy(value) for key,value in CONFIG.items() if key.startswith(root+'_') and key.endswith('_binding')} if bindings:rows.append(dict(name=identity(item), **bindings)) if kind == 'stat': if not rows:rows = [dict(name=name)] if name else [{}] for row in rows: row.update(totalrequests='0', requestsrate=0, establishedconn='0', throughput='0', throughputrate=0) if resource == 'system':row.update(cpuusage=2, memusagepcnt=18, disk0perusage=9) if resource == 'ns':row.update(totrxmbits='0', tottxmbits='0', tcpcurclientconn='0') if resource == 'nstime':rows = [dict(time=datetime.datetime.now(datetime.timezone.utc).strftime('%a %b %d %H:%M:%S %Y'))] if method in ('GET', 'HEAD'): if name:rows = [row for row in rows if identity(row) == name] for key in ('filter', 'args'): # ponytail: literal filters; add bounded regex matching if a client needs it. for condition in query.get(key, '').split(','): if ':' in condition: field, value = condition.split(':', 1) rows = [row for row in rows if str(row.get(field, '')) == value] if query.get('count') == 'yes':rows = [dict(__count=len(rows))] else: try: size = min(1000, max(1, int(query.get('pagesize', 1000)))) page = max(1, int(query.get('pageno', 1))) except ValueError:return reply(400, 258, 'Invalid argument') rows = rows[(page-1)*size:page*size] if query.get('attrs'): attrs = set(query['attrs'].split(','));rows = [{k:v for k,v in row.items() if k in attrs} for row in rows] return reply(**{resource: rows}) if method == 'OPTIONS':return 200, {}, [('Allow', 'GET, HEAD, POST, PUT, DELETE, OPTIONS')] if kind == 'stat':return reply(405, 258, 'Method not allowed') if method == 'DELETE': if not name:return reply(400, 258, 'Invalid argument') rows = [row for row in rows if identity(row) != name] elif method in ('POST', 'PUT'): incoming = payload.get(resource) if not isinstance(incoming, (dict, list)):return reply(400, 258, 'Invalid argument') incoming = incoming if isinstance(incoming, list) else [incoming] if len(incoming) > 128 or any(not isinstance(row, dict) for row in incoming):return reply(400, 258, 'Invalid argument') action = query.get('action') if action and action not in ('enable', 'disable', 'rename'): # Administrative actions are acknowledgements, never operations on this host. return reply() for row in incoming: row = {k:v for k,v in row.items() if k not in ('password', 'filecontent')} old = next((item for item in rows if identity(item) == identity(row)), None) if action in ('enable', 'disable'): row['state'] = 'ENABLED' if action == 'enable' else 'DISABLED' if action == 'rename' and 'newname' in row:row['name'] = row.pop('newname') if old is None:rows.append(row) else:old.update(row) else:return reply(405, 258, 'Method not allowed') updated = dict(fake['data'], **{resource:rows}) try: if len(json.dumps(updated)) > 65536:return reply(503, 258, 'Resource limit reached') except (ValueError, RecursionError):return reply(400, 258, 'Invalid argument') fake['data'] = updated return reply(201 if method == 'POST' and not query.get('action') else 200)