Merge lp:~vila/bzr/920455-ssl-defaults into lp:bzr/2.5

Proposed by Vincent Ladeuil
Status: Merged
Approved by: Martin Packman
Approved revision: no longer in the source branch.
Merged at revision: 6468
Proposed branch: lp:~vila/bzr/920455-ssl-defaults
Merge into: lp:bzr/2.5
Diff against target: 242 lines (+67/-47) (has conflicts)
4 files modified
bzrlib/errors.py (+2/-1)
bzrlib/tests/test_https_urllib.py (+8/-17)
bzrlib/transport/http/_urllib2_wrappers.py (+50/-29)
doc/en/release-notes/bzr-2.5.txt (+7/-0)
Text conflict in doc/en/release-notes/bzr-2.5.txt
To merge this branch: bzr merge lp:~vila/bzr/920455-ssl-defaults
Reviewer Review Type Date Requested Status
Martin Packman (community) Approve
Gordon Tyler Pending
Jelmer Vernooij Pending
Review via email: mp+90693@code.launchpad.net

This proposal supersedes a proposal from 2012-01-25.

Commit message

Provide an ``ssl.ca_certs`` default value for supported platforms.

Description of the change

Resubmitting a slightly simpler implementation to ensure we're still agreeing, see my replies to previous reviews too.

This add default values for the ssl.ca_certs config option pointing to the
most probable place where the certificates are place for supported
platforms.

Feedback needed from windows and osx packagers unless we rely on them to fix
it when building 2.5.0...

I've changed the tests so at least test_default_exists
bzrlib/tests/test_https_urllib.py fails if the default value is wrong.

I've also change the option to fail if a non-existing path is used and
changed the code to check the config option only if ssl.cert_reqs is not none.

With these changes, either:

- the certificates are there and they will be checked by default

- they are not but ssl.cert_reqs is none, no need to bother the user
  especially since that is the obvious workaround for now if something goes
  wrong with the verification,

- an error is raised if the user ask for verification but we can't find the
  CAs.

To post a comment you must log in.
Revision history for this message
Vincent Ladeuil (vila) wrote : Posted in a previous version of this proposal

@gz, Gordon: Feedback on where you expect to install the bundled ca certs expected ;)

Revision history for this message
Martin Packman (gz) wrote : Posted in a previous version of this proposal

So, on windows we're mostly stuffed because ideally we'd use the Internet Explorer certificate store, but that's a completely different interface to openssl and the cert dir used on nix systems.

The best we can do is bundle curl-ca-bundle.crt with the all-in-one installer like we did previously:

<http://wiki.bazaar.canonical.com/BzrWin32Installer#bzr-dependencies>

Then accept the fact branching over https will be broken for everyone else using a python installer or running setup.py themselves.

This is why I was picky about the error message given for the original change... unfortunately it's now just a ValueError and traceback due to the default config value being bad. This branch changes one of those but not the other.

Revision history for this message
Jelmer Vernooij (jelmer) wrote : Posted in a previous version of this proposal

32 def test_specified_doesnt_exist(self):
33 path = os.path.join(self.test_dir, "nonexisting.pem")
34 stack = self.get_stack("ssl.ca_certs = %s\n" % path)
35 - self.warnings = []
36 - def warning(*args):
37 - self.warnings.append(args[0] % args[1:])
38 - self.overrideAttr(trace, 'warning', warning)
39 - self.assertEquals(_urllib2_wrappers.DEFAULT_CA_PATH,
40 - stack.get('ssl.ca_certs'))
41 - self.assertLength(1, self.warnings)
42 - self.assertContainsRe(self.warnings[0],
43 - "is not valid for \"ssl.ca_certs\"")
44 + self.assertRaises(ConfigOptionValueError, stack.get, 'ssl.ca_certs')
Should it really be an error if the ssl.ca_certs path doesn't exist? What if e.g. "ssl.ca_reqs = optional", it doesn't seem like it should be a problem if the ca_certs are missing.

default_ca_cert() seems to return "/etc/ssl/..." on Windows. This seems wrong in any case. I realize you've asked for feedback from the packagers, but I think we should raise ValueError or return None for now at least.

review: Approve
Revision history for this message
Vincent Ladeuil (vila) wrote : Posted in a previous version of this proposal

