← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:https-mirrors-2 into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:https-mirrors-2 into launchpad:master.

Commit message:
Undoing git revert on: Allowing to register HTTPS mirrors (both for CD images and archives).

This MP is undoing the git revert of HTTPS mirrors feature on master (https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/379387). This feature got reverted because the database patch was not merged yet, so the code should wait for it's deployment to production before being landed.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/379913
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:https-mirrors-2 into launchpad:master.
diff --git a/lib/lp/registry/browser/distributionmirror.py b/lib/lp/registry/browser/distributionmirror.py
index fd671a0..6ae1eb7 100644
--- a/lib/lp/registry/browser/distributionmirror.py
+++ b/lib/lp/registry/browser/distributionmirror.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -207,9 +207,9 @@ class DistributionMirrorDeleteView(LaunchpadFormView):
 class DistributionMirrorAddView(LaunchpadFormView):
     schema = IDistributionMirror
     field_names = [
-        "display_name", "description", "whiteboard", "http_base_url",
-        "ftp_base_url", "rsync_base_url", "speed", "country", "content",
-        "official_candidate",
+        "display_name", "description", "whiteboard", "https_base_url",
+        "http_base_url", "ftp_base_url", "rsync_base_url", "speed", "country",
+        "content", "official_candidate",
         ]
     invariant_context = None
 
@@ -235,6 +235,7 @@ class DistributionMirrorAddView(LaunchpadFormView):
             content=data['content'], display_name=data['display_name'],
             description=data['description'],
             whiteboard=data['whiteboard'],
+            https_base_url=data['https_base_url'],
             http_base_url=data['http_base_url'],
             ftp_base_url=data['ftp_base_url'],
             rsync_base_url=data['rsync_base_url'],
@@ -279,8 +280,8 @@ class DistributionMirrorEditView(LaunchpadEditFormView):
     schema = IDistributionMirror
     field_names = [
         "name", "display_name", "description", "whiteboard",
-        "http_base_url", "ftp_base_url", "rsync_base_url", "speed",
-        "country", "content", "official_candidate",
+        "https_base_url", "http_base_url", "ftp_base_url", "rsync_base_url",
+        "speed", "country", "content", "official_candidate",
         ]
 
     @property
diff --git a/lib/lp/registry/browser/tests/distributionmirror-views.txt b/lib/lp/registry/browser/tests/distributionmirror-views.txt
index e11dbd9..f8fe4c5 100644
--- a/lib/lp/registry/browser/tests/distributionmirror-views.txt
+++ b/lib/lp/registry/browser/tests/distributionmirror-views.txt
@@ -44,18 +44,19 @@ The view provides a label, page_title, and cancel_url
     >>> print view.cancel_url
     http://launchpad.test/ubuntu
 
-A HTTP or FTP URL is required to register a mirror.
+A HTTP, HTTPS or FTP URL is required to register a mirror.
 
     >>> view.field_names
-    ['display_name', 'description', 'whiteboard', 'http_base_url',
-     'ftp_base_url', 'rsync_base_url', 'speed', 'country', 'content',
-     'official_candidate']
+    ['display_name', 'description', 'whiteboard', 'https_base_url',
+     'http_base_url', 'ftp_base_url', 'rsync_base_url', 'speed', 'country',
+     'content', 'official_candidate']
 
     >>> form = {
     ...     'field.display_name': 'Illuminati',
     ...     'field.description': 'description',
     ...     'field.whiteboard': 'whiteboard',
     ...     'field.http_base_url': 'http://secret.me/',
+    ...     'field.https_base_url': '',
     ...     'field.ftp_base_url': '',
     ...     'field.rsync_base_url': '',
     ...     'field.speed': 'S128K',
@@ -93,6 +94,7 @@ not significant).
 The same is true for a FTP URL.
 
     >>> mirror.ftp_base_url = 'ftp://now-here.me/'
+    >>> bad_form['field.https_base_url'] = ''
     >>> bad_form['field.http_base_url'] = ''
     >>> bad_form['field.ftp_base_url'] = 'ftp://now-here.me'
     >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
@@ -110,14 +112,15 @@ The same is true for a rsync URL.
     ...     print error[2]
     The distribution mirror ... is already registered with this URL.
 
-A mirror must have an ftp or http URL.
+A mirror must have an ftp, HTTPS or http URL.
 
+    >>> bad_form['field.https_base_url'] = ''
     >>> bad_form['field.http_base_url'] = ''
     >>> bad_form['field.ftp_base_url'] = ''
     >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
     >>> for message in view.errors:
     ...     print message
-    A mirror must have at least an HTTP or FTP URL.
+    A mirror must have at least an HTTP(S) or FTP URL.
 
 The URL cannot contain a fragment.
 
@@ -135,6 +138,34 @@ The URL cannot contain a query string.
     ...     print error[2]
     URIs with query strings are not allowed.
 
+The HTTPS URL may not have an HTTP scheme.
+
+    >>> bad_form['field.http_base_url'] = ''
+    >>> bad_form['field.https_base_url'] = 'http://secret.me/#fragement'
+    >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
+    >>> for error in view.errors:
+    ...     print error[2]
+    The URI scheme "http" is not allowed.
+    Only URIs with the following schemes may be used: https
+
+The HTTPS URL cannot contain a fragment.
+
+    >>> bad_form['field.http_base_url'] = ''
+    >>> bad_form['field.https_base_url'] = 'https://secret.me/#fragement'
+    >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
+    >>> for error in view.errors:
+    ...     print error[2]
+    URIs with fragment identifiers are not allowed.
+
+The URL cannot contain a query string.
+
+    >>> bad_form['field.http_base_url'] = ''
+    >>> bad_form['field.https_base_url'] = 'https://secret.me/?query=string'
+    >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
+    >>> for error in view.errors:
+    ...     print error[2]
+    URIs with query strings are not allowed.
+
 
 Reviewing a distribution mirror
 -------------------------------
