commit 57d591b47d481bca74c45abcbed566ed7bdbb737
parent 7d2e7e37c5ad30751e899a3b21889531a619df0f
Author: Jason McBrayer <jmcbray@carcosa.net>
Date: Thu, 10 Oct 2019 18:24:50 -0400
Working client (minimal testing)
Diffstat:
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