Introduction
At home, I recently set up an Ubuntu Home Server using the most recent version of Ubuntu, but did not want to let it run the whole time. But, I did not want to access it physically every time I needed it. Thus, I combined several different techniques to build my own TrayIcon to control the application.
This article sums up the three different approaches I needed to control my home-server:
- Wake on LAN
- FreeNX and NoMachine NX client
- SSH-connections using C#
You can see the TrayIcon's context menu below. That's all the application can do. There is no Windows Form in it. It's all about controlling the home server (called Majestix): turn it on, turn it off, restart it, and open a remote session.
In addition to these basic features, I configured the machine to turn itself off at 2 AM every day. That's what the submenu is about: you can turn the automatic suspend on and off.
The TrayIcon itself displays the current server's state:
- red: unavailable
- green: available
- blue: not yet initialized
- yellow: currently working, or waiting for the server's status change
You can find all the sources here: source code - 133 KB.
Let's now dive into the details.
The application's structure
There are only two structural components in use:
- program.cs: Has the control about the TrayIcon. All user-action is handled by this class. Normally, it calls a method in BusinessLogic.cs.
- BusinessLogic.cs: Contains the methods that are doing the actual work and that use some helper-methods to get the work done.
That's about everything you need to know about the basic structure.
Checking the server's status
The check for the server's status is done in Pinger.cs. This class deploys a worker-thread that pings the server every 10 seconds. As soon as the server's status changes, the event "HostStatusChanged
" is raised. This event is then consumed by program.cs in order to update the TrayIcon's color. It does also contain a method called "GetNervous
" that limits the time between the pings to 1 sec. It is used to detect server-changes faster after a user-action like "wake-up" or "suspend".
You can find the worker-thread's core below (the method "Work
" is used as ThreadStart
):
private void Work()
{
while (RunWorker)
{
Ping ping = new Ping();
PingReply reply = ping.Send(Address);
HostStatus newHostStatus = reply.Status == IPStatus.Success
? HostStatus.Online
: HostStatus.Offline;
if (newHostStatus != CurrentHostStatus)
{
CurrentHostStatus = newHostStatus;
if (HostStatusChanged != null)
{
HostStatusChanged(CurrentHostStatus);
}
}
try
{
if (DateTime.Now < NervousUntil)
{
Thread.Sleep(NERVOUS_SLEEP);
}
else
{
Thread.Sleep(SLEEP_BETWEEN_PINGS);
}
}
catch (ThreadInterruptedException)
{
}
}
}
This loop will run until "RunWorker
" is false
. In the beginning of the loop, a Ping
is issued. Depending on the result, the HostStatus
is changed and the appropriate event is triggered.
After that, the code decides about an appropriate sleep.
The Pinger
class also contains some methods to control the thread: StartWorker
and StopWorker
.
public void StartWoker()
{
lock (this)
{
Assert.IsNull(Worker, "Worker is already running");
RunWorker = true;
Worker = new Thread(Work);
Worker.Start();
}
}
StartWorker
does deploy the thread and lets it do its work.
public void StopWorker()
{
lock (this)
{
Assert.NotNull(Worker, "The Worker is currently not running.");
RunWorker = false;
Worker.Interrupt();
Worker.Join();
Worker = null;
}
}
In StopWorker
then, the thread is joined again: StopWorker
only returns after the Worker-Thread has exited. This interface offers a very simple control about the pinger-thread: call StartWorker
to start it, and call StopWorker
if you want to exit the application.
When consuming the HostStatusChanged
event in program.cs, two actions take place: Calm the pinger down to stop the frequent pinging, and update the menu- and icon-status.
static void Pinger_HostStatusChanged(Pinger.HostStatus newHostStatus)
{
Pinger.CalmDown();
UpdateMenuStatus();
}
Wake him up
Waking the server up is done via WakeOnLan. A modern main-boards normally support this. The network-component listens all the time and waits for the magic packet. You need to send the package to your network's broadcast-address to make sure that it reaches your server.
The method used to transmit the package is BusinessLogic.WakeOnLan()
. You can find the source-code in the attachment. I never tried to understand the method, since I copied it from somewhere (under a "use and redistribute however you like"-license).
Suspend or restart the server
This is a bit more tricky, since you need to connect to the server and tell it to suspend.
To achieve this, I am using an SSH-connection. There are two solutions available for C# that support SSH-connections:
SharpSSH is easy to use, but unfortunately freezes when the connection is lost - something that does happen every time you send the machine to standby. Thus, I was unable to use this library and had to use another one:
Granados does not have this disadvantage, but it is very complicated to use. There are no simple methods to connect and send commands, but only a very basic interface. I created a small wrapper-class in order to hide all the complicated stuff behind an easy-to-use interface.
One important method of this simple interface is the "Connect
" method. It uses the basic classes of Granados to set up the link:
public void Connect(string hostname, string username, string password)
{
SSHConnectionParameter parameters = new SSHConnectionParameter();
parameters.UserName = username;
parameters.Password = password;
parameters.Protocol = SSHProtocol.SSH2;
parameters.AuthenticationType = AuthenticationType.Password;
parameters.WindowSize = 0x1000;
IPAddress ip;
try
{
ip = Dns.GetHostEntry(hostname).AddressList[0];
}
catch (SocketException e)
{
throw new HostnameResolutionException(
"Could not resolve hostname", e, hostname);
}
Socket s = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
try
{
s.Connect(new IPEndPoint(ip, 22));
}
catch (SocketException e)
{
throw new ConnectionFailedException(
"Could not connect to host.", e, hostname);
}
EventReceiver eventReceiver = new EventReceiver(this);
Debug.WriteLine("Connection starts.");
eventReceiver.Notify += InitializationListener;
BaseConnection = SSHConnection.Connect(parameters, eventReceiver, s);
Channel = BaseConnection.OpenShell(eventReceiver);
ConnectionInfo = BaseConnection.ConnectionInfo;
lock (this)
{
while (!eventReceiver.Ready && !eventReceiver.Error)
{
Monitor.Wait(this);
}
if (eventReceiver.Error)
{
throw new Exception("Unexpected " +
"exception during initialization.");
}
}
lock (this)
{
while (!Ready && !eventReceiver.Error)
{
Monitor.Wait(this);
}
if (eventReceiver.Error)
{
throw new Exception("Unexpected exception while waiting for prompt.");
}
}
eventReceiver.Notify -= InitializationListener;
MyEventReceiver = eventReceiver;
}
First, it fills a class with parameters (this could also be done later). Then, it opens the socket to use for the connection. Then the dirty stuff begins. The following lines require some explanation:
EventReceiver eventReceiver = new EventReceiver(this);
Debug.WriteLine("Connection starts.");
eventReceiver.Notify += InitializationListener;
BaseConnection = SSHConnection.Connect(parameters, eventReceiver, s);
Channel = BaseConnection.OpenShell(eventReceiver);
ConnectionInfo = BaseConnection.ConnectionInfo;
First, I am creating a self-implemented EventReceiver
. This receiver consists of a bunch of methods, and is used by Granados to communicate everything of interest that happens on the connection. Most of the methods are still filled with a "NotImplementedException
" only, since they have never been called.
Then, I attach an InitializationListener
to the EventReceiver
that is being notified, as soon as some data arrives:
public void InitializationListener(string input)
{
if (input.EndsWith(":~$ "))
{
lock (this)
{
this.Ready = true;
Monitor.PulseAll(this);
}
}
}
As you can see, I am just waiting for the prompt ":~$ " to be transmitted from the server, and set "this.Ready
" to true
. Not the cleanest solution, but it works fine, and I cannot think of another one.
After these steps, it is time to create the connection itself: SSHconnection.connect
. These lines do return immediately, and the main-line runs into the while-wait-loop. It stays there until InitializationListener
sets "this.Ready
" to true
and wakes the main-line up again.
Then, some cleanup takes place in the main-line, and the method exits with a valid SSH-connection available.
When transmitting a command, a similar pattern is used:
public string WriteCommand(string command)
{
command += "\n";
MyEventReceiver.Notify += ReceiveCommandResult;
CommandFinished = false;
CommandResult = new StringBuilder();
Channel.Transmit(Encoding.ASCII.GetBytes(command), 0, command.Length);
lock (this)
{
while (!CommandFinished && !MyEventReceiver.Error)
{
Monitor.Wait(this);
}
}
MyEventReceiver.Notify -= ReceiveCommandResult;
string result = CommandResult.ToString();
result = result.Substring(result.IndexOf("\r\n")+2);
result = result.Substring(0, Math.Max(result.LastIndexOf("\r\n"), 0));
return result;
}
First, I am adding a listener-method that can receive the command's result. Then, I am transmitting the command, which returns immediately. After that, I wait for "ReceiveCommandResult
" to set "CommandFinished
" to true
:
private void ReceiveCommandResult(string data)
{
CommandResult.Append(data);
if (data.EndsWith(":~$ "))
{
lock (this)
{
CommandFinished = true;
Monitor.PulseAll(this);
}
}
}
This method also collects the result in a StringBuilder
that is then used to return the command's result to the caller of "WriteCommand
".
About the deadlock-issue, you probably saw in the first comment of "WriteCommand
": I never had a connection-loss in my LAN, thus I have not yet cared to fix this issue.
For the transmission of the suspend- and standby-commands, there is another method that just sends the command and returns directly after the command has been sent.
In order to send the server to standby, the SSH-connection to the server is opened and the commands "pmi action suspend" or "sudo shutdown -r now" are transmitted to suspend or restart the server. Don't forget to add your SSH-user to your sudoers-file.
Open a remote session
This is really easy: in my local environment, the TrayIcon comes with an installation of the NoMachineNX-client. This client is then started - that's it. I removed the executables of NoMashine from the files attached to this article, since I don't have the rights to redistribute it.
Control the automatic suspend
After you have an SSH-connection setup, controlling the automatic-suspend is no big deal anymore. The idea is that the server shuts down automatically at 2 AM every day. This way, I can just turn it on when I need it and forget about shutting it down again.
For the rare cases where a job needs more time to run, I want to be able to disable the automatic shutdown.
The tricky part is on the Linux-side. I created a job that runs the following script at 2 AM every day using GNOME-schedule:
date
exec < /etc/automaticShutdown.conf
read line
echo "Found the value $line in configuration"
if [ "true" = "$line" ]; then
echo "Going to suspend"
/usr/sbin/pmi action suspend
fi
As you can see, it suspends the machine if it finds the line "true" inside the file "automaticShutdown.conf". Thus, all the remote-control has to do is to change the value inside this file via SSH. It just submits one of the following two commands:
echo "true" > /etc/automaticShutdown.conf
echo "false" > /etc/automaticShutdown.conf
That's it.
Next open steps
- There is currently no mechanism to control the server from outside of the home-network. If you do any work in this direction, please let me know.
- I still need an option to disable the automatic shutdown just for one night. One idea would be to write an integer to the shutdown-file and every time the shutdown-script is executed, this number is decremented until it reaches 0. I did not yet have the time to implement this.
Conclusion
To create this little application, I searched many places on the net and had to read a lot of code. I hope that I can save you from having to dig around that much.
History
- 0.1: Initially created.
- 0.2: After the first comment to the article, I added some more code to the article, commented a bit more about what I did, and added the source-files which were not available by mistake in the first version.