Gage has submitted this change and it was merged.
Change subject: Strongswan: IPsec Puppet module
......................................................................
Strongswan: IPsec Puppet module
* Defines role::ipsec
* Reuses Puppet client's own SSL certs
* Uses Hiera instead of role::cache::configuration
* Works on Trusty and Jessie
* Enforces PFS
* See: T81543
Change-Id: Idd6df93fb35bb4a86d0e92f48741a52a34ab25c7
---
A hieradata/role/common/cache.yaml
A manifests/role/ipsec.pp
M manifests/site.pp
A modules/strongswan/.rspec
A modules/strongswan/Rakefile
A modules/strongswan/lib/puppet/parser/functions/ipresolve.rb
A modules/strongswan/manifests/init.pp
A modules/strongswan/spec/functions/ipresolve_spec.rb
A modules/strongswan/spec/spec_helper.rb
A modules/strongswan/templates/ipsec.conf.erb
A modules/strongswan/templates/ipsec.secrets.erb
11 files changed, 526 insertions(+), 0 deletions(-)
Approvals:
Gage: Looks good to me, approved
jenkins-bot: Verified
diff --git a/hieradata/role/common/cache.yaml b/hieradata/role/common/cache.yaml
new file mode 100644
index 0000000..c66d18a
--- /dev/null
+++ b/hieradata/role/common/cache.yaml
@@ -0,0 +1,109 @@
+# data synced from manifests/role/cache.pp on 2015-02-20
+text_eqiad:
+ - 'cp1052.eqiad.wmnet'
+ - 'cp1053.eqiad.wmnet'
+ - 'cp1054.eqiad.wmnet'
+ - 'cp1055.eqiad.wmnet'
+ - 'cp1065.eqiad.wmnet'
+ - 'cp1066.eqiad.wmnet'
+ - 'cp1067.eqiad.wmnet'
+ - 'cp1068.eqiad.wmnet'
+text_esams:
+ - 'amssq31.esams.wmnet'
+ - 'amssq32.esams.wmnet'
+# - 'amssq33.esams.wmnet' # powered down for now, RT # 7933
+ - 'amssq34.esams.wmnet'
+ - 'amssq35.esams.wmnet'
+ - 'amssq36.esams.wmnet'
+ - 'amssq37.esams.wmnet'
+ - 'amssq38.esams.wmnet'
+ - 'amssq39.esams.wmnet'
+ - 'amssq40.esams.wmnet'
+ - 'amssq41.esams.wmnet'
+ - 'amssq42.esams.wmnet'
+ - 'amssq43.esams.wmnet'
+ - 'amssq44.esams.wmnet'
+ - 'amssq45.esams.wmnet'
+ - 'amssq46.esams.wmnet'
+ - 'amssq47.esams.wmnet'
+ - 'amssq48.esams.wikimedia.org'
+ - 'amssq49.esams.wikimedia.org'
+ - 'amssq50.esams.wikimedia.org'
+ - 'amssq51.esams.wikimedia.org'
+ - 'amssq52.esams.wikimedia.org'
+ - 'amssq53.esams.wikimedia.org'
+ - 'amssq54.esams.wikimedia.org'
+ - 'amssq55.esams.wikimedia.org'
+ - 'amssq56.esams.wikimedia.org'
+ - 'amssq57.esams.wikimedia.org'
+ - 'amssq58.esams.wikimedia.org'
+ - 'amssq59.esams.wikimedia.org'
+ - 'amssq60.esams.wikimedia.org'
+ - 'amssq61.esams.wikimedia.org'
+ - 'amssq62.esams.wikimedia.org'
+text_ulsfo:
+ - 'cp4008.ulsfo.wmnet'
+ - 'cp4009.ulsfo.wmnet'
+ - 'cp4010.ulsfo.wmnet'
+ - 'cp4016.ulsfo.wmnet'
+ - 'cp4017.ulsfo.wmnet'
+ - 'cp4018.ulsfo.wmnet'
+bits_eqiad:
+ - 'cp1056.eqiad.wmnet'
+ - 'cp1057.eqiad.wmnet'
+ - 'cp1069.eqiad.wmnet'
+ - 'cp1070.eqiad.wmnet'
+bits_esams:
+ - 'cp3019.esams.wikimedia.org'
+ - 'cp3020.esams.wikimedia.org'
+ - 'cp3021.esams.wikimedia.org'
+ - 'cp3022.esams.wikimedia.org'
+bits_ulsfo:
+ - 'cp4001.ulsfo.wmnet'
+ - 'cp4002.ulsfo.wmnet'
+ - 'cp4003.ulsfo.wmnet'
+ - 'cp4004.ulsfo.wmnet'
+upload_eqiad:
+ - 'cp1048.eqiad.wmnet'
+ - 'cp1049.eqiad.wmnet'
+ - 'cp1050.eqiad.wmnet'
+ - 'cp1051.eqiad.wmnet'
+ - 'cp1061.eqiad.wmnet'
+ - 'cp1062.eqiad.wmnet'
+ - 'cp1063.eqiad.wmnet'
+ - 'cp1064.eqiad.wmnet'
+upload_esams:
+ - 'cp3003.esams.wikimedia.org'
+ - 'cp3004.esams.wikimedia.org'
+ - 'cp3005.esams.wikimedia.org'
+ - 'cp3006.esams.wikimedia.org'
+ - 'cp3007.esams.wikimedia.org'
+ - 'cp3008.esams.wikimedia.org'
+ - 'cp3009.esams.wikimedia.org'
+ - 'cp3010.esams.wikimedia.org'
+ - 'cp3015.esams.wmnet'
+ - 'cp3016.esams.wmnet'
+ - 'cp3017.esams.wmnet'
+ - 'cp3018.esams.wmnet'
+upload_ulsfo:
+ - 'cp4005.ulsfo.wmnet'
+ - 'cp4006.ulsfo.wmnet'
+ - 'cp4007.ulsfo.wmnet'
+ - 'cp4013.ulsfo.wmnet'
+ - 'cp4014.ulsfo.wmnet'
+ - 'cp4015.ulsfo.wmnet'
+mobile_eqiad:
+ - 'cp1046.eqiad.wmnet'
+# - 'cp1047.eqiad.wmnet' # hardware T88045
+ - 'cp1059.eqiad.wmnet'
+ - 'cp1060.eqiad.wmnet'
+mobile_esams:
+ - 'cp3011.esams.wikimedia.org'
+ - 'cp3012.esams.wikimedia.org'
+ - 'cp3013.esams.wmnet'
+ - 'cp3014.esams.wmnet'
+mobile_ulsfo:
+ - 'cp4011.ulsfo.wmnet'
+ - 'cp4012.ulsfo.wmnet'
+ - 'cp4019.ulsfo.wmnet'
+ - 'cp4020.ulsfo.wmnet'
diff --git a/manifests/role/ipsec.pp b/manifests/role/ipsec.pp
new file mode 100644
index 0000000..a608c58
--- /dev/null
+++ b/manifests/role/ipsec.pp
@@ -0,0 +1,49 @@
+class role::ipsec ($hosts = undef) {
+ case $::realm {
+ 'labs': {
+ # labs nodes use their EC2 ID as their puppet cert name
+ $puppet_certname = "${ec2id}.${domain}"
+ }
+ default: {
+ $puppet_certname = "${fqdn}"
+ }
+ }
+
+ if $hosts != undef {
+ $targets = $hosts
+ } else {
+ # determine site from domain name
+ case $domain {
+ 'eqiad.wmnet': { $site = 'eqiad' }
+ 'codfw.wmnet': { $site = 'codfw' }
+ 'esams.wmnet': { $site = 'esams' }
+ 'esams.wikimedia.org': { $site = 'esams' }
+ 'ulsfo.wmnet': { $site = 'ulsfo' }
+ }
+
+ # determine cache type based on whether it contains the local node
+ if $fqdn in hiera("text_${site}", "") { $cachetype = "text" }
+ if $fqdn in hiera("bits_${site}", "") { $cachetype = "bits" }
+ if $fqdn in hiera("upload_${site}", "") { $cachetype = "upload" }
+ if $fqdn in hiera("mobile_${site}", "") { $cachetype = "mobile" }
+
+ # enumerate hosts of the same cache type in other sites
+ if $site == "esams" or $site == "ulsfo" {
+ $targets = concat(
+ hiera("${cachetype}_eqiad", []),
+ hiera("${cachetype}_codfw", [])
+ )
+ }
+ if $site == "eqiad" or $site == "codfw" {
+ $targets = concat(
+ hiera("${cachetype}_esams", []),
+ hiera("${cachetype}_ulsfo", [])
+ )
+ }
+ }
+
+ class { '::strongswan':
+ puppet_certname => $puppet_certname,
+ hosts => $targets
+ }
+}
diff --git a/manifests/site.pp b/manifests/site.pp
index 9500687..8352207 100644
--- a/manifests/site.pp
+++ b/manifests/site.pp
@@ -370,6 +370,7 @@
content => "*.* @logstash1002.eqiad.wmnet:10514",
priority => 32,
}
+ role ipsec
}
node 'calcium.wikimedia.org' {
@@ -492,6 +493,7 @@
content => "*.* @logstash1002.eqiad.wmnet:10514",
priority => 32,
}
+ role ipsec
}
node /^cp30(0[3-9]|10|1[5-8])\.esams\.(wikimedia\.org|wmnet)$/ {
diff --git a/modules/strongswan/.rspec b/modules/strongswan/.rspec
new file mode 100644
index 0000000..f449dae
--- /dev/null
+++ b/modules/strongswan/.rspec
@@ -0,0 +1,2 @@
+--format doc
+--color
diff --git a/modules/strongswan/Rakefile b/modules/strongswan/Rakefile
new file mode 100644
index 0000000..a0c3b79
--- /dev/null
+++ b/modules/strongswan/Rakefile
@@ -0,0 +1,37 @@
+require 'rake'
+require 'fileutils'
+
+require 'rspec/core/rake_task'
+
+modulename = File.basename(File.expand_path(File.dirname(__FILE__)))
+
+symlinks = { 'spec/fixtures/modules/%s/files' % modulename =>
'../../../../files',
+ 'spec/fixtures/modules/%s/manifests' % modulename =>
'../../../../manifests',
+ 'spec/fixtures/modules/%s/templates' % modulename =>
'../../../../templates',
+ }
+
+
+task :setup do
+ FileUtils.mkdir_p('spec/fixtures/modules/%s' % modulename)
+ symlinks.each do |x|
+ if !File.exist?(x[0])
+ FileUtils.ln_s(x[1], x[0])
+ end
+ end
+end
+
+task :teardown do
+ symlinks.each { |x| FileUtils.rm(x[0], :force => true) }
+ FileUtils.rmdir('spec/fixtures/modules/%s' % modulename)
+ FileUtils.rmdir('spec/fixtures/modules')
+end
+
+RSpec::Core::RakeTask.new(:realspec) do |t|
+ t.fail_on_error = false
+ t.pattern = 'spec/*/*_spec.rb'
+end
+
+task :spec_standalone => [ :setup, :realspec, :teardown]
+
+task :default => :spec do
+end
diff --git a/modules/strongswan/lib/puppet/parser/functions/ipresolve.rb
b/modules/strongswan/lib/puppet/parser/functions/ipresolve.rb
new file mode 100644
index 0000000..160e743
--- /dev/null
+++ b/modules/strongswan/lib/puppet/parser/functions/ipresolve.rb
@@ -0,0 +1,115 @@
+# == Function: ipresolve( string $name_to_resolve, bool $ipv6 = false)
+#
+# Copyright (c) 2015 Wikimedia Foundation Inc.
+#
+# Performs a name resolution (for A AND AAAA records only) and returns
+# an hash of arrays.
+#
+# Takes one or more names to resolve, and returns an array of all the
+# A or AAAA records found. The resolution is actually only done when
+# the ttl has expired.
+#
+require 'resolv'
+
+class DNSCacheEntry
+ # Data structure for storing a DNS cached result.
+ def initialize(address, ttl)
+ @value = address
+ @ttl = Time.now.to_i + ttl
+ end
+
+ def is_valid?(time)
+ return @ttl > time
+ end
+
+ def value
+ return @value.to_s
+ end
+end
+
+class BasicTTLCache
+ def initialize
+ @cache = {}
+ end
+
+ def write(key, value, ttl)
+ @cache[key] = DNSCacheEntry.new(value, ttl)
+ end
+
+ def delete(key)
+ @cache.delete(key) if @cache.has_key?(key)
+ end
+
+ def is_valid?(key)
+ # If the key exists, and its ttl has not expired, return true.
+ # Return false (and maybe clean up the stale entry) otherwise.
+ return false unless @cache.has_key?(key)
+ t = Time.now.to_i
+ return true if @cache[key].is_valid?t
+ return false
+ end
+
+ def read(key)
+ if is_valid?key
+ return @cache[key].value
+ end
+ return nil
+ end
+
+ def read_stale(key)
+ if @cache.has_key?(key)
+ return @cache[key].value
+ end
+ return nil
+ end
+end
+
+class DNSCached
+ attr_accessor :dns
+ def initialize(cache = nil, default_ttl = 300)
+ @cache = cache || BasicTTLCache.new
+ @dns = Resolv::DNS.open()
+ @default_ttl = default_ttl
+ end
+
+ def get_resource(name, type)
+ cache_key = "#{name}_#{type}"
+ res = @cache.read(cache_key)
+ if (res.nil?)
+ begin
+ res = @dns.getresource(name, type)
+ # Ruby < 1.9 returns nil as the ttl...
+ if res.ttl
+ ttl = res.ttl
+ else
+ ttl = @default_ttl
+ end
+ @cache.write(cache_key, res.address, ttl)
+ res.address.to_s
+ rescue
+ # If resolution fails and we do have a cached stale value, use it
+ res = @cache.read_stale(cache_key)
+ res.to_s unless res.nil?
+ end
+ else
+ res.to_s
+ end
+ end
+end
+
+
+module Puppet::Parser::Functions
+ dns = DNSCached.new
+ newfunction(:ipresolve, :type => :rvalue, :arity => 2) do |args|
+ name = args[0]
+ type = args[1].to_i
+ if type == 4
+ source = Resolv::DNS::Resource::IN::A
+ elsif type == 6
+ source = Resolv::DNS::Resource::IN::AAAA
+ else
+ raise ArgumentError, 'Type must be 4 or 6'
+ end
+ return dns.get_resource(name, source).to_s
+ end
+end
diff --git a/modules/strongswan/manifests/init.pp
b/modules/strongswan/manifests/init.pp
new file mode 100644
index 0000000..7a75413
--- /dev/null
+++ b/modules/strongswan/manifests/init.pp
@@ -0,0 +1,92 @@
+class strongswan (
+ $puppet_certname = "",
+ $hosts = [],
+)
+{
+ package { [ 'strongswan', 'ipsec-tools' ]:
+ ensure => present,
+ }
+
+ # On Jessie we need an extra package which is only "recommended"
+ # rather than being a strict dependency.
+ # If you don't install this, on startup strongswan will say:
+ # loading certificate from 'i-00000894.eqiad.wmflabs.pem' failed
+ # and 'pki --verify --in /etc/ipsec.d/certs/i-00000894.eqiad.wmflabs.pem \
+ # --ca /etc/ipsec.d/cacerts/ca.pem' will say:
+ # building CRED_CERTIFICATE - X509 failed, tried 3 builders
+ # parsing certificate failed
+ if $operatingsystem == "Debian" and $operatingsystemmajrelease >= 8 {
+ package { 'libstrongswan-standard-plugins':
+ ensure => present,
+ before => Service['strongswan']
+ }
+ }
+
+ file { '/etc/ipsec.secrets':
+ content => template('strongswan/ipsec.secrets.erb'),
+ owner => 'root',
+ group => 'root',
+ mode => '0400',
+ notify => Service['strongswan'],
+ require => Package['strongswan'],
+ }
+
+ file { '/etc/ipsec.conf':
+ content => template('strongswan/ipsec.conf.erb'),
+ owner => 'root',
+ group => 'root',
+ mode => '0444',
+ notify => Service['strongswan'],
+ require => Package['strongswan'],
+ }
+
+ # For SSL certs, reuse Puppet client's certs.
+ # Strongswan won't accept symlinks, so make copies.
+
+ file { "/etc/ipsec.d/cacerts/ca.pem":
+ owner => 'root',
+ group => 'root',
+ mode => '0444',
+ ensure => present,
+ source => "/var/lib/puppet/ssl/certs/ca.pem",
+ notify => Service['strongswan'],
+ require => Package['strongswan'],
+ }
+
+ file { "/etc/ipsec.d/certs/${puppet_certname}.pem":
+ owner => 'root',
+ group => 'root',
+ mode => '0444',
+ ensure => present,
+ source => "/var/lib/puppet/ssl/certs/${puppet_certname}.pem",
+ notify => Service['strongswan'],
+ require => Package['strongswan'],
+ }
+
+ file { "/etc/ipsec.d/private/${puppet_certname}.pem":
+ owner => 'root',
+ group => 'root',
+ mode => '0444',
+ ensure => present,
+ source => "/var/lib/puppet/ssl/private_keys/${puppet_certname}.pem",
+ notify => Service['strongswan'],
+ require => Package['strongswan'],
+ }
+
+ $svcname = $::lsbdistcodename ? {
+ # in Ubuntu/Trusty this service is /etc/init/strongswan.conf
+ # in Ubuntu/Precise and Debian/Jessie it's /etc/init.d/ipsec
+ 'trusty' => 'strongswan',
+ 'precise' => 'ipsec',
+ 'jessie' => 'ipsec',
+ default => 'ipsec',
+ }
+ service { 'strongswan':
+ ensure => running,
+ enable => true,
+ name => $svcname,
+ pattern => "charon", # Strongswan IKEv2 daemon is called charon
+ hasstatus => true,
+ hasrestart => true,
+ }
+}
diff --git a/modules/strongswan/spec/functions/ipresolve_spec.rb
b/modules/strongswan/spec/functions/ipresolve_spec.rb
new file mode 100644
index 0000000..dd506e6
--- /dev/null
+++ b/modules/strongswan/spec/functions/ipresolve_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+describe 'ipresolve' do
+ before :each do
+ @compiler = Puppet::Parser::Compiler.new(Puppet::Node.new("foo"))
+ @scope = Puppet::Parser::Scope.new(@compiler)
+ end
+ it 'should be called with two parameters' do
+ should run.and_raise_error(ArgumentError)
+ should run.with_params(['google.com']).and_raise_error(ArgumentError)
+ end
+
+ it 'expects second parameter to be 4 or 6' do
+ should_not run.with_params('google.com',
'4').and_raise_error(ArgumentError)
+ should_not run.with_params('google.com',
'6').and_raise_error(ArgumentError)
+ end
+
+ it 'returns the resolved address as a string' do
+ r = Resolv::DNS::Resource::IN::A.new(Resolv::IPv4.create('74.125.29.113'))
+ Resolv::DNS.any_instance.stub(:getresource => r)
+ dns = DNSCached.new
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A).should
eq('74.125.29.113')
+ end
+
+ it 'uses cached results on subsequent lookups' do
+ r = Resolv::DNS::Resource::IN::A.new(Resolv::IPv4.create('74.125.29.113'))
+ resolv = double("Resolv::DNS", :getresource => r)
+ resolv.should_receive(:getresource).once
+ dns = DNSCached.new
+ dns.dns = resolv
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A)
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A)
+ end
+
+ it 'not uses cached results if ttl is zero' do
+ r = Resolv::DNS::Resource::IN::A.new(Resolv::IPv4.create('74.125.29.113'))
+ resolv = double("Resolv::DNS", :getresource => r)
+ resolv.should_receive(:getresource).twice
+ dns = DNSCached.new(nil, 0)
+ dns.dns = resolv
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A)
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A)
+ end
+
+ it 'uses cached result in case of failure' do
+ r = Resolv::DNS::Resource::IN::A.new(Resolv::IPv4.create('74.125.29.113'))
+ dns = DNSCached.new(nil,0)
+ dns.dns.stub(:getresource => r)
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A)
+ dns.dns.stub(:getresource => 'ciao')
+ dns.get_resource('google.com', Resolv::DNS::Resource::IN::A).should
eq('74.125.29.113')
+ end
+end
diff --git a/modules/strongswan/spec/spec_helper.rb
b/modules/strongswan/spec/spec_helper.rb
new file mode 100644
index 0000000..d3923f8
--- /dev/null
+++ b/modules/strongswan/spec/spec_helper.rb
@@ -0,0 +1,8 @@
+require 'rspec-puppet'
+
+fixture_path = File.expand_path(File.join(__FILE__, '..', 'fixtures'))
+
+RSpec.configure do |c|
+ c.module_path = File.join(fixture_path, 'modules')
+ c.manifest_dir = File.join(fixture_path, 'manifests')
+end
diff --git a/modules/strongswan/templates/ipsec.conf.erb
b/modules/strongswan/templates/ipsec.conf.erb
new file mode 100644
index 0000000..206bb6d
--- /dev/null
+++ b/modules/strongswan/templates/ipsec.conf.erb
@@ -0,0 +1,56 @@
+<%
+def individual_node(node)
+ result = []
+ if fqdn != node
+ left_ipv4 = scope.function_ipresolve([fqdn, 4])
+ right_ipv4 = scope.function_ipresolve([node, 4])
+ left_ipv6 = scope.function_ipresolve([fqdn, 6])
+ right_ipv6 = scope.function_ipresolve([node, 6])
+ if left_ipv4 != "" and right_ipv4 != ""
+ result << "conn #{fqdn}-#{node}_by_ipv4"
+ result << "\ttype=transport"
+ result << "\tauto=start"
+ #result << "\tauto=route"
+ result << "\tleft=#{left_ipv4} "
+ result << "\tleftcert=#{puppet_certname}.pem"
+ result << "\tright=#{right_ipv4}"
+ result << "\trightid=\"CN=#{node}\""
+ result << ""
+ end
+ if left_ipv6 != "" and right_ipv6 != ""
+ result << "conn #{fqdn}-#{node}_by_ipv6"
+ result << "\ttype=transport"
+ result << "\tauto=start"
+ #result << "\tauto=route"
+ result << "\tleft=#{left_ipv6} "
+ result << "\tleftcert=#{puppet_certname}.pem"
+ result << "\tright=#{right_ipv6}"
+ result << "\trightid=\"CN=#{node}\""
+ result << ""
+ end
+ end
+
+ return result
+end
+-%>
+# ipsec.conf - StrongSwan IPsec configuration file
+# Generated by Puppet
+
+# Basic configuration
+config setup
+ plutostart=no # IKEv1 daemon
+ charonstart=yes # IKEv2 daemon
+ charondebug="cfg 2, dmn 2"
+
+conn %default
+ # https://wiki.strongswan.org/projects/strongswan/wiki/IKEv2CipherSuites
+ #
https://wiki.strongswan.org/projects/strongswan/wiki/CipherSuiteExamples
+ # http://www.strongswan.org/uml/testresults/ikev2/alg-aes-gcm/
+ # modp2048 = DH group 14
+ ike=aes256gcm128-aesxcbc-modp2048!
+ esp=aes256gcm128-modp2048!
+
+# Connections
+<% hosts.each do |node| -%>
+<%= individual_node(node).join("\n") %>
+<% end -%>
diff --git a/modules/strongswan/templates/ipsec.secrets.erb
b/modules/strongswan/templates/ipsec.secrets.erb
new file mode 100644
index 0000000..c7362ee
--- /dev/null
+++ b/modules/strongswan/templates/ipsec.secrets.erb
@@ -0,0 +1,3 @@
+# Generated by Puppet, do not edit.
+
+: RSA <%= @puppet_certname %>.pem
--
To view, visit https://gerrit.wikimedia.org/r/181742
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: Idd6df93fb35bb4a86d0e92f48741a52a34ab25c7
Gerrit-PatchSet: 22
Gerrit-Project: operations/puppet
Gerrit-Branch: production
Gerrit-Owner: Gage <[email protected]>
Gerrit-Reviewer: Faidon Liambotis <[email protected]>
Gerrit-Reviewer: Gage <[email protected]>
Gerrit-Reviewer: Giuseppe Lavagetto <[email protected]>
Gerrit-Reviewer: Mark Bergsma <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits