sippy_cup 0.2.3 → 0.3.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.
- 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
|