|
| 1 | +//----------------------------------------------------------------------------- |
| 2 | +// Filename: Program.cs |
| 3 | +// |
| 4 | +// Description: Implements a WebRTC Echo Test Server suitable for interoperability |
| 5 | +// testing as per specification at: |
| 6 | +// https://github.com/sipsorcery/webrtc-echoes/blob/master/doc/EchoTestSpecification.md |
| 7 | +// |
| 8 | +// Author(s): |
| 9 | +// Aaron Clauson ([email protected]) |
| 10 | +// |
| 11 | +// History: |
| 12 | +// 19 Feb 2021 Aaron Clauson Created, Dublin, Ireland. |
| 13 | +// 14 Apr 2021 Aaron Clauson Added data channel support. |
| 14 | +// |
| 15 | +// License: |
| 16 | +// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file. |
| 17 | +//----------------------------------------------------------------------------- |
| 18 | + |
| 19 | +using System; |
| 20 | +using System.Collections.Generic; |
| 21 | +using System.Linq; |
| 22 | +using System.Net; |
| 23 | +using System.Text; |
| 24 | +using System.Text.Json; |
| 25 | +using System.Threading; |
| 26 | +using System.Threading.Tasks; |
| 27 | +using EmbedIO; |
| 28 | +using Microsoft.Extensions.Logging; |
| 29 | +using Microsoft.Extensions.Logging.Abstractions; |
| 30 | +using Serilog; |
| 31 | +using Serilog.Events; |
| 32 | +using Serilog.Extensions.Logging; |
| 33 | +using SIPSorcery.Net; |
| 34 | +using SIPSorceryMedia.Abstractions; |
| 35 | + |
| 36 | +namespace SIPSorcery.Examples |
| 37 | +{ |
| 38 | + public class EchoServerOptions |
| 39 | + { |
| 40 | + public const string DEFAULT_WEBSERVER_LISTEN_URL = "http://*:8080/"; |
| 41 | + public const LogEventLevel DEFAULT_VERBOSITY = LogEventLevel.Debug; |
| 42 | + public const int TEST_TIMEOUT_SECONDS = 10; |
| 43 | + |
| 44 | + //[Option('l', "listen", Required = false, Default = DEFAULT_WEBSERVER_LISTEN_URL, |
| 45 | + // HelpText = "The URL the web server will listen on.")] |
| 46 | + //public string ServerUrl { get; set; } |
| 47 | + |
| 48 | + //[Option("timeout", Required = false, Default = TEST_TIMEOUT_SECONDS, |
| 49 | + // HelpText = "Timeout in seconds to close the peer connection. Set to 0 for no timeout.")] |
| 50 | + //public int TestTimeoutSeconds { get; set; } |
| 51 | + |
| 52 | + //[Option('v', "verbosity", Required = false, Default = DEFAULT_VERBOSITY, |
| 53 | + // HelpText = "The log level verbosity (0=Verbose, 1=Debug, 2=Info, 3=Warn...).")] |
| 54 | + //public LogEventLevel Verbosity { get; set; } |
| 55 | + } |
| 56 | + |
| 57 | + public class EchoServer |
| 58 | + { |
| 59 | + private static Microsoft.Extensions.Logging.ILogger logger = NullLogger.Instance; |
| 60 | + |
| 61 | + private static List<IPAddress> _icePresets = new List<IPAddress>(); |
| 62 | + |
| 63 | + public void Start() |
| 64 | + { |
| 65 | + // Apply any command line options |
| 66 | + //if (args.Length > 0) |
| 67 | + //{ |
| 68 | + // url = args[0]; |
| 69 | + // for(int i=1; i<args.Length; i++) |
| 70 | + // { |
| 71 | + // if(IPAddress.TryParse(args[i], out var addr)) |
| 72 | + // { |
| 73 | + // _icePresets.Add(addr); |
| 74 | + // Console.WriteLine($"ICE candidate preset address {addr} added."); |
| 75 | + // } |
| 76 | + // } |
| 77 | + //} |
| 78 | + |
| 79 | + string listenUrl = EchoServerOptions.DEFAULT_WEBSERVER_LISTEN_URL; |
| 80 | + LogEventLevel verbosity = EchoServerOptions.DEFAULT_VERBOSITY; |
| 81 | + int pcTimeout = EchoServerOptions.TEST_TIMEOUT_SECONDS; |
| 82 | + |
| 83 | + //if (args != null) |
| 84 | + //{ |
| 85 | + // Options opts = null; |
| 86 | + // var parseResult = Parser.Default.ParseArguments<Options>(args) |
| 87 | + // .WithParsed(o => opts = o); |
| 88 | + |
| 89 | + // listenUrl = opts != null && !string.IsNullOrEmpty(opts.ServerUrl) ? opts.ServerUrl : listenUrl; |
| 90 | + // verbosity = opts != null ? opts.Verbosity : verbosity; |
| 91 | + // pcTimeout = opts != null ? opts.TestTimeoutSeconds : pcTimeout; |
| 92 | + //} |
| 93 | + |
| 94 | + logger = AddConsoleLogger(verbosity); |
| 95 | + |
| 96 | + // Start the web server. |
| 97 | + using (var server = CreateWebServer(listenUrl, pcTimeout)) |
| 98 | + { |
| 99 | + server.RunAsync(); |
| 100 | + |
| 101 | + Console.WriteLine("ctrl-c to exit."); |
| 102 | + var mre = new ManualResetEvent(false); |
| 103 | + Console.CancelKeyPress += (sender, eventArgs) => |
| 104 | + { |
| 105 | + // cancel the cancellation to allow the program to shutdown cleanly |
| 106 | + eventArgs.Cancel = true; |
| 107 | + mre.Set(); |
| 108 | + }; |
| 109 | + |
| 110 | + mre.WaitOne(); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + private static WebServer CreateWebServer(string url, int pcTimeout) |
| 115 | + { |
| 116 | + var server = new WebServer(o => o |
| 117 | + .WithUrlPrefix(url) |
| 118 | + .WithMode(HttpListenerMode.EmbedIO)) |
| 119 | + .WithCors("*", "*", "*") |
| 120 | + .WithAction("/offer", HttpVerbs.Post, (ctx) => Offer(ctx, pcTimeout)) |
| 121 | + .WithAction("/icecandidate", HttpVerbs.Post, (ctx) => Offer(ctx, pcTimeout)); |
| 122 | + //.WithStaticFolder("/", "../../html", false); |
| 123 | + server.StateChanged += (s, e) => Console.WriteLine($"WebServer New State - {e.NewState}"); |
| 124 | + |
| 125 | + return server; |
| 126 | + } |
| 127 | + |
| 128 | + private async static Task Offer(IHttpContext context, int pcTimeout) |
| 129 | + { |
| 130 | + var offer = await context.GetRequestDataAsync<RTCSessionDescriptionInit>(); |
| 131 | + |
| 132 | + var jsonOptions = new JsonSerializerOptions(); |
| 133 | + jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); |
| 134 | + |
| 135 | + var echoServer = new WebRTCEchoServer(_icePresets); |
| 136 | + var pc = await echoServer.GotOffer(offer); |
| 137 | + |
| 138 | + if (pc != null) |
| 139 | + { |
| 140 | + var answer = new RTCSessionDescriptionInit { type = RTCSdpType.answer, sdp = pc.localDescription.sdp.ToString() }; |
| 141 | + context.Response.ContentType = "application/json"; |
| 142 | + using (var responseStm = context.OpenResponseStream(false, false)) |
| 143 | + { |
| 144 | + await JsonSerializer.SerializeAsync(responseStm, answer, jsonOptions); |
| 145 | + } |
| 146 | + |
| 147 | + if (pcTimeout != 0) |
| 148 | + { |
| 149 | + logger.LogDebug($"Setting peer connection close timeout to {pcTimeout} seconds."); |
| 150 | + |
| 151 | + var timeout = new Timer((state) => |
| 152 | + { |
| 153 | + if (!pc.IsClosed) |
| 154 | + { |
| 155 | + logger.LogWarning("Test timed out."); |
| 156 | + pc.close(); |
| 157 | + } |
| 158 | + }, null, pcTimeout * 1000, Timeout.Infinite); |
| 159 | + pc.OnClosed += timeout.Dispose; |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + private static Microsoft.Extensions.Logging.ILogger AddConsoleLogger( |
| 165 | + LogEventLevel logLevel = LogEventLevel.Debug) |
| 166 | + { |
| 167 | + var serilogLogger = new LoggerConfiguration() |
| 168 | + .Enrich.FromLogContext() |
| 169 | + .MinimumLevel.Is(logLevel) |
| 170 | + .WriteTo.Console() |
| 171 | + .CreateLogger(); |
| 172 | + var factory = new SerilogLoggerFactory(serilogLogger); |
| 173 | + SIPSorcery.LogFactory.Set(factory); |
| 174 | + return factory.CreateLogger<Program>(); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + public class WebRTCEchoServer |
| 179 | + { |
| 180 | + private const int VP8_PAYLOAD_ID = 96; |
| 181 | + |
| 182 | + private static Microsoft.Extensions.Logging.ILogger logger = NullLogger.Instance; |
| 183 | + |
| 184 | + private List<IPAddress> _presetIceAddresses; |
| 185 | + |
| 186 | + public WebRTCEchoServer(List<IPAddress> presetAddresses) |
| 187 | + { |
| 188 | + logger = SIPSorcery.LogFactory.CreateLogger<WebRTCEchoServer>(); |
| 189 | + _presetIceAddresses = presetAddresses; |
| 190 | + } |
| 191 | + |
| 192 | + public async Task<RTCPeerConnection> GotOffer(RTCSessionDescriptionInit offer) |
| 193 | + { |
| 194 | + logger.LogTrace($"SDP offer received."); |
| 195 | + logger.LogTrace(offer.sdp); |
| 196 | + |
| 197 | + var pc = new RTCPeerConnection(); |
| 198 | + |
| 199 | + if (_presetIceAddresses != null) |
| 200 | + { |
| 201 | + foreach (var addr in _presetIceAddresses) |
| 202 | + { |
| 203 | + var rtpPort = pc.GetRtpChannel().RTPPort; |
| 204 | + var publicIPv4Candidate = new RTCIceCandidate(RTCIceProtocol.udp, addr, (ushort)rtpPort, RTCIceCandidateType.host); |
| 205 | + pc.addLocalIceCandidate(publicIPv4Candidate); |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + SDP offerSDP = SDP.ParseSDPDescription(offer.sdp); |
| 210 | + |
| 211 | + if (offerSDP.Media.Any(x => x.Media == SDPMediaTypesEnum.audio)) |
| 212 | + { |
| 213 | + MediaStreamTrack audioTrack = new MediaStreamTrack(SDPWellKnownMediaFormatsEnum.PCMU); |
| 214 | + pc.addTrack(audioTrack); |
| 215 | + } |
| 216 | + |
| 217 | + if (offerSDP.Media.Any(x => x.Media == SDPMediaTypesEnum.video)) |
| 218 | + { |
| 219 | + MediaStreamTrack videoTrack = new MediaStreamTrack(new VideoFormat(VideoCodecsEnum.VP8, VP8_PAYLOAD_ID)); |
| 220 | + pc.addTrack(videoTrack); |
| 221 | + } |
| 222 | + |
| 223 | + pc.OnRtpPacketReceived += (IPEndPoint rep, SDPMediaTypesEnum media, RTPPacket rtpPkt) => |
| 224 | + { |
| 225 | + pc.SendRtpRaw(media, rtpPkt.Payload, rtpPkt.Header.Timestamp, rtpPkt.Header.MarkerBit, rtpPkt.Header.PayloadType); |
| 226 | + }; |
| 227 | + |
| 228 | + pc.OnTimeout += (mediaType) => logger.LogWarning($"Timeout for {mediaType}."); |
| 229 | + pc.oniceconnectionstatechange += (state) => logger.LogInformation($"ICE connection state changed to {state}."); |
| 230 | + pc.onsignalingstatechange += () => logger.LogInformation($"Signaling state changed to {pc.signalingState}."); |
| 231 | + pc.onconnectionstatechange += (state) => |
| 232 | + { |
| 233 | + logger.LogInformation($"Peer connection state changed to {state}."); |
| 234 | + if (state == RTCPeerConnectionState.failed) |
| 235 | + { |
| 236 | + pc.Close("ice failure"); |
| 237 | + } |
| 238 | + }; |
| 239 | + |
| 240 | + pc.ondatachannel += (dc) => |
| 241 | + { |
| 242 | + logger.LogInformation($"Data channel opened for label {dc.label}, stream ID {dc.id}."); |
| 243 | + dc.onmessage += (rdc, proto, data) => |
| 244 | + { |
| 245 | + logger.LogInformation($"Data channel got message: {Encoding.UTF8.GetString(data)}"); |
| 246 | + rdc.send(Encoding.UTF8.GetString(data)); |
| 247 | + }; |
| 248 | + }; |
| 249 | + |
| 250 | + var setResult = pc.setRemoteDescription(offer); |
| 251 | + if (setResult == SetDescriptionResultEnum.OK) |
| 252 | + { |
| 253 | + var answer = pc.createAnswer(); |
| 254 | + await pc.setLocalDescription(answer); |
| 255 | + |
| 256 | + logger.LogTrace($"SDP answer created."); |
| 257 | + logger.LogTrace(answer.sdp); |
| 258 | + |
| 259 | + return pc; |
| 260 | + } |
| 261 | + else |
| 262 | + { |
| 263 | + logger.LogWarning($"Failed to set remote description {setResult}."); |
| 264 | + return null; |
| 265 | + } |
| 266 | + } |
| 267 | + } |
| 268 | +} |
0 commit comments