I’m happy to announce that I’m taking part in the The Third Annual C# Advent this year along side many great articles and podcasts. If you have even a passing interest in C# then you should go check it out and follow all of the authors on social media!
Okay, enough with the niceties! See, when it comes to holidays I am more of a Grinch than a Griswold so I’m going to show you how to do something in C# that is cool, powerful, and … dangerous! I’m going to show you how, from a website, to invoke processes in a command shell and stream the results back to the browser via SignalR.
And before we get any further I should note, this blog post will not be a good introduction to SignalR, that documentation already exists. This article is intended for developers who have web development experience with C#, and are at least somewhat familiar with the concepts of SignalR, and web sockets.
But first, a disclaimer:
Warning! We are literally about to create a remote code execution injection vulnerability, as it allows any user or process with access to the website to run arbitrary commands. However, there are times when you may want to do something similar, very carefully and in a limited fashion. Check out Azure Cloud Shell and Coder.com for examples of web apps that let you interact with shells in a browser.
Now, with the disclaimer out of the way – let’s get our hands dirty!
Ok so what exactly are we doing?
- Creating an ASP.NET web app that lets users run arbitrary cmd.exe commands on the webserver shell
- Streaming STDOUT and STDERROR output to the browser via SignalR
- Exploring ideas for how we might evolve this project into something really, really cool
Note: The full source code is available on Github in case you hate typing: https://github.com/codingblocks/streaming-stdout-with-signalr
Let’s write some code!
I’ve tried to keep things simple so that you can focus on the meat of the message. The downside is, that you may run into some dragons if you stray off the beaten path. Stray anyway! Please feel free to drop a comment with any questions or comments about things you run into.
Step 1: Create a new ASP.NET Core Web Application (tested with v3.0.101)
Assuming you have dotnet installed, the following command will set you up with a basic razor pages website, in a new directory named “streaming” :
1 2 |
dotnet new webapp -o streaming cd streaming |
Step 2: Add a dependency for SiginalR
SignalR is a Microsoft project that makes pub/sub interactions easy for a variety of different client and server technologies. In this example we’ll be using SignalR to stream output from our process, and we’ll also be using a SignalR JavaScript library on the front-end to subscribe to this output and display it on the website. We’ll only be using a small portion of what SignalR offers in this example, but hopefully it’s enough to whet your appetite.
This command will install the server-side bits, and we’ll have a client-side js file to add in the front-end when we get there
1 |
dotnet add package Microsoft.AspNetCore.SignalR.Client |
Step 3: Server-Side Changes
Now we’re ready for some code. Let’s create a class for managing our processes, ProcessHub.cs. Make it inherit from the SignalR “Hub” class so that it can access the relevant communication channels. I’m trying to keep the noise down in this post, so I’m only going to focus on the interesting bits, but the full file is on Github,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
private void Stream(string command, Action<string> outputHandler) { // I'm keeping a static copy of the process around, so that we don't end up spawning a new one every time the user asks for one. This also makes the app single threaded, which is most appropriate for many use cases anyway! TerminateProcessIfExists(); var process = new Process() { // Note: You can swap this for the particular shell you want to run, but I'm keeping this simple so sticking to windows with "cmd.exe" StartInfo = new ProcessStartInfo("cmd.exe") { // The process does not allow for redirection by default, so we need to instruct the process to explicitly allow it RedirectStandardOutput = true, RedirectStandardInput = true, RedirectStandardError = true, // This is also required for redirecting output, "Shell" here refers to an actual graphical shell - which isn't going to work for us here UseShellExecute = false, } }; // These events will trigger our custom outputHandler, which we'll see in the next code section process.OutputDataReceived += new DataReceivedEventHandler( (sendingProcess, outLine) => outputHandler(outLine.Data) ); process.ErrorDataReceived += new DataReceivedEventHandler( (sendingProcess, outLine) => outputHandler(outLine.Data) ); process.Start(); // We have to explicitly start our redirection in addition to starting the process process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Notice that we started the redirection BEFORE we executed the command process.StandardInput.Write(command + NEWLINE); // The process will return async by default, so we have to instruct the runtime to hang on to the process so we keep the redirections alive process.WaitForExit(); _process = process; } |
The method below is what SignalR actually calls, note that we pass in an Action that will send our output back to subscribing clients whenever it’s called.
1 2 3 4 5 6 7 8 9 |
// The "command" argument is an arbitrary shell command that we will execute on the server: public void Stream(string command) { // This calls our private method with the command passed from the client (this is super dangerous, never do this) as well as an Action that will publish output (o) text to any clients that are subscribed to "OutputReceived". Note: this proof-of-concept only has one subscriber, Stream(command, o => { Clients.All.SendAsync("OutputRecieved", o); }); } |
We’ll also need to make two minor changes to Startup.cs in order to hook things up: (Reminder: full file on Github)
1 2 3 4 5 6 7 8 |
// Add this line to the ConfigureServices method services.AddSignalR(); // Add these lines to the bottom of the Configure method to set up the url for the client to call: app.UseSignalR(routes => { routes.MapHub<CommunicationHub>("/stream"); }); |
Step 4: Client-Side Changes
Here’s a handy article for adding SignalR to the front-end in Visual Studio. or you can just use a cdn to keep things simple. The user interface is really minimal here, just a text input and a button. The output of the commands will just stream to an empty div below.
The JS shown below is mainly glue code, but it shows how we listen for the button press event, and create / start a new connection.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Invoke the "Stream" method, with the command document.getElementById("executeButton").addEventListener("click", () => { var command = document.getElementById("commandText").value; var connection = new signalR.HubConnectionBuilder().withUrl("/stream").build(); var output = document.getElementById("output"); output.innerHTML = ''; connection .start() .then(() => { connection.on("OutputRecieved", message => { var p = document.createElement("p"); p.textContent = message; output.appendChild(p); }); connection.invoke("Stream", command); }).catch(err => alert(`An error occured: ${err}`)); }); |
A good example command to run is “ping codingblocks.net” since it shows you what it looks like as data comes in piecemeal. Other cool examples to try “git status”, “dir”, or “ssh”.
Pretty neat right? At this point, the project is just a toy but I bet you can think of some cool stuff to do with it. Likeā¦
Okay, now what?
This project is a simple proof of concept showing how you can call processes in C#, and stream the output to a website. It’s not too useful by itself, but there are a myriad of cool ways you can evolve this project to make something really cool! Here are a couple ideas to get your brain juices flowing:
- Make a dashboard website for custom scripts on your computer
- Make a UI for git
- Make a UI for checking out Docker logs and exec-ing (I did this!)
- Make a full on browser based terminal using xterm, including STDIN and a shared session
- Add basic functionality to current websites for things like ping, grep, nmap, or telnet
- Fix all the minor bugs in this project (like, what happens when you run two commands without refreshing!)
Hopefully you got something out of this post. Make sure to check out the other Advent of C# posts.