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

Reply via email to