citadel

My dotfiles, scripts and nix configs
git clone git://jb55.com/citadel
Log | Files | Refs | README | LICENSE

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