Merge ~pappacena/launchpad:https-mirrors into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: b9738272f31434e053dfd23cd10e78bcf079a91d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:https-mirrors
Merge into: launchpad:master
Diff against target: 1116 lines (+477/-53)
16 files modified
lib/lp/registry/browser/distributionmirror.py (+7/-6)
lib/lp/registry/browser/tests/distributionmirror-views.txt (+40/-9)
lib/lp/registry/configure.zcml (+5/-3)
lib/lp/registry/interfaces/distribution.py (+4/-4)
lib/lp/registry/interfaces/distributionmirror.py (+21/-3)
lib/lp/registry/model/distribution.py (+7/-4)
lib/lp/registry/model/distributionmirror.py (+10/-2)
lib/lp/registry/scripts/distributionmirror_prober.py (+153/-7)
lib/lp/registry/stories/webservice/xx-distribution-mirror.txt (+4/-0)
lib/lp/registry/stories/webservice/xx-distribution.txt (+1/-0)
lib/lp/registry/templates/distributionmirror-index.pt (+4/-0)
lib/lp/registry/templates/distributionmirror-macros.pt (+3/-1)
lib/lp/registry/tests/distributionmirror_http_server.py (+15/-7)
lib/lp/registry/tests/test_distributionmirror_prober.py (+196/-3)
lib/lp/scripts/utilities/importpedant.py (+3/-1)
lib/lp/testing/factory.py (+4/-3)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+379387@code.launchpad.net

Commit message

Allowing to register HTTPS mirrors (both for CD images and archives).

Description of the change

Continuing the work done by Andy Brody, this MP allows us to register HTTPS mirrors, and adjust distributino mirror prober to check the health of those types of mirrors.

Original work done by Brody is available here: Original work by Brody here: https://code.launchpad.net/~abrody/launchpad/https-mirror

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I have a question about the prober: what should we do about mirrors using "invalid" certificates? Should we consider them or not?

I'm asking because I was thinking to mark those as not healthy, but the HTTPS version of releases.ubuntu.com and nl.archive.ubuntu.com, for example, both use a certificate that is not trusted (the domain does not match the cerfiticate).

Maybe cjwatson has an opinion about it.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Another topic is proxy: the current HTTP prober supports proxy (configured at config.launchpad.http_proxy). Is there any reason for us to support this type of proxy for HTTPS?

Revision history for this message
Colin Watson (cjwatson) wrote :

I'll answer the questions briefly without (yet) doing a code review:

 * Mirrors using invalid certificates should be treated as being broken, ideally with a clear error message in the prober log saying as much. releases.ubuntu.com and nl.archive.ubuntu.com don't yet officially advertise HTTPS; you only get an HTTPS response from them by coincidence (i.e. they happen to share an IP address with something that actually does HTTPS). This is one of the things mirror admins would have to fix in order to support HTTPS properly.

 * I think we need to support http_proxy, because IIRC some of the relevant deployments are in environments where that's how they talk to the outside world. But this shouldn't be a problem; we already use that for HTTPS in other places (e.g. lp.bugs.externalbugtracker.github, via lp.services.timeout). It may require refactoring the prober to use twisted.web.client or something built on top of it like treq; see for instance twisted.web.client.ProxyAgent, which knows how to do HTTPS proxying via CONNECT.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I think it's in a good state for a review now, cjwatson. It's including:

- Brody's work to add the new fields
- The changes on prober to deal with HTTPS (using treq), including proxy (using ProxyAgent) and dealing with invalid certificates (both generic SSL errors and validation errors, like DNS mismatches).

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :
Revision history for this message
Colin Watson (cjwatson) wrote :

Thanks for chasing this up! Looks like mostly a good job on the prober changes - just a few small things. Also, you're going to need to make at least basic changes to lib/lp/registry/stories/webservice/xx-distribution.txt and lib/lp/registry/stories/webservice/xx-distribution-mirror.txt due to the newly-exported fields, and it would be good to add suitable tests to lib/lp/registry/browser/tests/distributionmirror-views.txt and lib/lp/registry/doc/distribution-mirror.txt too (acknowledging that the doctests are unpleasant).

review: Needs Fixing
~pappacena/launchpad:https-mirrors updated
27f75ce... by Thiago F. Pappacena

recognizing https_base_url fields on webservices doc tests

c4c455a... by Thiago F. Pappacena

fixing mirrors test cases

bb1be86... by Thiago F. Pappacena

fixing typo and hidding missleading field description

e952bb1... by Thiago F. Pappacena

refactoring to better deal with errors on https

7ec4172... by Thiago F. Pappacena

refactoring tests to use MockPatchObject

331890b... by Thiago F. Pappacena

small refactorings for tests

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I have done the requested changes. I also added a better HTTPs error code handling, and a couple of tests.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
~pappacena/launchpad:https-mirrors updated
b973827... by Thiago F. Pappacena

