peerfinder.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. #!/usr/bin/env python
  2. from flask import Flask
  3. from flask import request, render_template
  4. from flask.ext.sqlalchemy import SQLAlchemy
  5. from flask.ext.script import Server, Manager
  6. from flask.ext.migrate import Migrate, MigrateCommand
  7. #from flask import session, request, url_for, redirect, render_template
  8. import netaddr
  9. from netaddr import IPAddress, IPNetwork, IPSet
  10. # Hack for python3
  11. from netaddr.strategy.ipv4 import packed_to_int as unpack_v4
  12. from netaddr.strategy.ipv6 import packed_to_int as unpack_v6
  13. import socket
  14. from datetime import datetime, timedelta
  15. from uuid import uuid4
  16. DN42 = IPSet(['172.22.0.0/15', '172.31.0.0/16', '10.0.0.0/8'])
  17. app = Flask(__name__)
  18. app.config.from_pyfile('config.py')
  19. db = SQLAlchemy(app)
  20. migrate = Migrate(app, db)
  21. manager = Manager(app)
  22. manager.add_command("runserver", Server(host='0.0.0.0', port=8888))
  23. manager.add_command("db", MigrateCommand)
  24. def unpack(ip):
  25. if len(ip) == 4:
  26. return unpack_v4(ip)
  27. elif len(ip) == 16:
  28. return unpack_v6(ip)
  29. def is_valid_ip(ip):
  30. return netaddr.valid_ipv4(ip) or netaddr.valid_ipv6(ip)
  31. def is_forbidden_ip(ip):
  32. # 0.0.0.0/8 is reserved, but for some reason, is_reserved() returns false
  33. return ip.is_link_local() or ip.is_loopback() or ip.is_multicast() or ip.is_reserved() or (ip in IPNetwork('0.0.0.0/8'))
  34. def resolve_name(hostname):
  35. try:
  36. return list({s[4][0] for s in socket.getaddrinfo(hostname, None)})
  37. except socket.gaierror:
  38. return []
  39. @app.template_filter()
  40. def ipaddress_pp(addr):
  41. """Pretty-print an IP address"""
  42. a = IPAddress(addr)
  43. try:
  44. # Handle v4-mapped addresses
  45. return a.ipv4()
  46. except netaddr.AddrConversionError:
  47. return a.ipv6()
  48. @app.template_filter()
  49. def not_dn42(addr):
  50. """Filter the input address if it is part of dn42"""
  51. a = IPAddress(addr)
  52. if a in DN42:
  53. return ""
  54. return a
  55. class Target(db.Model):
  56. """Target IP to ping"""
  57. id = db.Column(db.Integer, primary_key=True)
  58. # Unique ID for accessing the results (privacy reasons)
  59. unique_id = db.Column(db.String)
  60. # IP addresses are encoded as their binary representation
  61. ip = db.Column(db.BINARY(length=16))
  62. # Date at which a user asked for measurements to this target
  63. submitted = db.Column(db.DateTime)
  64. public = db.Column(db.Boolean)
  65. def __init__(self, ip, public=False):
  66. self.unique_id = str(uuid4())
  67. self.ip = IPAddress(ip).packed
  68. self.submitted = datetime.now()
  69. self.public = public
  70. def get_ip(self):
  71. return IPAddress(unpack(self.ip))
  72. def is_v4(self):
  73. return self.get_ip().version == 4
  74. def is_v6(self):
  75. return self.get_ip().version == 6
  76. def __repr__(self):
  77. return '%r' % self.get_ip()
  78. def __str__(self):
  79. return str(self.get_ip())
  80. # Many-to-many table to record which target has been given to which
  81. # participant.
  82. handled_targets = db.Table('handled_targets',
  83. db.Column('target_id', db.Integer, db.ForeignKey('target.id')),
  84. db.Column('participant_id', db.Integer, db.ForeignKey('participant.id'))
  85. )
  86. class Participant(db.Model):
  87. """Participant in the ping network"""
  88. id = db.Column(db.Integer, primary_key=True)
  89. # Used both as identification and password
  90. uuid = db.Column(db.String, unique=True)
  91. # Name of the machine
  92. name = db.Column(db.String)
  93. # Mostly free-form (nick, mail address, ...)
  94. contact = db.Column(db.String)
  95. # Optional
  96. country = db.Column(db.String)
  97. # Free-form (peering technology, DSL or fiber, etc)
  98. comment = db.Column(db.String)
  99. # Whether we accept this participant or not
  100. active = db.Column(db.Boolean)
  101. # Many-to-many relationship
  102. targets = db.relationship('Target',
  103. secondary=handled_targets,
  104. backref=db.backref('participants', lazy='dynamic'),
  105. lazy='dynamic')
  106. def __init__(self, name, contact, country, comment):
  107. self.uuid = str(uuid4())
  108. self.name = name
  109. self.contact = contact
  110. self.country = country
  111. self.comment = comment
  112. self.active = False
  113. def __str__(self):
  114. return "{} ({})".format(self.name, self.contact)
  115. class Result(db.Model):
  116. """Result of a ping measurement"""
  117. id = db.Column(db.Integer, primary_key=True)
  118. target_id = db.Column(db.Integer, db.ForeignKey('target.id'))
  119. target = db.relationship('Target',
  120. backref=db.backref('results', lazy='dynamic'))
  121. participant_id = db.Column(db.Integer, db.ForeignKey('participant.id'))
  122. participant = db.relationship('Participant',
  123. backref=db.backref('results', lazy='dynamic'))
  124. # Date at which the result was reported back to us
  125. date = db.Column(db.DateTime)
  126. # In milliseconds
  127. avgrtt = db.Column(db.Float)
  128. # All these are optional
  129. minrtt = db.Column(db.Float)
  130. maxrtt = db.Column(db.Float)
  131. jitter = db.Column(db.Float)
  132. # Number of ping requests
  133. probes_sent = db.Column(db.Integer)
  134. # Number of successful probes
  135. probes_received = db.Column(db.Integer)
  136. def __init__(self, target_id, participant_uuid, avgrtt, minrtt, maxrtt,
  137. jitter, probes_sent, probes_received):
  138. target = Target.query.get_or_404(int(target_id))
  139. participant = Participant.query.filter_by(uuid=participant_uuid,
  140. active=True).first_or_404()
  141. self.target = target
  142. self.participant = participant
  143. self.date = datetime.now()
  144. self.avgrtt = float(avgrtt)
  145. self.minrtt = float(minrtt) if minrtt is not None else None
  146. self.maxrtt = float(maxrtt) if maxrtt is not None else None
  147. self.jitter = float(jitter) if jitter is not None else None
  148. self.probes_sent = int(probes_sent) if probes_sent is not None else None
  149. self.probes_received = int(probes_received) if probes_received is not None else None
  150. def init_db():
  151. db.create_all()
  152. def get_targets(uuid):
  153. """Returns the queryset of potential targets for the given participant
  154. UUID, that is, targets that have not already been handed out to this
  155. participant.
  156. """
  157. participant = Participant.query.filter_by(uuid=uuid, active=True).first_or_404()
  158. # We want to get all targets that do not have a relationship with the
  159. # given participant. Note that the following lines manipulate SQL
  160. # queries, which are only executed at the very end.
  161. # This gives all targets that have already been sent to the given
  162. # participant.
  163. already_done = Target.query.join(handled_targets).filter_by(participant_id=participant.id).with_entities(Target.id)
  164. # This takes the negation of the previous set.
  165. new_tasks = Target.query.filter(~Target.id.in_(already_done))
  166. max_age = app.config.get('MAX_AGE', 0)
  167. if max_age == 0:
  168. return new_tasks
  169. else:
  170. limit = datetime.now() - timedelta(seconds=max_age)
  171. return new_tasks.filter(Target.submitted >= limit)
  172. @app.route('/')
  173. def homepage():
  174. public_targets = Target.query.filter_by(public=True).order_by("submitted DESC").all()
  175. return render_template('home.html', targets=public_targets)
  176. @app.route('/about')
  177. def about():
  178. return render_template('about.html')
  179. @app.route('/participate')
  180. def participate():
  181. return render_template('participate.html')
  182. @app.route('/privacy')
  183. def privacy():
  184. return render_template('privacy.html')
  185. @app.route('/dev')
  186. def dev():
  187. return render_template('dev.html')
  188. @app.route('/static/<path:path>')
  189. def static_proxy(path):
  190. # send_static_file will guess the correct MIME type
  191. return app.send_static_file(path)
  192. @app.route('/robots.txt')
  193. def robots():
  194. return app.send_static_file("robots.txt")
  195. @app.route('/submit', methods=['POST'])
  196. def submit_job():
  197. if 'target' in request.form:
  198. target = request.form['target'].strip()
  199. public = bool(request.form.get('public'))
  200. if is_valid_ip(target):
  201. # Explicit IP
  202. targets = [Target(target, public)]
  203. else:
  204. # DNS name, might give multiple IP
  205. ip_addresses = resolve_name(target)
  206. try:
  207. # We might still fail to recognise some addresses (e.g. "ff02::1%eth0")
  208. targets = [Target(ip, public) for ip in ip_addresses]
  209. except netaddr.core.AddrFormatError:
  210. return render_template('submit_error.html', target=request.form['target'])
  211. if targets == []:
  212. return render_template('submit_error.html', target=request.form['target'])
  213. # Check for forbidden targets
  214. for target in targets:
  215. if is_forbidden_ip(target.get_ip()):
  216. return render_template('submit_error_forbidden.html', ip=target.get_ip())
  217. for t in targets:
  218. db.session.add(t)
  219. db.session.commit()
  220. return render_template('submit.html', targets=targets)
  221. else:
  222. return "Invalid arguments"
  223. @app.route('/create/participant', methods=['POST'])
  224. def create_participant():
  225. fields = ['name', 'contact', 'country', 'comment']
  226. if set(fields).issubset(request.form) and request.form['name']:
  227. participant = Participant(*(request.form[f] for f in fields))
  228. db.session.add(participant)
  229. db.session.commit()
  230. return render_template('participant.html', participant=participant,
  231. uuid=participant.uuid,
  232. peerfinder=app.config["PEERFINDER_DN42"])
  233. else:
  234. return "Invalid arguments"
  235. @app.route('/script.sh')
  236. def get_script():
  237. r = render_template('run.sh', peerfinder=app.config["PEERFINDER_DN42"])
  238. return r, 200, {'Content-Type': 'text/x-shellscript'}
  239. @app.route('/cron.sh')
  240. def get_cron():
  241. r = render_template('cron.sh', peerfinder=app.config["PEERFINDER_DN42"])
  242. return r, 200, {'Content-Type': 'text/x-shellscript'}
  243. @app.route('/target/<uuid>/<family>')
  244. @app.route('/target/<uuid>')
  245. def get_next_target(uuid, family="any"):
  246. """"Returns the next target to ping for the given participant and family
  247. ("any", "ipv4", or "ipv6")"""
  248. if family not in ("ipv4", "ipv6", "any"):
  249. return "Invalid family, should be 'any', 'ipv4' or 'ipv6'\n"
  250. if family == "any":
  251. targets = get_targets(uuid).all()
  252. else:
  253. predicate = lambda t: t.is_v4() if family == "ipv4" else t.is_v6()
  254. targets = [t for t in get_targets(uuid).all() if predicate(t)]
  255. if targets:
  256. return "{} {}".format(targets[0].id, targets[0])
  257. return ""
  258. @app.route('/result/report/<uuid>', methods=['POST'])
  259. def report_result(uuid):
  260. if {'avgrtt', 'target'}.issubset(request.form):
  261. target_id = request.form['target']
  262. avgrtt = request.form['avgrtt']
  263. optional_args = [request.form.get(f) for f in
  264. ('minrtt', 'maxrtt', 'jitter', 'probes_sent',
  265. 'probes_received')]
  266. result = Result(target_id, uuid, avgrtt, *optional_args)
  267. db.session.add(result)
  268. # Record that the participant has returned a result
  269. participant = result.participant
  270. participant.targets.append(result.target)
  271. db.session.commit()
  272. return "OK\n"
  273. else:
  274. return "Invalid arguments\n"
  275. @app.route('/result/show/<target_uniqueid>')
  276. def show_results(target_uniqueid):
  277. target = Target.query.filter_by(unique_id=target_uniqueid).first_or_404()
  278. results = target.results.order_by('avgrtt').all()
  279. return render_template('results.html', target=target, results=results)
  280. if __name__ == '__main__':
  281. if not app.debug:
  282. import logging
  283. from logging.handlers import SMTPHandler
  284. smtp_server = app.config.get('SMTP_SERVER', "127.0.0.1")
  285. from_address = app.config.get('FROM_ADDRESS', "peerfinder@example.com")
  286. admins = app.config.get('ADMINS', [])
  287. if admins:
  288. mail_handler = SMTPHandler(smtp_server,
  289. from_address,
  290. admins, 'Peerfinder error')
  291. mail_handler.setLevel(logging.ERROR)
  292. app.logger.addHandler(mail_handler)
  293. init_db()
  294. manager.run()