n2n/scripts/n2nhttpd
Hamish Coleman ae502d9181
JSON Reply Management API - feature parity with old management interfaces (#861)
* Ensure that recent code additions pass the linter

* Include some of the more obviously correct lint fixes to edge_utils.c

* Refactor edge JSON api into its own source file

* Use shorter names for static management functions

* Implement a JSON RPC way of managing the verbosity

* Tidy up help display in n2nctl script

* Make note of issue with implementing the stop command

* Implement a JSON RPC call to fetch current community

* Make n2nhttpd time value be more self-contained

* Make n2nhttpd order more closely match the existing management stats output

* Wire up status page to the verbosity setting

* Add JSON versions of the remainder of the edge management stats

* Add new file to cmake

* Properly define management handler

* Only update the last updated timestamp after a successful data fetch

* Function and types definition cleanup

* Force correct type for python scripts mgmt port

* Implement initial JSON API for supernode

* Fix whitespace error

* Use helper function for rendering peers ip4 address

* Proxy the auth requirement back out to the http client, allowing normal http auth to be used

* Ensure that we do not leak the federation community

* Use the same rpc method name and output for both edge and supernode for peers/edges

* Allow n2nctl to show raw data returned without resorting to tricks

* Make n2nctl pretty printer understandable with an empty table

* Use the full name for supernodes RPC call

* Use same RPC method name (but some missing fields) for getting communities from both edge and supernode

* Add *_sup_broadcast stats to edge packet stats output

* Refacter the stats into a packetstats method for supernode RPC

* Even if I am not going to prettyprint the timestamps, at least make all the timestamps on the page the same unit

* Simplify the RPC handlers by flagging some as writable and checking that in the multiplexer

* Remove invalid edges data

* Avoid crash on bad data to verbose RPC

* Avoid showing bad or inconsistant protocol data in communities RPC

* Minor clarification on when --write is handled

* Make linter happy

* Fix changed method name in n2nhttpd

* Move mainloop stop flag into the n2n_edge_t structure, allowing access from management commands

* Implement edge RPC stop command

* Move mainloop stop flag into the n2n_sn_t structure, allowing access from management commands

* Implement supernode RPC stop command

* Allow multiple pages to be served from mini httpd

* Extract common script functions into a separate URL

* Handle an edge case in the python rpc class

With a proper tag-based demultiplexer, this case should be a nop,
but we are single-threaded and rely on the packet ordering in this
library.

* Add n2nhttpd support to query supernode using urls prefixed with /supernode/

* Handle missing values in javascript table print

* Add another less filtering javascript key/value renderer

* Add a supernode.html page to the n2nhttpd

* Address lint issue

* Mention the second html page on the Scripts doc

* Remove purgable column from supernode edges list - it looks like it is rarely going to be set

* Add a simple one-line example command at the top of the API documentation

* Acknowledge that this is not the most efficient protocol, but point out that it was not supposed to be

* Make it clear that the n2nctl script works for both edge and supernode

* Fight with inconsistant github runner results

* Turn off the /right/ coverage generator
2021-10-23 11:05:05 +05:45

533 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
# Licensed under GPLv3
#
# Simple http server to allow user control of n2n edge nodes
#
# Currently only for demonstration
# - needs nicer looking html written
# - needs more json interfaces in edge
#
# Try it out with
# http://localhost:8080/
# http://localhost:8080/edge/edges
# http://localhost:8080/edge/supernodes
import argparse
import socket
import json
import socketserver
import http.server
import signal
import functools
import base64
from http import HTTPStatus
class JsonUDP():
"""encapsulate communication with the edge"""
def __init__(self, port):
self.address = "127.0.0.1"
self.port = port
self.tag = 0
self.key = None
self.debug = False
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
def _next_tag(self):
tagstr = str(self.tag)
self.tag = (self.tag + 1) % 1000
return tagstr
def _cmdstr(self, msgtype, cmdline):
"""Create the full command string to send"""
tagstr = self._next_tag()
options = [tagstr]
if self.key is not None:
options += ['1'] # Flags set for auth key field
options += [self.key]
optionsstr = ':'.join(options)
return tagstr, ' '.join((msgtype, optionsstr, cmdline))
def _rx(self, tagstr):
"""Wait for rx packets"""
# TODO: there are no timeouts with any of the recv calls
data, _ = self.sock.recvfrom(1024)
data = json.loads(data.decode('utf8'))
# TODO: We assume the first packet we get will be tagged for us
# and be either an "error" or a "begin"
assert(data['_tag'] == tagstr)
if data['_type'] == 'error':
raise ValueError('Error: {}'.format(data['error']))
assert(data['_type'] == 'begin')
# Ideally, we would confirm that this is our "begin", but that
# would need the cmd passed into this method, and that would
# probably require parsing the cmdline passed to us :-(
# assert(data['cmd'] == cmd)
result = list()
error = None
while True:
data, _ = self.sock.recvfrom(1024)
data = json.loads(data.decode('utf8'))
if data['_tag'] != tagstr:
# this packet is not for us, ignore it
continue
if data['_type'] == 'error':
# we still expect an end packet, so save the error
error = ValueError('Error: {}'.format(data['error']))
continue
if data['_type'] == 'end':
if error:
raise error
return result
if data['_type'] != 'row':
raise ValueError('Unknown data type {} from '
'edge'.format(data['_type']))
# remove our boring metadata
del data['_tag']
del data['_type']
if self.debug:
print(data)
result.append(data)
def _call(self, msgtype, cmdline):
"""Perform a rpc call"""
tagstr, msgstr = self._cmdstr(msgtype, cmdline)
self.sock.sendto(msgstr.encode('utf8'), (self.address, self.port))
return self._rx(tagstr)
def read(self, cmdline):
return self._call('r', cmdline)
def write(self, cmdline):
return self._call('w', cmdline)
pages = {
"/script.js": {
"content_type": "text/javascript",
"content": """
var verbose=-1;
function rows2verbose(id, unused, data) {
row0 = data[0]
verbose = row0['traceLevel']
let div = document.getElementById(id);
div.innerHTML=verbose
}
function rows2keyvalue(id, keys, data) {
let s = "<table border=1 cellspacing=0>"
data.forEach((row) => {
keys.forEach((key) => {
if (key in row) {
s += "<tr><th>" + key + "<td>" + row[key];
}
});
});
s += "</table>"
let div = document.getElementById(id);
div.innerHTML=s
}
function rows2keyvalueall(id, unused, data) {
let s = "<table border=1 cellspacing=0>"
data.forEach((row) => {
Object.keys(row).forEach((key) => {
s += "<tr><th>" + key + "<td>" + row[key];
});
});
s += "</table>"
let div = document.getElementById(id);
div.innerHTML=s
}
function rows2table(id, columns, data) {
let s = "<table border=1 cellspacing=0>"
s += "<tr>"
columns.forEach((col) => {
s += "<th>" + col
});
data.forEach((row) => {
s += "<tr>"
columns.forEach((col) => {
val = row[col]
if (typeof val === "undefined") {
val = ''
}
s += "<td>" + val
});
});
s += "</table>"
let div = document.getElementById(id);
div.innerHTML=s
}
function do_get(url, id, handler, handler_param) {
fetch(url)
.then(function (response) {
return response.json();
})
.then(function (data) {
handler(id,handler_param,data);
// update the timestamp on success
let now = Math.round(new Date().getTime() / 1000);
let time = document.getElementById('time');
time.innerHTML=now;
})
.catch(function (err) {
console.log('error: ' + err);
});
}
function do_post(url, body, id, handler, handler_param) {
fetch(url, {method:'POST', body: body})
.then(function (response) {
return response.json();
})
.then(function (data) {
handler(id,handler_param,data);
})
.catch(function (err) {
console.log('error: ' + err);
});
}
function do_stop(tracelevel) {
// FIXME: uses global in script library
fetch(nodetype + '/stop', {method:'POST'})
}
function setverbose(tracelevel) {
if (tracelevel < 0) {
tracelevel = 0;
}
// FIXME: uses global in script library
do_post(
nodetype + '/verbose', tracelevel, 'verbose',
rows2verbose, null
);
}
function refresh_setup(interval) {
var timer = setInterval(refresh_job, interval);
}
""",
},
"/": {
"content_type": "text/html; charset=utf-8",
"content": """
<html>
<head>
<title>n2n edge management</title>
</head>
<body>
<table>
<tr>
<td>Last Updated:
<td><div id="time"></div>
<td><button onclick=refresh_job()>update</button>
<td><button onclick=do_stop()>stop edge</button>
<tr>
<td>Logging Verbosity:
<td>
<div id="verbose"></div>
<td>
<button onclick=setverbose(verbose+1)>+</button>
<button onclick=setverbose(verbose-1)>-</button>
</table>
<br>
<div id="communities"></div>
<br>
Edges/Peers:
<div id="edges"></div>
<br>
Supernodes:
<div id="supernodes"></div>
<br>
<div id="timestamps"></div>
<br>
<div id="packetstats"></div>
<script src="script.js"></script>
<script>
// FIXME: hacky global
var nodetype="edge";
function refresh_job() {
do_get(
nodetype + '/verbose', 'verbose',
rows2verbose, null
);
do_get(
nodetype + '/communities', 'communities',
rows2keyvalue, ['community']
);
do_get(
nodetype + '/supernodes', 'supernodes',
rows2table, ['version','current','macaddr','sockaddr','uptime']
);
do_get(
nodetype + '/edges', 'edges',
rows2table, ['mode','ip4addr','macaddr','sockaddr','desc']
);
do_get(
nodetype + '/timestamps', 'timestamps',
rows2keyvalueall, null
);
do_get(
nodetype + '/packetstats', 'packetstats',
rows2table, ['type','tx_pkt','rx_pkt']
);
}
refresh_setup(10000);
refresh_job();
</script>
</body>
</html>
""",
},
"/supernode.html": {
"content_type": "text/html; charset=utf-8",
"content": """
<html>
<head>
<title>n2n supernode management</title>
</head>
<body>
<table>
<tr>
<td>Last Updated:
<td><div id="time"></div>
<td><button onclick=refresh_job()>update</button>
<td><button onclick=do_stop()>stop supernode</button>
<tr>
<td>Logging Verbosity:
<td>
<div id="verbose"></div>
<td>
<button onclick=setverbose(verbose+1)>+</button>
<button onclick=setverbose(verbose-1)>-</button>
<td><button onclick=do_reload()>reload communities</button>
</table>
<br>
Communities:
<div id="communities"></div>
<br>
Edges/Peers:
<div id="edges"></div>
<br>
<div id="timestamps"></div>
<br>
<div id="packetstats"></div>
<script src="script.js"></script>
<script>
// FIXME: hacky global
var nodetype="supernode";
function do_reload() {
fetch(nodetype + '/reload_communities', {method:'POST'})
}
function refresh_job() {
do_get(
nodetype + '/verbose', 'verbose',
rows2verbose, null
);
do_get(
nodetype + '/communities', 'communities',
rows2table, ['community','ip4addr','is_federation','purgeable']
);
do_get(
nodetype + '/edges', 'edges',
rows2table,
['community','ip4addr','macaddr','sockaddr','proto','desc']
);
do_get(
nodetype + '/timestamps', 'timestamps',
rows2keyvalueall, null
);
do_get(
nodetype + '/packetstats', 'packetstats',
rows2table, ['type','tx_pkt','rx_pkt']
);
}
refresh_setup(10000);
refresh_job();
</script>
</body>
</html>
""",
},
}
class SimpleHandler(http.server.BaseHTTPRequestHandler):
def __init__(self, rpc, snrpc, *args, **kwargs):
self.rpc = rpc
self.snrpc = snrpc
super().__init__(*args, **kwargs)
def log_request(self, code='-', size='-'):
# Dont spam the output
pass
def _simplereply(self, number, message):
self.send_response(number)
self.end_headers()
self.wfile.write(message.encode('utf8'))
def _replyjson(self, data):
self.send_response(HTTPStatus.OK)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(data).encode('utf8'))
def _replyunauth(self):
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header('WWW-Authenticate', 'Basic realm="n2n"')
self.end_headers()
def _extractauth(self, rpc):
# Avoid caching the key inside the object for all clients
rpc.key = None
header = self.headers.get('Authorization')
if header is not None:
authtype, encoded = header.split(' ')
if authtype == 'Basic':
user, key = base64.b64decode(encoded).decode('utf8').split(':')
rpc.key = key
if rpc.key is None:
rpc.key = rpc.defaultkey
def _rpc(self, method, cmdline):
try:
data = method(cmdline)
except ValueError as e:
if str(e) == "Error: badauth":
self._replyunauth()
return
self._simplereply(HTTPStatus.BAD_REQUEST, 'Bad Command')
return
self._replyjson(data)
return
def _rpc_read(self, rpc):
self._extractauth(rpc)
tail = self.path.split('/')
cmd = tail[2]
# if reads ever need args, could use more of the tail
self._rpc(rpc.read, cmd)
def _rpc_write(self, rpc):
self._extractauth(rpc)
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf8')
tail = self.path.split('/')
cmd = tail[2]
cmdline = cmd + ' ' + post_data
self._rpc(rpc.write, cmdline)
def do_GET(self):
if self.path.startswith("/edge/"):
self._rpc_read(self.rpc)
return
if self.path.startswith("/supernode/"):
self._rpc_read(self.snrpc)
return
if self.path in pages:
page = pages[self.path]
self.send_response(HTTPStatus.OK)
self.send_header('Content-type', page['content_type'])
self.end_headers()
self.wfile.write(page['content'].encode('utf8'))
return
self._simplereply(HTTPStatus.NOT_FOUND, 'Not Found')
return
def do_POST(self):
if self.path.startswith("/edge/"):
self._rpc_write(self.rpc)
return
if self.path.startswith("/supernode/"):
self._rpc_write(self.snrpc)
return
def main():
ap = argparse.ArgumentParser(
description='Control the running local n2n edge via http')
ap.add_argument('-t', '--mgmtport', action='store', default=5644,
help='Management Port (default=5644)', type=int)
ap.add_argument('--snmgmtport', action='store', default=5645,
help='Supernode Management Port (default=5645)', type=int)
ap.add_argument('-k', '--key', action='store',
help='Password for mgmt commands')
ap.add_argument('-d', '--debug', action='store_true',
help='Also show raw internal data')
ap.add_argument('port', action='store',
default=8080, type=int, nargs='?',
help='Serve requests on TCP port (default 8080)')
args = ap.parse_args()
rpc = JsonUDP(args.mgmtport)
rpc.debug = args.debug
rpc.defaultkey = args.key
snrpc = JsonUDP(args.snmgmtport)
snrpc.debug = args.debug
snrpc.defaultkey = args.key
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
socketserver.TCPServer.allow_reuse_address = True
handler = functools.partial(SimpleHandler, rpc, snrpc)
with socketserver.TCPServer(("", args.port), handler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
return
if __name__ == '__main__':
main()