sippy_cup 0.2.3 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +9 -9
- data/.gitignore +3 -0
- data/.rspec +1 -0
- data/.travis.yml +15 -0
- data/CHANGELOG.md +20 -0
- data/Gemfile +2 -0
- data/Guardfile +4 -9
- data/README.markdown +28 -28
- data/Rakefile +2 -3
- data/bin/sippy_cup +16 -26
- data/lib/sippy_cup/media.rb +3 -1
- data/lib/sippy_cup/runner.rb +148 -50
- data/lib/sippy_cup/scenario.rb +436 -206
- data/lib/sippy_cup/tasks.rb +3 -3
- data/lib/sippy_cup/version.rb +1 -1
- data/lib/sippy_cup/xml_scenario.rb +57 -0
- data/lib/sippy_cup.rb +1 -0
- data/sippy_cup.gemspec +2 -1
- data/spec/fixtures/dtmf_2833_1.pcap +0 -0
- data/spec/fixtures/scenario.xml +73 -0
- data/spec/sippy_cup/fixtures/test.yml +16 -0
- data/spec/sippy_cup/runner_spec.rb +425 -71
- data/spec/sippy_cup/scenario_spec.rb +820 -71
- data/spec/sippy_cup/xml_scenario_spec.rb +103 -0
- data/spec/spec_helper.rb +5 -2
- metadata +30 -5
- data/tmp/rspec_guard_result +0 -1
data/lib/sippy_cup/scenario.rb
CHANGED
@@ -1,174 +1,254 @@
|
|
1
1
|
require 'nokogiri'
|
2
|
-
require '
|
2
|
+
require 'psych'
|
3
|
+
require 'active_support/core_ext/hash'
|
4
|
+
require 'tempfile'
|
3
5
|
|
4
6
|
module SippyCup
|
7
|
+
#
|
8
|
+
# A representation of a SippyCup scenario from a manifest or created in code. Allows building a scenario from a set of basic primitives, and then exporting to SIPp scenario files, including the XML scenario and PCAP audio.
|
9
|
+
#
|
5
10
|
class Scenario
|
6
11
|
USER_AGENT = "SIPp/sippy_cup"
|
7
12
|
VALID_DTMF = %w{0 1 2 3 4 5 6 7 8 9 0 * # A B C D}.freeze
|
8
13
|
MSEC = 1_000
|
9
14
|
|
10
|
-
|
11
|
-
|
12
|
-
|
15
|
+
#
|
16
|
+
# Build a scenario based on either a manifest string or a file handle. Manifests are supplied in YAML format.
|
17
|
+
# All manifest keys can be overridden by passing in a Hash of corresponding values.
|
18
|
+
#
|
19
|
+
# @param [String, File] manifest The YAML manifest
|
20
|
+
# @param [Hash] options Options to override (see #initialize)
|
21
|
+
# @option options [String] :input_filename The name of the input file if there is one. Used as a preferable fallback if no name is included in the manifest.
|
22
|
+
#
|
23
|
+
# @return [SippyCup::Scenario]
|
24
|
+
#
|
25
|
+
# @example Parse a manifest string
|
26
|
+
# manifest = <<-MANIFEST
|
27
|
+
# source: 192.168.1.1
|
28
|
+
# destination: 192.168.1.2
|
29
|
+
# steps:
|
30
|
+
# - invite
|
31
|
+
# - wait_for_answer
|
32
|
+
# - ack_answer
|
33
|
+
# - sleep 3
|
34
|
+
# - wait_for_hangup
|
35
|
+
# MANIFEST
|
36
|
+
# Scenario.from_manifest(manifest)
|
37
|
+
#
|
38
|
+
# @example Parse a manifest file by path
|
39
|
+
# File.open("/my/manifest.yml") { |f| Scenario.from_manifest(f) }
|
40
|
+
# # or
|
41
|
+
# Scenario.from_manifest(File.read("/my/manifest.yml"))
|
42
|
+
#
|
43
|
+
# @example Override keys from the manifest
|
44
|
+
# Scenario.from_manifest(manifest, source: '192.168.12.1')
|
45
|
+
#
|
46
|
+
def self.from_manifest(manifest, options = {})
|
47
|
+
args = ActiveSupport::HashWithIndifferentAccess.new(Psych.safe_load(manifest)).merge options
|
48
|
+
|
49
|
+
input_name = options.has_key?(:input_filename) ? File.basename(options[:input_filename]).gsub(/\.ya?ml/, '') : nil
|
50
|
+
name = args.delete(:name) || input_name || 'My Scenario'
|
51
|
+
|
52
|
+
scenario = if args[:scenario]
|
53
|
+
media = args.has_key?(:media) ? File.read(args[:media], mode: 'rb') : nil
|
54
|
+
SippyCup::XMLScenario.new name, File.read(args[:scenario]), media, args
|
55
|
+
else
|
56
|
+
steps = args.delete :steps
|
57
|
+
scenario = Scenario.new name, args
|
58
|
+
scenario.build steps
|
59
|
+
scenario
|
13
60
|
end
|
14
61
|
|
62
|
+
scenario
|
63
|
+
end
|
64
|
+
|
65
|
+
# @return [Hash] The options the scenario was created with, either from a manifest or passed as overrides
|
66
|
+
attr_reader :scenario_options
|
67
|
+
|
68
|
+
# @return [Array<Hash>] a collection of errors encountered while building the scenario.
|
69
|
+
attr_reader :errors
|
70
|
+
|
71
|
+
#
|
72
|
+
# Create a scenario instance
|
73
|
+
#
|
74
|
+
# @param [String] name The scenario's name
|
75
|
+
# @param [Hash] args options to customise the scenario
|
76
|
+
# @option options [String] :name The name of the scenario, used for the XML scenario and for determining the compiled filenames. Defaults to 'My Scenario'.
|
77
|
+
# @option options [String] :filename The name of the files to be saved to disk.
|
78
|
+
# @option options [String] :source The source IP/hostname with which to invoke SIPp.
|
79
|
+
# @option options [String, Numeric] :source_port The source port to bind SIPp to (defaults to 8836).
|
80
|
+
# @option options [String] :destination The target system at which to direct traffic.
|
81
|
+
# @option options [String] :from_user The SIP user from which traffic should appear.
|
82
|
+
# @option options [Integer] :media_port The RTCP (media) port to bind to locally.
|
83
|
+
# @option options [String, Numeric] :max_concurrent The maximum number of concurrent calls to execute.
|
84
|
+
# @option options [String, Numeric] :number_of_calls The maximum number of calls to execute in the test run.
|
85
|
+
# @option options [String, Numeric] :calls_per_second The rate at which to initiate calls.
|
86
|
+
# @option options [String] :stats_file The path at which to dump statistics.
|
87
|
+
# @option options [String, Numeric] :stats_interval The interval (in seconds) at which to dump statistics (defaults to 1s).
|
88
|
+
# @option options [String] :transport_mode The transport mode over which to direct SIP traffic.
|
89
|
+
# @option options [String] :scenario_variables A path to a CSV file of variables to be interpolated with the scenario at runtime.
|
90
|
+
# @option options [Array<String>] :steps A collection of steps
|
91
|
+
#
|
92
|
+
# @yield [scenario] Builder block to construct scenario
|
93
|
+
# @yieldparam [Scenario] scenario the initialized scenario instance
|
94
|
+
#
|
95
|
+
def initialize(name, args = {}, &block)
|
15
96
|
parse_args args
|
16
|
-
|
97
|
+
|
98
|
+
@scenario_options = args.merge name: name
|
17
99
|
@filename = args[:filename] || name.downcase.gsub(/\W+/, '_')
|
18
|
-
@filename = File.expand_path @filename
|
19
|
-
@doc = builder.doc
|
100
|
+
@filename = File.expand_path @filename, Dir.pwd
|
20
101
|
@media = Media.new '127.0.0.255', 55555, '127.255.255.255', 5060
|
21
|
-
@
|
22
|
-
@scenario = @doc.xpath('//scenario').first
|
102
|
+
@errors = []
|
23
103
|
|
24
104
|
instance_eval &block if block_given?
|
25
105
|
end
|
26
106
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
@from_addr, @from_port = args[:source].split ':'
|
32
|
-
@to_addr, @to_port = args[:destination].split ':'
|
33
|
-
@from_user = args[:from_user] || "sipp"
|
34
|
-
end
|
35
|
-
|
36
|
-
def get_scenario_opts(args)
|
37
|
-
defaults = { source: "#{@from_addr}", destination: "#{@to_addr}",
|
38
|
-
scenario: "#{@filename}.xml", max_concurrent: 10,
|
39
|
-
calls_per_second: 5, number_of_calls: 20 }
|
40
|
-
|
41
|
-
opts = args.select {|k,v| true unless [:source, :destination, :filename].include? k}
|
42
|
-
defaults.merge! args
|
43
|
-
end
|
44
|
-
|
45
|
-
def compile_media
|
46
|
-
@media.compile!
|
107
|
+
# @return [true, false] the validity of the scenario. Will be false if errors were encountered while building the scenario from a manifest
|
108
|
+
def valid?
|
109
|
+
@errors.size.zero?
|
47
110
|
end
|
48
111
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
112
|
+
#
|
113
|
+
# Build the scenario steps provided
|
114
|
+
#
|
115
|
+
# @param [Array<String>] steps A collection of steps to build the scenario
|
116
|
+
#
|
117
|
+
def build(steps)
|
118
|
+
raise ArgumentError, "Must provide scenario steps" unless steps
|
119
|
+
steps.each_with_index do |step, index|
|
120
|
+
begin
|
121
|
+
instruction, arg = step.split ' ', 2
|
122
|
+
if arg && !arg.empty?
|
123
|
+
# Strip leading/trailing quotes if present
|
124
|
+
arg.gsub!(/^'|^"|'$|"$/, '')
|
125
|
+
self.__send__ instruction, arg
|
126
|
+
else
|
127
|
+
self.__send__ instruction
|
128
|
+
end
|
129
|
+
rescue => e
|
130
|
+
@errors << {step: index + 1, message: "#{step}: #{e.message}"}
|
131
|
+
end
|
132
|
+
end
|
54
133
|
end
|
55
134
|
|
135
|
+
#
|
136
|
+
# Send an invite message
|
137
|
+
#
|
138
|
+
# @param [Hash] opts A set of options to modify the message
|
139
|
+
# @option opts [Integer] :retrans
|
140
|
+
# @option opts [String] :headers Extra headers to place into the INVITE
|
141
|
+
#
|
56
142
|
def invite(opts = {})
|
57
143
|
opts[:retrans] ||= 500
|
58
|
-
rtp_string = @static_rtcp ? "m=audio #{@rtcp_port.to_i - 1} RTP/AVP 0 101\na=rtcp:#{@rtcp_port}\n" : "m=audio [media_port] RTP/AVP 0 101\n"
|
59
144
|
# FIXME: The DTMF mapping (101) is hard-coded. It would be better if we could
|
60
145
|
# get this from the DTMF payload generator
|
61
|
-
msg = <<-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
send
|
86
|
-
@scenario << send
|
146
|
+
msg = <<-MSG
|
147
|
+
|
148
|
+
INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0
|
149
|
+
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
150
|
+
From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
|
151
|
+
To: <sip:[service]@[remote_ip]:[remote_port]>
|
152
|
+
Call-ID: [call_id]
|
153
|
+
CSeq: [cseq] INVITE
|
154
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
155
|
+
Max-Forwards: 100
|
156
|
+
User-Agent: #{USER_AGENT}
|
157
|
+
Content-Type: application/sdp
|
158
|
+
Content-Length: [len]
|
159
|
+
#{opts.has_key?(:headers) ? opts.delete(:headers).sub(/\n*\Z/, "\n") : ''}
|
160
|
+
v=0
|
161
|
+
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
162
|
+
s=-
|
163
|
+
c=IN IP[media_ip_type] [media_ip]
|
164
|
+
t=0 0
|
165
|
+
m=audio [media_port] RTP/AVP 0 101
|
166
|
+
a=rtpmap:0 PCMU/8000
|
167
|
+
a=rtpmap:101 telephone-event/8000
|
168
|
+
a=fmtp:101 0-15
|
169
|
+
MSG
|
170
|
+
send msg, opts
|
87
171
|
end
|
88
172
|
|
173
|
+
#
|
174
|
+
# Send a REGISTER message with the specified credentials
|
175
|
+
#
|
176
|
+
# @param [String] user the user to register as. May be given as a full SIP URI (sip:user@domain.com), in email-address format (user@domain.com) or as a simple username ('user'). If no domain is supplied, the source IP from SIPp will be used.
|
177
|
+
# @param [optional, String, nil] password the password to authenticate with.
|
178
|
+
# @param [Hash] opts A set of options to modify the message
|
179
|
+
#
|
180
|
+
# @example Register with authentication
|
181
|
+
# s.register 'frank@there.com', 'abc123'
|
182
|
+
#
|
183
|
+
# @example Register without authentication or a domain
|
184
|
+
# s.register 'frank'
|
185
|
+
#
|
89
186
|
def register(user, password = nil, opts = {})
|
90
187
|
opts[:retrans] ||= 500
|
91
188
|
user, domain = parse_user user
|
92
|
-
msg =
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
def register_message(user, opts = {})
|
99
|
-
<<-REGISTER
|
100
|
-
|
101
|
-
REGISTER sip:#{opts[:domain]} SIP/2.0
|
102
|
-
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
103
|
-
From: <sip:#{user}@#{opts[:domain]}>;tag=[call_number]
|
104
|
-
To: <sip:#{user}@#{opts[:domain]}>
|
105
|
-
Call-ID: [call_id]
|
106
|
-
CSeq: [cseq] REGISTER
|
107
|
-
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
108
|
-
Max-Forwards: 10
|
109
|
-
Expires: 120
|
110
|
-
User-Agent: #{USER_AGENT}
|
111
|
-
Content-Length: 0
|
112
|
-
REGISTER
|
113
|
-
end
|
114
|
-
|
115
|
-
def register_auth(user, password, opts = {})
|
116
|
-
opts[:retrans] ||= 500
|
117
|
-
@scenario << new_recv(response: '401', auth: true, optional: false)
|
118
|
-
msg = <<-AUTH
|
119
|
-
|
120
|
-
REGISTER sip:#{opts[:domain]} SIP/2.0
|
121
|
-
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
122
|
-
From: <sip:#{user}@#{opts[:domain]}>;tag=[call_number]
|
123
|
-
To: <sip:#{user}@#{opts[:domain]}>
|
124
|
-
Call-ID: [call_id]
|
125
|
-
CSeq: [cseq] REGISTER
|
126
|
-
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
127
|
-
Max-Forwards: 20
|
128
|
-
Expires: 3600
|
129
|
-
[authentication username=#{user} password=#{password}]
|
130
|
-
User-Agent: #{USER_AGENT}
|
131
|
-
Content-Length: 0
|
132
|
-
AUTH
|
133
|
-
send = new_send msg, opts
|
134
|
-
@scenario << send
|
189
|
+
msg = if password
|
190
|
+
register_auth domain, user, password
|
191
|
+
else
|
192
|
+
register_message domain, user
|
193
|
+
end
|
194
|
+
send msg, opts
|
135
195
|
end
|
136
196
|
|
197
|
+
#
|
198
|
+
# Sets an expectation for a SIP 100 message from the remote party
|
199
|
+
#
|
200
|
+
# @param [Hash] opts A set of options to modify the expectation
|
201
|
+
# @option opts [true, false] :optional Wether or not receipt of the message is optional. Defaults to true.
|
202
|
+
#
|
137
203
|
def receive_trying(opts = {})
|
138
|
-
|
139
|
-
opts.merge! response: 100
|
140
|
-
@scenario << new_recv(opts)
|
204
|
+
handle_response 100, opts
|
141
205
|
end
|
142
206
|
alias :receive_100 :receive_trying
|
143
207
|
|
208
|
+
#
|
209
|
+
# Sets an expectation for a SIP 180 message from the remote party
|
210
|
+
#
|
211
|
+
# @param [Hash] opts A set of options to modify the expectation
|
212
|
+
# @option opts [true, false] :optional Wether or not receipt of the message is optional. Defaults to true.
|
213
|
+
#
|
144
214
|
def receive_ringing(opts = {})
|
145
|
-
|
146
|
-
opts.merge! response: 180
|
147
|
-
@scenario << new_recv(opts)
|
215
|
+
handle_response 180, opts
|
148
216
|
end
|
149
217
|
alias :receive_180 :receive_ringing
|
150
218
|
|
219
|
+
#
|
220
|
+
# Sets an expectation for a SIP 183 message from the remote party
|
221
|
+
#
|
222
|
+
# @param [Hash] opts A set of options to modify the expectation
|
223
|
+
# @option opts [true, false] :optional Wether or not receipt of the message is optional. Defaults to true.
|
224
|
+
#
|
151
225
|
def receive_progress(opts = {})
|
152
|
-
|
153
|
-
opts.merge! response: 183
|
154
|
-
@scenario << new_recv(opts)
|
226
|
+
handle_response 183, opts
|
155
227
|
end
|
156
228
|
alias :receive_183 :receive_progress
|
157
229
|
|
230
|
+
#
|
231
|
+
# Sets an expectation for a SIP 200 message from the remote party
|
232
|
+
#
|
233
|
+
# @param [Hash] opts A set of options to modify the expectation
|
234
|
+
# @option opts [true, false] :optional Wether or not receipt of the message is optional. Defaults to true.
|
235
|
+
#
|
158
236
|
def receive_answer(opts = {})
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
237
|
+
options = {
|
238
|
+
response: 200,
|
239
|
+
rrs: true, # Record Record Set: Make the Route headers available via [route] later
|
240
|
+
rtd: true # Response Time Duration: Record the response time
|
241
|
+
}
|
242
|
+
|
243
|
+
recv options.merge(opts)
|
166
244
|
end
|
167
245
|
alias :receive_200 :receive_answer
|
168
246
|
|
169
|
-
|
170
|
-
# Shortcut
|
171
|
-
#
|
247
|
+
#
|
248
|
+
# Shortcut that sets expectations for optional SIP 100, 180 and 183, followed by a required 200.
|
249
|
+
#
|
250
|
+
# @param [Hash] opts A set of options to modify the expectations
|
251
|
+
#
|
172
252
|
def wait_for_answer(opts = {})
|
173
253
|
receive_trying({optional: true}.merge opts)
|
174
254
|
receive_ringing({optional: true}.merge opts)
|
@@ -176,114 +256,190 @@ module SippyCup
|
|
176
256
|
receive_answer opts
|
177
257
|
end
|
178
258
|
|
259
|
+
#
|
260
|
+
# Acknowledge a received answer message (SIP 200) and start media playback
|
261
|
+
#
|
262
|
+
# @param [Hash] opts A set of options to modify the message parameters
|
263
|
+
#
|
179
264
|
def ack_answer(opts = {})
|
180
|
-
msg = <<-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
265
|
+
msg = <<-BODY
|
266
|
+
|
267
|
+
ACK [next_url] SIP/2.0
|
268
|
+
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
269
|
+
From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
|
270
|
+
[last_To:]
|
271
|
+
Call-ID: [call_id]
|
272
|
+
CSeq: [cseq] ACK
|
273
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
274
|
+
Max-Forwards: 100
|
275
|
+
User-Agent: #{USER_AGENT}
|
276
|
+
Content-Length: 0
|
277
|
+
[routes]
|
278
|
+
BODY
|
279
|
+
send msg, opts
|
195
280
|
start_media
|
196
281
|
end
|
197
282
|
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
283
|
+
#
|
284
|
+
# Insert a pause into the scenario and its media of the specified duration
|
285
|
+
#
|
286
|
+
# @param [Numeric] seconds The duration of the pause in seconds
|
287
|
+
#
|
288
|
+
def sleep(seconds)
|
289
|
+
milliseconds = (seconds.to_f * MSEC).to_i
|
290
|
+
pause milliseconds
|
291
|
+
@media << "silence:#{milliseconds}"
|
206
292
|
end
|
207
293
|
|
208
|
-
|
294
|
+
#
|
209
295
|
# Send DTMF digits
|
210
|
-
#
|
211
|
-
|
212
|
-
|
296
|
+
#
|
297
|
+
# @param [String] DTMF digits to send. Must be 0-9, *, # or A-D
|
298
|
+
#
|
299
|
+
# @example Send a single DTMF digit
|
300
|
+
# send_digits '1'
|
301
|
+
#
|
302
|
+
# @example Enter a pin number
|
303
|
+
# send_digits '1234'
|
304
|
+
#
|
305
|
+
def send_digits(digits)
|
306
|
+
delay = (0.250 * MSEC).to_i # FIXME: Need to pass this down to the media layer
|
213
307
|
digits.split('').each do |digit|
|
214
308
|
raise ArgumentError, "Invalid DTMF digit requested: #{digit}" unless VALID_DTMF.include? digit
|
215
309
|
|
216
310
|
@media << "dtmf:#{digit}"
|
217
|
-
@media << "silence:#{delay
|
218
|
-
pause delay * 2
|
311
|
+
@media << "silence:#{delay}"
|
219
312
|
end
|
313
|
+
pause delay * 2 * digits.size
|
220
314
|
end
|
221
315
|
|
316
|
+
#
|
317
|
+
# Send a BYE message
|
318
|
+
#
|
319
|
+
# @param [Hash] opts A set of options to modify the message parameters
|
320
|
+
#
|
222
321
|
def send_bye(opts = {})
|
223
322
|
msg = <<-MSG
|
224
323
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
324
|
+
BYE [next_url] SIP/2.0
|
325
|
+
[last_Via:]
|
326
|
+
From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
|
327
|
+
[last_To:]
|
328
|
+
[last_Call-ID]
|
329
|
+
CSeq: [cseq] BYE
|
330
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
331
|
+
Max-Forwards: 100
|
332
|
+
User-Agent: #{USER_AGENT}
|
333
|
+
Content-Length: 0
|
334
|
+
[routes]
|
236
335
|
MSG
|
237
|
-
|
336
|
+
send msg, opts
|
238
337
|
end
|
239
338
|
|
240
|
-
|
241
|
-
#
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
end
|
246
|
-
|
247
|
-
|
339
|
+
#
|
340
|
+
# Expect to receive a BYE message
|
341
|
+
#
|
342
|
+
# @param [Hash] opts A set of options to modify the expectation
|
343
|
+
#
|
248
344
|
def receive_bye(opts = {})
|
249
|
-
opts.merge
|
250
|
-
@scenario << new_recv(opts)
|
345
|
+
recv opts.merge request: 'BYE'
|
251
346
|
end
|
252
347
|
|
348
|
+
#
|
349
|
+
# Acknowledge a received BYE message
|
350
|
+
#
|
351
|
+
# @param [Hash] opts A set of options to modify the message parameters
|
352
|
+
#
|
253
353
|
def ack_bye(opts = {})
|
254
354
|
msg = <<-ACK
|
255
355
|
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
356
|
+
SIP/2.0 200 OK
|
357
|
+
[last_Via:]
|
358
|
+
[last_From:]
|
359
|
+
[last_To:]
|
360
|
+
[last_Call-ID:]
|
361
|
+
[last_CSeq:]
|
362
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
363
|
+
Max-Forwards: 100
|
364
|
+
User-Agent: #{USER_AGENT}
|
365
|
+
Content-Length: 0
|
366
|
+
[routes]
|
267
367
|
ACK
|
268
|
-
|
368
|
+
send msg, opts
|
369
|
+
end
|
370
|
+
|
371
|
+
#
|
372
|
+
# Shortcut to set an expectation for a BYE and acknowledge it when received
|
373
|
+
#
|
374
|
+
# @param [Hash] opts A set of options to modify the expectation
|
375
|
+
#
|
376
|
+
def wait_for_hangup(opts = {})
|
377
|
+
receive_bye(opts)
|
378
|
+
ack_bye(opts)
|
269
379
|
end
|
270
380
|
|
381
|
+
#
|
382
|
+
# Dump the scenario to a SIPp XML string
|
383
|
+
#
|
384
|
+
# @return [String] the SIPp XML scenario
|
271
385
|
def to_xml
|
272
|
-
|
386
|
+
doc.to_xml
|
273
387
|
end
|
274
388
|
|
389
|
+
#
|
390
|
+
# Compile the scenario and its media to disk
|
391
|
+
#
|
392
|
+
# Writes the SIPp scenario file to disk at {filename}.xml, and the PCAP media to {filename}.pcap.
|
393
|
+
# {filename} is taken from the :filename option when creating the scenario, or falls back to a down-snake-cased version of the scenario name.
|
394
|
+
#
|
395
|
+
# @return [String] the path to the resulting scenario file
|
396
|
+
#
|
397
|
+
# @example Export a scenario to a specified filename
|
398
|
+
# scenario = Scenario.new 'Test Scenario', filename: 'my_scenario'
|
399
|
+
# scenario.compile! # Leaves files at my_scenario.xml and my_scenario.pcap
|
400
|
+
#
|
401
|
+
# @example Export a scenario to a calculated filename
|
402
|
+
# scenario = Scenario.new 'Test Scenario'
|
403
|
+
# scenario.compile! # Leaves files at test_scenario.xml and test_scenario.pcap
|
404
|
+
#
|
275
405
|
def compile!
|
276
|
-
|
277
|
-
|
278
|
-
|
406
|
+
scenario_filename = "#{@filename}.xml"
|
407
|
+
print "Compiling scenario to #{scenario_filename}..."
|
408
|
+
File.open scenario_filename, 'w' do |file|
|
409
|
+
file.write doc.to_xml
|
279
410
|
end
|
280
411
|
puts "done."
|
281
412
|
|
282
|
-
print "Compiling
|
413
|
+
print "Compiling media to #{@filename}.pcap..."
|
283
414
|
compile_media.to_file filename: "#{@filename}.pcap"
|
284
415
|
puts "done."
|
416
|
+
|
417
|
+
scenario_filename
|
285
418
|
end
|
286
419
|
|
420
|
+
#
|
421
|
+
# Write compiled Scenario XML and PCAP media to tempfiles.
|
422
|
+
#
|
423
|
+
# These will automatically be closed and deleted once they have gone out of scope, and can be used to execute the scenario without leaving stuff behind.
|
424
|
+
#
|
425
|
+
# @return [Hash<Symbol => Tempfile>] handles to created Tempfiles at :scenario and :media
|
426
|
+
#
|
427
|
+
# @see http://www.ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/Tempfile.html
|
428
|
+
#
|
429
|
+
def to_tmpfiles
|
430
|
+
scenario_file = Tempfile.new 'scenario'
|
431
|
+
scenario_file.write to_xml
|
432
|
+
scenario_file.rewind
|
433
|
+
|
434
|
+
media_file = Tempfile.new 'media'
|
435
|
+
media_file.write compile_media.to_s
|
436
|
+
media_file.rewind
|
437
|
+
|
438
|
+
{scenario: scenario_file, media: media_file}
|
439
|
+
end
|
440
|
+
|
441
|
+
private
|
442
|
+
|
287
443
|
#TODO: SIPS support?
|
288
444
|
def parse_user(user)
|
289
445
|
user.slice! 0, 4 if user =~ /sip:/
|
@@ -293,36 +449,110 @@ module SippyCup
|
|
293
449
|
[user, domain]
|
294
450
|
end
|
295
451
|
|
296
|
-
|
452
|
+
def doc
|
453
|
+
@doc ||= begin
|
454
|
+
Nokogiri::XML::Builder.new do |xml|
|
455
|
+
xml.scenario name: @scenario_options[:name]
|
456
|
+
end.doc
|
457
|
+
end
|
458
|
+
end
|
459
|
+
|
460
|
+
def scenario_node
|
461
|
+
@scenario_node = doc.xpath('//scenario').first
|
462
|
+
end
|
463
|
+
|
464
|
+
def parse_args(args)
|
465
|
+
raise ArgumentError, "Must include source IP:PORT" unless args.has_key? :source
|
466
|
+
raise ArgumentError, "Must include destination IP:PORT" unless args.has_key? :destination
|
467
|
+
|
468
|
+
@from_addr, @from_port = args[:source].split ':'
|
469
|
+
@to_addr, @to_port = args[:destination].split ':'
|
470
|
+
@from_user = args[:from_user] || "sipp"
|
471
|
+
end
|
472
|
+
|
473
|
+
def compile_media
|
474
|
+
@media.compile!
|
475
|
+
end
|
476
|
+
|
477
|
+
def register_message(domain, user, opts = {})
|
478
|
+
<<-BODY
|
479
|
+
|
480
|
+
REGISTER sip:#{domain} SIP/2.0
|
481
|
+
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
482
|
+
From: <sip:#{user}@#{domain}>;tag=[call_number]
|
483
|
+
To: <sip:#{user}@#{domain}>
|
484
|
+
Call-ID: [call_id]
|
485
|
+
CSeq: [cseq] REGISTER
|
486
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
487
|
+
Max-Forwards: 10
|
488
|
+
Expires: 120
|
489
|
+
User-Agent: #{USER_AGENT}
|
490
|
+
Content-Length: 0
|
491
|
+
BODY
|
492
|
+
end
|
493
|
+
|
494
|
+
def register_auth(domain, user, password, opts = {})
|
495
|
+
recv response: '401', auth: true, optional: false
|
496
|
+
<<-AUTH
|
497
|
+
|
498
|
+
REGISTER sip:#{domain} SIP/2.0
|
499
|
+
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
500
|
+
From: <sip:#{user}@#{domain}>;tag=[call_number]
|
501
|
+
To: <sip:#{user}@#{domain}>
|
502
|
+
Call-ID: [call_id]
|
503
|
+
CSeq: [cseq] REGISTER
|
504
|
+
Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
|
505
|
+
Max-Forwards: 20
|
506
|
+
Expires: 3600
|
507
|
+
[authentication username=#{user} password=#{password}]
|
508
|
+
User-Agent: #{USER_AGENT}
|
509
|
+
Content-Length: 0
|
510
|
+
AUTH
|
511
|
+
end
|
512
|
+
|
513
|
+
def start_media
|
514
|
+
nop = Nokogiri::XML::Node.new 'nop', doc
|
515
|
+
action = Nokogiri::XML::Node.new 'action', doc
|
516
|
+
nop << action
|
517
|
+
exec = Nokogiri::XML::Node.new 'exec', doc
|
518
|
+
exec['play_pcap_audio'] = "#{@filename}.pcap"
|
519
|
+
action << exec
|
520
|
+
scenario_node << nop
|
521
|
+
end
|
522
|
+
|
297
523
|
def pause(msec)
|
298
|
-
pause = Nokogiri::XML::Node.new 'pause',
|
524
|
+
pause = Nokogiri::XML::Node.new 'pause', doc
|
299
525
|
pause['milliseconds'] = msec.to_i
|
300
|
-
|
526
|
+
scenario_node << pause
|
301
527
|
end
|
302
528
|
|
303
|
-
def
|
304
|
-
send = Nokogiri::XML::Node.new 'send',
|
529
|
+
def send(msg, opts = {})
|
530
|
+
send = Nokogiri::XML::Node.new 'send', doc
|
305
531
|
opts.each do |k,v|
|
306
532
|
send[k.to_s] = v
|
307
533
|
end
|
308
534
|
send << "\n"
|
309
|
-
send << Nokogiri::XML::CDATA.new(
|
535
|
+
send << Nokogiri::XML::CDATA.new(doc, msg)
|
310
536
|
send << "\n" #Newlines are required before and after CDATA so SIPp will parse properly
|
311
|
-
send
|
537
|
+
scenario_node << send
|
312
538
|
end
|
313
539
|
|
314
|
-
def
|
540
|
+
def recv(opts = {})
|
315
541
|
raise ArgumentError, "Receive must include either a response or a request" unless opts.keys.include?(:response) || opts.keys.include?(:request)
|
316
|
-
recv = Nokogiri::XML::Node.new 'recv',
|
317
|
-
recv['request'] = opts.delete :request if opts.keys.include? :request
|
318
|
-
recv['response'] = opts.delete :response if opts.keys.include? :response
|
319
|
-
recv['optional'] = !!opts.delete(:optional)
|
542
|
+
recv = Nokogiri::XML::Node.new 'recv', doc
|
320
543
|
opts.each do |k,v|
|
321
544
|
recv[k.to_s] = v
|
322
545
|
end
|
323
|
-
recv
|
546
|
+
scenario_node << recv
|
324
547
|
end
|
325
|
-
end
|
326
548
|
|
327
|
-
|
549
|
+
def optional_recv(opts)
|
550
|
+
opts[:optional] = true if opts[:optional].nil?
|
551
|
+
recv opts
|
552
|
+
end
|
328
553
|
|
554
|
+
def handle_response(code, opts)
|
555
|
+
optional_recv opts.merge(response: code)
|
556
|
+
end
|
557
|
+
end
|
558
|
+
end
|