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.
@@ -1,174 +1,254 @@
1
1
  require 'nokogiri'
2
- require 'yaml'
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
- def initialize(name, args = {}, &block)
11
- builder = Nokogiri::XML::Builder.new do |xml|
12
- xml.scenario name: name
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
- @rtcp_port = args[:rtcp_port]
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
- @scenario_opts = get_scenario_opts args
22
- @scenario = @doc.xpath('//scenario').first
102
+ @errors = []
23
103
 
24
104
  instance_eval &block if block_given?
25
105
  end
26
106
 
27
- def parse_args(args)
28
- raise ArgumentError, "Must include source IP:PORT" unless args.keys.include? :source
29
- raise ArgumentError, "Must include destination IP:PORT" unless args.keys.include? :destination
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
- def sleep(seconds)
50
- seconds = seconds.to_i
51
- # TODO play silent audio files to the server to fill the gap
52
- pause seconds * MSEC
53
- @media << "silence:#{seconds * MSEC}"
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 = <<-INVITE
62
-
63
- INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0
64
- Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
65
- From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
66
- To: <sip:[service]@[remote_ip]:[remote_port]>
67
- Call-ID: [call_id]
68
- CSeq: [cseq] INVITE
69
- Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
70
- Max-Forwards: 100
71
- User-Agent: #{USER_AGENT}
72
- Content-Type: application/sdp
73
- Content-Length: [len]
74
-
75
- v=0
76
- o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
77
- s=-
78
- c=IN IP[media_ip_type] [media_ip]
79
- t=0 0
80
- #{rtp_string}
81
- a=rtpmap:0 PCMU/8000
82
- a=rtpmap:101 telephone-event/8000
83
- a=fmtp:101 0-15
84
- INVITE
85
- send = new_send msg, opts
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 = register_message user, domain: domain
93
- send = new_send msg, opts
94
- @scenario << send
95
- register_auth(user, password, domain: domain) if password
96
- end
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
- opts[:optional] = true if opts[:optional].nil?
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
- opts[:optional] = true if opts[:optional].nil?
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
- opts[:optional] = true if opts[:optional].nil?
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
- opts.merge! response: 200
160
- recv = new_recv opts
161
- # Record Record Set: Make the Route headers available via [route] later
162
- recv['rrs'] = true
163
- # Response Time Duration: Record the response time
164
- recv['rtd'] = true
165
- @scenario << recv
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 method that tells SIPp optionally receive
171
- # SIP 100, 180, and 183 messages, and require a SIP 200 message.
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 = <<-ACK
181
-
182
- ACK [next_url] SIP/2.0
183
- Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
184
- From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
185
- [last_To:]
186
- Call-ID: [call_id]
187
- CSeq: [cseq] ACK
188
- Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
189
- Max-Forwards: 100
190
- User-Agent: #{USER_AGENT}
191
- Content-Length: 0
192
- [routes]
193
- ACK
194
- @scenario << new_send(msg, opts)
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
- def start_media
199
- nop = Nokogiri::XML::Node.new 'nop', @doc
200
- action = Nokogiri::XML::Node.new 'action', @doc
201
- nop << action
202
- exec = Nokogiri::XML::Node.new 'exec', @doc
203
- exec['play_pcap_audio'] = "#{@filename}.pcap"
204
- action << exec
205
- @scenario << nop
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
- # @param[String] DTMF digits to send. Must be 0-9, *, # or A-D
211
- def send_digits(digits, delay = 0.250)
212
- delay = 0.250 * MSEC # FIXME: Need to pass this down to the media layer
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.to_i}"
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
- BYE [next_url] SIP/2.0
226
- [last_Via:]
227
- From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
228
- [last_To:]
229
- [last_Call-ID]
230
- CSeq: [cseq] BYE
231
- Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
232
- Max-Forwards: 100
233
- User-Agent: #{USER_AGENT}
234
- Content-Length: 0
235
- [routes]
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
- @scenario << new_send(msg, opts)
336
+ send msg, opts
238
337
  end
239
338
 
240
- ##
241
- # Shortcut method that tells SIPp receive a BYE and acknowledge it
242
- def wait_for_hangup(opts = {})
243
- receive_bye(opts)
244
- ack_bye(opts)
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! request: 'BYE'
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
- SIP/2.0 200 OK
257
- [last_Via:]
258
- [last_From:]
259
- [last_To:]
260
- [last_Call-ID:]
261
- [last_CSeq:]
262
- Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
263
- Max-Forwards: 100
264
- User-Agent: #{USER_AGENT}
265
- Content-Length: 0
266
- [routes]
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
- @scenario << new_send(msg, opts)
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
- @doc.to_xml
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
- print "Compiling media to #{@filename}.xml..."
277
- File.open "#{@filename}.xml", 'w' do |file|
278
- file.write @doc.to_xml
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 scenario to #{@filename}.pcap..."
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
- private
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', @doc
524
+ pause = Nokogiri::XML::Node.new 'pause', doc
299
525
  pause['milliseconds'] = msec.to_i
300
- @scenario << pause
526
+ scenario_node << pause
301
527
  end
302
528
 
303
- def new_send(msg, opts = {})
304
- send = Nokogiri::XML::Node.new 'send', @doc
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(@doc, msg)
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 new_recv(opts = {})
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', @doc
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
- end
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