@@ -239,9 +270,9 @@ The +edit view provides a label, page_title, and cancel_url.
 The user can edit the mirror fields.
 
     >>> view.field_names
-    ['name', 'display_name', 'description', 'whiteboard', 'http_base_url',
-     'ftp_base_url', 'rsync_base_url', 'speed', 'country', 'content',
-     'official_candidate']
+    ['name', 'display_name', 'description', 'whiteboard', 'https_base_url',
+     'http_base_url', 'ftp_base_url', 'rsync_base_url', 'speed', 'country',
+     'content', 'official_candidate']
 
     >>> print mirror.ftp_base_url
     None
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 9be68e0..d5875c5 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -2046,6 +2046,7 @@
                 description
                 distribution
                 http_base_url
+                https_base_url
                 ftp_base_url
                 rsync_base_url
                 enabled
@@ -2084,8 +2085,9 @@
         <require
             permission="launchpad.Edit"
             set_attributes="name display_name description whiteboard
-                            http_base_url ftp_base_url rsync_base_url enabled
-                            speed country content official_candidate owner"
+                            http_base_url https_base_url ftp_base_url
+                            rsync_base_url enabled speed country content
+                            official_candidate owner"
             attributes="official_candidate whiteboard resubmitForReview" />
         <require
             permission="launchpad.Admin"
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index c7ddb95..a7106f6 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces including and related to IDistribution."""
@@ -491,13 +491,13 @@ class IDistributionPublic(
         """Return the country DNS mirror for a country and content type."""
 
     def newMirror(owner, speed, country, content, display_name=None,
-                  description=None, http_base_url=None,
+                  description=None, http_base_url=None, https_base_url=None,
                   ftp_base_url=None, rsync_base_url=None, enabled=False,
                   official_candidate=False, whiteboard=None):
         """Create a new DistributionMirror for this distribution.
 
-        At least one of http_base_url or ftp_base_url must be provided in
-        order to create a mirror.
+        At least one of {http,https,ftp}_base_url must be provided in order to
+        create a mirror.
         """
 
     def getOCIProject(name):
diff --git a/lib/lp/registry/interfaces/distributionmirror.py b/lib/lp/registry/interfaces/distributionmirror.py
index ce6720e..4849e7a 100644
--- a/lib/lp/registry/interfaces/distributionmirror.py
+++ b/lib/lp/registry/interfaces/distributionmirror.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -302,6 +302,12 @@ class DistroMirrorHTTPURIField(DistroMirrorURIField):
         return getUtility(IDistributionMirrorSet).getByHttpUrl(url)
 
 
+class DistroMirrorHTTPSURIField(DistroMirrorURIField):
+
+    def getMirrorByURI(self, url):
+        return getUtility(IDistributionMirrorSet).getByHttpsUrl(url)
+
+
 class DistroMirrorFTPURIField(DistroMirrorURIField):
 
     def getMirrorByURI(self, url):
@@ -349,6 +355,14 @@ class IDistributionMirror(Interface):
         allowed_schemes=['http'], allow_userinfo=False,
         allow_query=False, allow_fragment=False, trailing_slash=True,
         description=_('e.g.: http://archive.ubuntu.com/ubuntu/')))
+    https_base_url = exported(DistroMirrorHTTPSURIField(
+        title=_('HTTPS URL'), required=False, readonly=False,
+        allowed_schemes=['https'], allow_userinfo=False,
+        allow_query=False, allow_fragment=False, trailing_slash=True,
+        # XXX: pappacena 2020-02-21: Add description field with a more
+        # suitable example once we have https for archive.ubuntu.com, like:
+        # description=_('e.g.: http://archive.ubuntu.com/ubuntu/')
+        ))
     ftp_base_url = exported(DistroMirrorFTPURIField(
         title=_('FTP URL'), required=False, readonly=False,
         allowed_schemes=['ftp'], allow_userinfo=False,
@@ -435,8 +449,9 @@ class IDistributionMirror(Interface):
 
     @invariant
     def mirrorMustHaveHTTPOrFTPURL(mirror):
-        if not (mirror.http_base_url or mirror.ftp_base_url):
-            raise Invalid('A mirror must have at least an HTTP or FTP URL.')
+        if not (mirror.http_base_url or mirror.https_base_url or
+                mirror.ftp_base_url):
+            raise Invalid('A mirror must have at least an HTTP(S) or FTP URL.')
 
     def getSummarizedMirroredSourceSeries():
         """Return a summarized list of this distribution_mirror's
@@ -614,6 +629,9 @@ class IDistributionMirrorSet(Interface):
     def getByHttpUrl(url):
         """Return the mirror with the given HTTP URL or None."""
 
