Files

175 lines
11 KiB
Python

"""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)