> This is why I was picky about the error message given for the original
> change... unfortunately it's now just a ValueError and traceback due to the
> default config value being bad. This branch changes one of those but not the
> other.

No, please do try again (with this proposal):

  bzr config --remove launchpad_username
  bzr config ssl.ca_certs=/I-dont-exist

vila:~/src/bzr/bugs/920455-ssl-defaults :) $ ./bzr info lp:bzr
bzr: ERROR: Bad value "/I-dont-exist" for option "ssl.ca_certs".

So you get a proper error message that we may want to clarify but you don't
get a traceback.

You got a traceback prior to this change because default_ca_certs() was
raising it which is why I changed the implementation as ValueError is caught
for the *_from_store() functions, not the _default() functions.

I could change the ConfigValueError message to include 'See bzr help
ssl.ca_certs' but we froze the strings for 2.5 so I'd rather do that for
trunk.

I'll file bugs for windows and osx installers to make sure a bundle is
included.

Revision history for this message
Vincent Ladeuil (vila) wrote : Posted in a previous version of this proposal

    > Review: Approve
    > 32 def test_specified_doesnt_exist(self):
    > 33 path = os.path.join(self.test_dir, "nonexisting.pem")
    > 34 stack = self.get_stack("ssl.ca_certs = %s\n" % path)
    > 35 - self.warnings = []
    > 36 - def warning(*args):
    > 37 - self.warnings.append(args[0] % args[1:])
    > 38 - self.overrideAttr(trace, 'warning', warning)
    > 39 - self.assertEquals(_urllib2_wrappers.DEFAULT_CA_PATH,
    > 40 - stack.get('ssl.ca_certs'))
    > 41 - self.assertLength(1, self.warnings)
    > 42 - self.assertContainsRe(self.warnings[0],
    > 43 - "is not valid for \"ssl.ca_certs\"")
    > 44 + self.assertRaises(ConfigOptionValueError, stack.get, 'ssl.ca_certs')

    > Should it really be an error if the ssl.ca_certs path doesn't
    > exist? What if e.g. "ssl.ca_reqs = optional", it doesn't seem like
    > it should be a problem if the ca_certs are missing.

Indeed ! That's why I also change the code to query the option only if
required ;)

    > default_ca_cert() seems to return "/etc/ssl/..." on Windows. This
    > seems wrong in any case.

Yes.

    > I realize you've asked for feedback from the packagers, but I
    > think we should raise ValueError or return None for now at least.

That's what will happen if the option is needed, see
ca_certs_from_store() and invalid='error' ! ;)

Revision history for this message
Jelmer Vernooij (jelmer) wrote : Posted in a previous version of this proposal

On 01/30/2012 03:19 PM, Vincent Ladeuil wrote:
> > Review: Approve
> > 32 def test_specified_doesnt_exist(self):
> > 33 path = os.path.join(self.test_dir, "nonexisting.pem")
> > 34 stack = self.get_stack("ssl.ca_certs = %s\n" % path)
> > 35 - self.warnings = []
> > 36 - def warning(*args):
> > 37 - self.warnings.append(args[0] % args[1:])
> > 38 - self.overrideAttr(trace, 'warning', warning)
> > 39 - self.assertEquals(_urllib2_wrappers.DEFAULT_CA_PATH,
> > 40 - stack.get('ssl.ca_certs'))
> > 41 - self.assertLength(1, self.warnings)
> > 42 - self.assertContainsRe(self.warnings[0],
> > 43 - "is not valid for \"ssl.ca_certs\"")
> > 44 + self.assertRaises(ConfigOptionValueError, stack.get, 'ssl.ca_certs')
>
> > Should it really be an error if the ssl.ca_certs path doesn't
> > exist? What if e.g. "ssl.ca_reqs = optional", it doesn't seem like
> > it should be a problem if the ca_certs are missing.
>
> Indeed ! That's why I also change the code to query the option only if
> required ;)
You're (correctly) trying to retrieve the ca certs too if
ssl.ca_reqs=optional, but if the ca certs path doesn't exist, that
becomes a terminal error. It shouldn't be if ssl.ca_reqs=optional, only
if ssl.ca_reqs=required.
>
>
> > I realize you've asked for feedback from the packagers, but I
> > think we should raise ValueError or return None for now at least.
>
> That's what will happen if the option is needed, see
> ca_certs_from_store() and invalid='error' ! ;)
I don't see how that's the case. We'll be trying to retrieve the ca
certs in that case and it'll cause ConfigOptionValueError to be raised
(and bzr to be aborted), right?