+    def getByHttpsUrl(url):
+        """Return the mirror with the given HTTPS URL or None."""
+
     def getByFtpUrl(url):
         """Return the mirror with the given FTP URL or None."""
 
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 2288d0e..f78908b 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database classes for implementing distribution items."""
@@ -709,7 +709,7 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             country_dns_mirror=True).one()
 
     def newMirror(self, owner, speed, country, content, display_name=None,
-                  description=None, http_base_url=None,
+                  description=None, http_base_url=None, https_base_url=None,
                   ftp_base_url=None, rsync_base_url=None,
                   official_candidate=False, enabled=False,
                   whiteboard=None):
@@ -722,15 +722,17 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             return None
 
         urls = {'http_base_url': http_base_url,
+                'https_base_url': https_base_url,
                 'ftp_base_url': ftp_base_url,
                 'rsync_base_url': rsync_base_url}
         for name, value in urls.items():
             if value is not None:
                 urls[name] = IDistributionMirror[name].normalize(value)
 
-        url = urls['http_base_url'] or urls['ftp_base_url']
+        url = (urls['https_base_url'] or urls['http_base_url'] or
+               urls['ftp_base_url'])
         assert url is not None, (
-            "A mirror must provide either an HTTP or FTP URL (or both).")
+            "A mirror must provide at least one HTTP/HTTPS/FTP URL.")
         dummy, host, dummy, dummy, dummy, dummy = urlparse(url)
         name = sanitize_name('%s-%s' % (host, content.name.lower()))
 
@@ -744,6 +746,7 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             distribution=self, owner=owner, name=name, speed=speed,
             country=country, content=content, display_name=display_name,
             description=description, http_base_url=urls['http_base_url'],
+            https_base_url=urls['https_base_url'],
             ftp_base_url=urls['ftp_base_url'],
             rsync_base_url=urls['rsync_base_url'],
             official_candidate=official_candidate, enabled=enabled,
diff --git a/lib/lp/registry/model/distributionmirror.py b/lib/lp/registry/model/distributionmirror.py
index 747a2b0..323fb26 100644
--- a/lib/lp/registry/model/distributionmirror.py
+++ b/lib/lp/registry/model/distributionmirror.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Module docstring goes here."""
@@ -129,6 +129,8 @@ class DistributionMirror(SQLBase):
         notNull=False, default=None)
     http_base_url = StringCol(
         notNull=False, default=None, unique=True)