fixing typo and ignoring unused variable

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -207,9 +207,9 @@ class DistributionMirrorDeleteView(LaunchpadFormView):
207class DistributionMirrorAddView(LaunchpadFormView):207class DistributionMirrorAddView(LaunchpadFormView):
208 schema = IDistributionMirror208 schema = IDistributionMirror
209 field_names = [209 field_names = [
210 "display_name", "description", "whiteboard", "http_base_url",210 "display_name", "description", "whiteboard", "https_base_url",
211 "ftp_base_url", "rsync_base_url", "speed", "country", "content",211 "http_base_url", "ftp_base_url", "rsync_base_url", "speed", "country",
212 "official_candidate",212 "content", "official_candidate",
213 ]213 ]
214 invariant_context = None214 invariant_context = None
215215
@@ -235,6 +235,7 @@ class DistributionMirrorAddView(LaunchpadFormView):
235 content=data['content'], display_name=data['display_name'],235 content=data['content'], display_name=data['display_name'],
236 description=data['description'],236 description=data['description'],
237 whiteboard=data['whiteboard'],237 whiteboard=data['whiteboard'],
238 https_base_url=data['https_base_url'],
238 http_base_url=data['http_base_url'],239 http_base_url=data['http_base_url'],
239 ftp_base_url=data['ftp_base_url'],240 ftp_base_url=data['ftp_base_url'],
240 rsync_base_url=data['rsync_base_url'],241 rsync_base_url=data['rsync_base_url'],
@@ -279,8 +280,8 @@ class DistributionMirrorEditView(LaunchpadEditFormView):
279 schema = IDistributionMirror280 schema = IDistributionMirror
280 field_names = [281 field_names = [
281 "name", "display_name", "description", "whiteboard",282 "name", "display_name", "description", "whiteboard",
282 "http_base_url", "ftp_base_url", "rsync_base_url", "speed",283 "https_base_url", "http_base_url", "ftp_base_url", "rsync_base_url",
283 "country", "content", "official_candidate",284 "speed", "country", "content", "official_candidate",
284 ]285 ]
285286
286 @property287 @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
44 >>> print view.cancel_url44 >>> print view.cancel_url
45 http://launchpad.test/ubuntu45 http://launchpad.test/ubuntu
4646
47A HTTP or FTP URL is required to register a mirror.47A HTTP, HTTPS or FTP URL is required to register a mirror.
4848
49 >>> view.field_names49 >>> view.field_names
50 ['display_name', 'description', 'whiteboard', 'http_base_url',50 ['display_name', 'description', 'whiteboard', 'https_base_url',
51 'ftp_base_url', 'rsync_base_url', 'speed', 'country', 'content',51 'http_base_url', 'ftp_base_url', 'rsync_base_url', 'speed', 'country',
52 'official_candidate']52 'content', 'official_candidate']
5353
54 >>> form = {54 >>> form = {
55 ... 'field.display_name': 'Illuminati',55 ... 'field.display_name': 'Illuminati',
56 ... 'field.description': 'description',56 ... 'field.description': 'description',
57 ... 'field.whiteboard': 'whiteboard',57 ... 'field.whiteboard': 'whiteboard',
58 ... 'field.http_base_url': 'http://secret.me/',58 ... 'field.http_base_url': 'http://secret.me/',
59 ... 'field.https_base_url': '',
59 ... 'field.ftp_base_url': '',60 ... 'field.ftp_base_url': '',
60 ... 'field.rsync_base_url': '',61 ... 'field.rsync_base_url': '',
61 ... 'field.speed': 'S128K',62 ... 'field.speed': 'S128K',
@@ -93,6 +94,7 @@ not significant).
93The same is true for a FTP URL.94The same is true for a FTP URL.
9495
95 >>> mirror.ftp_base_url = 'ftp://now-here.me/'96 >>> mirror.ftp_base_url = 'ftp://now-here.me/'
97 >>> bad_form['field.https_base_url'] = ''
96 >>> bad_form['field.http_base_url'] = ''98 >>> bad_form['field.http_base_url'] = ''
97 >>> bad_form['field.ftp_base_url'] = 'ftp://now-here.me'99 >>> bad_form['field.ftp_base_url'] = 'ftp://now-here.me'
98 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)100 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
@@ -110,14 +112,15 @@ The same is true for a rsync URL.
110 ... print error[2]112 ... print error[2]
111 The distribution mirror ... is already registered with this URL.113 The distribution mirror ... is already registered with this URL.
112114
113A mirror must have an ftp or http URL.115A mirror must have an ftp, HTTPS or http URL.
114116
117 >>> bad_form['field.https_base_url'] = ''
115 >>> bad_form['field.http_base_url'] = ''118 >>> bad_form['field.http_base_url'] = ''
116 >>> bad_form['field.ftp_base_url'] = ''119 >>> bad_form['field.ftp_base_url'] = ''
117 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)120 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
118 >>> for message in view.errors:121 >>> for message in view.errors:
119 ... print message122 ... print message
120 A mirror must have at least an HTTP or FTP URL.123 A mirror must have at least an HTTP(S) or FTP URL.
121124
122The URL cannot contain a fragment.125The URL cannot contain a fragment.
123126
@@ -135,6 +138,34 @@ The URL cannot contain a query string.
135 ... print error[2]138 ... print error[2]
136 URIs with query strings are not allowed.139 URIs with query strings are not allowed.
137140
141The HTTPS URL may not have an HTTP scheme.
142
143 >>> bad_form['field.http_base_url'] = ''
144 >>> bad_form['field.https_base_url'] = 'http://secret.me/#fragement'
145 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
146 >>> for error in view.errors:
147 ... print error[2]
148 The URI scheme "http" is not allowed.
149 Only URIs with the following schemes may be used: https
150
151The HTTPS URL cannot contain a fragment.
152
153 >>> bad_form['field.http_base_url'] = ''
154 >>> bad_form['field.https_base_url'] = 'https://secret.me/#fragement'
155 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
156 >>> for error in view.errors:
157 ... print error[2]
158 URIs with fragment identifiers are not allowed.
159
160The URL cannot contain a query string.
161
162 >>> bad_form['field.http_base_url'] = ''
163 >>> bad_form['field.https_base_url'] = 'https://secret.me/?query=string'
164 >>> view = create_initialized_view(ubuntu, '+newmirror', form=bad_form)
165 >>> for error in view.errors:
166 ... print error[2]
167 URIs with query strings are not allowed.
168
138169
139Reviewing a distribution mirror170Reviewing a distribution mirror
140-------------------------------171-------------------------------
@@ -239,9 +270,9 @@ The +edit view provides a label, page_title, and cancel_url.
239The user can edit the mirror fields.270The user can edit the mirror fields.
240271
241 >>> view.field_names272 >>> view.field_names
242 ['name', 'display_name', 'description', 'whiteboard', 'http_base_url',273 ['name', 'display_name', 'description', 'whiteboard', 'https_base_url',
243 'ftp_base_url', 'rsync_base_url', 'speed', 'country', 'content',274 'http_base_url', 'ftp_base_url', 'rsync_base_url', 'speed', 'country',
244 'official_candidate']275 'content', 'official_candidate']
245276
246 >>> print mirror.ftp_base_url277 >>> print mirror.ftp_base_url
247 None278 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 @@
1<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -2046,6 +2046,7 @@
2046 description2046 description
2047 distribution2047 distribution
2048 http_base_url2048 http_base_url
2049 https_base_url
2049 ftp_base_url2050 ftp_base_url
2050 rsync_base_url2051 rsync_base_url
2051 enabled2052 enabled
@@ -2084,8 +2085,9 @@
2084 <require2085 <require
2085 permission="launchpad.Edit"2086 permission="launchpad.Edit"
2086 set_attributes="name display_name description whiteboard2087 set_attributes="name display_name description whiteboard
2087 http_base_url ftp_base_url rsync_base_url enabled2088 http_base_url https_base_url ftp_base_url
2088 speed country content official_candidate owner"2089 rsync_base_url enabled speed country content
2090 official_candidate owner"
2089 attributes="official_candidate whiteboard resubmitForReview" />2091 attributes="official_candidate whiteboard resubmitForReview" />
2090 <require2092 <require
2091 permission="launchpad.Admin"2093 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 @@
1# Copyright 2009-2015 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Interfaces including and related to IDistribution."""4"""Interfaces including and related to IDistribution."""
@@ -491,13 +491,13 @@ class IDistributionPublic(
491 """Return the country DNS mirror for a country and content type."""491 """Return the country DNS mirror for a country and content type."""
492492
493 def newMirror(owner, speed, country, content, display_name=None,493 def newMirror(owner, speed, country, content, display_name=None,
494 description=None, http_base_url=None,494 description=None, http_base_url=None, https_base_url=None,
495 ftp_base_url=None, rsync_base_url=None, enabled=False,495 ftp_base_url=None, rsync_base_url=None, enabled=False,
496 official_candidate=False, whiteboard=None):496 official_candidate=False, whiteboard=None):
497 """Create a new DistributionMirror for this distribution.497 """Create a new DistributionMirror for this distribution.
498498
499 At least one of http_base_url or ftp_base_url must be provided in499 At least one of {http,https,ftp}_base_url must be provided in order to
500 order to create a mirror.500 create a mirror.
501 """501 """
502502
503 def getOCIProject(name):503 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 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -302,6 +302,12 @@ class DistroMirrorHTTPURIField(DistroMirrorURIField):
302 return getUtility(IDistributionMirrorSet).getByHttpUrl(url)302 return getUtility(IDistributionMirrorSet).getByHttpUrl(url)
303303
304304
305class DistroMirrorHTTPSURIField(DistroMirrorURIField):
306
307 def getMirrorByURI(self, url):
308 return getUtility(IDistributionMirrorSet).getByHttpsUrl(url)
309
310
305class DistroMirrorFTPURIField(DistroMirrorURIField):311class DistroMirrorFTPURIField(DistroMirrorURIField):
306312
307 def getMirrorByURI(self, url):313 def getMirrorByURI(self, url):
@@ -349,6 +355,14 @@ class IDistributionMirror(Interface):
349 allowed_schemes=['http'], allow_userinfo=False,355 allowed_schemes=['http'], allow_userinfo=False,
350 allow_query=False, allow_fragment=False, trailing_slash=True,356 allow_query=False, allow_fragment=False, trailing_slash=True,
351 description=_('e.g.: http://archive.ubuntu.com/ubuntu/')))357 description=_('e.g.: http://archive.ubuntu.com/ubuntu/')))
358 https_base_url = exported(DistroMirrorHTTPSURIField(
359 title=_('HTTPS URL'), required=False, readonly=False,
360 allowed_schemes=['https'], allow_userinfo=False,
361 allow_query=False, allow_fragment=False, trailing_slash=True,
362 # XXX: pappacena 2020-02-21: Add description field with a more
363 # suitable example once we have https for archive.ubuntu.com, like:
364 # description=_('e.g.: http://archive.ubuntu.com/ubuntu/')
365 ))
352 ftp_base_url = exported(DistroMirrorFTPURIField(366 ftp_base_url = exported(DistroMirrorFTPURIField(
353 title=_('FTP URL'), required=False, readonly=False,367 title=_('FTP URL'), required=False, readonly=False,
354 allowed_schemes=['ftp'], allow_userinfo=False,368 allowed_schemes=['ftp'], allow_userinfo=False,
@@ -435,8 +449,9 @@ class IDistributionMirror(Interface):
435449
436 @invariant450 @invariant
437 def mirrorMustHaveHTTPOrFTPURL(mirror):451 def mirrorMustHaveHTTPOrFTPURL(mirror):
438 if not (mirror.http_base_url or mirror.ftp_base_url):452 if not (mirror.http_base_url or mirror.https_base_url or
439 raise Invalid('A mirror must have at least an HTTP or FTP URL.')453 mirror.ftp_base_url):
454 raise Invalid('A mirror must have at least an HTTP(S) or FTP URL.')
440455
441 def getSummarizedMirroredSourceSeries():456 def getSummarizedMirroredSourceSeries():
442 """Return a summarized list of this distribution_mirror's457 """Return a summarized list of this distribution_mirror's
@@ -614,6 +629,9 @@ class IDistributionMirrorSet(Interface):
614 def getByHttpUrl(url):629 def getByHttpUrl(url):
615 """Return the mirror with the given HTTP URL or None."""630 """Return the mirror with the given HTTP URL or None."""
616631
632 def getByHttpsUrl(url):
633 """Return the mirror with the given HTTPS URL or None."""
634
617 def getByFtpUrl(url):635 def getByFtpUrl(url):
618 """Return the mirror with the given FTP URL or None."""636 """Return the mirror with the given FTP URL or None."""
619637
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 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Database classes for implementing distribution items."""4"""Database classes for implementing distribution items."""
@@ -709,7 +709,7 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
709 country_dns_mirror=True).one()709 country_dns_mirror=True).one()
710710
711 def newMirror(self, owner, speed, country, content, display_name=None,711 def newMirror(self, owner, speed, country, content, display_name=None,
712 description=None, http_base_url=None,712 description=None, http_base_url=None, https_base_url=None,
713 ftp_base_url=None, rsync_base_url=None,713 ftp_base_url=None, rsync_base_url=None,
714 official_candidate=False, enabled=False,714 official_candidate=False, enabled=False,
715 whiteboard=None):715 whiteboard=None):
@@ -722,15 +722,17 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
722 return None722 return None
723723
724 urls = {'http_base_url': http_base_url,724 urls = {'http_base_url': http_base_url,
725 'https_base_url': https_base_url,
725 'ftp_base_url': ftp_base_url,726 'ftp_base_url': ftp_base_url,
726 'rsync_base_url': rsync_base_url}727 'rsync_base_url': rsync_base_url}
727 for name, value in urls.items():728 for name, value in urls.items():
728 if value is not None:729 if value is not None:
729 urls[name] = IDistributionMirror[name].normalize(value)730 urls[name] = IDistributionMirror[name].normalize(value)
730731
731 url = urls['http_base_url'] or urls['ftp_base_url']732 url = (urls['https_base_url'] or urls['http_base_url'] or
733 urls['ftp_base_url'])
732 assert url is not None, (734 assert url is not None, (
733 "A mirror must provide either an HTTP or FTP URL (or both).")735 "A mirror must provide at least one HTTP/HTTPS/FTP URL.")
734 dummy, host, dummy, dummy, dummy, dummy = urlparse(url)736 dummy, host, dummy, dummy, dummy, dummy = urlparse(url)
735 name = sanitize_name('%s-%s' % (host, content.name.lower()))737 name = sanitize_name('%s-%s' % (host, content.name.lower()))
736738
@@ -744,6 +746,7 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
744 distribution=self, owner=owner, name=name, speed=speed,746 distribution=self, owner=owner, name=name, speed=speed,
745 country=country, content=content, display_name=display_name,747 country=country, content=content, display_name=display_name,
746 description=description, http_base_url=urls['http_base_url'],748 description=description, http_base_url=urls['http_base_url'],
749 https_base_url=urls['https_base_url'],
747 ftp_base_url=urls['ftp_base_url'],750 ftp_base_url=urls['ftp_base_url'],
748 rsync_base_url=urls['rsync_base_url'],751 rsync_base_url=urls['rsync_base_url'],
749 official_candidate=official_candidate, enabled=enabled,752 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 @@
1# Copyright 2009-2015 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Module docstring goes here."""4"""Module docstring goes here."""
@@ -129,6 +129,8 @@ class DistributionMirror(SQLBase):
129 notNull=False, default=None)129 notNull=False, default=None)
130 http_base_url = StringCol(130 http_base_url = StringCol(
131 notNull=False, default=None, unique=True)131 notNull=False, default=None, unique=True)
132 https_base_url = StringCol(
133 notNull=False, default=None, unique=True)
132 ftp_base_url = StringCol(134 ftp_base_url = StringCol(
133 notNull=False, default=None, unique=True)135 notNull=False, default=None, unique=True)
134 rsync_base_url = StringCol(136 rsync_base_url = StringCol(
@@ -155,7 +157,9 @@ class DistributionMirror(SQLBase):
155 @property157 @property
156 def base_url(self):158 def base_url(self):
157 """See IDistributionMirror"""159 """See IDistributionMirror"""
158 if self.http_base_url is not None:160 if self.https_base_url is not None:
161 return self.https_base_url
162 elif self.http_base_url is not None:
159 return self.http_base_url163 return self.http_base_url
160 else:164 else:
161 return self.ftp_base_url165 return self.ftp_base_url
@@ -677,6 +681,10 @@ class DistributionMirrorSet:
677 """See IDistributionMirrorSet"""681 """See IDistributionMirrorSet"""
678 return DistributionMirror.selectOneBy(http_base_url=url)682 return DistributionMirror.selectOneBy(http_base_url=url)
679683
684 def getByHttpsUrl(self, url):
685 """See IDistributionMirrorSet"""
686 return DistributionMirror.selectOneBy(https_base_url=url)
687
680 def getByFtpUrl(self, url):688 def getByFtpUrl(self, url):
681 """See IDistributionMirrorSet"""689 """See IDistributionMirrorSet"""
682 return DistributionMirror.selectOneBy(ftp_base_url=url)690 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 @@
1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -12,6 +12,7 @@ import logging
12import os.path12import os.path
13from StringIO import StringIO13from StringIO import StringIO
1414
15import OpenSSL
15import requests16import requests
16from six.moves import http_client17from six.moves import http_client
17from six.moves.urllib.parse import (18from six.moves.urllib.parse import (
@@ -20,14 +21,27 @@ from six.moves.urllib.parse import (
20 urlparse,21 urlparse,
21 urlunparse,22 urlunparse,
22 )23 )
24from treq.client import HTTPClient as TreqHTTPClient
23from twisted.internet import (25from twisted.internet import (
24 defer,26 defer,
25 protocol,27 protocol,
26 reactor,28 reactor,
27 )29 )
28from twisted.internet.defer import DeferredSemaphore30from twisted.internet.defer import (
31 CancelledError,
32 DeferredSemaphore,
33 )
34from twisted.internet.endpoints import HostnameEndpoint
35from twisted.internet.ssl import VerificationError
29from twisted.python.failure import Failure36from twisted.python.failure import Failure
37from twisted.web.client import (
38 Agent,
39 BrowserLikePolicyForHTTPS,
40 ProxyAgent,
41 ResponseNeverReceived,
42 )
30from twisted.web.http import HTTPClient43from twisted.web.http import HTTPClient
44from twisted.web.iweb import IResponse
31from zope.component import getUtility45from zope.component import getUtility
3246
33from lp.app.interfaces.launchpad import ILaunchpadCelebrities47from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -50,6 +64,7 @@ from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
50# IMPORTANT: Changing these values can cause lots of false negatives when64# IMPORTANT: Changing these values can cause lots of false negatives when
51# probing mirrors, so please don't change them unless you know what you're65# probing mirrors, so please don't change them unless you know what you're
52# doing.66# doing.
67
53MIN_REQUEST_TIMEOUT_RATIO = 368MIN_REQUEST_TIMEOUT_RATIO = 3
54MIN_REQUESTS_TO_CONSIDER_RATIO = 3069MIN_REQUESTS_TO_CONSIDER_RATIO = 30
5570
@@ -57,6 +72,9 @@ MIN_REQUESTS_TO_CONSIDER_RATIO = 30
57# We need to get rid of these global dicts in this module.72# We need to get rid of these global dicts in this module.
58host_requests = {}73host_requests = {}
59host_timeouts = {}74host_timeouts = {}
75# Set of invalid certificate (host, port) tuples, to avoid doing HTTPS calls
76# to hosts we already know they are not valid.
77invalid_certificate_hosts = set()
6078
61MAX_REDIRECTS = 379MAX_REDIRECTS = 3
6280
@@ -161,6 +179,64 @@ class ProberProtocol(HTTPClient):
161 pass179 pass
162180
163181
182class HTTPSProbeFailureHandler:
183 """Handler to translate general errors into expected errors on HTTPS
184 connections."""
185 def __init__(self, factory):
186 self.factory = factory
187
188 def handleResponse(self, response):
189 """Translates any request with return code different from 200 into
190 an error in the callback chain.
191
192 Note that other 2xx codes that are not 200 are considered errors too.
193 This behaviour is the same as seen in ProberProtocol.handleStatus,
194 for HTTP responses.
195 """
196 status = response.code
197 if status == http_client.OK:
198 return response
199 else:
200 raise BadResponseCode(status, response)
201
202 def handleErrors(self, error):
203 """Handle exceptions in https requests.
204 """
205 if self.isInvalidCertificateError(error):
206 invalid_certificate_hosts.add(
207 (self.factory.request_host, self.factory.request_port))
208 reason = InvalidHTTPSCertificate(
209 self.factory.request_host, self.factory.request_port)
210 raise reason
211 if self.isTimeout(error):
212 raise ProberTimeout(self.factory.url, self.factory.timeout)
213 raise error
214
215 def isTimeout(self, error):
216 """Checks if the error was caused by a timeout.
217 """
218 return self._isErrorFromType(error, CancelledError)
219
220 def isInvalidCertificateError(self, error):
221 """Checks if the error was caused by an invalid certificate.
222 """
223 # It might be a raw SSL error, or a twisted-encapsulated
224 # verification error (such as DNSMismatch error when the
225 # certificate is valid for a different domain, for example).
226 return self._isErrorFromType(
227 error, OpenSSL.SSL.Error, VerificationError)
228
229 def _isErrorFromType(self, error, *types):
230 """Checks if the error was caused by any of the given types.
231 """
232 if not isinstance(error.value, ResponseNeverReceived):
233 return False
234 for reason in error.value.reasons:
235 if reason.check(*types) is not None:
236 return True
237 return False
238
239
164class RedirectAwareProberProtocol(ProberProtocol):240class RedirectAwareProberProtocol(ProberProtocol):
165 """A specialized version of ProberProtocol that follows HTTP redirects."""241 """A specialized version of ProberProtocol that follows HTTP redirects."""
166242
@@ -216,6 +292,8 @@ class ProberFactory(protocol.ClientFactory):
216 connect_port = None292 connect_port = None
217 connect_path = None293 connect_path = None
218294
295 https_agent_policy = BrowserLikePolicyForHTTPS
296
219 def __init__(self, url, timeout=config.distributionmirrorprober.timeout):297 def __init__(self, url, timeout=config.distributionmirrorprober.timeout):
220 # We want the deferred to be a private attribute (_deferred) to make298 # We want the deferred to be a private attribute (_deferred) to make
221 # sure our clients will only use the deferred returned by the probe()299 # sure our clients will only use the deferred returned by the probe()
@@ -225,9 +303,14 @@ class ProberFactory(protocol.ClientFactory):
225 self.timeout = timeout303 self.timeout = timeout
226 self.timeoutCall = None304 self.timeoutCall = None
227 self.setURL(url.encode('ascii'))305 self.setURL(url.encode('ascii'))
306 self.logger = logging.getLogger('distributionmirror-prober')
307
308 @property
309 def is_https(self):
310 return self.request_scheme == 'https'
228311
229 def probe(self):312 def probe(self):
230 logger = logging.getLogger('distributionmirror-prober')313 logger = self.logger
231 # NOTE: We don't want to issue connections to any outside host when314 # NOTE: We don't want to issue connections to any outside host when
232 # running the mirror prober in a development machine, so we do this315 # running the mirror prober in a development machine, so we do this
233 # hack here.316 # hack here.
@@ -244,13 +327,46 @@ class ProberFactory(protocol.ClientFactory):
244 "host already." % self.url)327 "host already." % self.url)
245 return self._deferred328 return self._deferred
246329
330 if (self.request_host, self.request_port) in invalid_certificate_hosts:
331 reactor.callLater(
332 0, self.failed, InvalidHTTPSCertificateSkipped(self.url))
333 logger.debug("Skipping %s as it doesn't have a valid HTTPS "
334 "certificate" % self.url)
335 return self._deferred
336
247 self.connect()337 self.connect()
248 logger.debug('Probing %s' % self.url)338 logger.debug('Probing %s' % self.url)
249 return self._deferred339 return self._deferred
250340
341 def getHttpsClient(self):
342 # Should we use a proxy?
343 if not config.launchpad.http_proxy:
344 agent = Agent(
345 reactor=reactor, contextFactory=self.https_agent_policy())
346 else:
347 endpoint = HostnameEndpoint(
348 reactor, self.connect_host, self.connect_port)
349 agent = ProxyAgent(endpoint)
350 return TreqHTTPClient(agent)
351
251 def connect(self):352 def connect(self):
353 """Starts the connection and sets the self._deferred to the proper
354 task.
355 """
252 host_requests[self.request_host] += 1356 host_requests[self.request_host] += 1
253 reactor.connectTCP(self.connect_host, self.connect_port, self)357 if self.is_https:
358 treq = self.getHttpsClient()
359 self._deferred.addCallback(
360 lambda _: treq.head(
361 self.url, reactor=reactor, allow_redirects=True,
362 timeout=self.timeout))
363 error_handler = HTTPSProbeFailureHandler(self)
364 self._deferred.addCallback(error_handler.handleResponse)
365 self._deferred.addErrback(error_handler.handleErrors)
366 reactor.callWhenRunning(self._deferred.callback, None)
367 else:
368 reactor.connectTCP(self.connect_host, self.connect_port, self)
369
254 if self.timeoutCall is not None and self.timeoutCall.active():370 if self.timeoutCall is not None and self.timeoutCall.active():
255 self._cancelTimeout(None)371 self._cancelTimeout(None)
256 self.timeoutCall = reactor.callLater(372 self.timeoutCall = reactor.callLater(
@@ -269,6 +385,8 @@ class ProberFactory(protocol.ClientFactory):
269 self.connector = connector385 self.connector = connector
270386
271 def succeeded(self, status):387 def succeeded(self, status):
388 if IResponse.providedBy(status):
389 status = str(status.code)
272 self._deferred.callback(status)390 self._deferred.callback(status)
273391
274 def failed(self, reason):392 def failed(self, reason):
@@ -288,7 +406,7 @@ class ProberFactory(protocol.ClientFactory):
288 # https://bugs.squid-cache.org/show_bug.cgi?id=1758 applied. So, if406 # https://bugs.squid-cache.org/show_bug.cgi?id=1758 applied. So, if
289 # you encounter any problems with FTP URLs you'll probably have to nag407 # you encounter any problems with FTP URLs you'll probably have to nag
290 # the sysadmins to fix squid for you.408 # the sysadmins to fix squid for you.
291 if scheme not in ('http', 'ftp'):409 if scheme not in ('http', 'https', 'ftp'):
292 raise UnknownURLScheme(url)410 raise UnknownURLScheme(url)
293411
294 if scheme and host:412 if scheme and host:
@@ -376,14 +494,26 @@ class ProberTimeout(ProberError):
376494
377class BadResponseCode(ProberError):495class BadResponseCode(ProberError):
378496
379 def __init__(self, status, *args):497 def __init__(self, status, response=None, *args):
380 ProberError.__init__(self, *args)498 ProberError.__init__(self, *args)
381 self.status = status499 self.status = status
500 self.response = response
382501
383 def __str__(self):502 def __str__(self):
384 return "Bad response code: %s" % self.status503 return "Bad response code: %s" % self.status
385504
386505
506class InvalidHTTPSCertificate(ProberError):
507 def __init__(self, host, port, *args):
508 super(InvalidHTTPSCertificate, self).__init__(*args)
509 self.host = host
510 self.port = port
511
512 def __str__(self):
513 return "Invalid SSL certificate when trying to probe %s:%s" % (
514 self.host, self.port)
515
516
387class RedirectToDifferentFile(ProberError):517class RedirectToDifferentFile(ProberError):
388518
389 def __init__(self, orig_path, new_path, *args):519 def __init__(self, orig_path, new_path, *args):
@@ -409,6 +539,14 @@ class ConnectionSkipped(ProberError):
409 "host. It will be retried on the next probing run.")539 "host. It will be retried on the next probing run.")
410540
411541
542class InvalidHTTPSCertificateSkipped(ProberError):
543
544 def __str__(self):
545 return ("Connection skipped because the server doesn't have a valid "
546 "HTTPS certificate. It will be retried on the next "
547 "probing run.")
548
549
412class UnknownURLScheme(ProberError):550class UnknownURLScheme(ProberError):
413551
414 def __init__(self, url, *args):552 def __init__(self, url, *args):
@@ -429,7 +567,13 @@ class UnknownURLSchemeAfterRedirect(UnknownURLScheme):
429567
430class ArchiveMirrorProberCallbacks(LoggingMixin):568class ArchiveMirrorProberCallbacks(LoggingMixin):
431569
432 expected_failures = (BadResponseCode, ProberTimeout, ConnectionSkipped)570 expected_failures = (
571 BadResponseCode,
572 ProberTimeout,
573 ConnectionSkipped,
574 InvalidHTTPSCertificate,
575 InvalidHTTPSCertificateSkipped,
576 )
433577
434 def __init__(self, mirror, series, pocket, component, url, log_file):578 def __init__(self, mirror, series, pocket, component, url, log_file):
435 self.mirror = mirror579 self.mirror = mirror
@@ -574,6 +718,8 @@ class MirrorCDImageProberCallbacks(LoggingMixin):
574 ProberTimeout,718 ProberTimeout,
575 RedirectToDifferentFile,719 RedirectToDifferentFile,
576 UnknownURLSchemeAfterRedirect,720 UnknownURLSchemeAfterRedirect,
721 InvalidHTTPSCertificate,
722 InvalidHTTPSCertificateSkipped,
577 )723 )
578724
579 def __init__(self, mirror, distroseries, flavour, log_file):725 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:
23 enabled: True23 enabled: True
24 ftp_base_url: None24 ftp_base_url: None
25 http_base_url: u'http://archive.ubuntu.com/ubuntu/'25 http_base_url: u'http://archive.ubuntu.com/ubuntu/'
26 https_base_url: None
26 name: u'canonical-archive'27 name: u'canonical-archive'
27 official_candidate: True28 official_candidate: True
28 owner_link: u'http://.../~mark'29 owner_link: u'http://.../~mark'
@@ -52,6 +53,7 @@ And CD image mirrors:
52 enabled: True53 enabled: True
53 ftp_base_url: None54 ftp_base_url: None
54 http_base_url: u'http://releases.ubuntu.com/'55 http_base_url: u'http://releases.ubuntu.com/'
56 https_base_url: None
55 name: u'canonical-releases'57 name: u'canonical-releases'
56 official_candidate: True58 official_candidate: True
57 owner_link: u'http://.../~mark'59 owner_link: u'http://.../~mark'
@@ -145,6 +147,7 @@ Mirror listing admins may see all:
145 enabled: True147 enabled: True
146 ftp_base_url: None148 ftp_base_url: None
147 http_base_url: u'http://localhost:11375/archive-mirror/'149 http_base_url: u'http://localhost:11375/archive-mirror/'
150 https_base_url: None
148 name: u'archive-404-mirror'151 name: u'archive-404-mirror'
149 official_candidate: True152 official_candidate: True
150 owner_link: u'http://.../~name12'153 owner_link: u'http://.../~name12'
@@ -227,6 +230,7 @@ While others can be set with the appropriate authorization:
227 enabled: True230 enabled: True
228 ftp_base_url: None231 ftp_base_url: None
229 http_base_url: u'http://releases.ubuntu.com/'232 http_base_url: u'http://releases.ubuntu.com/'
233 https_base_url: None
230 name: u'canonical-releases'234 name: u'canonical-releases'
231 official_candidate: True235 official_candidate: True
232 owner_link: u'http://.../~mark'236 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.
159 enabled: True159 enabled: True
160 ftp_base_url: None160 ftp_base_url: None
161 http_base_url: u'http://releases.ubuntu.com/'161 http_base_url: u'http://releases.ubuntu.com/'
162 https_base_url: None
162 name: u'canonical-releases'163 name: u'canonical-releases'
163 official_candidate: True164 official_candidate: True
164 owner_link: u'http://.../~mark'165 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 @@
118 <h2>Mirror location information</h2>118 <h2>Mirror location information</h2>
119119
120 <ul class="webref" id="mirror-urls">120 <ul class="webref" id="mirror-urls">
121 <li tal:condition="context/https_base_url" >
122 <a tal:content="context/https_base_url"
123 tal:attributes="href context/https_base_url">https://url/</a>
124 </li>
121 <li tal:condition="context/http_base_url" >125 <li tal:condition="context/http_base_url" >
122 <a tal:content="context/http_base_url"126 <a tal:content="context/http_base_url"
123 tal:attributes="href context/http_base_url">http://url/</a>127 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 @@
17 <tbody>17 <tbody>
18 <tal:country_and_mirrors repeat="country_and_mirrors mirrors_by_country">18 <tal:country_and_mirrors repeat="country_and_mirrors mirrors_by_country">
19 <tr class="head">19 <tr class="head">
20 <th colspan="2" 20 <th colspan="2"
21 tal:content="country_and_mirrors/country" />21 tal:content="country_and_mirrors/country" />
22 <th tal:content="country_and_mirrors/throughput"/>22 <th tal:content="country_and_mirrors/throughput"/>
23 <th tal:condition="show_mirror_type">23 <th tal:condition="show_mirror_type">
@@ -35,6 +35,8 @@
35 tal:content="mirror/title">Mirror Name</a>35 tal:content="mirror/title">Mirror Name</a>
36 </td>36 </td>
37 <td>37 <td>
38 <a tal:condition="mirror/https_base_url"
39 tal:attributes="href mirror/https_base_url">https</a>
38 <a tal:condition="mirror/http_base_url"40 <a tal:condition="mirror/http_base_url"
39 tal:attributes="href mirror/http_base_url">http</a>41 tal:attributes="href mirror/http_base_url">http</a>
40 <a tal:condition="mirror/ftp_base_url"42 <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 @@
1#!/usr/bin/python1#!/usr/bin/python
2#2#
3# Copyright 2009 Canonical Ltd. This software is licensed under the3# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6from twisted.web.resource import Resource6from twisted.web.resource import Resource
@@ -21,10 +21,10 @@ class DistributionMirrorTestHTTPServer(Resource):
21 :error: Respond with a '500 Internal Server Error' status.21 :error: Respond with a '500 Internal Server Error' status.
2222
23 :redirect-to-valid-mirror/*: Respond with a '302 Found' status,23 :redirect-to-valid-mirror/*: Respond with a '302 Found' status,
24 redirecting to http://localhost:%(port)s/valid-mirror/*.24 redirecting to http(s)://localhost:%(port)s/valid-mirror/*.
2525
26 :redirect-infinite-loop: Respond with a '302 Found' status, redirecting26 :redirect-infinite-loop: Respond with a '302 Found' status, redirecting
27 to http://localhost:%(port)s/redirect-infinite-loop.27 to http(s)://localhost:%(port)s/redirect-infinite-loop.
2828
29 :redirect-unknown-url-scheme: Respond with a '302 Found' status,29 :redirect-unknown-url-scheme: Respond with a '302 Found' status,
30 redirecting to ssh://localhost/redirect-unknown-url-scheme.30 redirecting to ssh://localhost/redirect-unknown-url-scheme.
@@ -32,11 +32,13 @@ class DistributionMirrorTestHTTPServer(Resource):
32 Any other path will cause the server to respond with a '404 Not Found'32 Any other path will cause the server to respond with a '404 Not Found'
33 status.33 status.
34 """34 """
35 protocol = "http"
3536
36 def getChild(self, name, request):37 def getChild(self, name, request):
38 protocol = self.protocol
37 port = request.getHost().port39 port = request.getHost().port
38 if name == 'valid-mirror':40 if name == 'valid-mirror':
39 leaf = DistributionMirrorTestHTTPServer()41 leaf = self.__class__()
40 leaf.isLeaf = True42 leaf.isLeaf = True
41 return leaf43 return leaf
42 elif name == 'timeout':44 elif name == 'timeout':
@@ -49,12 +51,14 @@ class DistributionMirrorTestHTTPServer(Resource):
49 'than one component.')51 'than one component.')
50 remaining_path = request.path.replace('/%s' % name, '')52 remaining_path = request.path.replace('/%s' % name, '')
51 leaf = RedirectingResource(53 leaf = RedirectingResource(
52 'http://localhost:%s/valid-mirror%s' % (port, remaining_path))54 '%s://localhost:%s/valid-mirror%s' % (
55 protocol, port, remaining_path))
53 leaf.isLeaf = True56 leaf.isLeaf = True
54 return leaf57 return leaf
55 elif name == 'redirect-infinite-loop':58 elif name == 'redirect-infinite-loop':
56 return RedirectingResource(59 return RedirectingResource(
57 'http://localhost:%s/redirect-infinite-loop' % port)60 '%s://localhost:%s/redirect-infinite-loop' %
61 (protocol, port))
58 elif name == 'redirect-unknown-url-scheme':62 elif name == 'redirect-unknown-url-scheme':
59 return RedirectingResource(63 return RedirectingResource(
60 'ssh://localhost/redirect-unknown-url-scheme')64 'ssh://localhost/redirect-unknown-url-scheme')
@@ -65,6 +69,11 @@ class DistributionMirrorTestHTTPServer(Resource):
65 return "Hi"69 return "Hi"
6670
6771
72class DistributionMirrorTestSecureHTTPServer(DistributionMirrorTestHTTPServer):
73 """HTTPS version of DistributionMirrorTestHTTPServer"""
74 protocol = "https"
75
76
68class RedirectingResource(Resource):77class RedirectingResource(Resource):
6978
70 def __init__(self, redirection_url):79 def __init__(self, redirection_url):
@@ -85,4 +94,3 @@ class FiveHundredResource(Resource):
85 def render_GET(self, request):94 def render_GET(self, request):
86 request.setResponseCode(500)95 request.setResponseCode(500)
87 request.write('ASPLODE!!!')96 request.write('ASPLODE!!!')
88 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 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""distributionmirror-prober tests."""4"""distributionmirror-prober tests."""
@@ -11,6 +11,7 @@ import logging
11import os11import os
12from StringIO import StringIO12from StringIO import StringIO
1313
14from fixtures import MockPatchObject
14from lazr.uri import URI15from lazr.uri import URI
15import responses16import responses
16from six.moves import http_client17from six.moves import http_client
@@ -28,9 +29,14 @@ from testtools.twistedsupport import (
28from twisted.internet import (29from twisted.internet import (
29 defer,30 defer,
30 reactor,31 reactor,
32 ssl,
31 )33 )
32from twisted.python.failure import Failure34from twisted.python.failure import Failure
33from twisted.web import server35from twisted.web import server
36from twisted.web.client import (
37 BrowserLikePolicyForHTTPS,
38 ProxyAgent,
39 )
34from zope.component import getUtility40from zope.component import getUtility
35from zope.security.proxy import removeSecurityProxy41from zope.security.proxy import removeSecurityProxy
3642
@@ -44,6 +50,8 @@ from lp.registry.scripts.distributionmirror_prober import (
44 BadResponseCode,50 BadResponseCode,
45 ConnectionSkipped,51 ConnectionSkipped,
46 InfiniteLoopDetected,52 InfiniteLoopDetected,
53 InvalidHTTPSCertificate,
54 InvalidHTTPSCertificateSkipped,
47 LoggingMixin,55 LoggingMixin,
48 MAX_REDIRECTS,56 MAX_REDIRECTS,
49 MIN_REQUEST_TIMEOUT_RATIO,57 MIN_REQUEST_TIMEOUT_RATIO,
@@ -65,6 +73,7 @@ from lp.registry.scripts.distributionmirror_prober import (
65 )73 )
66from lp.registry.tests.distributionmirror_http_server import (74from lp.registry.tests.distributionmirror_http_server import (
67 DistributionMirrorTestHTTPServer,75 DistributionMirrorTestHTTPServer,
76 DistributionMirrorTestSecureHTTPServer,
68 )77 )
69from lp.services.config import config78from lp.services.config import config
70from lp.services.daemons.tachandler import TacTestSetup79from lp.services.daemons.tachandler import TacTestSetup
@@ -103,6 +112,186 @@ class HTTPServerTestSetup(TacTestSetup):
103 return os.path.join(self.root, 'distributionmirror_http_server.log')112 return os.path.join(self.root, 'distributionmirror_http_server.log')
104113
105114
115
116class LocalhostWhitelistedHTTPSPolicy(BrowserLikePolicyForHTTPS):
117 """HTTPS policy that bypasses SSL certificate check when doing requests
118 to localhost.
119 """
120
121 def creatorForNetloc(self, hostname, port):
122 # check if the hostname is in the the whitelist,
123 # otherwise return the default policy
124 if hostname == 'localhost':
125 return ssl.CertificateOptions(verify=False)
126 return super(LocalhostWhitelistedHTTPSPolicy, self).creatorForNetloc(
127 hostname, port)
128
129
130class TestProberHTTPSProtocolAndFactory(TestCase):
131 layer = TwistedLayer
132 run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
133 timeout=30)
134
135 def setUp(self):
136 super(TestProberHTTPSProtocolAndFactory, self).setUp()
137 root = DistributionMirrorTestSecureHTTPServer()
138 site = server.Site(root)
139 site.displayTracebacks = False
140 keys_path = os.path.join(config.root, "configs", "development")
141 keys = ssl.DefaultOpenSSLContextFactory(
142 os.path.join(keys_path, "launchpad.key"),
143 os.path.join(keys_path, "launchpad.crt"),
144 )
145 self.listening_port = reactor.listenSSL(0, site, keys)
146
147 self.addCleanup(self.listening_port.stopListening)
148
149 # Change the default policy to accept localhost self-signed
150 # certificates.
151 original_probefactory_policy = ProberFactory.https_agent_policy
152 original_redirect_policy = (
153 RedirectAwareProberFactory.https_agent_policy)
154 ProberFactory.https_agent_policy = LocalhostWhitelistedHTTPSPolicy
155 RedirectAwareProberFactory.https_agent_policy = (
156 LocalhostWhitelistedHTTPSPolicy)
157
158 for factory in (ProberFactory, RedirectAwareProberFactory):
159 self.useFixture(MockPatchObject(
160 factory, "https_agent_policy",
161 LocalhostWhitelistedHTTPSPolicy))
162
163 self.port = self.listening_port.getHost().port
164
165 self.urls = {'timeout': u'https://localhost:%s/timeout' % self.port,
166 '200': u'https://localhost:%s/valid-mirror' % self.port,
167 '500': u'https://localhost:%s/error' % self.port,
168 '404': u'https://localhost:%s/invalid-mirror' % self.port}
169 self.pushConfig('launchpad', http_proxy=None)
170
171 self.useFixture(MockPatchObject(
172 distributionmirror_prober, "host_requests", {}))
173 self.useFixture(MockPatchObject(
174 distributionmirror_prober, "host_timeouts", {}))
175 self.useFixture(MockPatchObject(
176 distributionmirror_prober, "invalid_certificate_hosts", set()))
177
178 def _createProberAndProbe(self, url):
179 prober = ProberFactory(url)
180 return prober.probe()
181
182 def test_timeout(self):
183 prober = ProberFactory(self.urls['timeout'], timeout=0.5)
184 d = prober.probe()
185 return assert_fails_with(d, ProberTimeout)
186
187 def test_500(self):
188 d = self._createProberAndProbe(self.urls['500'])
189 return assert_fails_with(d, BadResponseCode)
190
191 def test_notfound(self):
192 d = self._createProberAndProbe(self.urls['404'])
193 return assert_fails_with(d, BadResponseCode)
194
195 def test_config_no_https_proxy(self):
196 prober = ProberFactory(self.urls['200'])
197 self.assertThat(prober, MatchesStructure.byEquality(
198 request_scheme='https',
199 request_host='localhost',
200 request_port=self.port,
201 request_path='/valid-mirror',
202 connect_scheme='https',
203 connect_host='localhost',
204 connect_port=self.port,
205 connect_path='/valid-mirror'))
206
207 def test_RedirectAwareProber_follows_https_redirect(self):
208 url = 'https://localhost:%s/redirect-to-valid-mirror/file' % self.port
209 prober = RedirectAwareProberFactory(url)
210 self.assertEqual(prober.url, url)
211 deferred = prober.probe()
212
213 def got_result(result):
214 self.assertEqual(http_client.OK, result.code)
215 self.assertEqual(
216 'https://localhost:%s/valid-mirror/file' % self.port,
217 result.request.absoluteURI)
218
219 return deferred.addCallback(got_result)
220
221 def test_https_prober_uses_proxy(self):
222 root = DistributionMirrorTestSecureHTTPServer()
223 site = server.Site(root)
224 proxy_listen_port = reactor.listenTCP(0, site)
225 proxy_port = proxy_listen_port.getHost().port
226 self.pushConfig(
227 'launchpad', http_proxy='http://localhost:%s/valid-mirror/file'
228 % proxy_port)
229
230 url = 'https://localhost:%s/valid-mirror/file' % self.port
231 prober = RedirectAwareProberFactory(url)
232 self.assertEqual(prober.url, url)
233 deferred = prober.probe()
234
235 def got_result(result):
236 # We basically don't care about the result here. We just want to
237 # check that it did the request to the correct URI,
238 # and ProxyAgent was used pointing to the correct proxy.
239 agent = prober.getHttpsClient()._agent
240 self.assertIsInstance(agent, ProxyAgent)
241 self.assertEqual('localhost', agent._proxyEndpoint._hostText)
242 self.assertEqual(proxy_port, agent._proxyEndpoint._port)
243
244 self.assertEqual(
245 'https://localhost:%s/valid-mirror/file' % self.port,
246 result.value.response.request.absoluteURI)
247
248 def cleanup(*args, **kwargs):
249 proxy_listen_port.stopListening()
250
251 # Doing the proxy checks on the error callback because the
252 # proxy is dummy and always returns 404.
253 deferred.addErrback(got_result)
254 deferred.addBoth(cleanup)
255 return deferred
256
257 def test_https_fails_on_invalid_certificates(self):
258 """Changes set back the default browser-like policy for HTTPS
259 request and make sure the request is failing due to invalid
260 (self-signed) certificate.
261 """
262 url = 'https://localhost:%s/valid-mirror/file' % self.port
263 prober = RedirectAwareProberFactory(url)
264 prober.https_agent_policy = BrowserLikePolicyForHTTPS
265 self.assertEqual(prober.url, url)
266 deferred = prober.probe()
267
268 def on_failure(result):
269 self.assertIsInstance(result.value, InvalidHTTPSCertificate)
270 self.assertIn(
271 ("localhost", self.port),
272 distributionmirror_prober.invalid_certificate_hosts)
273
274 def on_success(result):
275 if result is not None:
276 self.fail(
277 "Should have raised SSL error. Got '%s' instead" % result)
278
279 deferred.addErrback(on_failure)
280 deferred.addCallback(on_success)
281 return deferred
282
283 def test_https_skips_invalid_certificates_hosts(self):
284 distributionmirror_prober.invalid_certificate_hosts.add(
285 ("localhost", self.port))
286 url = 'https://localhost:%s/valid-mirror/file' % self.port
287 prober = RedirectAwareProberFactory(url)
288 prober.https_agent_policy = BrowserLikePolicyForHTTPS
289 self.assertEqual(prober.url, url)
290 deferred = prober.probe()
291
292 return assert_fails_with(deferred, InvalidHTTPSCertificateSkipped)
293
294
106class TestProberProtocolAndFactory(TestCase):295class TestProberProtocolAndFactory(TestCase):
107296
108 layer = TwistedLayer297 layer = TwistedLayer
@@ -212,7 +401,7 @@ class TestProberProtocolAndFactory(TestCase):
212 self.assertTrue(prober.url == new_url)401 self.assertTrue(prober.url == new_url)
213 self.assertTrue(result == str(http_client.OK))402 self.assertTrue(result == str(http_client.OK))
214403
215 return deferred.addCallback(got_result)404 return deferred.addBoth(got_result)
216405
217 def test_redirectawareprober_detects_infinite_loop(self):406 def test_redirectawareprober_detects_infinite_loop(self):
218 prober = RedirectAwareProberFactory(407 prober = RedirectAwareProberFactory(
@@ -737,12 +926,16 @@ class TestMirrorCDImageProberCallbacks(TestCaseWithFactory):
737 ConnectionSkipped,926 ConnectionSkipped,
738 RedirectToDifferentFile,927 RedirectToDifferentFile,
739 UnknownURLSchemeAfterRedirect,928 UnknownURLSchemeAfterRedirect,
929 InvalidHTTPSCertificate,
930 InvalidHTTPSCertificateSkipped,
740 ]))931 ]))
741 exceptions = [BadResponseCode(str(http_client.NOT_FOUND)),932 exceptions = [BadResponseCode(str(http_client.NOT_FOUND)),
742 ProberTimeout('http://localhost/', 5),933 ProberTimeout('http://localhost/', 5),
743 ConnectionSkipped(),934 ConnectionSkipped(),
744 RedirectToDifferentFile('/foo', '/bar'),935 RedirectToDifferentFile('/foo', '/bar'),
745 UnknownURLSchemeAfterRedirect('https://localhost')]936 UnknownURLSchemeAfterRedirect('https://localhost'),
937 InvalidHTTPSCertificate('localhost', 443),
938 InvalidHTTPSCertificateSkipped("https://localhost/xx")]
746 for exception in exceptions:939 for exception in exceptions:
747 failure = callbacks.ensureOrDeleteMirrorCDImageSeries(940 failure = callbacks.ensureOrDeleteMirrorCDImageSeries(
748 [(defer.FAILURE, Failure(exception))])941 [(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 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from __future__ import absolute_import, print_function, unicode_literals4from __future__ import absolute_import, print_function, unicode_literals
@@ -42,6 +42,8 @@ valid_imports_not_in_all = {
42 'textwrap': set(['dedent']),42 'textwrap': set(['dedent']),
43 'testtools.testresult.real': set(['_details_to_str']),43 'testtools.testresult.real': set(['_details_to_str']),
44 'twisted.internet.threads': set(['deferToThreadPool']),44 'twisted.internet.threads': set(['deferToThreadPool']),
45 # Even docs tell us to use this class. See docs on WebClientContextFactory.
46 'twisted.web.client': set(['BrowserLikePolicyForHTTPS']),
45 'zope.component': set(47 'zope.component': set(
46 ['adapter',48 ['adapter',
47 'ComponentLookupError',49 '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 @@
2# NOTE: The first line above must stay first; do not move the copyright2# NOTE: The first line above must stay first; do not move the copyright
3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
4#4#
5# Copyright 2009-2019 Canonical Ltd. This software is licensed under the5# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
6# GNU Affero General Public License version 3 (see the file LICENSE).6# GNU Affero General Public License version 3 (see the file LICENSE).
77
8"""Testing infrastructure for the Launchpad application.8"""Testing infrastructure for the Launchpad application.
@@ -3527,13 +3527,13 @@ class BareLaunchpadObjectFactory(ObjectFactory):
3527 return proberecord3527 return proberecord
35283528
3529 def makeMirror(self, distribution, displayname=None, country=None,3529 def makeMirror(self, distribution, displayname=None, country=None,
3530 http_url=None, ftp_url=None, rsync_url=None,3530 http_url=None, https_url=None, ftp_url=None, rsync_url=None,
3531 official_candidate=False):3531 official_candidate=False):
3532 """Create a mirror for the distribution."""3532 """Create a mirror for the distribution."""
3533 if displayname is None:3533 if displayname is None:
3534 displayname = self.getUniqueString("mirror")3534 displayname = self.getUniqueString("mirror")
3535 # If no URL is specified create an HTTP URL.3535 # If no URL is specified create an HTTP URL.
3536 if http_url is None and ftp_url is None and rsync_url is None:3536 if http_url is https_url is ftp_url is rsync_url is None:
3537 http_url = self.getUniqueURL()3537 http_url = self.getUniqueURL()
3538 # If no country is given use Argentina.3538 # If no country is given use Argentina.
3539 if country is None:3539 if country is None:
@@ -3547,6 +3547,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
3547 display_name=displayname,3547 display_name=displayname,
3548 description=None,3548 description=None,
3549 http_base_url=http_url,3549 http_base_url=http_url,
3550 https_base_url=https_url,
3550 ftp_base_url=ftp_url,3551 ftp_base_url=ftp_url,
3551 rsync_base_url=rsync_url,3552 rsync_base_url=rsync_url,
3552 official_candidate=official_candidate)3553 official_candidate=official_candidate)