agena (9115B)
1 #!/usr/bin/env python3 2 3 import argparse 4 import mimetypes 5 import os 6 import shlex 7 import subprocess 8 import socket 9 import socketserver 10 import ssl 11 import sys 12 import tempfile 13 import urllib.parse 14 15 try: 16 import chardet 17 _HAS_CHARDET = True 18 except ImportError: 19 _HAS_CHARDET = False 20 21 HOST, PORT = "0.0.0.0", 1965 22 23 class AgenaHandler(socketserver.BaseRequestHandler): 24 25 def setup(self): 26 """ 27 Wrap socket in SSL session. 28 """ 29 self.request = context.wrap_socket(self.request, server_side=True) 30 31 def handle(self): 32 # Parse request URL, make sure it's for a Gopher resource 33 self.parse_request() 34 if self.request_scheme != "gopher": 35 self.send_gemini_header(50, "Agena only proxies to gopher resources.") 36 return 37 # Try to do a Gopher transaction with the remote host 38 try: 39 filename = self.download_gopher_resource() 40 except UnicodeError: 41 self.send_gemini_header(43, "Remote gopher host served content in an unrecognisable character encoding.") 42 return 43 except: 44 self.send_gemini_header(43, "Couldn't connect to remote gopher host.") 45 return 46 # Handle what we received based on item type 47 if self.gopher_itemtype == "0": 48 self.handle_text(filename) 49 elif self.gopher_itemtype == "1": 50 self.handle_menu(filename) 51 elif self.gopher_itemtype == "h": 52 self.handle_html(filename) 53 elif self.gopher_itemtype in ("9", "g", "I", "s"): 54 self.handle_binary(filename) 55 # Clean up 56 self.request.close() 57 os.unlink(filename) 58 59 def send_gemini_header(self, status, meta): 60 """ 61 Send a Gemini header, and close the connection if the status code does 62 not indicate success. 63 """ 64 self.request.send("{} {}\r\n".format(status, meta).encode("UTF-8")) 65 if status / 10 != 2: 66 self.request.close() 67 68 def parse_request(self): 69 """ 70 Read a URL from the Gemini client and parse it up into parts, 71 including separating out the Gopher item type. 72 """ 73 requested_url = self.request.recv(1024).decode("UTF-8").strip() 74 if "://" not in requested_url: 75 requested_url = "gemini://" + requested_url 76 parsed = urllib.parse.urlparse(requested_url) 77 self.request_scheme = parsed.scheme 78 self.gopher_host = parsed.hostname 79 self.gopher_port = parsed.port or 70 80 if parsed.path and parsed.path[0] == '/' and len(parsed.path) > 1: 81 self.gopher_itemtype = parsed.path[1] 82 self.gopher_selector = parsed.path[2:] 83 else: 84 # Use item type 1 for top-level selector 85 self.gopher_itemtype = "1" 86 self.gopher_selector = parsed.path 87 self.gopher_query = parsed.query 88 89 def download_gopher_resource(self): 90 """ 91 Download the requested Gopher resource to a temporary file. 92 """ 93 print("Requesting {} from {}...".format(self.gopher_selector, self.gopher_host), end="") 94 95 # Send request and read response 96 s = socket.create_connection((self.gopher_host, self.gopher_port)) 97 if self.gopher_query: 98 request = self.gopher_selector + '\t' + self.gopher_query 99 else: 100 request = self.gopher_selector 101 request += '\r\n' 102 s.sendall(request.encode("UTF-8")) 103 response= s.makefile("rb").read() 104 105 # Transcode text responses into UTF-8 106 if self.gopher_itemtype in ("0", "1", "h"): 107 # Try some common encodings 108 for encoding in ("UTF-8", "ISO-8859-1"): 109 try: 110 response = response.decode("UTF-8") 111 break 112 except UnicodeDecodeError: 113 pass 114 else: 115 # If we didn't break out of the loop above, none of the 116 # common encodings worked. If we have chardet installed, 117 # try to autodetect. 118 if _HAS_CHARDET: 119 detected = chardet.detect(response) 120 response = response.decode(detected["encoding"]) 121 else: 122 # Surrender. 123 raise UnicodeDecodeError 124 # Re-encode as God-fearing UTF-8 125 response = response.encode("UTF-8") 126 127 # Write gopher response to temp file 128 tmpf = tempfile.NamedTemporaryFile("wb", delete=False) 129 size = tmpf.write(response) 130 tmpf.close() 131 print("wrote {} bytes to {}...".format(size, tmpf.name)) 132 return tmpf.name 133 134 def handle_text(self, filename): 135 """ 136 Send a Gemini response for a downloaded Gopher resource whose item 137 type indicates it should be plain text. 138 """ 139 self._serve_file("text/plain", filename) 140 141 def handle_menu(self, filename): 142 """ 143 Send a Gemini response for a downloaded Gopher resource whose item 144 type indicates it should be a menu. 145 """ 146 self.send_gemini_header(20, "text/gemini") 147 with open(filename,"r") as fp: 148 for line in fp: 149 if line.strip() == ".": 150 continue 151 elif line.startswith("i"): 152 # This is an "info" line. Just strip off the item type 153 # and send the item name, ignorin the dummy selector, etc. 154 self.request.send((line[1:].split("\t")[0]+"\r\n").encode("UTF-8")) 155 else: 156 # This is an actual link to a Gopher resource 157 gemini_link = self.gopher_link_to_gemini_link(line) 158 self.request.send(gemini_link.encode("UTF-8")) 159 160 def gopher_link_to_gemini_link(self, line): 161 """ 162 Convert one line of a Gopher menu to one line of a Geminimap. 163 """ 164 165 # Code below pinched from VF-1 166 167 # Split on tabs. Strip final element after splitting, 168 # since if we split first we loose empty elements. 169 parts = line.split("\t") 170 parts[-1] = parts[-1].strip() 171 # Discard Gopher+ noise 172 if parts[-1] == "+": 173 parts = parts[:-1] 174 175 # Attempt to assign variables. This may fail. 176 # It's up to the caller to catch the Exception. 177 name, path, host, port = parts 178 itemtype = name[0] 179 name = name[1:] 180 port = int(port) 181 if itemtype == "h" and path.startswith("URL:"): 182 url = path[4:] 183 else: 184 url = "gopher://%s%s/%s%s" % ( 185 host, 186 "" if port == 70 else ":%d" % port, 187 itemtype, 188 path 189 ) 190 191 return "=> {} {}\r\n".format(url, name) 192 193 def handle_html(self, filename): 194 """ 195 Send a Gemini response for a downloaded Gopher resource whose item 196 type indicates it should be HTML. 197 """ 198 self._serve_file("text/html", filename) 199 200 def handle_binary(self, filename): 201 """ 202 Send a Gemini response for a downloaded Gopher resource whose item 203 type indicates it should be a binary file. Uses file(1) to sniff MIME 204 types. 205 """ 206 # Detect MIME type 207 out = subprocess.check_output( 208 shlex.split("file --brief --mime-type %s" % filename) 209 ) 210 mimetype = out.decode("UTF-8").strip() 211 self._serve_file(mimetype, filename) 212 213 def _serve_file(self, mime, filename): 214 """ 215 Send a Gemini response with a given MIME type whose body is the 216 contents of the specified file. 217 """ 218 self.send_gemini_header(20, mime) 219 with open(filename,"rb") as fp: 220 self.request.send(fp.read()) 221 222 if __name__ == "__main__": 223 224 parser = argparse.ArgumentParser(description= 225 """Agena is a simple Gemini-to-Gopher designed to be run locally to 226 let you seamlessly access Gopherspace from inside a Gemini client.""") 227 parser.add_argument('--cert', type=str, nargs="?", default="cert.pem", 228 help='TLS certificate file.') 229 parser.add_argument('--key', type=str, nargs="?", default="key.pem", 230 help='TLS private key file.') 231 parser.add_argument('--port', type=int, nargs="?", default=PORT, 232 help='TCP port to serve on.') 233 parser.add_argument('--host', type=str, nargs="?", default=HOST, 234 help='TCP host to serve on.') 235 args = parser.parse_args() 236 print(args) 237 238 if not (os.path.exists(args.cert) and os.path.exists(args.key)): 239 print("Couldn't find cert.pem and/or key.pem. :(") 240 sys.exit(1) 241 242 context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 243 context.load_cert_chain(certfile=args.cert, keyfile=args.key) 244 245 socketserver.TCPServer.allow_reuse_address = True 246 agena = socketserver.TCPServer((args.host, args.port), AgenaHandler) 247 try: 248 agena.serve_forever() 249 except KeyboardInterrupt: 250 agena.shutdown() 251 agena.server_close() 252