+    https_base_url = StringCol(
+        notNull=False, default=None, unique=True)
     ftp_base_url = StringCol(
         notNull=False, default=None, unique=True)
     rsync_base_url = StringCol(
@@ -155,7 +157,9 @@ class DistributionMirror(SQLBase):
     @property
     def base_url(self):
         """See IDistributionMirror"""
-        if self.http_base_url is not None:
+        if self.https_base_url is not None:
+            return self.https_base_url
+        elif self.http_base_url is not None:
             return self.http_base_url
         else:
             return self.ftp_base_url
@@ -677,6 +681,10 @@ class DistributionMirrorSet:
         """See IDistributionMirrorSet"""
         return DistributionMirror.selectOneBy(http_base_url=url)
 
+    def getByHttpsUrl(self, url):
+        """See IDistributionMirrorSet"""
+        return DistributionMirror.selectOneBy(https_base_url=url)
+
     def getByFtpUrl(self, url):
         """See IDistributionMirrorSet"""
         return DistributionMirror.selectOneBy(ftp_base_url=url)
diff --git a/lib/lp/registry/scripts/distributionmirror_prober.py b/lib/lp/registry/scripts/distributionmirror_prober.py
index 916d4ba..c633fb8 100644
--- a/lib/lp/registry/scripts/distributionmirror_prober.py
+++ b/lib/lp/registry/scripts/distributionmirror_prober.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -12,6 +12,7 @@ import logging
 import os.path
 from StringIO import StringIO
 
+import OpenSSL
 import requests
 from six.moves import http_client
 from six.moves.urllib.parse import (
@@ -20,14 +21,27 @@ from six.moves.urllib.parse import (
     urlparse,
     urlunparse,
     )
+from treq.client import HTTPClient as TreqHTTPClient
 from twisted.internet import (
     defer,
     protocol,
     reactor,
     )
-from twisted.internet.defer import DeferredSemaphore
+from twisted.internet.defer import (
+    CancelledError,
+    DeferredSemaphore,
+    )
+from twisted.internet.endpoints import HostnameEndpoint
+from twisted.internet.ssl import VerificationError
 from twisted.python.failure import Failure
+from twisted.web.client import (
+    Agent,
+    BrowserLikePolicyForHTTPS,
+    ProxyAgent,
+    ResponseNeverReceived,
+    )
 from twisted.web.http import HTTPClient
+from twisted.web.iweb import IResponse
 from zope.component import getUtility
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -50,6 +64,7 @@ from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 # IMPORTANT: Changing these values can cause lots of false negatives when
 # probing mirrors, so please don't change them unless you know what you're
 # doing.
+
 MIN_REQUEST_TIMEOUT_RATIO = 3
 MIN_REQUESTS_TO_CONSIDER_RATIO = 30
 
@@ -57,6 +72,9 @@ MIN_REQUESTS_TO_CONSIDER_RATIO = 30
 # We need to get rid of these global dicts in this module.
 host_requests = {}
 host_timeouts = {}
+# Set of invalid certificate (host, port) tuples, to avoid doing HTTPS calls
+# to hosts we already know they are not valid.
+invalid_certificate_hosts = set()
 
 MAX_REDIRECTS = 3
 
@@ -161,6 +179,64 @@ class ProberProtocol(HTTPClient):
         pass
 
 
+class HTTPSProbeFailureHandler:
+    """Handler to translate general errors into expected errors on HTTPS
+    connections."""
+    def __init__(self, factory):
+        self.factory = factory
+
+    def handleResponse(self, response):
+        """Translates any request with return code different from 200 into
+        an error in the callback chain.
+
+        Note that other 2xx codes that are not 200 are considered errors too.
+        This behaviour is the same as seen in ProberProtocol.handleStatus,
+        for HTTP responses.
+        """
+        status = response.code
+        if status == http_client.OK:
+            return response
+        else:
+            raise BadResponseCode(status, response)
+
+    def handleErrors(self, error):
+        """Handle exceptions in https requests.
+        """
+        if self.isInvalidCertificateError(error):
+            invalid_certificate_hosts.add(
+                (self.factory.request_host, self.factory.request_port))
+            reason = InvalidHTTPSCertificate(
+                self.factory.request_host, self.factory.request_port)
+            raise reason
+        if self.isTimeout(error):
+            raise ProberTimeout(self.factory.url, self.factory.timeout)
+        raise error
+
+    def isTimeout(self, error):
+        """Checks if the error was caused by a timeout.
+        """
+        return self._isErrorFromType(error, CancelledError)
+
+    def isInvalidCertificateError(self, error):
+        """Checks if the error was caused by an invalid certificate.
+        """
+        # It might be a raw SSL error, or a twisted-encapsulated
+        # verification error (such as DNSMismatch error when the
+        # certificate is valid for a different domain, for example).
+        return self._isErrorFromType(
+            error, OpenSSL.SSL.Error, VerificationError)
+
+    def _isErrorFromType(self, error, *types):
+        """Checks if the error was caused by any of the given types.
+        """
+        if not isinstance(error.value, ResponseNeverReceived):
+            return False
+        for reason in error.value.reasons:
+            if reason.check(*types) is not None:
+                return True
+        return False
+
+
 class RedirectAwareProberProtocol(ProberProtocol):
     """A specialized version of ProberProtocol that follows HTTP redirects."""
 
@@ -216,6 +292,8 @@ class ProberFactory(protocol.ClientFactory):
     connect_port = None
     connect_path = None
 
+    https_agent_policy = BrowserLikePolicyForHTTPS
+
     def __init__(self, url, timeout=config.distributionmirrorprober.timeout):
         # We want the deferred to be a private attribute (_deferred) to make
         # sure our clients will only use the deferred returned by the probe()
@@ -225,9 +303,14 @@ class ProberFactory(protocol.ClientFactory):
         self.timeout = timeout
         self.timeoutCall = None
         self.setURL(url.encode('ascii'))
+        self.logger = logging.getLogger('distributionmirror-prober')
+
+    @property
+    def is_https(self):
+        return self.request_scheme == 'https'
 
     def probe(self):
-        logger = logging.getLogger('distributionmirror-prober')
+        logger = self.logger
         # NOTE: We don't want to issue connections to any outside host when
         # running the mirror prober in a development machine, so we do this
         # hack here.
@@ -244,13 +327,46 @@ class ProberFactory(protocol.ClientFactory):
                          "host already." % self.url)
             return self._deferred
 
+        if (self.request_host, self.request_port) in invalid_certificate_hosts:
+            reactor.callLater(
+                0, self.failed, InvalidHTTPSCertificateSkipped(self.url))
+            logger.debug("Skipping %s as it doesn't have a valid HTTPS "
+                         "certificate" % self.url)
+            return self._deferred
+
         self.connect()
         logger.debug('Probing %s' % self.url)
         return self._deferred
 
+    def getHttpsClient(self):
+        # Should we use a proxy?
+        if not config.launchpad.http_proxy:
+            agent = Agent(
+                reactor=reactor, contextFactory=self.https_agent_policy())
+        else:
+            endpoint = HostnameEndpoint(
+                reactor, self.connect_host, self.connect_port)
+            agent = ProxyAgent(endpoint)
+        return TreqHTTPClient(agent)
+
     def connect(self):
+        """Starts the connection and sets the self._deferred to the proper
+        task.
+        """
         host_requests[self.request_host] += 1
-        reactor.connectTCP(self.connect_host, self.connect_port, self)
+        if self.is_https:
+            treq = self.getHttpsClient()
+            self._deferred.addCallback(
+                lambda _: treq.head(
+                    self.url, reactor=reactor, allow_redirects=True,
+                    timeout=self.timeout))
+            error_handler = HTTPSProbeFailureHandler(self)
+            self._deferred.addCallback(error_handler.handleResponse)
+            self._deferred.addErrback(error_handler.handleErrors)
+            reactor.callWhenRunning(self._deferred.callback, None)
+        else:
+            reactor.connectTCP(self.connect_host, self.connect_port, self)
+
         if self.timeoutCall is not None and self.timeoutCall.active():
             self._cancelTimeout(None)
         self.timeoutCall = reactor.callLater(
@@ -269,6 +385,8 @@ class ProberFactory(protocol.ClientFactory):
         self.connector = connector
 
     def succeeded(self, status):
+        if IResponse.providedBy(status):
+            status = str(status.code)
         self._deferred.callback(status)
 
     def failed(self, reason):
@@ -288,7 +406,7 @@ class ProberFactory(protocol.ClientFactory):
         # https://bugs.squid-cache.org/show_bug.cgi?id=1758 applied. So, if
         # you encounter any problems with FTP URLs you'll probably have to nag
         # the sysadmins to fix squid for you.
-        if scheme not in ('http', 'ftp'):
+        if scheme not in ('http', 'https', 'ftp'):
             raise UnknownURLScheme(url)
 
         if scheme and host:
@@ -376,14 +494,26 @@ class ProberTimeout(ProberError):
 
 class BadResponseCode(ProberError):
 
-    def __init__(self, status, *args):
+    def __init__(self, status, response=None, *args):
         ProberError.__init__(self, *args)
         self.status = status
+        self.response = response
 
     def __str__(self):
         return "Bad response code: %s" % self.status
 
 
+class InvalidHTTPSCertificate(ProberError):
+    def __init__(self, host, port, *args):
+        super(InvalidHTTPSCertificate, self).__init__(*args)
+        self.host = host
+        self.port = port
+
+    def __str__(self):
+        return "Invalid SSL certificate when trying to probe %s:%s" % (
+            self.host, self.port)
+
+
 class RedirectToDifferentFile(ProberError):
 
     def __init__(self, orig_path, new_path, *args):
@@ -409,6 +539,14 @@ class ConnectionSkipped(ProberError):
                 "host. It will be retried on the next probing run.")
 
 
+class InvalidHTTPSCertificateSkipped(ProberError):
+
+    def __str__(self):
+        return ("Connection skipped because the server doesn't have a valid "
+                "HTTPS certificate. It will be retried on the next "
+                "probing run.")
+
+
 class UnknownURLScheme(ProberError):
 
     def __init__(self, url, *args):
@@ -429,7 +567,13 @@ class UnknownURLSchemeAfterRedirect(UnknownURLScheme):
 
 class ArchiveMirrorProberCallbacks(LoggingMixin):
 
-    expected_failures = (BadResponseCode, ProberTimeout, ConnectionSkipped)
+    expected_failures = (
+        BadResponseCode,
+        ProberTimeout,
+        ConnectionSkipped,
+        InvalidHTTPSCertificate,
+        InvalidHTTPSCertificateSkipped,
+        )
 
     def __init__(self, mirror, series, pocket, component, url, log_file):
         self.mirror = mirror
@@ -574,6 +718,8 @@ class MirrorCDImageProberCallbacks(LoggingMixin):
         ProberTimeout,
         RedirectToDifferentFile,
         UnknownURLSchemeAfterRedirect,
+        InvalidHTTPSCertificate,
+        InvalidHTTPSCertificateSkipped,
         )
 
     def __init__(self, mirror, distroseries, flavour, log_file):
