gusmobile

python gemini client library
git clone https://git.clttr.info/gusmobile.git
Log (Feed) | Files | Refs (Tags) | README | LICENSE

client.py (9057B)


      1 # client.py
      2 #
      3 # Copyright 2019 Jason McBrayer
      4 #
      5 # This program is free software: you can redistribute it and/or modify
      6 # it under the terms of the GNU Affero General Public License as published by
      7 # the Free Software Foundation, either version 3 of the License, or
      8 # (at your option) any later version.
      9 #
     10 # This program is distributed in the hope that it will be useful,
     11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13 # GNU Affero General Public License for more details.
     14 #
     15 # You should have received a copy of the GNU Affero General Public License
     16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
     17 
     18 from email.message import EmailMessage
     19 import codecs
     20 import collections
     21 import fnmatch
     22 import io
     23 import mimetypes
     24 import os.path
     25 import random
     26 import shlex
     27 import shutil
     28 import socket
     29 import subprocess
     30 import tempfile
     31 import urllib.parse
     32 import ssl
     33 import sys
     34 import time
     35 
     36 class Response:
     37     content = None
     38     content_type = None
     39     charset = None
     40     lang = None
     41     url = None
     42     status = None
     43     status_meta = None
     44     prompt = None
     45     num_bytes = None
     46     error_message = None
     47 
     48     def __init__(
     49             self, content=None, content_type=None, charset=None, lang=None, url=None, status=None, status_meta=None, prompt=None, num_bytes=None, error_message=None
     50     ):
     51         self.content = content
     52         self.content_type = content_type
     53         self.charset = charset
     54         self.lang = lang
     55         self.url = url
     56         self.status = status
     57         self.status_meta = status_meta
     58         self.prompt = prompt
     59         self.num_bytes = num_bytes
     60         self.error_message = error_message
     61 
     62 
     63 def fetch(raw_url):
     64     # Do everything which touches the network in one block,
     65     # so we only need to catch exceptions once
     66     url = urllib.parse.urlparse(raw_url, 'gemini')
     67     header = ""
     68     try:
     69         # Is this a local file?
     70         if not url.netloc:
     71             print("ERROR: {} parses with no netloc".format(raw_url))
     72             f.close()
     73             return
     74         else:
     75             address, f = _send_request(url)
     76         # Read response header
     77         header = f.readline(1027)
     78         header = header.decode("UTF-8")
     79         if not header or header[-1] != '\n':
     80             _debug("ERROR: Received invalid header from server!")
     81             return
     82         header = header.strip()
     83         _debug("Response header: %s." % header)
     84 
     85     # Catch network errors which may happen on initial connection
     86     except Exception as err:
     87         # Print an error message
     88         if isinstance(err, socket.gaierror):
     89             print("ERROR: DNS error!")
     90             return
     91         elif isinstance(err, ConnectionRefusedError):
     92             print("ERROR: Connection refused!")
     93             return
     94         elif isinstance(err, ConnectionResetError):
     95             print("ERROR: Connection reset!")
     96             return
     97         elif isinstance(err, (TimeoutError, socket.timeout)):
     98             print(
     99                 """ERROR: Connection timed out!
    100                 Slow internet connection?  Use 'set timeout' to be more patient."""
    101             )
    102             return
    103         else:
    104             print("ERROR: " + str(err))
    105             return
    106     # Validate header
    107     header_split = header.split(maxsplit=1)
    108     if len(header_split) < 1:
    109         print("ERROR: Received invalid header from server!")
    110         f.close()
    111         return
    112     status = header_split[0]
    113     if len(header_split) > 1:
    114         meta = header_split[1]
    115     if len(header) > 1024 or len(status) != 2 or not status.isnumeric():
    116         print("ERROR: Received invalid header from server!")
    117         f.close()
    118         return
    119 
    120     # Handle headers. Not all headers are handled yet.
    121     # Input
    122     if status.startswith("1"):
    123         if len(header_split) < 2:
    124             print("ERROR: Input status requires a meta value in header!")
    125             return
    126         return Response(
    127             url=url.geturl(),
    128             status=status,
    129             prompt=meta,
    130         )
    131     # Redirects
    132     elif status.startswith("3"):
    133         if len(header_split) < 2:
    134             print("ERROR: Redirect status requires a meta value in header!")
    135             return
    136         return Response(
    137             url=urllib.parse.urlparse(meta).geturl(),
    138             status=status,
    139         )
    140     # Errors
    141     elif status.startswith("4") or status.startswith("5"):
    142         if len(header_split) < 2:
    143             print("ERROR: Error status requires a meta value in header!")
    144             return
    145         return Response(
    146             status=status,
    147             error_message=meta,
    148         )
    149         return
    150     # Client cert
    151     elif status.startswith("6"):
    152         print("ERROR: The requested resource requires client-certificate")
    153         return
    154     # Invalid status
    155     elif not status.startswith("2"):
    156         print("ERROR: Server returned undefined status code %s!" % status)
    157         return
    158 
    159     # Handle success
    160     assert status.startswith("2")
    161     if len(header_split) < 2:
    162         print("ERROR: Success status requires a meta value in header!")
    163         return
    164     mime = meta
    165     if mime == "":
    166         mime = "text/gemini; charset=utf-8"
    167     msg = EmailMessage()
    168     msg['content-type'] = mime
    169     mime, mime_options = msg.get_content_type(), msg['Content-Type'].params
    170     default_charset = "utf-8"
    171     charset = None
    172     if "charset" in mime_options:
    173         try:
    174             codecs.lookup(mime_options["charset"])
    175             charset = mime_options["charset"]
    176         except LookupError:
    177             print("Header declared unknown encoding %s" % mime_options["charset"])
    178             return
    179     lang = mime_options["lang"] if "lang" in mime_options else None
    180     # Read the response body over the network
    181     try:
    182         body = f.read()
    183     except Exception:
    184         print("Error reading response over network!")
    185         return
    186     if mime.startswith("text/"):
    187         try:
    188             content = codecs.decode(body, charset or default_charset)
    189         except:
    190             # print("ERROR: problem decoding content with %s charset" % charset)
    191             return
    192     else:
    193         content = body
    194     return Response(
    195         content=content,
    196         content_type=mime,
    197         charset=charset,
    198         lang=lang,
    199         num_bytes=len(body),
    200         url=url.geturl(),
    201         status=status,
    202     )
    203 
    204 
    205 def _send_request(url):
    206     """Send a selector to a given host and port.
    207     Returns the resolved address and binary file with the reply."""
    208     port = url.port if url.port is not None else 1965
    209     addresses = _get_addresses(url.hostname, port)
    210     # Connect to remote host by any address possible
    211     err = None
    212     for address in addresses:
    213         _debug("Connecting to: " + str(address[4]))
    214         s = socket.socket(address[0], address[1])
    215         s.settimeout(15.0)
    216         context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
    217         context.check_hostname = False
    218         context.verify_mode = ssl.CERT_NONE
    219         # Impose minimum TLS version
    220         if sys.version_info.minor == 7:
    221             context.minimum_version = ssl.TLSVersion.TLSv1_2
    222         else:
    223             context.options | ssl.OP_NO_TLSv1_1
    224             context.options | ssl.OP_NO_SSLv3
    225             context.options | ssl.OP_NO_SSLv2
    226         context.set_ciphers(
    227             "AES256-GCM-SHA384:AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK"
    228         )
    229         # print(context.get_ciphers())
    230         s = context.wrap_socket(s, server_hostname=url.hostname)
    231         try:
    232             s.connect(address[4])
    233             break
    234         except OSError as e:
    235             err = e
    236     else:
    237         # If we couldn't connect to *any* of the addresses, just
    238         # bubble up the exception from the last attempt and deny
    239         # knowledge of earlier failures.
    240         raise err
    241 
    242     _debug("Established {} connection.".format(s.version()))
    243     _debug("Cipher is: {}.".format(s.cipher()))
    244 
    245     # Send request and wrap response in a file descriptor
    246     _debug("Sending %s<CRLF>" % url.geturl())
    247     s.sendall((url.geturl() + "\r\n").encode("UTF-8"))
    248     return address, s.makefile(mode="rb")
    249 
    250 
    251 def _get_addresses(host, port):
    252     # DNS lookup - will get IPv4 and IPv6 records if IPv6 is enabled
    253     if ":" in host:
    254         # This is likely a literal IPv6 address, so we can *only* ask for
    255         # IPv6 addresses or getaddrinfo will complain
    256         family_mask = socket.AF_INET6
    257     elif socket.has_ipv6:
    258         # Accept either IPv4 or IPv6 addresses
    259         family_mask = 0
    260     else:
    261         # IPv4 only
    262         family_mask = socket.AF_INET
    263     addresses = socket.getaddrinfo(
    264         host, port, family=family_mask, type=socket.SOCK_STREAM
    265     )
    266     # Sort addresses so IPv6 ones come first
    267     addresses.sort(key=lambda add: add[0] == socket.AF_INET6, reverse=True)
    268     return addresses
    269 
    270 
    271 def _parse_url(url):
    272     """Work around issues with Python's urrlib.parse"""
    273     pass
    274 
    275 
    276 def _debug(message):
    277     pass