├── .gitignore ├── LICENSE ├── README.md ├── mailer.py ├── mock_data.json ├── requirements.txt ├── server.py ├── tor_weather_discontinued_email.txt ├── torweather.py └── verifier.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | webapp/local_settings.py 60 | 61 | venv/ 62 | 63 | torweather.db 64 | TorWeather.db 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 thingless 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tor Weather 2 | 3 | Tor Weather will inform the user listed in [torrc's](https://support.torproject.org/tbb/tbb-editing-torrc/) `ContactInfo` field via email in the event of downtime lasting longer than 48 hours. 4 | 5 | ## Tor Weather's History 6 | 7 | The original Tor Weather was decommissioned by the Tor project. This replacement is now maintained independently and its source code can be found in this GitHub repo. More info about about the original Tor Weather can be found [here](https://lists.torproject.org/pipermail/tor-relays/2016-June/009424.html). 8 | 9 | Unlike the original Tor Weather, this project only sends emails for down time to the nodes owner. It does not send emails about t-shirts or support any configuration (beyond "unsubscribe"). Reducing the scope of Tor Weather makes it easier to maintain. 10 | 11 | ## Running the Source Code 12 | 13 | ```bash 14 | # Create the virtualenv 15 | virtualenv venv 16 | source venv/bin/activate 17 | pip install -r requirements.txt 18 | 19 | # Common configuration 20 | DIR=$(dirname $0) 21 | export MAILGUN_KEY=(your-mailgun-creds) 22 | export PROD=1 23 | export UNSUB_KEY=(random-string-for-key) 24 | export PYTHONPATH=$DIR 25 | export PORT=8888 26 | 27 | # To run the unsubscribe server 28 | venv/bin/python server.py 29 | 30 | # To run the cron job (run every hour) 31 | venv/bin/python torweather.py 32 | ``` 33 | -------------------------------------------------------------------------------- /mailer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import logging 4 | from tornado import template 5 | import verifier 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | DOMAIN_NAME = 'torweather.org' 10 | API_KEY = os.environ.get('MAILGUN_KEY') 11 | 12 | EMAIL_DOWN_SUBJECT = '[Tor Weather] Node Down!' 13 | EMAIL_DOWN_BODY = ''' 14 | It appears that the Tor node {{nickname}} (fingerprint: {{fingerprint}}) has been uncontactable through the Tor network for at least 48 hours. You may wish to look at it to see why. 15 | 16 | You can find more information about the Tor node at: 17 | https://metrics.torproject.org/rs.html#details/{{fingerprint}} 18 | 19 | You can unsubscribe from these reports at any time by visiting the following url: 20 | https://www.torweather.org/unsubscribe?hmac={{hmac}}&fingerprint={{fingerprint}} 21 | 22 | The original Tor Weather was decommissioned by the Tor project and this replacement is now maintained independently. You can learn more here: 23 | https://github.com/thingless/torweather/blob/master/README.md 24 | ''' 25 | 26 | email_down_template = template.Template(EMAIL_DOWN_BODY) 27 | 28 | def alert_down(node): 29 | parms = dict(node) 30 | parms['hmac'] = verifier.generate(parms['fingerprint']) 31 | logger.info('Emailing node_down %r', parms) 32 | if os.environ.get('PROD'): 33 | assert API_KEY 34 | try: 35 | return requests.post("https://api.mailgun.net/v3/{}/messages".format(DOMAIN_NAME), 36 | auth=("api", API_KEY), 37 | data={ 38 | "from": "Tor Weather ".format(DOMAIN_NAME), 39 | "to": [parms['email']], 40 | "subject": EMAIL_DOWN_SUBJECT, 41 | "text": email_down_template.generate(**parms) 42 | }) 43 | except Exception as e: 44 | logger.exception("Failed to send email :(") 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports-abc==0.4 2 | certifi==2016.8.8 3 | python-dateutil==2.5.3 4 | requests==2.11.0 5 | singledispatch==3.4.0.3 6 | six==1.10.0 7 | tornado==4.4.1 8 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import tornado.ioloop 4 | import tornado.template 5 | import tornado.web 6 | 7 | from verifier import verify 8 | 9 | def wrap_render(template, *args, **kwargs): 10 | if isinstance(template, tornado.template.Template): 11 | out = template.generate(*args, **kwargs) 12 | else: 13 | out = template 14 | 15 | return """Tor 17 | Weather 20 | """ + out + "" 21 | 22 | UNSUB_GET_TEMPLATE = tornado.template.Template(""" 23 |

TorWeather Unsubscribe

24 |

Sorry to see you go. :(

25 |

(To change the frequency of emails instead, you can go 26 | here.)

27 |
28 | 29 | 30 | 31 |
32 |

Go home?

33 | """) 34 | 35 | SUB_GET_TEMPLATE = tornado.template.Template(""" 36 |

TorWeather Email Frequency

37 |

Update your notification sensitivity.

38 |
39 | 46 | 47 | 48 | 49 |
50 |

Go home?

51 | """) 52 | 53 | class UnsubscribeHandler(tornado.web.RequestHandler): 54 | def get(self): 55 | fingerprint = self.get_argument('fingerprint') 56 | hmac = self.get_argument('hmac') 57 | 58 | if not verify(hmac, fingerprint): 59 | self.set_status(403) 60 | self.write('hmac invalid :(') 61 | return 62 | 63 | self.write(wrap_render(UNSUB_GET_TEMPLATE, fingerprint=fingerprint, hmac=hmac)) 64 | 65 | def post(self): 66 | fingerprint = self.get_argument('fingerprint') 67 | hmac = self.get_argument('hmac') 68 | 69 | if not verify(hmac, fingerprint): 70 | self.set_status(403) 71 | self.write('hmac invalid :(') 72 | return 73 | 74 | conn = sqlite3.connect('TorWeather.db') 75 | with conn: 76 | conn.execute("INSERT OR REPLACE INTO unsubscribe (fingerprint) VALUES (:fingerprint);", 77 | {'fingerprint': fingerprint}) 78 | 79 | self.write(wrap_render("Successfully unsubscribed.")) 80 | 81 | class SubscribeHandler(tornado.web.RequestHandler): 82 | def get(self): 83 | fingerprint = self.get_argument('fingerprint') 84 | hmac = self.get_argument('hmac') 85 | 86 | if not verify(hmac, fingerprint): 87 | self.set_status(403) 88 | self.write('hmac invalid :(') 89 | return 90 | 91 | self.write(wrap_render(SUB_GET_TEMPLATE, fingerprint=fingerprint, hmac=hmac)) 92 | 93 | def post(self): 94 | fingerprint = self.get_argument('fingerprint') 95 | frequency = self.get_argument('frequency') 96 | parsedfreq = 60 * 60 * 24 * 2 97 | hmac = self.get_argument('hmac') 98 | 99 | if not verify(hmac, fingerprint): 100 | self.set_status(403) 101 | self.write('hmac invalid :(') 102 | return 103 | 104 | try: 105 | parsedfreq = int(frequency) 106 | if parsedfreq < 1: 107 | self.set_status(403) 108 | self.write('frequency invalid :(') 109 | return 110 | except ValueError: 111 | self.set_status(403) 112 | self.write('frequency invalid :(') 113 | return 114 | 115 | conn = sqlite3.connect('TorWeather.db') 116 | with conn: 117 | conn.execute("INSERT OR REPLACE INTO subscribe (fingerprint, frequency) VALUES (:fingerprint, :frequency);", 118 | {'fingerprint': fingerprint, 'frequency': parsedfreq}) 119 | 120 | self.write(wrap_render("Successfully unsubscribed.")) 121 | 122 | 123 | class RootHandler(tornado.web.RequestHandler): 124 | def get(self): 125 | self.redirect('https://github.com/thingless/torweather') 126 | 127 | if __name__ == "__main__": 128 | app = tornado.web.Application([ 129 | (r"/unsubscribe", UnsubscribeHandler), 130 | (r"/subscribe", SubscribeHandler), 131 | (r"/", RootHandler), 132 | ]) 133 | port = os.environ.get('PORT', 8080) 134 | print "listening on 127.0.0.1:{}".format(port) 135 | app.listen(port) 136 | tornado.ioloop.IOLoop.current().start() 137 | -------------------------------------------------------------------------------- /tor_weather_discontinued_email.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNED MESSAGE----- 2 | Hash: SHA1 3 | 4 | Dear relay operators, 5 | 6 | I learned today that Tor Weather is already offline since May 24 due 7 | to problems with our hosting company. 8 | 9 | We briefly thought about recreating it from backups, but it seems that 10 | we'd rather spend that effort on other things. 11 | 12 | Again, sorry for any inconvenience. 13 | 14 | All the best, 15 | Karsten 16 | 17 | 18 | On 04/04/16 16:48, Karsten Loesing wrote: 19 | > Dear relay operators, 20 | > 21 | > as of April 4, 2016, Tor Weather has been discontinued. 22 | > 23 | > Tor Weather [0] provided an email notification service to any user 24 | > who wanted to monitor the status of a Tor node. Upon subscribing, 25 | > they could specify what types of alerts they would like to receive. 26 | > The main purpose of Tor Weather was to notify node operators via 27 | > email if their node was down for longer than a specified period, 28 | > but other notification types were available, including one where 29 | > operators would be informed when their node was around long enough 30 | > to qualify for a t-shirt. 31 | > 32 | > The main reason for discontinuing Tor Weather is the fact that 33 | > software requires maintenance, and Tor Weather is no exception. 34 | > Tor Weather was promising t-shirts for relays that have not been 35 | > around long enough or that provided too little bandwidth to be 36 | > useful to the network, and it was almost impossible to deny a 37 | > t-shirt after Tor Weather has promised it. Apart from that, Tor 38 | > Weather was likely not offering t-shirts to people who have long 39 | > earned it, thereby confusing them. An unreliable notification 40 | > system is worse than not having a system at all. Relay operators 41 | > shouldn't rely on Tor Weather to notify them when their relay 42 | > fails. They should rather set up their own system instead. 43 | > 44 | > We have tried to find a new maintainer for Tor Weather for years, 45 | > but without success. We started rewriting Tor Weather [1] using 46 | > Onionoo [2] as data back-end in 2014, and even though that project 47 | > didn't produce working code, somebody could pick up this efforts 48 | > and finish the rewrite. The Roster developers said that they're 49 | > planning to include an email notification function in Roster [3]. 50 | > And we developed a simple Python script that provides information 51 | > about a relay operator's eligibility for acquiring a t-shirt [4]. 52 | > None of these alternatives is a full replacement of Weather, 53 | > though. 54 | > 55 | > We encourage you, the community of Tor relay operators, to step up 56 | > to start your own notification systems and to share designs and 57 | > code. Tor Weather is still a good idea, it just needs somebody to 58 | > implement it. 59 | > 60 | > Tor Weather is discontinued in two steps. For now, new 61 | > subscriptions are disabled, new welcome messages are not sent out 62 | > anymore, and existing subscriptions continue working until June 30, 63 | > 2016. From July 1, 2016 on, Tor Weather will not be sending out 64 | > any emails. 65 | > 66 | > Sorry for any inconvenience caused by this. 67 | > 68 | > All the best, Karsten 69 | > 70 | > 71 | > [0] https://weather.torproject.org/ 72 | > 73 | > [1] 74 | > https://trac.torproject.org/projects/tor/wiki/doc/weather-in-2014 75 | > 76 | > [2] https://onionoo.torproject.org/ 77 | > 78 | > [3] http://www.tor-roster.org/ 79 | > 80 | > [4] 81 | > https://gitweb.torproject.org/metrics-tasks.git/tree/task-9889/tshirt.py 82 | > 83 | > 84 | > 85 | 86 | -----BEGIN PGP SIGNATURE----- 87 | Comment: GPGTools - http://gpgtools.org 88 | 89 | iQEcBAEBAgAGBQJXUEjoAAoJEC3ESO/4X7XBd/4IALwN5pOft2AleZNM2JVEpIcE 90 | lG+NaGWp+SfbAQ1Y94UEC69Z417/OWLcRk2eBpxEUia8PBschqiJYG39HLOzoet6 91 | lFbz/l6oxG3dbYpO5Y46TrCt/HlgGUAFuljH4Z9VyGEg4IkW8OgSieg+c/PtKPS6 92 | /ri0kCfc6MEoK605MexvzUnXTUsi9fk0dRvG49mKNnIe6s+j7PXbJH+QDqvp5KVS 93 | SFj+C2Zvi19QOXjPcbn5qjb4Bql6htoesDuKbyUIrSI2Tfe0awSkgSYNfc5Xnhqg 94 | ui8E4SG1wKLHCzWZtkUWnGdq0y74dHqUL+U/aFihKP+eIaq1HpSKBbEntg68AWc= 95 | =RpHg 96 | -----END PGP SIGNATURE----- -------------------------------------------------------------------------------- /torweather.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import requests 3 | import dateutil.parser 4 | import json 5 | import time 6 | import datetime 7 | import re 8 | import os 9 | import logging 10 | 11 | from mailer import alert_down 12 | 13 | NODE_DOWN_ALERT_TIMEOUT = 48*60*60 #How long to wait before sending node down alert 14 | 15 | # TODO: Set this to the day we deploy the project 16 | LAST_SEEN_HORIZON = 1471410499 #last_seen before this time stamp will not be emailed 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | def parse_time_str(tim): 21 | return dateutil.parser.parse(tim) 22 | 23 | def to_timestamp(dt): 24 | return time.mktime(dt.timetuple()) 25 | 26 | def from_timestamp(timestamp): 27 | return datetime.datetime.fromtimestamp(int(timestamp)) 28 | 29 | def scrape_email(text): 30 | pass 31 | 32 | def main(): 33 | logging.basicConfig(level=logging.INFO) 34 | 35 | #get json file 36 | if not os.environ.get('PROD'): 37 | logger.info("Reading details from mock_data.json...") 38 | with open('mock_data.json') as data_file: 39 | data = json.load(data_file) 40 | else: 41 | logger.info("Fetching details from onionoo...") 42 | data = requests.get('https://onionoo.torproject.org/details').json() 43 | 44 | #connect and init db if not inited 45 | conn = sqlite3.connect('TorWeather.db') 46 | conn.row_factory = sqlite3.Row 47 | try: 48 | conn.execute("SELECT COUNT(1) FROM nodes;") #if there is not a nodes table this will fail 49 | except sqlite3.OperationalError: 50 | logger.info("Creating database table 'nodes'...") 51 | conn.execute('''CREATE TABLE nodes ( 52 | fingerprint TEXT PRIMARY KEY, 53 | last_seen INTEGER, 54 | email TEXT, 55 | first_seen INTEGER, 56 | consensus_weight REAL, 57 | contact TEXT, 58 | nickname TEXT, 59 | last_alert_last_seen INTEGER);''') 60 | 61 | try: 62 | conn.execute("SELECT COUNT(1) FROM unsubscribe;") 63 | except sqlite3.OperationalError: 64 | logger.info("Creating database table 'unsubscribe'...") 65 | conn.execute('CREATE TABLE unsubscribe (fingerprint TEXT PRIMARY KEY);') 66 | 67 | try: 68 | conn.execute("SELECT COUNT(1) FROM subscribe;") 69 | except sqlite3.OperationalError: 70 | logger.info("Creating database table 'subscribe'...") 71 | conn.execute('CREATE TABLE subscribe (fingerprint TEXT PRIMARY KEY, frequency INTEGER);') 72 | 73 | #update or add new records 74 | with conn: 75 | logger.info("Updating database of nodes from onionoo data...") 76 | for node in data['relays']: 77 | fingerprint = node['fingerprint'] 78 | email = re.search(r'[\w\.+-]+@[\w\.+-]+\.[\w\.+-]+', node.get('contact') or '') #TODO: make this less shit 79 | email = email and email.group(0) 80 | assert node['last_seen'], 'How has a node never been seen?' 81 | conn.execute(''' 82 | INSERT OR REPLACE INTO nodes 83 | (fingerprint, last_seen, email, first_seen, consensus_weight, contact, nickname, last_alert_last_seen) VALUES ( 84 | :fingerprint, 85 | :last_seen, 86 | :email, 87 | :first_seen, 88 | :consensus_weight, 89 | :contact, 90 | :nickname, 91 | (select last_alert_last_seen from nodes where fingerprint = :fingerprint) 92 | ); 93 | ''', { 94 | "fingerprint":fingerprint, 95 | "last_seen":to_timestamp(parse_time_str(node['last_seen'])), 96 | "email":email or None, 97 | "first_seen":to_timestamp(parse_time_str(node['first_seen'])), 98 | "consensus_weight":node.get('consensus_weight'), 99 | "contact":node.get('contact'), 100 | "nickname":node.get('nickname'), 101 | }) 102 | logger.info("Updated %r nodes.", len(data['relays'])) 103 | 104 | #find nodes whos down/up state has changed 105 | published = to_timestamp(parse_time_str(data['relays_published'])) 106 | logger.info("Checking which nodes to alert...") 107 | with conn: 108 | for node in conn.execute("SELECT n.* FROM nodes n " 109 | "LEFT OUTER JOIN unsubscribe u ON u.fingerprint = n.fingerprint " 110 | "LEFT OUTER JOIN subscribe s ON s.fingerprint = n.fingerprint " 111 | "WHERE ((s.fingerprint IS NULL AND last_seen < :threshold) OR " 112 | "last_seen < (:published - s.frequency)) AND " 113 | "u.fingerprint IS NULL AND " 114 | "n.last_seen > :last_seen_horizon AND " 115 | "n.email IS NOT NULL AND " 116 | "(last_alert_last_seen IS NULL OR last_alert_last_seen <> last_seen);", { 117 | 'published': published, 118 | 'threshold': published - NODE_DOWN_ALERT_TIMEOUT, 119 | 'last_seen_horizon': LAST_SEEN_HORIZON, 120 | }): 121 | # Send the email! 122 | alert_down(node) 123 | 124 | # Mark us as having alerted on this node 125 | conn.execute("UPDATE nodes SET last_alert_last_seen = last_seen WHERE fingerprint = :fingerprint;", 126 | {"fingerprint": node['fingerprint']}) 127 | 128 | if __name__ == "__main__": 129 | main() 130 | -------------------------------------------------------------------------------- /verifier.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import os 3 | import warnings 4 | 5 | KEY = os.environ.get('UNSUB_KEY') 6 | if not KEY: 7 | warnings.warn("Using insecure key for HMAC!") 8 | KEY = 'thisisinsecure' 9 | 10 | def generate(msg): 11 | return hmac.new(KEY, msg).hexdigest() 12 | 13 | def verify(sec, msg): 14 | if isinstance(msg, unicode): 15 | msg = msg.encode('utf-8') 16 | if isinstance(sec, unicode): 17 | sec = sec.encode('utf-8') 18 | return hmac.compare_digest(generate(msg), sec) 19 | 20 | if __name__ == '__main__': 21 | assert verify(generate('12345'), '12345') 22 | print 'tests passed' 23 | --------------------------------------------------------------------------------