diff --git a/lib/lp/registry/stories/webservice/xx-distribution-mirror.txt b/lib/lp/registry/stories/webservice/xx-distribution-mirror.txt
index db17b40..3e49ebe 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution-mirror.txt
+++ b/lib/lp/registry/stories/webservice/xx-distribution-mirror.txt
@@ -23,6 +23,7 @@ mirrors:
     enabled: True
     ftp_base_url: None
     http_base_url: u'http://archive.ubuntu.com/ubuntu/'
+    https_base_url: None
     name: u'canonical-archive'
     official_candidate: True
     owner_link: u'http://.../~mark'
@@ -52,6 +53,7 @@ And CD image mirrors:
     enabled: True
     ftp_base_url: None
     http_base_url: u'http://releases.ubuntu.com/'
+    https_base_url: None
     name: u'canonical-releases'
     official_candidate: True
     owner_link: u'http://.../~mark'
@@ -145,6 +147,7 @@ Mirror listing admins may see all:
     enabled: True
     ftp_base_url: None
     http_base_url: u'http://localhost:11375/archive-mirror/'
+    https_base_url: None
     name: u'archive-404-mirror'
     official_candidate: True
     owner_link: u'http://.../~name12'
@@ -227,6 +230,7 @@ While others can be set with the appropriate authorization:
     enabled: True
     ftp_base_url: None
     http_base_url: u'http://releases.ubuntu.com/'
+    https_base_url: None
     name: u'canonical-releases'
     official_candidate: True
     owner_link: u'http://.../~mark'
diff --git a/lib/lp/registry/stories/webservice/xx-distribution.txt b/lib/lp/registry/stories/webservice/xx-distribution.txt
index 1c0ed87..1486904 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution.txt
+++ b/lib/lp/registry/stories/webservice/xx-distribution.txt
@@ -159,6 +159,7 @@ packages matching (substring) the given text.
     enabled: True
     ftp_base_url: None
     http_base_url: u'http://releases.ubuntu.com/'
+    https_base_url: None
     name: u'canonical-releases'
     official_candidate: True
     owner_link: u'http://.../~mark'
diff --git a/lib/lp/registry/templates/distributionmirror-index.pt b/lib/lp/registry/templates/distributionmirror-index.pt
index 78e934d..27f3139 100644
--- a/lib/lp/registry/templates/distributionmirror-index.pt
+++ b/lib/lp/registry/templates/distributionmirror-index.pt
@@ -118,6 +118,10 @@
     <h2>Mirror location information</h2>
 
     <ul class="webref" id="mirror-urls">
+      <li tal:condition="context/https_base_url" >
+        <a tal:content="context/https_base_url"
+           tal:attributes="href context/https_base_url">https://url/</a>
+      </li>
       <li tal:condition="context/http_base_url" >
         <a tal:content="context/http_base_url"
            tal:attributes="href context/http_base_url">http://url/</a>
diff --git a/lib/lp/registry/templates/distributionmirror-macros.pt b/lib/lp/registry/templates/distributionmirror-macros.pt
index 18f82af..489f770 100644
--- a/lib/lp/registry/templates/distributionmirror-macros.pt
+++ b/lib/lp/registry/templates/distributionmirror-macros.pt
@@ -17,7 +17,7 @@
   <tbody>
     <tal:country_and_mirrors repeat="country_and_mirrors mirrors_by_country">
       <tr class="head">
