Onionr/onionr/api.py

527 lines
20 KiB
Python
Raw Normal View History

2017-12-26 07:25:29 +00:00
'''
Onionr - Private P2P Communication
This file handles all incoming http requests to the client, using Flask
'''
'''
2017-12-26 07:25:29 +00:00
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
import random, threading, hmac, base64, time, os, json, socket
2019-01-20 02:23:26 +00:00
from gevent.pywsgi import WSGIServer, WSGIHandler
from gevent import Timeout
import flask
from flask import request, Response, abort, send_from_directory
2018-10-27 03:29:25 +00:00
import core
2018-05-19 22:11:51 +00:00
from onionrblockapi import Block
2019-02-21 20:25:45 +00:00
import onionrutils, onionrexceptions, onionrcrypto, blockimporter, onionrevents as events, logger, config
2019-03-02 06:22:59 +00:00
import httpapi
from httpapi import friendsapi, profilesapi, configapi, miscpublicapi
2019-03-26 04:25:46 +00:00
from onionrservices import httpheaders
2019-02-21 20:25:45 +00:00
import onionr
config.reload()
2019-01-20 02:23:26 +00:00
class FDSafeHandler(WSGIHandler):
2019-02-11 22:36:43 +00:00
'''Our WSGI handler. Doesn't do much non-default except timeouts'''
2019-01-20 02:23:26 +00:00
def handle(self):
timeout = Timeout(60, exception=Exception)
2019-01-20 02:23:26 +00:00
timeout.start()
try:
WSGIHandler.handle(self)
except Timeout as ex:
raise
2019-03-25 23:46:25 +00:00
def setBindIP(filePath=''):
2019-01-08 05:51:39 +00:00
'''Set a random localhost IP to a specified file (intended for private or public API localhost IPs)'''
if config.get('general.random_bind_ip', True):
hostOctets = [str(127), str(random.randint(0x02, 0xFF)), str(random.randint(0x02, 0xFF)), str(random.randint(0x02, 0xFF))]
data = '.'.join(hostOctets)
# Try to bind IP. Some platforms like Mac block non normal 127.x.x.x
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind((data, 0))
except OSError:
# if mac/non-bindable, show warning and default to 127.0.0.1
logger.warn('Your platform appears to not support random local host addresses 127.x.x.x. Falling back to 127.0.0.1.')
data = '127.0.0.1'
s.close()
else:
data = '127.0.0.1'
2019-03-25 23:46:25 +00:00
if filePath != '':
with open(filePath, 'w') as bindFile:
bindFile.write(data)
2019-01-08 05:51:39 +00:00
return data
class PublicAPI:
'''
The new client api server, isolated from the public api
'''
def __init__(self, clientAPI):
assert isinstance(clientAPI, API)
app = flask.Flask('PublicAPI')
2018-06-14 04:17:58 +00:00
self.i2pEnabled = config.get('i2p.host', False)
self.hideBlocks = [] # Blocks to be denied sharing
2019-01-08 05:51:39 +00:00
self.host = setBindIP(clientAPI._core.publicApiHostFile)
self.torAdder = clientAPI._core.hsAddress
self.i2pAdder = clientAPI._core.i2pAddress
self.bindPort = config.get('client.public.port')
2019-02-14 23:48:41 +00:00
self.lastRequest = 0
2019-01-08 05:51:39 +00:00
logger.info('Running public api on %s:%s' % (self.host, self.bindPort))
@app.before_request
2019-01-08 05:51:39 +00:00
def validateRequest():
'''Validate request has the correct hostname'''
2019-02-12 05:30:56 +00:00
# If high security level, deny requests to public (HS should be disabled anyway for Tor, but might not be for I2P)
if config.get('general.security_level', default=1) > 0:
abort(403)
2019-01-08 05:51:39 +00:00
if type(self.torAdder) is None and type(self.i2pAdder) is None:
# abort if our hs addresses are not known
abort(403)
if request.host not in (self.i2pAdder, self.torAdder):
2019-02-12 05:30:56 +00:00
# Disallow connection if wrong HTTP hostname, in order to prevent DNS rebinding attacks
2019-01-08 05:51:39 +00:00
abort(403)
2017-12-27 05:00:02 +00:00
@app.after_request
2019-01-08 05:51:39 +00:00
def sendHeaders(resp):
'''Send api, access control headers'''
2019-03-26 04:25:46 +00:00
resp = httpheaders.set_default_onionr_http_headers(resp)
2019-02-12 05:30:56 +00:00
# Network API version
2019-01-08 05:51:39 +00:00
resp.headers['X-API'] = onionr.API_VERSION
2019-02-14 23:48:41 +00:00
self.lastRequest = clientAPI._core._utils.getRoundedEpoch(roundS=5)
2017-12-27 05:00:02 +00:00
return resp
2018-05-02 06:50:29 +00:00
2018-04-25 06:56:40 +00:00
@app.route('/')
def banner():
2019-02-12 05:30:56 +00:00
# Display a bit of information to people who visit a node address in their browser
2018-04-25 06:56:40 +00:00
try:
with open('static-data/index.html', 'r') as html:
2018-12-09 17:29:39 +00:00
resp = Response(html.read(), mimetype='text/html')
2018-04-25 06:56:40 +00:00
except FileNotFoundError:
resp = Response("")
return resp
2017-12-27 05:00:02 +00:00
2019-01-08 05:51:39 +00:00
@app.route('/getblocklist')
def getBlockList():
2019-05-07 17:56:20 +00:00
return httpapi.miscpublicapi.public_block_list(clientAPI, self, request)
2019-01-08 05:51:39 +00:00
@app.route('/getdata/<name>')
def getBlockData(name):
2019-02-12 05:30:56 +00:00
# Share data for a block if we have it
2019-05-07 17:56:20 +00:00
return httpapi.miscpublicapi.public_get_block_data(clientAPI, self, name)
2019-01-08 05:51:39 +00:00
@app.route('/www/<path:path>')
def wwwPublic(path):
2019-02-12 05:30:56 +00:00
# A way to share files directly over your .onion
2019-01-08 05:51:39 +00:00
if not config.get("www.public.run", True):
abort(403)
return send_from_directory(config.get('www.public.path', 'static-data/www/public/'), path)
2019-01-08 05:51:39 +00:00
@app.route('/ping')
def ping():
2019-02-12 05:30:56 +00:00
# Endpoint to test if nodes are up
2019-01-08 05:51:39 +00:00
return Response("pong!")
@app.route('/pex')
def peerExchange():
2019-01-11 22:59:21 +00:00
response = ','.join(clientAPI._core.listAdders(recent=3600))
2019-01-08 05:51:39 +00:00
if len(response) == 0:
2019-01-11 22:59:21 +00:00
response = ''
2019-01-08 05:51:39 +00:00
return Response(response)
@app.route('/announce', methods=['post'])
def acceptAnnounce():
2019-05-07 17:56:20 +00:00
resp = httpapi.miscpublicapi.announce(clientAPI, request)
2018-09-08 06:45:33 +00:00
return resp
2019-01-08 05:51:39 +00:00
@app.route('/upload', methods=['post'])
def upload():
2019-02-12 05:30:56 +00:00
'''Accept file uploads. In the future this will be done more often than on creation
to speed up block sync
'''
2019-05-07 17:56:20 +00:00
return httpapi.miscpublicapi.upload(clientAPI, request)
2019-02-12 05:30:56 +00:00
# Set instances, then startup our public api server
2019-01-08 05:51:39 +00:00
clientAPI.setPublicAPIInstance(self)
while self.torAdder == '':
clientAPI._core.refreshFirstStartVars()
self.torAdder = clientAPI._core.hsAddress
time.sleep(0.1)
2019-01-20 02:23:26 +00:00
self.httpServer = WSGIServer((self.host, self.bindPort), app, log=None, handler_class=FDSafeHandler)
2019-01-08 05:51:39 +00:00
self.httpServer.serve_forever()
2019-01-08 05:51:39 +00:00
class API:
'''
Client HTTP api
'''
2019-01-08 05:51:39 +00:00
callbacks = {'public' : {}, 'private' : {}}
2018-12-09 17:29:39 +00:00
2019-01-08 05:51:39 +00:00
def __init__(self, onionrInst, debug, API_VERSION):
'''
Initialize the api server, preping variables for later use
This initialization defines all of the API entry points and handlers for the endpoints and errors
2019-01-08 05:51:39 +00:00
This also saves the used host (random localhost IP address) to the data folder in host.txt
'''
2018-01-26 07:22:48 +00:00
2019-01-08 05:51:39 +00:00
self.debug = debug
self._core = onionrInst.onionrCore
self.startTime = self._core._utils.getEpoch()
2019-01-08 05:51:39 +00:00
self._crypto = onionrcrypto.OnionrCrypto(self._core)
self._utils = onionrutils.OnionrUtils(self._core)
app = flask.Flask(__name__)
bindPort = int(config.get('client.client.port', 59496))
self.bindPort = bindPort
2019-02-12 05:30:56 +00:00
# Be extremely mindful of this. These are endpoints available without a password
2019-04-27 15:43:16 +00:00
self.whitelistEndpoints = ('site', 'www', 'onionrhome', 'homedata', 'board', 'profiles', 'profilesindex',
'boardContent', 'sharedContent', 'mail', 'mailindex', 'friends', 'friendsindex',
'clandestine', 'clandestineIndex')
2018-12-26 06:14:05 +00:00
2019-01-08 05:51:39 +00:00
self.clientToken = config.get('client.webpassword')
self.timeBypassToken = base64.b16encode(os.urandom(32)).decode()
2019-01-08 05:51:39 +00:00
self.publicAPI = None # gets set when the thread calls our setter... bad hack but kinda necessary with flask
#threading.Thread(target=PublicAPI, args=(self,)).start()
self.host = setBindIP(self._core.privateApiHostFile)
logger.info('Running api on %s:%s' % (self.host, self.bindPort))
self.httpServer = ''
self.queueResponse = {}
2019-01-08 05:51:39 +00:00
onionrInst.setClientAPIInst(self)
2019-02-21 20:25:45 +00:00
app.register_blueprint(friendsapi.friends)
app.register_blueprint(profilesapi.profile_BP)
2019-04-27 15:43:16 +00:00
app.register_blueprint(configapi.config_BP)
2019-03-02 06:22:59 +00:00
httpapi.load_plugin_blueprints(app)
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
@app.before_request
def validateRequest():
'''Validate request has set password and is the correct hostname'''
2019-02-12 05:30:56 +00:00
# For the purpose of preventing DNS rebinding attacks
2019-01-08 05:51:39 +00:00
if request.host != '%s:%s' % (self.host, self.bindPort):
abort(403)
2018-12-26 06:14:05 +00:00
if request.endpoint in self.whitelistEndpoints:
return
2019-01-08 05:51:39 +00:00
try:
if not hmac.compare_digest(request.headers['token'], self.clientToken):
2019-02-22 21:04:03 +00:00
if not hmac.compare_digest(request.form['token'], self.clientToken):
abort(403)
2019-01-08 05:51:39 +00:00
except KeyError:
2019-02-22 21:04:03 +00:00
if not hmac.compare_digest(request.form['token'], self.clientToken):
abort(403)
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
@app.after_request
def afterReq(resp):
2019-02-12 05:30:56 +00:00
# Security headers
2019-03-26 04:25:46 +00:00
resp = httpheaders.set_default_onionr_http_headers(resp)
if request.endpoint == 'site':
resp.headers['Content-Security-Policy'] = "default-src 'none'; style-src data: 'unsafe-inline'; img-src data:"
else:
resp.headers['Content-Security-Policy'] = "default-src 'none'; script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self'; media-src 'none'; frame-src 'none'; font-src 'none'; connect-src 'self'"
2019-01-08 05:51:39 +00:00
return resp
2018-07-30 00:37:12 +00:00
2018-12-26 06:14:05 +00:00
@app.route('/board/', endpoint='board')
def loadBoard():
return send_from_directory('static-data/www/board/', "index.html")
@app.route('/mail/<path:path>', endpoint='mail')
def loadMail(path):
return send_from_directory('static-data/www/mail/', path)
@app.route('/mail/', endpoint='mailindex')
def loadMailIndex():
return send_from_directory('static-data/www/mail/', 'index.html')
@app.route('/clandestine/<path:path>', endpoint='clandestine')
def loadClandestine(path):
return send_from_directory('static-data/www/clandestine/', path)
@app.route('/clandestine/', endpoint='clandestineIndex')
def loadClandestineIndex():
return send_from_directory('static-data/www/clandestine/', 'index.html')
2019-02-21 20:25:45 +00:00
@app.route('/friends/<path:path>', endpoint='friends')
def loadContacts(path):
return send_from_directory('static-data/www/friends/', path)
@app.route('/friends/', endpoint='friendsindex')
def loadContacts():
return send_from_directory('static-data/www/friends/', 'index.html')
@app.route('/profiles/<path:path>', endpoint='profiles')
def loadContacts(path):
return send_from_directory('static-data/www/profiles/', path)
@app.route('/profiles/', endpoint='profilesindex')
def loadContacts():
return send_from_directory('static-data/www/profiles/', 'index.html')
@app.route('/serviceactive/<pubkey>')
def serviceActive(pubkey):
try:
if pubkey in self._core.onionrInst.communicatorInst.active_services:
return Response('true')
except AttributeError as e:
pass
return Response('false')
2018-12-26 06:14:05 +00:00
@app.route('/board/<path:path>', endpoint='boardContent')
def boardContent(path):
return send_from_directory('static-data/www/board/', path)
2018-12-27 05:27:46 +00:00
@app.route('/shared/<path:path>', endpoint='sharedContent')
def sharedContent(path):
return send_from_directory('static-data/www/shared/', path)
2018-12-26 06:14:05 +00:00
2019-04-27 15:43:16 +00:00
@app.route('/', endpoint='onionrhome')
def hello():
# ui home
return send_from_directory('static-data/www/private/', 'index.html')
@app.route('/private/<path:path>', endpoint='homedata')
def homedata(path):
return send_from_directory('static-data/www/private/', path)
2018-12-26 06:14:05 +00:00
@app.route('/www/<path:path>', endpoint='www')
def wwwPublic(path):
if not config.get("www.private.run", True):
abort(403)
return send_from_directory(config.get('www.private.path', 'static-data/www/private/'), path)
@app.route('/queueResponseAdd/<name>', methods=['post'])
def queueResponseAdd(name):
2019-02-12 05:30:56 +00:00
# Responses from the daemon. TODO: change to direct var access instead of http endpoint
self.queueResponse[name] = request.form['data']
return Response('success')
@app.route('/queueResponse/<name>')
def queueResponse(name):
2019-02-12 05:30:56 +00:00
# Fetch a daemon queue response
resp = 'failure'
try:
resp = self.queueResponse[name]
except KeyError:
pass
else:
del self.queueResponse[name]
if resp == 'failure':
return resp, 404
else:
return resp
2019-01-08 05:51:39 +00:00
@app.route('/ping')
def ping():
2019-02-12 05:30:56 +00:00
# Used to check if client api is working
2019-01-08 05:51:39 +00:00
return Response("pong!")
2018-12-26 06:14:05 +00:00
@app.route('/getblocksbytype/<name>')
def getBlocksByType(name):
blocks = self._core.getBlocksByType(name)
return Response(','.join(blocks))
2019-02-04 23:48:21 +00:00
@app.route('/getblockbody/<name>')
def getBlockBodyData(name):
2018-12-26 06:14:05 +00:00
resp = ''
if self._core._utils.validateHash(name):
try:
2019-02-04 23:48:21 +00:00
resp = Block(name, decrypt=True).bcontent
2018-12-26 06:14:05 +00:00
except TypeError:
pass
else:
abort(404)
return Response(resp)
@app.route('/getblockdata/<name>')
def getData(name):
resp = ""
if self._core._utils.validateHash(name):
if name in self._core.getBlockList():
try:
resp = self.getBlockData(name, decrypt=True)
except ValueError:
pass
else:
abort(404)
else:
abort(404)
return Response(resp)
2018-07-30 00:37:12 +00:00
2019-02-04 23:48:21 +00:00
@app.route('/getblockheader/<name>')
def getBlockHeader(name):
resp = self.getBlockData(name, decrypt=True, headerOnly=True)
return Response(resp)
2019-02-14 23:48:41 +00:00
@app.route('/lastconnect')
def lastConnect():
return Response(str(self.publicAPI.lastRequest))
2018-12-26 06:14:05 +00:00
@app.route('/site/<name>', endpoint='site')
def site(name):
bHash = name
2019-01-08 05:51:39 +00:00
resp = 'Not Found'
if self._core._utils.validateHash(bHash):
2018-12-26 06:14:05 +00:00
try:
resp = Block(bHash).bcontent
2019-02-22 21:04:03 +00:00
except onionrexceptions.NoDataAvailable:
abort(404)
2018-12-26 06:14:05 +00:00
except TypeError:
pass
2019-01-08 05:51:39 +00:00
try:
resp = base64.b64decode(resp)
except:
pass
2019-02-22 21:04:03 +00:00
if resp == 'Not Found' or not resp:
abort(404)
2019-01-08 05:51:39 +00:00
return Response(resp)
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
@app.route('/waitforshare/<name>', methods=['post'])
def waitforshare(name):
2019-02-12 05:30:56 +00:00
'''Used to prevent the **public** api from sharing blocks we just created'''
2019-01-08 05:51:39 +00:00
assert name.isalnum()
if name in self.publicAPI.hideBlocks:
self.publicAPI.hideBlocks.remove(name)
return Response("removed")
else:
self.publicAPI.hideBlocks.append(name)
return Response("added")
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
@app.route('/shutdown')
def shutdown():
try:
self.publicAPI.httpServer.stop()
self.httpServer.stop()
except AttributeError:
pass
return Response("bye")
2018-07-30 00:37:12 +00:00
@app.route('/shutdownclean')
def shutdownClean():
# good for calling from other clients
self._core.daemonQueueAdd('shutdown')
return Response("bye")
@app.route('/getstats')
def getStats():
2019-02-12 05:30:56 +00:00
# returns node stats
#return Response("disabled")
while True:
try:
return Response(self._core.serializer.getStats())
except AttributeError:
pass
@app.route('/getuptime')
def showUptime():
return Response(str(self.getUptime()))
2019-02-04 00:31:03 +00:00
@app.route('/getActivePubkey')
def getActivePubkey():
return Response(self._core._crypto.pubKey)
@app.route('/getHumanReadable/<name>')
def getHumanReadable(name):
return Response(self._core._utils.getHumanReadableID(name))
@app.route('/insertblock', methods=['POST'])
def insertBlock():
2019-02-10 18:43:45 +00:00
encrypt = False
bData = request.get_json(force=True)
message = bData['message']
# Detect if message (block body) is not specified
if type(message) is None:
return 'failure', 406
subject = 'temp'
2019-02-10 18:43:45 +00:00
encryptType = ''
sign = True
meta = {}
to = ''
try:
if bData['encrypt']:
to = bData['to']
encrypt = True
encryptType = 'asym'
except KeyError:
pass
try:
if not bData['sign']:
sign = False
except KeyError:
pass
try:
bType = bData['type']
except KeyError:
bType = 'bin'
try:
meta = json.loads(bData['meta'])
except KeyError:
pass
2019-02-11 22:36:43 +00:00
threading.Thread(target=self._core.insertBlock, args=(message,), kwargs={'header': bType, 'encryptType': encryptType, 'sign':sign, 'asymPeer': to, 'meta': meta}).start()
return Response('success')
2019-01-20 02:23:26 +00:00
self.httpServer = WSGIServer((self.host, bindPort), app, log=None, handler_class=FDSafeHandler)
2019-01-08 05:51:39 +00:00
self.httpServer.serve_forever()
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
def setPublicAPIInstance(self, inst):
assert isinstance(inst, PublicAPI)
self.publicAPI = inst
2018-07-30 00:37:12 +00:00
2019-01-08 05:51:39 +00:00
def validateToken(self, token):
'''
2019-02-12 05:30:56 +00:00
Validate that the client token matches the given token. Used to prevent CSRF and data exfiltration
2019-01-08 05:51:39 +00:00
'''
if len(self.clientToken) == 0:
logger.error("client password needs to be set")
return False
try:
if not hmac.compare_digest(self.clientToken, token):
return False
else:
return True
except TypeError:
return False
def getUptime(self):
2019-01-28 22:49:04 +00:00
while True:
try:
return self._utils.getEpoch() - self.startTime
except (AttributeError, NameError):
2019-01-28 22:49:04 +00:00
# Don't error on race condition with startup
pass
2019-02-04 23:48:21 +00:00
def getBlockData(self, bHash, decrypt=False, raw=False, headerOnly=False):
assert self._core._utils.validateHash(bHash)
bl = Block(bHash, core=self._core)
if decrypt:
bl.decrypt()
if bl.isEncrypted and not bl.decrypted:
raise ValueError
if not raw:
2019-02-04 23:48:21 +00:00
if not headerOnly:
retData = {'meta':bl.bheader, 'metadata': bl.bmetadata, 'content': bl.bcontent}
for x in list(retData.keys()):
try:
retData[x] = retData[x].decode()
except AttributeError:
pass
else:
validSig = False
signer = self._core._utils.bytesToStr(bl.signer)
if bl.isSigned() and self._core._utils.validatePubKey(signer) and bl.isSigner(signer):
validSig = True
bl.bheader['validSig'] = validSig
2019-02-04 23:48:21 +00:00
bl.bheader['meta'] = ''
retData = {'meta': bl.bheader, 'metadata': bl.bmetadata}
return json.dumps(retData)
else:
2019-02-02 03:35:18 +00:00
return bl.raw