lanet 0.1.0

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.
data/lib/lanet/ping.rb ADDED
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "open3"
5
+ require "timeout"
6
+
7
+ module Lanet
8
+ class Ping
9
+ attr_reader :timeout, :count
10
+
11
+ def initialize(timeout: 1, count: 3)
12
+ @timeout = timeout
13
+ @count = count
14
+ @continuous = false
15
+ end
16
+
17
+ # Ping a single host with real-time output
18
+ # @param host [String] The IP address or hostname to ping
19
+ # @param realtime [Boolean] Whether to print output in real-time
20
+ # @param continuous [Boolean] Whether to ping continuously until interrupted
21
+ # @return [Hash] Result with status, response time, and output
22
+ def ping_host(host, realtime = false, continuous = false)
23
+ @continuous = continuous
24
+
25
+ result = {
26
+ host: host,
27
+ status: false,
28
+ response_time: nil,
29
+ packet_loss: 100,
30
+ output: "",
31
+ responses: [] # Store individual ping responses
32
+ }
33
+
34
+ begin
35
+ # Command varies by OS
36
+ ping_cmd = ping_command(host)
37
+
38
+ # Use different approaches based on output mode
39
+ if realtime
40
+ process_realtime_ping(ping_cmd, result)
41
+ else
42
+ # Use backticks for quiet mode - much more reliable than Open3 for this case
43
+ process_quiet_ping(ping_cmd, result)
44
+ end
45
+ rescue Timeout::Error
46
+ result[:output] = "Ping timed out after #{@timeout * 2} seconds"
47
+ rescue Interrupt
48
+ # Handle Ctrl+C gracefully for continuous mode
49
+ print_ping_statistics(host, result) if realtime
50
+ exit(0) if realtime # Only exit if in realtime mode - otherwise let the caller handle it
51
+ rescue StandardError => e
52
+ result[:output] = "Error: #{e.message}"
53
+ end
54
+
55
+ # Only print statistics in realtime mode and not continuous
56
+ print_ping_statistics(host, result) if realtime && !@continuous
57
+
58
+ result
59
+ end
60
+
61
+ # Check if a host is reachable
62
+ # @param host [String] The IP address or hostname to check
63
+ # @return [Boolean] True if the host is reachable
64
+ def reachable?(host)
65
+ ping_host(host)[:status]
66
+ end
67
+
68
+ # Get the response time for a host
69
+ # @param host [String] The IP address or hostname to check
70
+ # @return [Float, nil] The response time in ms, or nil if unreachable
71
+ def response_time(host)
72
+ ping_host(host)[:response_time]
73
+ end
74
+
75
+ # Ping multiple hosts in parallel
76
+ # @param hosts [Array<String>] Array of IP addresses or hostnames
77
+ # @param realtime [Boolean] Whether to print output in real-time
78
+ # @param continuous [Boolean] Whether to ping continuously until interrupted
79
+ # @return [Hash] Results indexed by host
80
+ def ping_hosts(hosts, realtime = false, continuous = false)
81
+ results = {}
82
+ if realtime
83
+ # For real-time output, run pings sequentially
84
+ hosts.each do |host|
85
+ results[host] = ping_host(host, true, continuous)
86
+ puts "\n" unless host == hosts.last
87
+ end
88
+ else
89
+ # For non-realtime output, run pings in parallel
90
+ threads = []
91
+
92
+ hosts.each do |host|
93
+ threads << Thread.new do
94
+ results[host] = ping_host(host)
95
+ end
96
+ end
97
+
98
+ threads.each(&:join)
99
+ end
100
+ results
101
+ end
102
+
103
+ private
104
+
105
+ def process_quiet_ping(ping_cmd, result)
106
+ # Use backticks for simplest, most reliable execution in quiet mode
107
+ Timeout.timeout(@timeout * @count * 2) do
108
+ output = `#{ping_cmd}`
109
+ result[:output] = output
110
+ exit_status = $CHILD_STATUS.exitstatus
111
+
112
+ # Process the output
113
+ if exit_status.zero? || output.include?("bytes from")
114
+ result[:status] = true
115
+
116
+ # Extract individual ping responses
117
+ extract_ping_responses(output, result)
118
+
119
+ # Calculate average response time
120
+ if result[:responses].any?
121
+ result[:response_time] = result[:responses].map { |r| r[:time] }.sum / result[:responses].size
122
+ end
123
+
124
+ # Extract packet loss
125
+ result[:packet_loss] = ::Regexp.last_match(1).to_f if output =~ /(\d+(?:\.\d+)?)% packet loss/
126
+ else
127
+ # No responses
128
+ result[:status] = false
129
+ result[:packet_loss] = 100.0
130
+ end
131
+ end
132
+ rescue Timeout::Error
133
+ result[:output] = "Ping timed out after #{@timeout * @count * 2} seconds"
134
+ rescue StandardError => e
135
+ result[:output] = "Error: #{e.message}"
136
+ end
137
+
138
+ def process_realtime_ping(ping_cmd, result)
139
+ all_output = ""
140
+ thread = nil
141
+
142
+ begin
143
+ Open3.popen3(ping_cmd) do |_stdin, stdout, _stderr, process_thread|
144
+ thread = process_thread
145
+ stdout_thread = Thread.new do
146
+ # Read stdout in real time
147
+ while (line = stdout.gets)
148
+ all_output += line
149
+ print line # Print in real-time
150
+
151
+ # Parse and store responses as they come
152
+ case RbConfig::CONFIG["host_os"]
153
+ when /mswin|mingw|cygwin/
154
+ if line =~ /Reply from .* time=(\d+)ms TTL=(\d+)/
155
+ seq = result[:responses].size
156
+ ttl = ::Regexp.last_match(2).to_i
157
+ time = ::Regexp.last_match(1).to_f
158
+ result[:responses] << { seq: seq, ttl: ttl, time: time }
159
+ end
160
+ else
161
+ if line =~ /icmp_seq=(\d+) ttl=(\d+) time=([\d.]+) ms/
162
+ seq = ::Regexp.last_match(1).to_i
163
+ ttl = ::Regexp.last_match(2).to_i
164
+ time = ::Regexp.last_match(3).to_f
165
+ result[:responses] << { seq: seq, ttl: ttl, time: time }
166
+ end
167
+ end
168
+
169
+ # For non-continuous mode, exit when we've collected enough responses
170
+ break if !@continuous && result[:responses].size >= @count
171
+ end
172
+ rescue IOError
173
+ # Stream may be closed - this is ok
174
+ end
175
+
176
+ # Wait for the stdout thread to complete or the process to exit
177
+ if @continuous
178
+ stdout_thread.join # Wait indefinitely in continuous mode
179
+ else
180
+ # For non-continuous mode, wait for completion with a reasonable timeout
181
+ begin
182
+ Timeout.timeout(@timeout * @count * 2) do
183
+ process_thread.join
184
+ end
185
+ rescue Timeout::Error
186
+ # If it takes too long, we'll terminate below in the ensure block
187
+ ensure
188
+ stdout_thread.kill if stdout_thread.alive?
189
+ end
190
+ end
191
+
192
+ # Set success status
193
+ result[:status] = !result[:responses].empty?
194
+ result[:output] = all_output
195
+
196
+ # Calculate response time
197
+ if result[:responses].any?
198
+ result[:response_time] = result[:responses].map { |r| r[:time] }.sum / result[:responses].size
199
+
200
+ # Calculate packet loss for non-continuous mode
201
+ unless @continuous
202
+ total_expected = @count
203
+ result[:packet_loss] = ((total_expected - result[:responses].size) / total_expected.to_f * 100).round(1)
204
+ end
205
+ end
206
+ end
207
+ rescue IOError => e
208
+ # Handle IOError specifically
209
+ result[:output] += "\nWarning: IO operation failed: #{e.message}"
210
+ ensure
211
+ # Clean up any threads and processes
212
+ if thread&.alive?
213
+
214
+ begin
215
+ Process.kill("TERM", thread.pid)
216
+ rescue StandardError
217
+ nil
218
+ end
219
+
220
+ end
221
+ end
222
+ end
223
+
224
+ def print_ping_statistics(host, result)
225
+ puts "\n--- #{host} ping statistics ---"
226
+ if result[:responses].any?
227
+ avg_time = result[:responses].map { |r| r[:time] }.sum / result[:responses].size
228
+ min_time = result[:responses].map { |r| r[:time] }.min
229
+ max_time = result[:responses].map { |r| r[:time] }.max
230
+
231
+ # Calculate proper packet loss
232
+ if @continuous
233
+ highest_seq = result[:responses].map { |r| r[:seq] }.max
234
+ unique_seqs = result[:responses].map { |r| r[:seq] }.uniq.size
235
+ transmitted = highest_seq + 1
236
+ packet_loss = ((transmitted - unique_seqs) / transmitted.to_f * 100).round(1)
237
+
238
+ puts "#{transmitted} packets transmitted, #{unique_seqs} packets received, #{packet_loss}% packet loss"
239
+ else
240
+ # Normal mode - compare against expected count
241
+ packet_loss = ((@count - result[:responses].size) / @count.to_f * 100).round(1)
242
+ puts "#{@count} packets transmitted, #{result[:responses].size} packets received, #{packet_loss}% packet loss"
243
+ end
244
+
245
+ puts "round-trip min/avg/max = #{min_time.round(3)}/#{avg_time.round(3)}/#{max_time.round(3)} ms"
246
+ else
247
+ puts "0 packets transmitted, 0 packets received, 100% packet loss"
248
+ end
249
+ end
250
+
251
+ def ping_command(host)
252
+ if @continuous
253
+ # Continuous mode - don't specify count
254
+ case RbConfig::CONFIG["host_os"]
255
+ when /mswin|mingw|cygwin/
256
+ # Windows - use -t for continuous ping
257
+ "ping -t -w #{@timeout * 1000} #{host}"
258
+ when /darwin/
259
+ # macOS - for continuous ping, simply omit the count parameter
260
+ "ping #{host}"
261
+ else
262
+ # Linux/Unix - no count flag means continuous
263
+ "ping -W #{@timeout} #{host}"
264
+ end
265
+ else
266
+ # Normal mode with count
267
+ case RbConfig::CONFIG["host_os"]
268
+ when /mswin|mingw|cygwin/
269
+ # Windows
270
+ "ping -n #{@count} -w #{@timeout * 1000} #{host}"
271
+ when /darwin/
272
+ # macOS
273
+ "ping -c #{@count} #{host}"
274
+ else
275
+ # Linux/Unix
276
+ "ping -c #{@count} -W #{@timeout} #{host}"
277
+ end
278
+ end
279
+ end
280
+
281
+ def extract_ping_responses(output, result)
282
+ # Extract individual ping responses based on OS format
283
+ case RbConfig::CONFIG["host_os"]
284
+ when /mswin|mingw|cygwin/
285
+ # Windows format
286
+ output.scan(/Reply from .* time=(\d+)ms TTL=(\d+)/).each_with_index do |match, seq|
287
+ result[:responses] << { seq: seq, ttl: match[1].to_i, time: match[0].to_f }
288
+ end
289
+ else
290
+ # Unix-like format (Linux/macOS)
291
+ output.scan(/icmp_seq=(\d+) ttl=(\d+) time=([\d.]+) ms/).each do |match|
292
+ result[:responses] << { seq: match[0].to_i, ttl: match[1].to_i, time: match[2].to_f }
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Lanet
6
+ class Receiver
7
+ def initialize(port)
8
+ @port = port
9
+ @socket = UDPSocket.new
10
+ @socket.bind("0.0.0.0", @port)
11
+ end
12
+
13
+ def listen(&block)
14
+ loop do
15
+ data, addr = @socket.recvfrom(1024)
16
+ ip = addr[3]
17
+ block.call(data, ip)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "ipaddr"
5
+ require "socket"
6
+ require "timeout"
7
+ require "resolv"
8
+
9
+ module Lanet
10
+ class Scanner
11
+ COMMON_PORTS = {
12
+ 21 => "FTP",
13
+ 22 => "SSH",
14
+ 23 => "Telnet",
15
+ 25 => "SMTP",
16
+ 80 => "HTTP",
17
+ 443 => "HTTPS",
18
+ 3389 => "RDP",
19
+ 5900 => "VNC",
20
+ 8080 => "HTTP-ALT",
21
+ 137 => "NetBIOS",
22
+ 139 => "NetBIOS",
23
+ 445 => "SMB",
24
+ 1025 => "RPC",
25
+ 8443 => "HTTPS-ALT"
26
+ }.freeze
27
+
28
+ # Ports to check during scan
29
+ QUICK_CHECK_PORTS = [80, 443, 22, 445, 139, 8080].freeze
30
+
31
+ def initialize
32
+ @hosts = []
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ # Scan network and return active hosts
37
+ def scan(cidr, timeout = 1, max_threads = 32, verbose = false)
38
+ @verbose = verbose
39
+ @timeout = timeout
40
+
41
+ # Clear previous scan results
42
+ @hosts = []
43
+
44
+ # Get the range of IP addresses to scan
45
+ range = IPAddr.new(cidr).to_range
46
+
47
+ # Create a queue of IPs to scan
48
+ queue = Queue.new
49
+ range.each { |ip| queue << ip.to_s }
50
+
51
+ total_ips = queue.size
52
+ completed = 0
53
+
54
+ # Pre-populate ARP table to improve MAC address resolution
55
+ update_arp_table(cidr, max_threads)
56
+
57
+ # Create worker threads to process the queue
58
+ threads = Array.new([max_threads, total_ips].min) do
59
+ Thread.new do
60
+ while (ip = begin
61
+ queue.pop(true)
62
+ rescue ThreadError
63
+ nil
64
+ end)
65
+ scan_host(ip)
66
+ @mutex.synchronize do
67
+ completed += 1
68
+ if total_ips < 100 || (completed % 10).zero? || completed == total_ips
69
+ print_progress(completed,
70
+ total_ips)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ begin
78
+ threads.each(&:join)
79
+ print_progress(total_ips, total_ips)
80
+ puts "\nScan complete. Found #{@hosts.size} active hosts."
81
+ @verbose ? @hosts : @hosts.map { |h| h[:ip] }
82
+ rescue Interrupt
83
+ puts "\nScan interrupted. Returning partial results..."
84
+ @verbose ? @hosts : @hosts.map { |h| h[:ip] }
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def print_progress(completed, total)
91
+ percent = (completed.to_f / total * 100).round(1)
92
+ print "\rScanning network: #{percent}% complete (#{completed}/#{total})"
93
+ end
94
+
95
+ def update_arp_table(cidr, max_threads = 10)
96
+ # Use fast ping method to update ARP table
97
+ range = IPAddr.new(cidr).to_range
98
+ queue = Queue.new
99
+ range.each { |ip| queue << ip.to_s }
100
+
101
+ total = queue.size
102
+ processed = 0
103
+
104
+ threads = Array.new([max_threads, total].min) do
105
+ Thread.new do
106
+ while (ip = begin
107
+ queue.pop(true)
108
+ rescue ThreadError
109
+ nil
110
+ end)
111
+ # Use system ping to update ARP table
112
+ system("ping -c 1 -W 1 #{ip} > /dev/null 2>&1 &")
113
+ sleep 0.01 # Small delay to prevent overwhelming the system
114
+ processed += 1
115
+ end
116
+ end
117
+ end
118
+
119
+ # Wait for ping operations to complete
120
+ threads.each(&:join)
121
+ sleep 1 # Give the system time to update ARP table
122
+ end
123
+
124
+ def scan_host(ip)
125
+ # Skip special addresses to save time
126
+ return if ip.end_with?(".0") && !ip.end_with?(".0.0") # Skip network addresses except 0.0.0.0
127
+
128
+ # Use multiple methods to detect if a host is alive
129
+ is_active = false
130
+ detection_method = nil
131
+ response_time = nil
132
+ start_time = Time.now
133
+ open_ports = []
134
+
135
+ # Method 1: Try TCP port scan (most reliable)
136
+ tcp_result = tcp_port_scan(ip, QUICK_CHECK_PORTS)
137
+ if tcp_result[:active]
138
+ is_active = true
139
+ detection_method = "TCP"
140
+ open_ports = tcp_result[:open_ports]
141
+ end
142
+
143
+ # Method 2: Try ICMP ping if TCP didn't work
144
+ unless is_active
145
+ ping_result = ping_check(ip)
146
+ if ping_result
147
+ is_active = true
148
+ detection_method = "ICMP"
149
+ end
150
+ end
151
+
152
+ # Method 3: If host is a common network device (e.g., router), check with UDP
153
+ if !is_active && (ip.end_with?(".1") || ip.end_with?(".254") || ip.end_with?(".255"))
154
+ udp_result = udp_check(ip)
155
+ if udp_result
156
+ is_active = true
157
+ detection_method = "UDP"
158
+ end
159
+ end
160
+
161
+ # Method 4: ARP Check - if we have a MAC, the host is likely active
162
+ unless is_active
163
+ mac_address = get_mac_address(ip)
164
+ if mac_address && mac_address != "(incomplete)"
165
+ is_active = true
166
+ detection_method = "ARP"
167
+ end
168
+ end
169
+
170
+ # For broadcast addresses, always consider them active
171
+ if ip.end_with?(".255") || ip == "255.255.255.255"
172
+ is_active = true
173
+ detection_method = "Broadcast"
174
+ end
175
+
176
+ # Calculate response time
177
+ response_time = ((Time.now - start_time) * 1000).round(2) if is_active
178
+
179
+ return unless is_active
180
+
181
+ # For active hosts, collect more information if in verbose mode
182
+ host_info = {
183
+ ip: ip,
184
+ mac: get_mac_address(ip),
185
+ response_time: response_time,
186
+ detection_method: detection_method
187
+ }
188
+
189
+ if @verbose
190
+ # For verbose mode, try to resolve hostname
191
+ begin
192
+ Timeout.timeout(1) do
193
+ host_info[:hostname] = Resolv.getname(ip)
194
+ end
195
+ rescue Resolv::ResolvError, Timeout::Error
196
+ host_info[:hostname] = "Unknown"
197
+ end
198
+
199
+ # For verbose mode, scan more ports if TCP detection method was successful
200
+ if detection_method == "TCP"
201
+ extra_ports = tcp_port_scan(ip, COMMON_PORTS.keys - QUICK_CHECK_PORTS)[:open_ports]
202
+ open_ports += extra_ports
203
+ end
204
+
205
+ host_info[:ports] = open_ports.map { |port| [port, COMMON_PORTS[port] || "Unknown"] }.to_h
206
+ end
207
+
208
+ @mutex.synchronize { @hosts << host_info }
209
+ rescue StandardError => e
210
+ puts "\nError scanning host #{ip}: #{e.message}" if $DEBUG
211
+ end
212
+
213
+ def tcp_port_scan(ip, ports)
214
+ open_ports = []
215
+ is_active = false
216
+
217
+ ports.each do |port|
218
+ Timeout.timeout(@timeout) do
219
+ socket = TCPSocket.new(ip, port)
220
+ is_active = true
221
+ open_ports << port
222
+ socket.close
223
+ end
224
+ rescue Errno::ECONNREFUSED
225
+ # Connection refused means host is up but port is closed
226
+ is_active = true
227
+ rescue StandardError
228
+ # Other errors mean port is probably closed or filtered
229
+ end
230
+
231
+ { active: is_active, open_ports: open_ports }
232
+ end
233
+
234
+ def ping_check(ip)
235
+ cmd = case RbConfig::CONFIG["host_os"]
236
+ when /darwin/
237
+ "ping -c 1 -W 1 #{ip}"
238
+ when /linux/
239
+ "ping -c 1 -W 1 #{ip}"
240
+ when /mswin|mingw|cygwin/
241
+ "ping -n 1 -w 1000 #{ip}"
242
+ else
243
+ "ping -c 1 -W 1 #{ip}"
244
+ end
245
+
246
+ system("#{cmd} > /dev/null 2>&1")
247
+ $CHILD_STATUS.exitstatus.zero?
248
+ end
249
+
250
+ def udp_check(ip)
251
+ common_udp_ports = [53, 67, 68, 123, 137, 138, 1900, 5353]
252
+
253
+ common_udp_ports.each do |port|
254
+ Timeout.timeout(0.5) do
255
+ socket = UDPSocket.new
256
+ socket.connect(ip, port)
257
+ socket.send("PING", 0)
258
+ socket.close
259
+ return true
260
+ end
261
+ rescue Errno::ECONNREFUSED
262
+ return true # Connection refused means host is up
263
+ rescue StandardError
264
+ # Try next port
265
+ end
266
+ false
267
+ end
268
+
269
+ def get_mac_address(ip)
270
+ return "ff:ff:ff:ff:ff:ff" if ip.end_with?(".255") # Special case for broadcast
271
+
272
+ # Get MAC from ARP table
273
+ cmd = case RbConfig::CONFIG["host_os"]
274
+ when /darwin/
275
+ "arp -n #{ip}"
276
+ when /linux/
277
+ "arp -n #{ip}"
278
+ when /mswin|mingw|cygwin/
279
+ "arp -a #{ip}"
280
+ else
281
+ "arp -n #{ip}"
282
+ end
283
+
284
+ output = `#{cmd}`
285
+
286
+ if output =~ /([0-9a-fA-F]{1,2}[:-][0-9a-fA-F]{1,2}[:-][0-9a-fA-F]{1,2}[:-][0-9a-fA-F]{1,2}[:-][0-9a-fA-F]{1,2}[:-][0-9a-fA-F]{1,2})/
287
+ ::Regexp.last_match(1).downcase
288
+ else
289
+ "(incomplete)"
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Lanet
6
+ class Sender
7
+ def initialize(port)
8
+ @port = port
9
+ @socket = UDPSocket.new
10
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
11
+ end
12
+
13
+ def send_to(target_ip, message)
14
+ @socket.send(message, 0, target_ip, @port)
15
+ end
16
+
17
+ def broadcast(message)
18
+ @socket.send(message, 0, "255.255.255.255", @port)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lanet
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lanet.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lanet/version"
4
+ require "lanet/sender"
5
+ require "lanet/receiver"
6
+ require "lanet/scanner"
7
+ require "lanet/encryptor"
8
+ require "lanet/cli"
9
+ require "lanet/ping"
10
+
11
+ module Lanet
12
+ class Error < StandardError; end
13
+
14
+ # Default port used for communication
15
+ DEFAULT_PORT = 5000
16
+
17
+ # Creates a new sender instance
18
+ def self.sender(port = DEFAULT_PORT)
19
+ Sender.new(port)
20
+ end
21
+
22
+ # Creates a new receiver instance
23
+ def self.receiver(port = DEFAULT_PORT)
24
+ Receiver.new(port)
25
+ end
26
+
27
+ # Creates a new scanner instance
28
+ def self.scanner
29
+ Scanner.new
30
+ end
31
+
32
+ # Helper to encrypt a message
33
+ def self.encrypt(message, key)
34
+ Encryptor.prepare_message(message, key)
35
+ end
36
+
37
+ # Helper to decrypt a message
38
+ def self.decrypt(data, key)
39
+ Encryptor.process_message(data, key)
40
+ end
41
+
42
+ def self.pinger(timeout: 1, count: 3)
43
+ Ping.new(timeout: timeout, count: count)
44
+ end
45
+ end
data/sig/lanet.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Lanet
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end