-        <th colspan="2" 
+        <th colspan="2"
             tal:content="country_and_mirrors/country" />
         <th tal:content="country_and_mirrors/throughput"/>
         <th tal:condition="show_mirror_type">
@@ -35,6 +35,8 @@
              tal:content="mirror/title">Mirror Name</a>
         </td>
         <td>
+          <a tal:condition="mirror/https_base_url"
+             tal:attributes="href mirror/https_base_url">https</a>
           <a tal:condition="mirror/http_base_url"
              tal:attributes="href mirror/http_base_url">http</a>
           <a tal:condition="mirror/ftp_base_url"
diff --git a/lib/lp/registry/tests/distributionmirror_http_server.py b/lib/lp/registry/tests/distributionmirror_http_server.py
index e3e0a39..d12c9b3 100644
--- a/lib/lp/registry/tests/distributionmirror_http_server.py
+++ b/lib/lp/registry/tests/distributionmirror_http_server.py
@@ -1,6 +1,6 @@
 #!/usr/bin/python
 #
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from twisted.web.resource import Resource
@@ -21,10 +21,10 @@ class DistributionMirrorTestHTTPServer(Resource):
     :error: Respond with a '500 Internal Server Error' status.
 
     :redirect-to-valid-mirror/*: Respond with a '302 Found' status,
-        redirecting to http://localhost:%(port)s/valid-mirror/*.
+        redirecting to http(s)://localhost:%(port)s/valid-mirror/*.
 
     :redirect-infinite-loop: Respond with a '302 Found' status, redirecting
-        to http://localhost:%(port)s/redirect-infinite-loop.
+        to http(s)://localhost:%(port)s/redirect-infinite-loop.
 
     :redirect-unknown-url-scheme: Respond with a '302 Found' status,
         redirecting to ssh://localhost/redirect-unknown-url-scheme.
@@ -32,11 +32,13 @@ class DistributionMirrorTestHTTPServer(Resource):
     Any other path will cause the server to respond with a '404 Not Found'
     status.
     """
+    protocol = "http"
 
     def getChild(self, name, request):
+        protocol = self.protocol
         port = request.getHost().port
         if name == 'valid-mirror':
-            leaf = DistributionMirrorTestHTTPServer()
+            leaf = self.__class__()
             leaf.isLeaf = True
             return leaf
         elif name == 'timeout':
@@ -49,12 +51,14 @@ class DistributionMirrorTestHTTPServer(Resource):
                 'than one component.')
             remaining_path = request.path.replace('/%s' % name, '')
             leaf = RedirectingResource(
-                'http://localhost:%s/valid-mirror%s' % (port, remaining_path))
+                '%s://localhost:%s/valid-mirror%s' % (
+                    protocol, port, remaining_path))
             leaf.isLeaf = True
             return leaf
         elif name == 'redirect-infinite-loop':
             return RedirectingResource(
-                'http://localhost:%s/redirect-infinite-loop' % port)
+                '%s://localhost:%s/redirect-infinite-loop' %
+                (protocol, port))
         elif name == 'redirect-unknown-url-scheme':
             return RedirectingResource(
                 'ssh://localhost/redirect-unknown-url-scheme')
@@ -65,6 +69,11 @@ class DistributionMirrorTestHTTPServer(Resource):
         return "Hi"
 
 
+class DistributionMirrorTestSecureHTTPServer(DistributionMirrorTestHTTPServer):
+    """HTTPS version of DistributionMirrorTestHTTPServer"""
+    protocol = "https"
+
+
 class RedirectingResource(Resource):
 
     def __init__(self, redirection_url):
@@ -85,4 +94,3 @@ class FiveHundredResource(Resource):
     def render_GET(self, request):
         request.setResponseCode(500)
         request.write('ASPLODE!!!')
-        return NOT_DONE_YET
diff --git a/lib/lp/registry/tests/test_distributionmirror_prober.py b/lib/lp/registry/tests/test_distributionmirror_prober.py
index 39228d2..c3e363c 100644
--- a/lib/lp/registry/tests/test_distributionmirror_prober.py
+++ b/lib/lp/registry/tests/test_distributionmirror_prober.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """distributionmirror-prober tests."""
@@ -11,6 +11,7 @@ import logging
 import os
 from StringIO import StringIO
 
+from fixtures import MockPatchObject
 from lazr.uri import URI
 import responses
 from six.moves import http_client
@@ -28,9 +29,14 @@ from testtools.twistedsupport import (
 from twisted.internet import (
     defer,
     reactor,
+    ssl,
     )
 from twisted.python.failure import Failure
 from twisted.web import server
+from twisted.web.client import (
+    BrowserLikePolicyForHTTPS,
+    ProxyAgent,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -44,6 +50,8 @@ from lp.registry.scripts.distributionmirror_prober import (
     BadResponseCode,
     ConnectionSkipped,
     InfiniteLoopDetected,
+    InvalidHTTPSCertificate,
+    InvalidHTTPSCertificateSkipped,
     LoggingMixin,
     MAX_REDIRECTS,
     MIN_REQUEST_TIMEOUT_RATIO,
@@ -65,6 +73,7 @@ from lp.registry.scripts.distributionmirror_prober import (
     )
 from lp.registry.tests.distributionmirror_http_server import (
     DistributionMirrorTestHTTPServer,
+    DistributionMirrorTestSecureHTTPServer,
     )
 from lp.services.config import config
 from lp.services.daemons.tachandler import TacTestSetup
@@ -103,6 +112,186 @@ class HTTPServerTestSetup(TacTestSetup):
         return os.path.join(self.root, 'distributionmirror_http_server.log')
 
 
+
+class LocalhostWhitelistedHTTPSPolicy(BrowserLikePolicyForHTTPS):
+    """HTTPS policy that bypasses SSL certificate check when doing requests
+    to localhost.
+    """
+
+    def creatorForNetloc(self, hostname, port):
+        # check if the hostname is in the the whitelist,
+        # otherwise return the default policy
+        if hostname == 'localhost':
+            return ssl.CertificateOptions(verify=False)
+        return super(LocalhostWhitelistedHTTPSPolicy, self).creatorForNetloc(
+            hostname, port)
+
+
+class TestProberHTTPSProtocolAndFactory(TestCase):
+    layer = TwistedLayer
+    run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
+        timeout=30)
+
+    def setUp(self):
+        super(TestProberHTTPSProtocolAndFactory, self).setUp()
+        root = DistributionMirrorTestSecureHTTPServer()
+        site = server.Site(root)
+        site.displayTracebacks = False
+        keys_path = os.path.join(config.root, "configs", "development")
+        keys = ssl.DefaultOpenSSLContextFactory(
+            os.path.join(keys_path, "launchpad.key"),
+            os.path.join(keys_path, "launchpad.crt"),
+            )
+        self.listening_port = reactor.listenSSL(0, site, keys)
+
+        self.addCleanup(self.listening_port.stopListening)
+
+        # Change the default policy to accept localhost self-signed
+        # certificates.
+        original_probefactory_policy = ProberFactory.https_agent_policy
+        original_redirect_policy = (
+            RedirectAwareProberFactory.https_agent_policy)
+        ProberFactory.https_agent_policy = LocalhostWhitelistedHTTPSPolicy
+        RedirectAwareProberFactory.https_agent_policy = (
+            LocalhostWhitelistedHTTPSPolicy)
+
+        for factory in (ProberFactory, RedirectAwareProberFactory):
+            self.useFixture(MockPatchObject(
+                factory, "https_agent_policy",
+                LocalhostWhitelistedHTTPSPolicy))
+
+        self.port = self.listening_port.getHost().port
+
+        self.urls = {'timeout': u'https://localhost:%s/timeout' % self.port,
+                     '200': u'https://localhost:%s/valid-mirror' % self.port,
+                     '500': u'https://localhost:%s/error' % self.port,
+                     '404': u'https://localhost:%s/invalid-mirror' % self.port}
+        self.pushConfig('launchpad', http_proxy=None)
+
+        self.useFixture(MockPatchObject(
+            distributionmirror_prober, "host_requests", {}))
+        self.useFixture(MockPatchObject(
+            distributionmirror_prober, "host_timeouts", {}))
+        self.useFixture(MockPatchObject(
+            distributionmirror_prober, "invalid_certificate_hosts", set()))
+
+    def _createProberAndProbe(self, url):
+        prober = ProberFactory(url)
+        return prober.probe()
+
+    def test_timeout(self):
+        prober = ProberFactory(self.urls['timeout'], timeout=0.5)
+        d = prober.probe()
+        return assert_fails_with(d, ProberTimeout)
+
+    def test_500(self):
+        d = self._createProberAndProbe(self.urls['500'])
+        return assert_fails_with(d, BadResponseCode)
+
+    def test_notfound(self):
+        d = self._createProberAndProbe(self.urls['404'])
+        return assert_fails_with(d, BadResponseCode)
+
+    def test_config_no_https_proxy(self):
+        prober = ProberFactory(self.urls['200'])
+        self.assertThat(prober, MatchesStructure.byEquality(
+            request_scheme='https',
+            request_host='localhost',
+            request_port=self.port,
+            request_path='/valid-mirror',
+            connect_scheme='https',
+            connect_host='localhost',
+            connect_port=self.port,
+            connect_path='/valid-mirror'))
+
+    def test_RedirectAwareProber_follows_https_redirect(self):
+        url = 'https://localhost:%s/redirect-to-valid-mirror/file' % self.port
+        prober = RedirectAwareProberFactory(url)
+        self.assertEqual(prober.url, url)
+        deferred = prober.probe()
+
+        def got_result(result):
+            self.assertEqual(http_client.OK, result.code)
+            self.assertEqual(
+                'https://localhost:%s/valid-mirror/file' % self.port,
+                result.request.absoluteURI)
+
+        return deferred.addCallback(got_result)
+
+    def test_https_prober_uses_proxy(self):
+        root = DistributionMirrorTestSecureHTTPServer()
+        site = server.Site(root)
+        proxy_listen_port = reactor.listenTCP(0, site)
+        proxy_port = proxy_listen_port.getHost().port
+        self.pushConfig(
+            'launchpad', http_proxy='http://localhost:%s/valid-mirror/file'
+                                    % proxy_port)
+
+        url = 'https://localhost:%s/valid-mirror/file' % self.port
+        prober = RedirectAwareProberFactory(url)
+        self.assertEqual(prober.url, url)
+        deferred = prober.probe()
+
+        def got_result(result):
+            # We basically don't care about the result here. We just want to
+            # check that it did the request to the correct URI,
+            # and ProxyAgent was used pointing to the correct proxy.
+            agent = prober.getHttpsClient()._agent
+            self.assertIsInstance(agent, ProxyAgent)
+            self.assertEqual('localhost', agent._proxyEndpoint._hostText)
+            self.assertEqual(proxy_port, agent._proxyEndpoint._port)
+
+            self.assertEqual(
+                'https://localhost:%s/valid-mirror/file' % self.port,
+                result.value.response.request.absoluteURI)
+
+        def cleanup(*args, **kwargs):
+            proxy_listen_port.stopListening()
+
+        # Doing the proxy checks on the error callback because the
+        # proxy is dummy and always returns 404.
+        deferred.addErrback(got_result)
+        deferred.addBoth(cleanup)
+        return deferred
+
+    def test_https_fails_on_invalid_certificates(self):
+        """Changes set back the default browser-like policy for HTTPS
+        request and make sure the request is failing due to invalid
+        (self-signed) certificate.
+        """
+        url = 'https://localhost:%s/valid-mirror/file' % self.port
+        prober = RedirectAwareProberFactory(url)
+        prober.https_agent_policy = BrowserLikePolicyForHTTPS
+        self.assertEqual(prober.url, url)
+        deferred = prober.probe()
+
+        def on_failure(result):
+            self.assertIsInstance(result.value, InvalidHTTPSCertificate)
+            self.assertIn(
+                ("localhost", self.port),
+                distributionmirror_prober.invalid_certificate_hosts)
+
+        def on_success(result):
+            if result is not None:
+                self.fail(
+                    "Should have raised SSL error. Got '%s' instead" % result)
+
+        deferred.addErrback(on_failure)
+        deferred.addCallback(on_success)
+        return deferred
+
+    def test_https_skips_invalid_certificates_hosts(self):
+        distributionmirror_prober.invalid_certificate_hosts.add(
+            ("localhost", self.port))
+        url = 'https://localhost:%s/valid-mirror/file' % self.port
+        prober = RedirectAwareProberFactory(url)
+        prober.https_agent_policy = BrowserLikePolicyForHTTPS
+        self.assertEqual(prober.url, url)
+        deferred = prober.probe()
+
+        return assert_fails_with(deferred, InvalidHTTPSCertificateSkipped)
+
+
 class TestProberProtocolAndFactory(TestCase):
 
     layer = TwistedLayer
@@ -212,7 +401,7 @@ class TestProberProtocolAndFactory(TestCase):
             self.assertTrue(prober.url == new_url)
             self.assertTrue(result == str(http_client.OK))
 
-        return deferred.addCallback(got_result)
+        return deferred.addBoth(got_result)
 
     def test_redirectawareprober_detects_infinite_loop(self):
         prober = RedirectAwareProberFactory(
@@ -737,12 +926,16 @@ class TestMirrorCDImageProberCallbacks(TestCaseWithFactory):
                 ConnectionSkipped,
                 RedirectToDifferentFile,
                 UnknownURLSchemeAfterRedirect,
+                InvalidHTTPSCertificate,
+                InvalidHTTPSCertificateSkipped,
                 ]))
         exceptions = [BadResponseCode(str(http_client.NOT_FOUND)),
                       ProberTimeout('http://localhost/', 5),
                       ConnectionSkipped(),
                       RedirectToDifferentFile('/foo', '/bar'),
-                      UnknownURLSchemeAfterRedirect('https://localhost')]
+                      UnknownURLSchemeAfterRedirect('https://localhost'),
+                      InvalidHTTPSCertificate('localhost', 443),
+                      InvalidHTTPSCertificateSkipped("https://localhost/xx";)]
         for exception in exceptions:
             failure = callbacks.ensureOrDeleteMirrorCDImageSeries(
                 [(defer.FAILURE, Failure(exception))])
diff --git a/lib/lp/scripts/utilities/importpedant.py b/lib/lp/scripts/utilities/importpedant.py
index ec41baf..c7859a4 100644
--- a/lib/lp/scripts/utilities/importpedant.py
+++ b/lib/lp/scripts/utilities/importpedant.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import, print_function, unicode_literals
@@ -42,6 +42,8 @@ valid_imports_not_in_all = {
     'textwrap': set(['dedent']),
     'testtools.testresult.real': set(['_details_to_str']),
     'twisted.internet.threads': set(['deferToThreadPool']),
+    # Even docs tell us to use this class. See docs on WebClientContextFactory.
+    'twisted.web.client': set(['BrowserLikePolicyForHTTPS']),
     'zope.component': set(
         ['adapter',
          'ComponentLookupError',
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 85a05b6..6c0492f 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Testing infrastructure for the Launchpad application.
@@ -3527,13 +3527,13 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         return proberecord
 
     def makeMirror(self, distribution, displayname=None, country=None,
-                   http_url=None, ftp_url=None, rsync_url=None,
+                   http_url=None, https_url=None, ftp_url=None, rsync_url=None,
                    official_candidate=False):
         """Create a mirror for the distribution."""
         if displayname is None:
             displayname = self.getUniqueString("mirror")
         # If no URL is specified create an HTTP URL.
-        if http_url is None and ftp_url is None and rsync_url is None:
+        if http_url is https_url is ftp_url is rsync_url is None:
             http_url = self.getUniqueURL()
         # If no country is given use Argentina.
         if country is None:
@@ -3547,6 +3547,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             display_name=displayname,
             description=None,
             http_base_url=http_url,
+            https_base_url=https_url,
             ftp_base_url=ftp_url,
             rsync_base_url=rsync_url,
             official_candidate=official_candidate)

References