gusmobile

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

commit 57d591b47d481bca74c45abcbed566ed7bdbb737
parent 7d2e7e37c5ad30751e899a3b21889531a619df0f
Author: Jason McBrayer <jmcbray@carcosa.net>
Date:   Thu, 10 Oct 2019 18:24:50 -0400

Working client (minimal testing)

Diffstat:
Mgusmobile/__init__.py | 3++-
Mgusmobile/client.py | 286++++++++++++++++++++++++++++++++++++++++++-------------------------------------
2 files changed, 153 insertions(+), 136 deletions(-)

diff --git a/gusmobile/__init__.py b/gusmobile/__init__.py @@ -1 +1,2 @@ -from .client import Response, GeminiClient +from .client import Response +from .client import fetch diff --git a/gusmobile/client.py b/gusmobile/client.py @@ -30,8 +30,11 @@ import subprocess import tempfile import urllib.parse import ssl +import sys import time +GEMINI_TIMEOUT = 15 + class Response: content = None @@ -40,153 +43,166 @@ class Response: status = None status_meta = None - def __init__(self, content=None, content_type=None, url=None, status=None): + def __init__( + self, content=None, content_type=None, url=None, status=None, status_meta=None + ): self.content = content self.content_type = content_type self.url = url self.status = status + self.status_meta = status_meta -class GeminiClient: - def fetch(self, url): - # Do everything which touches the network in one block, - # so we only need to catch exceptions once - try: - # Is this a local file? - if not url.host: - address, f = None, open(url.path, "rb") - else: - address, f = self._send_request(url) +def fetch(url): + # Do everything which touches the network in one block, + # so we only need to catch exceptions once + url = urllib.parse.urlparse(url) + header = "" + try: + # Is this a local file? + if not url.netloc: + address, f = None, open(url.path, "rb") + else: + address, f = _send_request(url) # Read response header - header = f.readline() - header = header.decode("UTF-8").strip() - self._debug("Response header: %s." % header) - - # Catch network errors which may happen on initial connection - except Exception as err: - # Print an error message - if isinstance(err, socket.gaierror): - self.log["dns_failures"] += 1 - print("ERROR: DNS error!") - elif isinstance(err, ConnectionRefusedError): - self.log["refused_connections"] += 1 - print("ERROR: Connection refused!") - elif isinstance(err, ConnectionResetError): - self.log["reset_connections"] += 1 - print("ERROR: Connection reset!") - elif isinstance(err, (TimeoutError, socket.timeout)): - self.log["timeouts"] += 1 - print( - """ERROR: Connection timed out! -Slow internet connection? Use 'set timeout' to be more patient.""" - ) - else: - print("ERROR: " + str(err)) - return - # Validate header - status, meta = header.split(maxsplit=1) - if len(header) > 1024 or len(status) != 2 or not status.isnumeric(): - print("ERROR: Received invalid header from server!") - f.close() + header = f.readline() + header = header.decode("UTF-8").strip() + _debug("Response header: %s." % header) + + # Catch network errors which may happen on initial connection + except Exception as err: + # Print an error message + if isinstance(err, socket.gaierror): + print("ERROR: DNS error!") + elif isinstance(err, ConnectionRefusedError): + print("ERROR: Connection refused!") + elif isinstance(err, ConnectionResetError): + print("ERROR: Connection reset!") + elif isinstance(err, (TimeoutError, socket.timeout)): + print( + """ERROR: Connection timed out! + Slow internet connection? Use 'set timeout' to be more patient.""" + ) + else: + print("ERROR: " + str(err)) return + # Validate header + status, meta = header.split(maxsplit=1) + if len(header) > 1024 or len(status) != 2 or not status.isnumeric(): + print("ERROR: Received invalid header from server!") + f.close() + return + + # Handle headers. Not all headers are handled yet. + if status.startswith("1"): + raise NotImplementedError() + # Redirects + elif status.startswith("3"): + raise NotImplementedError() + # Errors + elif status.startswith("4") or status.startswith("5"): + raise NotImplementedError() + # Client cert + elif status.startswith("6"): + raise NotImplementedError() + # Invalid status + elif not status.startswith("2"): + print("ERROR: Server returned undefined status code %s!" % status) + return - # Handle headers. Not all headers are handled yet. - if status.startswith("1"): - raise NotImplementedError() - # Redirects - elif status.startswith("3"): - raise NotImplementedError() - # Errors - elif status.startswith("4") or status.startswith("5"): - raise NotImplementedError() - # Client cert - elif status.startswith("6"): - raise NotImplementedError() - # Invalid status - elif not status.startswith("2"): - print("ERROR: Server returned undefined status code %s!" % status) + # Handle success + assert status.startswith("2") + mime = meta + if mime == "": + mime = "text/gemini; charset=utf-8" + mime, mime_options = cgi.parse_header(mime) + charset = "utf-8" + if "charset" in mime_options: + try: + codecs.lookup(mime_options["charset"]) + charset = mime_options["charset"] + except LookupError: + print("Header declared unknown encoding %s" % value) return + # Read the response body over the network + body = f.read() + return Response( + content=codecs.decode(body, charset), + content_type=mime, + url=url.geturl(), + status=status, + ) - # Handle success - assert status.startswith("2") - mime = meta - if mime == "": - mime = "text/gemini; charset=utf-8" - mime, mime_options = cgi.parse_header(mime) - if "charset" in mime_options: - try: - codecs.lookup(mime_options["charset"]) - except LookupError: - print("Header declared unknown encoding %s" % value) - return - # Read the response body over the network - body = f.read() - - # And return. - return Response( - content=body, content_type=mime, url=url.geturl(), status=status - ) - def _send_request(self, url): - """Send a selector to a given host and port. - Returns the resolved address and binary file with the reply.""" - addresses = self._get_addresses(url.host, url.port) - # Connect to remote host by any address possible - err = None - for address in addresses: - self._debug("Connecting to: " + str(address[4])) - s = socket.socket(address[0], address[1]) - s.settimeout(self.options["timeout"]) - context = ssl.SSLContext() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - # Impose minimum TLS version - if sys.version_info.minor == 7: - context.minimum_version = ssl.TLSVersion.TLSv1_2 - else: - context.options | ssl.OP_NO_TLSv1_1 - context.options | ssl.OP_NO_SSLv3 - context.options | ssl.OP_NO_SSLv2 - context.set_ciphers( - "AES+DHE:AES+ECDHE:CHACHA20+DHE:CHACHA20+ECDHE:!SHA1:@STRENGTH" - ) - # print(context.get_ciphers()) - s = context.wrap_socket(s, server_hostname=url.host) - try: - s.connect(address[4]) - break - except OSError as e: - err = e - else: - # If we couldn't connect to *any* of the addresses, just - # bubble up the exception from the last attempt and deny - # knowledge of earlier failures. - raise err - - self._debug("Established {} connection.".format(s.version())) - self._debug("Cipher is: {}.".format(s.cipher())) - - # Send request and wrap response in a file descriptor - self._debug("Sending %s<CRLF>" % url.geturl()) - s.sendall((url.geturl() + CRLF).encode("UTF-8")) - return address, s.makefile(mode="rb") - - def _get_addresses(self, host, port): - # DNS lookup - will get IPv4 and IPv6 records if IPv6 is enabled - if ":" in host: - # This is likely a literal IPv6 address, so we can *only* ask for - # IPv6 addresses or getaddrinfo will complain - family_mask = socket.AF_INET6 - elif socket.has_ipv6 and self.options["ipv6"]: - # Accept either IPv4 or IPv6 addresses - family_mask = 0 +def _send_request(url): + """Send a selector to a given host and port. + Returns the resolved address and binary file with the reply.""" + addresses = _get_addresses(url.hostname, url.port) + # Connect to remote host by any address possible + err = None + for address in addresses: + _debug("Connecting to: " + str(address[4])) + s = socket.socket(address[0], address[1]) + s.settimeout(GEMINI_TIMEOUT) + context = ssl.SSLContext() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + # Impose minimum TLS version + if sys.version_info.minor == 7: + context.minimum_version = ssl.TLSVersion.TLSv1_2 else: - # IPv4 only - family_mask = socket.AF_INET - addresses = socket.getaddrinfo( - host, port, family=family_mask, type=socket.SOCK_STREAM + context.options | ssl.OP_NO_TLSv1_1 + context.options | ssl.OP_NO_SSLv3 + context.options | ssl.OP_NO_SSLv2 + context.set_ciphers( + "AES+DHE:AES+ECDHE:CHACHA20+DHE:CHACHA20+ECDHE:!SHA1:@STRENGTH" ) - # Sort addresses so IPv6 ones come first - addresses.sort(key=lambda add: add[0] == socket.AF_INET6, reverse=True) + # print(context.get_ciphers()) + s = context.wrap_socket(s, server_hostname=url.hostname) + try: + s.connect(address[4]) + break + except OSError as e: + err = e + else: + # If we couldn't connect to *any* of the addresses, just + # bubble up the exception from the last attempt and deny + # knowledge of earlier failures. + raise err + + _debug("Established {} connection.".format(s.version())) + _debug("Cipher is: {}.".format(s.cipher())) + + # Send request and wrap response in a file descriptor + _debug("Sending %s<CRLF>" % url.geturl()) + s.sendall((url.geturl() + "\r\n").encode("UTF-8")) + return address, s.makefile(mode="rb") + + +def _get_addresses(host, port): + # DNS lookup - will get IPv4 and IPv6 records if IPv6 is enabled + if ":" in host: + # This is likely a literal IPv6 address, so we can *only* ask for + # IPv6 addresses or getaddrinfo will complain + family_mask = socket.AF_INET6 + elif socket.has_ipv6: + # Accept either IPv4 or IPv6 addresses + family_mask = 0 + else: + # IPv4 only + family_mask = socket.AF_INET + addresses = socket.getaddrinfo( + host, port, family=family_mask, type=socket.SOCK_STREAM + ) + # Sort addresses so IPv6 ones come first + addresses.sort(key=lambda add: add[0] == socket.AF_INET6, reverse=True) + return addresses + + +def _parse_url(url): + """Work around issues with Python's urrlib.parse""" + pass + - return addresses +def _debug(message): + pass