mcfi.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. #!/usr/bin/env python3
  2. __authors__ = [ 'zPlus <zplus@peers.community>' ]
  3. __version__ = '0.0.0'
  4. __license__ = 'AGPL-3.0-or-later'
  5. ###############################################################################
  6. import bottle, json, requests
  7. import configparser
  8. from bottle import abort, get, post, request, response, route
  9. import datetime
  10. from string import Template
  11. from Crypto.PublicKey import RSA
  12. #from requests_http_signature import HTTPSignatureAuth, HTTPSignatureHeaderAuth
  13. import base64, hashlib
  14. import httpsig
  15. from httpsig.requests_auth import HTTPSignatureAuth
  16. from httpsig.verify import HeaderVerifier
  17. from time import mktime
  18. from wsgiref.handlers import format_date_time
  19. from email.utils import formatdate
  20. from urllib.parse import urlparse
  21. # Load settings
  22. import settings
  23. # Load actors (from plain text file)
  24. actors = configparser.ConfigParser()
  25. actors.read('/var/lib/gitolite3/.gitolite/forgefed/actors')
  26. # Fire up database
  27. import database
  28. # This is used to export the bottle object for the WSGI server
  29. application = bottle.app()
  30. # HTTP headers to use when making requests
  31. """
  32. request_headers={ 'Accept': 'application/activity+json; charset=utf-8',
  33. 'Content-Type': 'application/activity+json; charset=utf-8'}
  34. """
  35. request_headers={ 'Accept': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  36. 'Content-Type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'}
  37. jsonld_context = [
  38. 'https://www.w3.org/ns/activitystreams',
  39. 'https://w3id.org/security/v1',
  40. 'https://forgefed.peers.community/ns' ]
  41. class McfiSignatureAuth(HTTPSignatureAuth):
  42. """
  43. Extend HTTPSignatureAuth to replace HeaderSigner initialization, in
  44. order to include sign_header.
  45. See also: https://requests.kennethreitz.org//en/master/user/authentication/#new-forms-of-authentication
  46. """
  47. def __init__(self, key_id='', secret='', algorithm=None,
  48. headers=None, sign_header='Authorization'):
  49. # Init parent class
  50. super().__init__(key_id, secret, algorithm, headers)
  51. self.required_headers = headers or []
  52. # Replace HeaderSigner initialization
  53. self.header_signer = httpsig.HeaderSigner(
  54. key_id=key_id, secret=secret, algorithm=algorithm,
  55. headers=headers, sign_header=sign_header)
  56. def __call__(self, r):
  57. # Python Requests does not add a Date header by default?
  58. # Date format: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date
  59. r.headers['Date'] = formatdate(
  60. timeval=mktime(datetime.datetime.now().timetuple()),
  61. localtime=False,
  62. usegmt=True)
  63. # Add a "Digest:" header
  64. if 'digest' in self.required_headers and 'Digest' not in r.headers and r.body is not None:
  65. digest = hashlib.sha256(r.body).digest()
  66. r.headers['Digest'] = 'SHA-256=' + base64.b64encode(digest).decode()
  67. return super().__call__(r)
  68. def _url(name, *anons, **parameters):
  69. """
  70. Return a URL string corresponding to a Bottle route, given its arguments.
  71. We use this function because it's shorter than calling application.router.build.
  72. """
  73. return application.router.build(name, *anons, **parameters)
  74. def _get_base_url():
  75. return (request.headers['X-Forwarded-Proto']
  76. if 'X-Forwarded-Proto' in request.headers
  77. else request.urlparts[0]) + '://' + request.headers['Host']
  78. def _get_private_key(actor):
  79. # Get the local actor's private key used to sign the Activity
  80. key = database.get_private_key(actor)
  81. # Key doesn't exist?
  82. if not key:
  83. # Create one
  84. key = RSA.generate(2048).exportKey('PEM')
  85. # Save copy in the database
  86. database.set_private_key(actor, key)
  87. return key
  88. def _get_http_auth(actor):
  89. """
  90. return HTTPSignatureAuth(
  91. headers=[ '(request-target)', 'host', 'date', 'digest' ],
  92. algorithm='rsa-sha256',
  93. key=_get_private_key(actor),
  94. key_id=_get_base_url() + '/' + actor + '#publicKey')
  95. """
  96. return McfiSignatureAuth(
  97. key_id=_get_base_url() + '/' + actor + '#publicKey',
  98. secret=_get_private_key(actor),
  99. algorithm='rsa-sha256',
  100. headers=[ '(request-target)', 'host', 'date', 'digest' ],
  101. sign_header='Signature')
  102. def _send(actor, activity):
  103. """
  104. Send an activity to a actor's INBOX.
  105. """
  106. if 'actor' not in activity:
  107. print('This activity does not have an actor.')
  108. print(activity)
  109. return None
  110. if 'to' not in activity:
  111. if activity['type'] == 'Follow':
  112. activity['to'] = activity['object']
  113. else:
  114. abort(400, 'Error: No recipient defined.')
  115. # Normalize "to:" to a list
  116. if isinstance(activity['to'], str):
  117. activity['to'] = [ activity['to'] ]
  118. if isinstance(activity['to'], dict):
  119. print('Dict "to:" not implemented.')
  120. response.status = 500
  121. return
  122. # Find the INBOX of all recipients
  123. recipients = list(activity['to'])
  124. inboxes = []
  125. if 'cc' in activity:
  126. recipients.extend(activity['cc'])
  127. # Remove properties according to spec.
  128. # https://www.w3.org/TR/activitypub/#client-to-server-interactions
  129. if 'bto' in activity:
  130. recipients.extend(activity['bto'])
  131. del activity['bto']
  132. if 'bcc' in activity:
  133. recipients.extend(activity['bcc'])
  134. del activity['bcc']
  135. while len(recipients) > 0:
  136. recipient = recipients[0]
  137. del recipients[0]
  138. # Retrieve remote actor
  139. try:
  140. remote_actor = requests.get(recipient, headers=request_headers)
  141. assert remote_actor.status_code == 200
  142. remote_actor = remote_actor.json()
  143. except Exception as e:
  144. print('Actor error: ' + recipient)
  145. continue
  146. if remote_actor['type'] == 'Collection':
  147. recipients.extend(remote_actor['items'])
  148. elif remote_actor['type'] == 'OrderedCollection':
  149. recipients.extend(remote_actor['orderedItems'])
  150. else:
  151. if 'inbox' in remote_actor:
  152. inboxes.append(remote_actor['inbox'])
  153. else:
  154. print('Actor does not have an inbox: ' + remote_actor)
  155. # Remove duplicate inboxes
  156. inboxes = list(set(inboxes))
  157. # Deliver messages
  158. for inbox in inboxes:
  159. # Now send the activity to the actor's INBOX
  160. #result = requests.post(inbox,
  161. # headers=request_headers,
  162. # data=json.dumps(activity).encode(),
  163. # auth=_get_http_auth(actor))
  164. req = requests.Request('POST',
  165. inbox,
  166. headers=request_headers,
  167. data=json.dumps(activity).encode(),
  168. auth=_get_http_auth(actor))
  169. prepared = req.prepare()
  170. def pretty_print_POST(req):
  171. with open('/opt/mcfi/TEST', 'w') as f:
  172. f.write('{}\n{}\r\n{}\r\n\r\n{}\n{}\n'.format(
  173. '-----------START-----------',
  174. req.method + ' ' + req.url,
  175. '\r\n'.join('{}: {}'.format(k, v) for k, v in req.headers.items()),
  176. req.body,
  177. '------------END------------',
  178. ))
  179. pretty_print_POST(prepared)
  180. requests.Session().send(prepared)
  181. ################################################################################
  182. # Controllers for federation.
  183. # These should respond to "Accept: application/activity+json" requests.
  184. ################################################################################
  185. @get('/inbox/<actor:path>')
  186. def read_inbox(actor):
  187. """
  188. Read an actor's INBOX.
  189. """
  190. if actor not in actors:
  191. abort(401)
  192. if 'Authorization' not in request.headers:
  193. # Should the public be able to read actors' INBOX?
  194. abort(403) # Not implemented
  195. if request.get_header('Authorization') != 'Bearer {}'.format(actors[actor]['authorization_token']):
  196. abort(401)
  197. messages = database.get_inbox_messages(actor)
  198. return {
  199. '@context': jsonld_context,
  200. 'type': 'OrderedCollection',
  201. 'totalItems': len(messages),
  202. 'orderedItems': messages
  203. }
  204. @post('/inbox/<actor:path>')
  205. def write_inbox(actor):
  206. """
  207. Somebody wants to write to a local actor's INBOX.
  208. TODO Verify Activity signatures instead of accepting everything.
  209. """
  210. """
  211. print("HEADER:")
  212. for h in request.headers:
  213. print(h + ':[' + request.headers[h] + ']')
  214. print('\n')
  215. v = HeaderVerifier(
  216. request.headers,
  217. RSA.import_key(_get_private_key("zplus")).publickey().exportKey('PEM'),
  218. required_headers=[ '(request-target)', 'host', 'date' ],
  219. method='post', path='/inbox/'+actor,
  220. sign_header='Signature')
  221. print(">> ", v.verify())
  222. return
  223. """
  224. if actor not in actors:
  225. abort(401, 'Not a valid actor.')
  226. if 'Authorization' in request.headers:
  227. # Should a user write to his own inbox?
  228. abort(403)
  229. # We have received a messaged from a remote actor to our INBOX.
  230. # Parse the JSON payload
  231. activity = json.loads(request.body.getvalue().decode('UTF-8'))
  232. if not activity:
  233. abort(400)
  234. # Store a copy of the activity
  235. database.store_inbox(actor, activity)
  236. # Make sure post_activity is an absolute URLs
  237. if not bool(urlparse(settings.post_activity).netloc):
  238. settings.post_activity = _get_base_url() + settings.post_activity
  239. # Relay activity to the forge (avoid forge polling)
  240. answer = requests.post(settings.post_activity + '/' + actor, json=activity)
  241. if answer.status_code != 200:
  242. return abort(500, 'Could not contact forge.')
  243. answer = answer.json()
  244. # No Activity was returned
  245. if 'type' not in answer:
  246. return
  247. # Automatically add properties to the given Activity (which contains partial data)
  248. answer['@context'] = jsonld_context,
  249. answer['actor'] = _get_base_url() + '/' + actor
  250. if answer['type'] == 'Accept':
  251. if answer['object']['type'] == 'Follow':
  252. database.add_follower(actor, activity['actor'])
  253. # Send Activity
  254. _send(actor, answer)
  255. @get('/outbox/<actor:path>')
  256. def read_outbox(actor):
  257. """
  258. Read an actor's OUTBOX.
  259. """
  260. if actor not in actors:
  261. abort(400)
  262. if 'Authorization' in request.headers:
  263. abort(403) # Not implemented
  264. else:
  265. abort(403) # Not implemented
  266. @post('/outbox/<actor:path>')
  267. def write_outbox(actor):
  268. """
  269. A local actor is writing to his OUTBOX.
  270. """
  271. if actor not in actors:
  272. abort(400, 'Actor doesn\'t exist.')
  273. # Must be authenticated to write OUTBOX
  274. if 'Authorization' not in request.headers:
  275. abort(401, 'Missing authorization token.')
  276. # Validate authentication token
  277. if request.get_header('Authorization') != 'Bearer ' + actors[actor]['authorization_token']:
  278. abort(401, 'Bad authorization token.')
  279. # The Activity payload, a JSON object that was sent with the request
  280. message = json.loads(request.body.getvalue().decode('UTF-8'))
  281. if not message:
  282. abort(400, 'Bad message body.')
  283. # Server must ignore any given ID and generate a new one, as per spec
  284. if 'id' in message:
  285. del message['id']
  286. # Use an hash of the Activity as ID
  287. message['id'] = _get_base_url() + _url('activity', id=hashlib.sha512(json.dumps(message, sort_keys=True).encode()).hexdigest())
  288. # Add publishing date
  289. message['published'] = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()
  290. # Keep a copy of the message in the database
  291. database.store_outbox(actor, message)
  292. # Send message
  293. # TODO make this async
  294. _send(actor, message)
  295. if message['type'] == 'Follow':
  296. # Add remote actor to local actor's list of "following"
  297. database.add_following(actor, message['object'])
  298. response.status = 201 # Created
  299. response.set_header('Location', message['id'])
  300. @get('/followers/<actor:path>', name='followers')
  301. def read_followers(actor):
  302. """
  303. Read an actor's collection of followers.
  304. """
  305. followers = database.get_followers(actor)
  306. return {
  307. '@context': jsonld_context,
  308. 'type': 'Collection',
  309. 'totalItems': len(followers),
  310. 'items': followers
  311. }
  312. @get('/following/<actor:path>', name='following')
  313. def read_following(actor):
  314. """
  315. Read an actor's collection of following.
  316. """
  317. @get('/activity/<id>', name='activity')
  318. def get_activity(id):
  319. """
  320. Retrieve an Activity.
  321. """
  322. response.set_header('Accept', request_headers['Accept'])
  323. response.set_header('Content-Type', request_headers['Content-Type'])
  324. return json.dumps(database.get_activity(_get_base_url() + '/activity/' + id))
  325. ################################################################################
  326. # Controllers for a demo forge
  327. ################################################################################
  328. @get('/.well-known/host-meta')
  329. def webfinger_host_meta():
  330. response.set_header('Content-Type', 'application/xrd+xml; charset=utf-8')
  331. return Template("""
  332. <?xml version="1.0" encoding="UTF-8"?>
  333. <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  334. <Link rel="lrdd" type="application/xrd+xml" template="$baseurl/.well-known/webfinger?resource={uri}"/>
  335. </XRD>
  336. """).substitute({ 'baseurl': _get_base_url() })
  337. @get('/.well-known/webfinger')
  338. def webfinger_resource():
  339. if 'resource' not in request.GET:
  340. return abort(404)
  341. resource = request.GET.getunicode('resource')
  342. # Remove prefix "acct:" and split hostname
  343. actor, host = resource[5:].rsplit('@', 1)
  344. if host != request.headers['Host'] or actor not in actors:
  345. abort(404)
  346. response.set_header('Content-Type', 'application/jrd+json; charset=utf-8')
  347. return Template("""
  348. {
  349. "subject": "acct:$actor@$host",
  350. "aliases": [
  351. "$baseurl/$actor"
  352. ],
  353. "links": [
  354. {
  355. "rel": "http://webfinger.net/rel/profile-page",
  356. "type": "text/html",
  357. "href": "$baseurl/$actor"
  358. },
  359. {
  360. "rel": "self",
  361. "type": "application/activity+json",
  362. "href": "$baseurl/$actor"
  363. }
  364. ]
  365. }
  366. """).substitute({
  367. 'actor': actor,
  368. 'host': host,
  369. 'baseurl': _get_base_url()
  370. })
  371. @get('/federation/actor/<actor:path>')
  372. def get_actor(actor):
  373. if actor not in actors:
  374. abort(404)
  375. base_url = _get_base_url()
  376. if actors[actor]['type'] == 'Person':
  377. return {
  378. 'type': 'Person',
  379. 'name': actor,
  380. }
  381. if actors[actor]['type'] == 'Repository':
  382. return {
  383. 'type': 'Repository',
  384. 'name': actor,
  385. 'clone_url': '{}/{}.git'.format(base_url, actor),
  386. }
  387. @post('/federation/activity/<actor:path>')
  388. def post_activity(actor):
  389. if actor not in actors:
  390. abort(404)
  391. # Ignore users. This forge does not automatically respond to any incoming
  392. # Activity for a Person. It only responds automatically for Repository actors.
  393. # Use CLIFF instead.
  394. if actors[actor]['type'] == 'Person':
  395. return {}
  396. # Bottle: If the Content-Type header is application/json or
  397. # application/json-rpc, the "json" property holds the parsed content of the
  398. # request body.
  399. activity = request.json
  400. # Automatically accept Follow requests
  401. if activity['type'] == 'Follow':
  402. return {
  403. 'type': 'Accept',
  404. 'to': _get_base_url() + _url('followers', actor=actor),
  405. 'object': activity }
  406. if activity['type'] == 'Offer':
  407. # Automatically accept new tickets
  408. if activity['object']['type'] == 'Ticket':
  409. ticket_number = database.add_ticket(actor)
  410. # Assign a new Ticket number
  411. activity['object']['number'] = ticket_number
  412. # Assign a new Ticket ID
  413. activity['object']['id'] = '{}/{}/ticket/{}'.format(_get_base_url(),
  414. actor, ticket_number)
  415. return {
  416. 'type': 'Accept',
  417. 'to': _get_base_url() + _url('followers', actor=actor),
  418. 'object': activity }
  419. if activity['type'] == 'Create':
  420. if activity['object']['type'] == 'Note':
  421. # Return the same Activity. Will be sent to followers
  422. activity['to'] = _get_base_url() + _url('followers', actor=actor)
  423. return activity
  424. @post('/git-hooks-post-receive')
  425. def post_receive_hook():
  426. """
  427. This is called by the post-receive hook after every push. The JSON request
  428. body contains a list of the pushed commits.
  429. """
  430. # Allow requests from this machine only.
  431. if request.environ.get('REMOTE_ADDR') != '127.0.0.1':
  432. abort(403, 'Who are you?')
  433. # Parse "git push" data
  434. push = request.json
  435. commits_object = []
  436. for commit in push['commits']:
  437. commits_object.append({
  438. '@context': jsonld_context,
  439. 'type': 'Commit',
  440. 'id': 'todo: assign an id here',
  441. 'attributedTo': commit['author']['name'],
  442. 'committedBy': commit['committer']['name'],
  443. 'hash': commit['id'],
  444. 'description': {
  445. 'mediaType': 'text/plain',
  446. 'content': commit['message']
  447. },
  448. 'created': commit['author']['time'],
  449. 'committed': commit['commit_time']
  450. })
  451. _send(
  452. push['REMOTE_USER'],
  453. {
  454. '@context': jsonld_context,
  455. 'type': 'Push',
  456. 'actor': '{}/{}'.format(_get_base_url(), push['repository_name']),
  457. 'to': _get_base_url() + _url('followers', actor=push['repository_name']),
  458. 'object': commits_object
  459. }
  460. )
  461. @get('/<actor:path>')
  462. def actor_document(actor):
  463. """
  464. Return profile of an actor.
  465. """
  466. if actor not in actors:
  467. abort(404)
  468. base_url = _get_base_url()
  469. # Make sure get_actor is an absolute URLs
  470. if not bool(urlparse(settings.get_actor).netloc):
  471. settings.get_actor = _get_base_url() + settings.get_actor
  472. # Retrieve the actor from the forge
  473. actor_document = requests.get(settings.get_actor + '/' + actor)
  474. if actor_document.status_code != 200:
  475. abort(404)
  476. # Parse forge data
  477. actor_document = actor_document.json()
  478. # Add federation properties to user document
  479. actor_document = {
  480. **actor_document,
  481. '@context': jsonld_context,
  482. 'id': '{}/{}'.format(base_url, actor),
  483. 'inbox': '{}/inbox/{}'.format(base_url, actor),
  484. 'outbox': '{}/outbox/{}'.format(base_url, actor),
  485. 'followers': '{}/followers/{}'.format(base_url, actor),
  486. 'following': '{}/following/{}'.format(base_url, actor),
  487. 'publicKey' : {
  488. 'id': '{}/{}#publicKey'.format(base_url, actor),
  489. 'owner': '{}/{}'.format(base_url, actor),
  490. 'publicKeyPem': RSA.import_key(_get_private_key(actor)).publickey().exportKey('PEM').decode(),
  491. 'https://forgefed.angeley.es/ns#isShared': False
  492. }}
  493. # This is text/html request
  494. if 'application/activity+json' not in request.headers.get('Accept', '*/*') and \
  495. 'application/activity+json' not in request.headers.get('Content-Type', '*/*') and \
  496. 'application/ld+json' not in request.headers.get('Accept', '*/*') and \
  497. 'application/ld+json' not in request.headers.get('Content-Type', '*/*'):
  498. if actor_document['type'] == 'Person':
  499. return Template("""
  500. <html>
  501. <head>
  502. </head>
  503. <body>
  504. <p><strong>User:</strong> $user</p>
  505. <p><strong>Federation URL:</strong> $fed</p>
  506. </body>
  507. </html>
  508. """).substitute({
  509. 'user': actor_document['name'],
  510. 'fed': actor_document['id'] })
  511. if actor_document['type'] == 'Repository':
  512. return Template("""
  513. <html>
  514. <head>
  515. </head>
  516. <body>
  517. <p><strong>Repository:</strong> $name</p>
  518. <p><strong>Clone URL:</strong> git clone $url</p>
  519. <p><strong>Federation URL:</strong> $fed</p>
  520. </body>
  521. </html>
  522. """).substitute({
  523. 'name': actor_document['name'],
  524. 'url': actor_document['clone_url'],
  525. 'fed': actor_document['id'] })
  526. return
  527. # ActivityPub clients accept 'Accept: application/ld+json'
  528. response.set_header('Accept', request_headers['Accept'])
  529. response.set_header('Content-Type', request_headers['Content-Type'])
  530. return json.dumps(actor_document)
  531. @route('/')
  532. def index():
  533. return '<p><em>This is a demo forge.</em></p>' + \
  534. 'MCFI version ' + __version__