wg-admin 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.dependabot/config.yml +16 -0
- data/.gitignore +2 -0
- data/.rspec +5 -0
- data/.rubocop.yml +17 -0
- data/.ruby-version +1 -0
- data/.travis.yml +11 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +135 -0
- data/Guardfile +23 -0
- data/LICENSE +21 -0
- data/README.markdown +87 -0
- data/Rakefile +25 -0
- data/TODO.markdown +3 -0
- data/exe/wg-admin +7 -0
- data/lib/wire_guard/admin/cli.rb +69 -0
- data/lib/wire_guard/admin/cli/clients.rb +49 -0
- data/lib/wire_guard/admin/cli/helpers.rb +52 -0
- data/lib/wire_guard/admin/cli/networks.rb +55 -0
- data/lib/wire_guard/admin/cli/peers.rb +36 -0
- data/lib/wire_guard/admin/cli/servers.rb +54 -0
- data/lib/wire_guard/admin/client.rb +87 -0
- data/lib/wire_guard/admin/repository.rb +150 -0
- data/lib/wire_guard/admin/server.rb +56 -0
- data/lib/wire_guard/admin/templates/client.rb +46 -0
- data/lib/wire_guard/admin/templates/server.rb +48 -0
- data/lib/wire_guard/admin/version.rb +7 -0
- data/wg-admin.gemspec +40 -0
- metadata +253 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
#
|
|
8
|
+
# Commands for working with clients
|
|
9
|
+
#
|
|
10
|
+
class Clients < Thor
|
|
11
|
+
extend ClassHelpers
|
|
12
|
+
include InstanceHelpers
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Metrics/AbcSize
|
|
15
|
+
desc 'add NAME', 'Adds a new client with the given NAME'
|
|
16
|
+
long_desc 'Adds a new client to the configuration database.'
|
|
17
|
+
method_option :network, desc: 'network', aliases: '-n', default: default_network
|
|
18
|
+
method_option :ip, desc: 'the IP address of the new client', aliases: '-i', required: false
|
|
19
|
+
def add(name)
|
|
20
|
+
warn "Using database #{repository.path}" if options[:verbose]
|
|
21
|
+
client = Client.new(name: name, ip: ip)
|
|
22
|
+
repository.add_peer(network, client)
|
|
23
|
+
if options[:verbose]
|
|
24
|
+
warn 'New client was successfully added:'
|
|
25
|
+
warn ''
|
|
26
|
+
warn client
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc 'list', 'Lists all clients'
|
|
33
|
+
long_desc 'For a given network, lists all clients in the configuration database.'
|
|
34
|
+
method_option :network, desc: 'network', aliases: '-n', default: default_network
|
|
35
|
+
def list
|
|
36
|
+
if options[:verbose]
|
|
37
|
+
warn "Using database #{repository.path}"
|
|
38
|
+
warn "No clients in network #{network}." if repository.networks.empty?
|
|
39
|
+
end
|
|
40
|
+
repository.clients(network).each do |client|
|
|
41
|
+
puts client
|
|
42
|
+
end
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Metrics/AbcSize
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TODO: Check ~/workspace/rtc-cli/test/test_helper.rb on how to improve this
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
#
|
|
8
|
+
# Shared class methods
|
|
9
|
+
#
|
|
10
|
+
module ClassHelpers
|
|
11
|
+
def default_network
|
|
12
|
+
if repository.networks.size != 1
|
|
13
|
+
ENV['WG_ADMIN_NETWORK']
|
|
14
|
+
else
|
|
15
|
+
nw = repository.networks.first
|
|
16
|
+
ENV.fetch('WG_ADMIN_NETWORK', "#{nw}/#{nw.prefix}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def path
|
|
21
|
+
ENV['WG_ADMIN_STORE'] || File.expand_path('~/.wg-admin.pstore')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def repository
|
|
25
|
+
@repository ||= Repository.new(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Thor.class_option :verbose, type: :boolean, aliases: '-v'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#
|
|
32
|
+
# Shared instance methods
|
|
33
|
+
#
|
|
34
|
+
module InstanceHelpers
|
|
35
|
+
def repository
|
|
36
|
+
self.class.repository
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ip
|
|
40
|
+
if options[:ip]
|
|
41
|
+
IPAddr.new(options[:ip])
|
|
42
|
+
else
|
|
43
|
+
repository.next_address(network)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def network
|
|
48
|
+
IPAddr.new(options[:network])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'wire_guard/admin/cli/helpers'
|
|
5
|
+
|
|
6
|
+
module WireGuard
|
|
7
|
+
module Admin
|
|
8
|
+
#
|
|
9
|
+
# Commands for working with networks
|
|
10
|
+
#
|
|
11
|
+
class Networks < Thor
|
|
12
|
+
extend ClassHelpers
|
|
13
|
+
include InstanceHelpers
|
|
14
|
+
|
|
15
|
+
# rubocop:disable Metrics/AbcSize
|
|
16
|
+
desc 'list', 'Lists all known networks'
|
|
17
|
+
long_desc 'List the networks in the configuration database.'
|
|
18
|
+
def list
|
|
19
|
+
if options[:verbose]
|
|
20
|
+
warn "Using database #{repository.path}"
|
|
21
|
+
warn 'No networks defined.' if repository.networks.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
repository.networks.each do |network|
|
|
25
|
+
puts "#{network}/#{network.prefix}"
|
|
26
|
+
end
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
# rubocop:enable Metrics/AbcSize
|
|
31
|
+
|
|
32
|
+
desc 'add NETWORK', 'Adds a new network'
|
|
33
|
+
long_desc 'Adds a new network to the configuration database.'
|
|
34
|
+
def add(network)
|
|
35
|
+
warn "Using database #{repository.path}" if options[:verbose]
|
|
36
|
+
nw = IPAddr.new(network)
|
|
37
|
+
repository.add_network(nw)
|
|
38
|
+
warn "Network #{nw}/#{nw.prefix} was successfully added." if options[:verbose]
|
|
39
|
+
rescue Repository::NetworkAlreadyExists => e
|
|
40
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'delete NETWORK', 'Deletes a network'
|
|
44
|
+
long_desc 'Deletes an existingnetwork from the configuration database.'
|
|
45
|
+
def delete(network)
|
|
46
|
+
warn "Using database #{repository.path}" if options[:verbose]
|
|
47
|
+
nw = IPAddr.new(network)
|
|
48
|
+
repository.delete_network(nw)
|
|
49
|
+
warn "Network #{nw}/#{nw.prefix} was successfully deleted." if options[:verbose]
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
#
|
|
8
|
+
# Commands for working with peers (servers and clients)
|
|
9
|
+
#
|
|
10
|
+
class Peers < Thor
|
|
11
|
+
extend ClassHelpers
|
|
12
|
+
include InstanceHelpers
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
15
|
+
desc 'list', 'Lists all peers'
|
|
16
|
+
long_desc 'For a given network, lists all peers (servers and clients) in the configuration database.'
|
|
17
|
+
method_option :network, desc: 'network', aliases: '-n', default: default_network
|
|
18
|
+
def list
|
|
19
|
+
if options[:verbose]
|
|
20
|
+
warn "Using database #{repository.path}"
|
|
21
|
+
warn "No clients in network #{network}." if repository.networks.empty?
|
|
22
|
+
end
|
|
23
|
+
repository.peers(network).each do |peer|
|
|
24
|
+
if STDOUT.tty?
|
|
25
|
+
puts peer
|
|
26
|
+
else
|
|
27
|
+
puts peer.name
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
#
|
|
8
|
+
# Commands for working with servers
|
|
9
|
+
#
|
|
10
|
+
class Servers < Thor
|
|
11
|
+
extend ClassHelpers
|
|
12
|
+
include InstanceHelpers
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
15
|
+
desc 'add NAME', 'Adds a new server'
|
|
16
|
+
long_desc 'Adds a new server with the given public DNS NAME to the configuration database.'
|
|
17
|
+
method_option :network, desc: 'network', aliases: '-n', default: default_network
|
|
18
|
+
method_option :ip, desc: 'the (private) IP address of the new server (within the VPN)', aliases: '-i', required: false
|
|
19
|
+
method_option :port, desc: 'port to listen on', aliases: '-p', required: false
|
|
20
|
+
method_option :allowed_ips, desc: 'The range of allowed IP addresses that this server is routing', aliases: '-a', required: false
|
|
21
|
+
method_option :device, desc: 'The network device used for forwarding traffic', aliases: '-d', required: false
|
|
22
|
+
def add(name)
|
|
23
|
+
warn "Using database #{repository.path}" if options[:verbose]
|
|
24
|
+
server = Server.new(name: name, ip: ip, allowed_ips: options[:allowed_ips] || repository.find_network(network))
|
|
25
|
+
server.device = options[:device] if options[:device]
|
|
26
|
+
server.port = options[:port] if options[:port]
|
|
27
|
+
repository.add_peer(network, server)
|
|
28
|
+
if options[:verbose]
|
|
29
|
+
warn 'New server was successfully added:'
|
|
30
|
+
warn ''
|
|
31
|
+
warn server
|
|
32
|
+
end
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc 'list', 'Lists all servers'
|
|
38
|
+
long_desc 'For a given network, lists all servers in the configuration database.'
|
|
39
|
+
method_option :network, desc: 'network', aliases: '-n', default: default_network
|
|
40
|
+
def list
|
|
41
|
+
if options[:verbose]
|
|
42
|
+
warn "Using database #{repository.path}"
|
|
43
|
+
warn "No servers in network #{network}." if repository.networks.empty?
|
|
44
|
+
end
|
|
45
|
+
repository.servers(network).each do |server|
|
|
46
|
+
puts server
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise Thor::Error, "Error: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
InvocationError = Class.new(StandardError) do
|
|
8
|
+
def initialize(lines)
|
|
9
|
+
super(lines.first)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ProgramNotFoundError = Class.new(StandardError) do
|
|
14
|
+
def initialize
|
|
15
|
+
super('wg was not found in the PATH. Perhaps it is not installed?')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#
|
|
20
|
+
# A host that connects to the VPN and registers a VPN subnet address such as 192.0.2.3 for itself.
|
|
21
|
+
#
|
|
22
|
+
# @see https://github.com/pirate/wireguard-docs#peernodedevice
|
|
23
|
+
#
|
|
24
|
+
class Client
|
|
25
|
+
attr_reader :name, :ip, :private_key, :public_key
|
|
26
|
+
|
|
27
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
28
|
+
def initialize(name:, ip:, private_key: nil, public_key: nil)
|
|
29
|
+
raise ArgumentError, 'name must be present' if name.nil?
|
|
30
|
+
raise ArgumentError, 'name must not be empty' if name.empty?
|
|
31
|
+
raise ArgumentError, 'ip must be present' if ip.nil?
|
|
32
|
+
raise ArgumentError, 'private_key must not be empty' if private_key&.empty?
|
|
33
|
+
raise ArgumentError, 'public_key must not be empty' if public_key&.empty?
|
|
34
|
+
|
|
35
|
+
@name = name
|
|
36
|
+
@ip = ip
|
|
37
|
+
@private_key = private_key || generate_private_key
|
|
38
|
+
@public_key = public_key || generate_public_key
|
|
39
|
+
end
|
|
40
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
"#{self.class.name.split('::').last} #{name}: #{ip}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def hash
|
|
47
|
+
name.hash
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def eql?(other)
|
|
51
|
+
hash == other.hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ==(other)
|
|
55
|
+
name == other.name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def generate_public_key
|
|
61
|
+
Open3.popen3('wg pubkey') do |stdin, stdout, stderr, waiter|
|
|
62
|
+
stdin.write(private_key)
|
|
63
|
+
stdin.close
|
|
64
|
+
raise InvocationError, stderr.lines unless waiter.value.success?
|
|
65
|
+
|
|
66
|
+
stdout.read.chomp
|
|
67
|
+
end
|
|
68
|
+
rescue SystemCallError => e
|
|
69
|
+
raise ProgramNotFoundError if e.message =~ /No such file or directory/
|
|
70
|
+
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def generate_private_key
|
|
75
|
+
Open3.popen3('wg genkey') do |_, stdout, stderr, waiter|
|
|
76
|
+
raise InvocationError, stderr.lines unless waiter.value.success?
|
|
77
|
+
|
|
78
|
+
stdout.read.chomp
|
|
79
|
+
end
|
|
80
|
+
rescue SystemCallError => e
|
|
81
|
+
raise ProgramNotFoundError if e.message =~ /No such file or directory/
|
|
82
|
+
|
|
83
|
+
raise
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pstore'
|
|
4
|
+
|
|
5
|
+
module WireGuard
|
|
6
|
+
module Admin
|
|
7
|
+
#
|
|
8
|
+
# The place where networks, clients and servers can be found and are persisted
|
|
9
|
+
#
|
|
10
|
+
class Repository
|
|
11
|
+
#
|
|
12
|
+
# Raised if the network was not specified
|
|
13
|
+
#
|
|
14
|
+
class NetworkNotSpecified < StandardError
|
|
15
|
+
def initialize
|
|
16
|
+
super('Network not specified')
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#
|
|
21
|
+
# Raised if the network is not known
|
|
22
|
+
#
|
|
23
|
+
class UnknownNetwork < StandardError
|
|
24
|
+
def initialize(unknown)
|
|
25
|
+
super("Network #{unknown} is unknown")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#
|
|
30
|
+
# Raised if the network already exists
|
|
31
|
+
#
|
|
32
|
+
class NetworkAlreadyExists < StandardError
|
|
33
|
+
def initialize(existing)
|
|
34
|
+
super("Network #{existing} already exists")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Raised if the network already exists
|
|
40
|
+
#
|
|
41
|
+
class PeerAlreadyExists < StandardError
|
|
42
|
+
def initialize(peer, network)
|
|
43
|
+
super("A peer named #{peer.name} already exists in network #{network}.")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :path
|
|
48
|
+
|
|
49
|
+
def initialize(path)
|
|
50
|
+
@path = path
|
|
51
|
+
@backend = PStore.new(@path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# Get all networks
|
|
56
|
+
#
|
|
57
|
+
def networks
|
|
58
|
+
@backend.transaction do
|
|
59
|
+
@backend.roots
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#
|
|
64
|
+
# Find a network within all known ones
|
|
65
|
+
#
|
|
66
|
+
def find_network(network)
|
|
67
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
68
|
+
|
|
69
|
+
networks.select { |n| n == network }.first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#
|
|
73
|
+
# Find a peer by name within the given network
|
|
74
|
+
#
|
|
75
|
+
def find_peer(network, name)
|
|
76
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
77
|
+
|
|
78
|
+
peers(network).select { |p| p.name == name }.first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#
|
|
82
|
+
# Get all peers of the given network
|
|
83
|
+
#
|
|
84
|
+
def peers(network)
|
|
85
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
86
|
+
|
|
87
|
+
@backend.transaction do
|
|
88
|
+
raise UnknownNetwork, network unless @backend.root?(network)
|
|
89
|
+
|
|
90
|
+
@backend[network]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#
|
|
95
|
+
# Add a new network
|
|
96
|
+
#
|
|
97
|
+
def add_network(network)
|
|
98
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
99
|
+
|
|
100
|
+
@backend.transaction do
|
|
101
|
+
raise NetworkAlreadyExists, network if @backend.root?(network)
|
|
102
|
+
|
|
103
|
+
@backend[network] = []
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def delete_network(network)
|
|
108
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
109
|
+
|
|
110
|
+
@backend.transaction do
|
|
111
|
+
raise UnknownNetwork, network unless @backend.root?(network)
|
|
112
|
+
|
|
113
|
+
@backend.delete(network)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#
|
|
118
|
+
# Add a peer to the given network
|
|
119
|
+
#
|
|
120
|
+
def add_peer(network, peer)
|
|
121
|
+
raise PeerAlreadyExists.new(peer, network) if find_peer(network, peer.name)
|
|
122
|
+
|
|
123
|
+
@backend.transaction do
|
|
124
|
+
raise UnknownNetwork, network unless @backend.root?(network)
|
|
125
|
+
|
|
126
|
+
@backend[network] << peer
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#
|
|
131
|
+
# Find the next address within the given network that is not assigned to a peer
|
|
132
|
+
#
|
|
133
|
+
def next_address(network)
|
|
134
|
+
raise ArgumentError, 'network must be an IP address range' unless network.is_a?(IPAddr)
|
|
135
|
+
|
|
136
|
+
peers(network).inject(network.succ) do |candidate, peer|
|
|
137
|
+
candidate == peer.ip ? candidate.succ : peer.ip
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def servers(network)
|
|
142
|
+
peers(network).select { |p| p.is_a?(Server) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def clients(network)
|
|
146
|
+
peers(network).select { |p| p.is_a?(Client) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|