Cheers,

Jelmer

Revision history for this message
Vincent Ladeuil (vila) wrote :

> You're (correctly) trying to retrieve the ca certs too if
  ssl.ca_reqs=optional, but if the ca certs path doesn't exist, that becomes
  a terminal error. It shouldn't be if ssl.ca_reqs=optional, only if
  ssl.ca_reqs=required.

Right, trying to fix that led to too much complications which I want to
avoid for 2.5. So, as discussed on IRC, I've file http:/pad.lv/924220 to
support 'optional' later and just removed it from the actual implementation.

... and I'll take care of the news conflicts when/if this proposal is approved ;)

Revision history for this message
Martin Packman (gz) wrote :

Trying a value relative to sys.executable is right for the installer, but what is absolutely required is the pretty message in the case where there are no certs, rather than what I currently get with this branch:

C:\bzr\bzr\920455-ssl-defaults>%PY27% bzr info https://bazaar.launchpad.net/
bzr: ERROR: Bad value "C:\Dev\python\2.7\PCbuild\ca_bundle.crt" for option "ssl.ca_certs".

That's no good to man or beast, if it can't validate the cert, it *must* say what needs fixing, and how to disable the validation. This nearly exists already, but the logic seems to have been broken so the "no valid trusted SSL CA certificates file set" message branch doesn't get hit:

<https://code.launchpad.net/~jelmer/bzr/urllib-verifies-ssl-certs/+merge/86360>

What I expect is something like:

    >bzr info https://bazaar.launchpad.net/
    bzr: ERROR: No valid trusted SSL CA certificates file found.

    See `bzr help ssl.ca_certs` for how to specify trusted CA certificates.
    Pass -Ossl.cert_reqs=none to disable certificate verification entirely.

We nearly have all the bits, they just need connecting and tidying up.

review: Needs Fixing
Revision history for this message
Martin Packman (gz) wrote :

With changes, the output is now:

C:\bzr\bzr\920455-ssl-defaults>%PY27% bzr info https://bazaar.launchpad.net/
Value "C:\Dev\python\2.7\PCbuild\ca_bundle.crt" is not valid for "ssl.ca_certs"
No valid trusted SSL CA certificates file set. See 'bzr help ssl.ca_certs' for more information on setting trusted CAs.
bzr: ERROR: _ssl.c:331: No root certificates specified for verification of other-side certificates.

Still not totally ideal but is good enough and I an poke later when some other exception stuff is sorted.

review: Approve
Revision history for this message
Vincent Ladeuil (vila) wrote :

