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.
@@ -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