sent to pqm by email

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'bzrlib/errors.py'
--- bzrlib/errors.py 2012-01-27 15:47:12 +0000
+++ bzrlib/errors.py 2012-01-31 17:04:31 +0000
@@ -1761,7 +1761,8 @@
17611761
1762class ConfigOptionValueError(BzrError):1762class ConfigOptionValueError(BzrError):
17631763
1764 _fmt = """Bad value "%(value)s" for option "%(name)s"."""1764 _fmt = ('Bad value "%(value)s" for option "%(name)s".\n'
1765 'See ``bzr help %(name)s``')
17651766
1766 def __init__(self, name, value):1767 def __init__(self, name, value):
1767 BzrError.__init__(self, name=name, value=value)1768 BzrError.__init__(self, name=name, value=value)
17681769
=== modified file 'bzrlib/tests/test_https_urllib.py'
--- bzrlib/tests/test_https_urllib.py 2012-01-19 15:27:47 +0000
+++ bzrlib/tests/test_https_urllib.py 2012-01-31 17:04:31 +0000
@@ -1,4 +1,4 @@
1# Copyright (C) 2011 Canonical Ltd1# Copyright (C) 2011,2012 Canonical Ltd
2#2#
3# This program is free software; you can redistribute it and/or modify3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by4# it under the terms of the GNU General Public License as published by
@@ -41,18 +41,10 @@
41 def get_stack(self, content):41 def get_stack(self, content):
42 return config.MemoryStack(content.encode('utf-8'))42 return config.MemoryStack(content.encode('utf-8'))
4343
44 def test_default_raises_value_error(self):
45 stack = self.get_stack("")
46 self.overrideAttr(_urllib2_wrappers, "DEFAULT_CA_PATH",
47 "/i-do-not-exist")
48 self.assertRaises(ValueError, stack.get, 'ssl.ca_certs')
49
50 def test_default_exists(self):44 def test_default_exists(self):
51 self.build_tree(['cacerts.pem'])45 """Check that the default we provide exists for the tested platform."""
52 stack = self.get_stack("")46 stack = self.get_stack("")
53 path = os.path.join(self.test_dir, "cacerts.pem")47 self.assertPathExists(stack.get('ssl.ca_certs'))
54 self.overrideAttr(_urllib2_wrappers, "DEFAULT_CA_PATH", path)
55 self.assertEquals(path, stack.get('ssl.ca_certs'))
5648
57 def test_specified(self):49 def test_specified(self):
58 self.build_tree(['cacerts.pem'])50 self.build_tree(['cacerts.pem'])
@@ -61,14 +53,15 @@
61 self.assertEquals(path, stack.get('ssl.ca_certs'))53 self.assertEquals(path, stack.get('ssl.ca_certs'))
6254
63 def test_specified_doesnt_exist(self):55 def test_specified_doesnt_exist(self):
64 path = os.path.join(self.test_dir, "nonexisting.pem")56 stack = self.get_stack('')
65 stack = self.get_stack("ssl.ca_certs = %s\n" % path)57 # Disable the default value mechanism to force the behavior we want
58 self.overrideAttr(_urllib2_wrappers.opt_ssl_ca_certs, 'default',
59 os.path.join(self.test_dir, u"nonexisting.pem"))
66 self.warnings = []60 self.warnings = []
67 def warning(*args):61 def warning(*args):
68 self.warnings.append(args[0] % args[1:])62 self.warnings.append(args[0] % args[1:])
69 self.overrideAttr(trace, 'warning', warning)63 self.overrideAttr(trace, 'warning', warning)
70 self.assertEquals(_urllib2_wrappers.DEFAULT_CA_PATH,64 self.assertEquals(None, stack.get('ssl.ca_certs'))
71 stack.get('ssl.ca_certs'))
72 self.assertLength(1, self.warnings)65 self.assertLength(1, self.warnings)
73 self.assertContainsRe(self.warnings[0],66 self.assertContainsRe(self.warnings[0],
74 "is not valid for \"ssl.ca_certs\"")67 "is not valid for \"ssl.ca_certs\"")
@@ -83,8 +76,6 @@
83 def test_from_string(self):76 def test_from_string(self):
84 stack = config.MemoryStack("ssl.cert_reqs = none\n")77 stack = config.MemoryStack("ssl.cert_reqs = none\n")
85 self.assertEquals(ssl.CERT_NONE, stack.get("ssl.cert_reqs"))78 self.assertEquals(ssl.CERT_NONE, stack.get("ssl.cert_reqs"))
86 stack = config.MemoryStack("ssl.cert_reqs = optional\n")
87 self.assertEquals(ssl.CERT_OPTIONAL, stack.get("ssl.cert_reqs"))
88 stack = config.MemoryStack("ssl.cert_reqs = required\n")79 stack = config.MemoryStack("ssl.cert_reqs = required\n")
89 self.assertEquals(ssl.CERT_REQUIRED, stack.get("ssl.cert_reqs"))80 self.assertEquals(ssl.CERT_REQUIRED, stack.get("ssl.cert_reqs"))
90 stack = config.MemoryStack("ssl.cert_reqs = invalid\n")81 stack = config.MemoryStack("ssl.cert_reqs = invalid\n")
9182
=== modified file 'bzrlib/transport/http/_urllib2_wrappers.py'
--- bzrlib/transport/http/_urllib2_wrappers.py 2012-01-20 09:19:14 +0000
+++ bzrlib/transport/http/_urllib2_wrappers.py 2012-01-31 17:04:31 +0000
@@ -1,4 +1,4 @@
1# Copyright (C) 2006-2011 Canonical Ltd1# Copyright (C) 2006-2012 Canonical Ltd
2#2#
3# This program is free software; you can redistribute it and/or modify3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by4# it under the terms of the GNU General Public License as published by
@@ -74,14 +74,36 @@
74import ssl74import ssl
75""")75""")
7676
77DEFAULT_CA_PATH = u"/etc/ssl/certs/ca-certificates.crt"
7877
78# Note for packagers: if there is no package providing certs for your platform,
79# the curl project produces http://curl.haxx.se/ca/cacert.pem weekly.
80_ssl_ca_certs_known_locations = [
81 u'/etc/ssl/certs/ca-certificates.crt', # Ubuntu/debian/gentoo
82 u'/etc/pki/tls/certs/ca-bundle.crt', # Fedora/CentOS/RH
83 u'/etc/ssl/ca-bundle.pem', # OpenSuse
84 u'/etc/ssl/cert.pem', # OpenSuse
85 u"/usr/local/share/certs/ca-root-nss.crt", # FreeBSD
86 # XXX: Needs checking, can't trust the interweb ;) -- vila 2012-01-25
87 u'/etc/openssl/certs/ca-certificates.crt', # Solaris
88 ]
7989
80def default_ca_certs():90def default_ca_certs():
81 if not os.path.exists(DEFAULT_CA_PATH):91 if sys.platform == 'win32':
82 raise ValueError("default ca certs path %s does not exist" %92 return os.path.join(os.path.dirname(sys.executable), u"ca_bundle.crt")
83 DEFAULT_CA_PATH)93 elif sys.platform == 'darwin':
84 return DEFAULT_CA_PATH94 # FIXME: Needs some default value for osx, waiting for osx installers
95 # guys feedback -- vila 2012-01-25
96 pass
97 else:
98 # Try known locations for friendly OSes providing the root certificates
99 # without making them hard to use for any https client.
100 for path in _ssl_ca_certs_known_locations:
101 if os.path.exists(path):
102 # First found wins
103 return path
104 # A default path that makes sense and will be mentioned in the error
105 # presented to the user, even if not correct for all platforms
106 return _ssl_ca_certs_known_locations[0]
85107
86108
87def ca_certs_from_store(path):109def ca_certs_from_store(path):
@@ -90,16 +112,11 @@
90 return path112 return path
91113
92114
93def default_cert_reqs():
94 return u"required"
95
96
97def cert_reqs_from_store(unicode_str):115def cert_reqs_from_store(unicode_str):
98 import ssl116 import ssl
99 try:117 try:
100 return {118 return {
101 "required": ssl.CERT_REQUIRED,119 "required": ssl.CERT_REQUIRED,
102 "optional": ssl.CERT_OPTIONAL,
103 "none": ssl.CERT_NONE120 "none": ssl.CERT_NONE
104 }[unicode_str]121 }[unicode_str]
105 except KeyError:122 except KeyError:
@@ -112,10 +129,15 @@
112 invalid='warning',129 invalid='warning',
113 help="""\130 help="""\
114Path to certification authority certificates to trust.131Path to certification authority certificates to trust.
132
133This should be a valid path to a bundle containing all root Certificate
134Authorities used to verify an https server certificate.
135
136Use ssl.cert_reqs=none to disable certificate verification.
115""")137""")
116138
117opt_ssl_cert_reqs = config.Option('ssl.cert_reqs',139opt_ssl_cert_reqs = config.Option('ssl.cert_reqs',
118 default=default_cert_reqs,140 default=u"required",
119 from_unicode=cert_reqs_from_store,141 from_unicode=cert_reqs_from_store,
120 invalid='error',142 invalid='error',
121 help="""\143 help="""\
@@ -123,8 +145,7 @@
123145
124Possible values:146Possible values:
125 * none: Certificates ignored147 * none: Certificates ignored
126 * optional: Certificates not required, but validated if provided148 * required: Certificates required and validated
127 * required: Certificates required, and validated
128""")149""")
129150
130checked_kerberos = False151checked_kerberos = False
@@ -448,34 +469,34 @@
448 def connect_to_origin(self):469 def connect_to_origin(self):
449 # FIXME JRV 2011-12-18: Use location config here?470 # FIXME JRV 2011-12-18: Use location config here?
450 config_stack = config.GlobalStack()471 config_stack = config.GlobalStack()
451 if self.ca_certs is None:
452 ca_certs = config_stack.get('ssl.ca_certs')
453 else:
454 ca_certs = self.ca_certs
455 cert_reqs = config_stack.get('ssl.cert_reqs')472 cert_reqs = config_stack.get('ssl.cert_reqs')
456 if cert_reqs == ssl.CERT_NONE:473 if cert_reqs == ssl.CERT_NONE:
457 trace.warning("not checking SSL certificates for %s: %d",474 trace.warning("Not checking SSL certificate for %s: %d",
458 self.host, self.port)475 self.host, self.port)
476 ca_certs = None
459 else:477 else:
478 if self.ca_certs is None:
479 ca_certs = config_stack.get('ssl.ca_certs')
480 else:
481 ca_certs = self.ca_certs
460 if ca_certs is None:482 if ca_certs is None:
461 trace.warning(483 trace.warning(
462 "no valid trusted SSL CA certificates file set. See "484 "No valid trusted SSL CA certificates file set. See "
463 "'bzr help ssl.ca_certs' for more information on setting "485 "'bzr help ssl.ca_certs' for more information on setting "
464 "trusted CA's.")486 "trusted CAs.")
465 try:487 try:
466 ssl_sock = ssl.wrap_socket(self.sock, self.key_file, self.cert_file,488 ssl_sock = ssl.wrap_socket(self.sock, self.key_file, self.cert_file,
467 cert_reqs=cert_reqs, ca_certs=ca_certs)489 cert_reqs=cert_reqs, ca_certs=ca_certs)
468 except ssl.SSLError, e:490 except ssl.SSLError, e:
469 if e.errno != ssl.SSL_ERROR_SSL:
470 raise
471 trace.note(491 trace.note(
472 "To disable SSL certificate verification, use "492 "\n"
473 "-Ossl.cert_reqs=none. See ``bzr help ssl.ca_certs`` for "493 "See `bzr help ssl.ca_certs` for how to specify trusted CA"
474 "more information on specifying trusted CA certificates.")494 "certificates.\n"
495 "Pass -Ossl.cert_reqs=none to disable certificate "
496 "verification entirely.\n")
475 raise497 raise
476 peer_cert = ssl_sock.getpeercert()498 if cert_reqs == ssl.CERT_REQUIRED:
477 if (cert_reqs == ssl.CERT_REQUIRED or499 peer_cert = ssl_sock.getpeercert()
478 (cert_reqs == ssl.CERT_OPTIONAL and peer_cert)):
479 match_hostname(peer_cert, self.host)500 match_hostname(peer_cert, self.host)
480501
481 # Wrap the ssl socket before anybody use it502 # Wrap the ssl socket before anybody use it
482503
=== modified file 'doc/en/release-notes/bzr-2.5.txt'
--- doc/en/release-notes/bzr-2.5.txt 2012-01-31 15:43:17 +0000
+++ doc/en/release-notes/bzr-2.5.txt 2012-01-31 17:04:31 +0000
@@ -56,12 +56,19 @@
56* ``bzr branch`` now fetches revisions when branching into an empty56* ``bzr branch`` now fetches revisions when branching into an empty
57 control directory. (Jelmer Vernooij, #905594)57 control directory. (Jelmer Vernooij, #905594)
5858
59<<<<<<< TREE
59* ``bzr branch`` generates correct target branch locations again if not60* ``bzr branch`` generates correct target branch locations again if not
60 specified. (Jelmer Vernooij, #919218)61 specified. (Jelmer Vernooij, #919218)
6162
62* ``bzr send`` works on treeless branches again.63* ``bzr send`` works on treeless branches again.
63 (Jelmer Vernooij, #921591)64 (Jelmer Vernooij, #921591)
6465
66=======
67* A sane default is provided for ``ssl.ca_certs`` which should points to the
68 Certificate Authority bundle for supported platforms.
69 (Vincent Ladeuil, #920455)
70
71>>>>>>> MERGE-SOURCE
65* Support scripts that don't call bzrlib.initialize() but still call run_bzr().72* Support scripts that don't call bzrlib.initialize() but still call run_bzr().
66 (Vincent Ladeuil, #917733)73 (Vincent Ladeuil, #917733)
6774

Subscribers

People subscribed via source and target branches