Friday, May 8, 2015

Creating a Simple Golang Network Application, Part 4

At the end of the last blog post port_listen had gained some networking abilities. But one thing stuck out; I'm duplicating code for extracting the remote client's IP. It would be trivial to run that routine once and pass the result to the GrabInput for reuse rather than re-running the code snippet.

First I modified the call to GrabInput in the ListenToPort function so it passes the remote address, like "go GrabInput(conn, strPortToListenOn, strRemoteAddrSansPort)"

The next step is to tell GrabInput about the change. First, the declaration needs to change to

func GrabInput(conn net.Conn, strFromListenerNumber string, strClientIP string) {

The two strRemoteAddr* variables declared at the top of GrabInput can be removed, as can these lines:

strRemoteAddr = conn.RemoteAddr().String()
strRemoteAddrSansPort, _, _ = net.SplitHostPort(strRemoteAddr)

Now for one last change; the call to LogEvent needs to be changed from

LogEvent(strFromListenerNumber + ": Remote IP " + strRemoteAddrSansPort, strMessage)

to this

LogEvent(strFromListenerNumber + ": Remote IP " + strClientIP, strMessage)

While reviewing the source code I noticed that when a client disconnects the application just spits out an EOF with the port number. But what if there are two simultaneous connections on the same port? Which client closed the connection? Also, the EOF isn't very user friendly. Most people don't know what EOF means, and it's very simple to reword the message so non-technical people can understand that the connection was closed.

The EOF comes from the error checking code in GrabInput, at which point there's already a known client IP. I change the error checking to this:


if err != nil {
 if err.Error() == "EOF" {
  LogEvent(strFromListenerNumber, "Client located at " + strClientIP + " has disconnected.")
 } else {
  LogEvent(strFromListenerNumber + ": Remote IP " + strClientIP, err.Error())
 }
 return
}

Now if (well, when) the client disconnects the user gets a friendly disconnect message that includes the IP address. At the same time, if the error isn't a disconnect, the regular error message (plus the IP) will be printed to the log.

While I'm on the topic of making the application a little more friendly for the user, I want to eliminate having to explain to the user how to change kernel and shell limits before running the program. That means running a couple of system commands, launchctl and ulimit. Doing that in Go means adding a few more libraries, os/exec and syscall. I also cleaned up the growing list of imports using a slightly different invocation...


import (
 "fmt"
 "strconv"
 "os"
 "strings"
 "net"
 "os/exec"
 "syscall"
)

Running a couple of commands should be simple enough to execute in the main() function, but to help keep things clean I'm going to put them into a separate function.

In main(), declare a call to the SetupEnvironment() function I'm about to define along with a variable to hold the return value.


var intEnvironmentReady int

intEnvironmentReady = SetupEnvironment()

Right after that I run a check to see what value was returned. If it's not 0 I want it to spit an error and exit. Otherwise, keep going.


if intEnvironmentReady != 0 {
 fmt.Println("Something has gone wrong setting up the system environment, error " + strconv.Itoa(intEnvironmentReady))
 os.Exit(1)
}

There's a couple of values that could be returned from the function, hence the reason for the message formatted as it is. This calls strconv's Itoa (integer to ascii) function to convert intEnvironmentReady's value to a string.

Here's the SetupEnvironment() function definition:


func SetupEnvironment() int {
 
 var rLimit syscall.Rlimit
 
 command := exec.Command("/bin/launchctl", "limit", "maxfiles", "1000000","1000000")
 _,err := command.Output()
 if err != nil {
  return 1
 }
 err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return 3
 }
 rLimit.Cur = 1400
 err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return 2
 }
 return 0
}

Okay; first, what I thought would be simple, wasn't. I'll get to that shortly.

This function returns an integer value, so in the declaration there's an added "int" before the curly brace and after the closed parenthesis.

Next I create a variable called rLimit of type syscall.Rlimit; I'll get into that in a moment. Syscall is a library that lets you dig into system calls; they are operating-system low level calls. Suffice it to say for now that Rlimit is related to system resource limits.

The next block is fairly straightforward; command is getting the output of running the external command /bin/launchctl, with the arguments "limit maxfiles 1000000 1000000". It then checks to see if there's an error in the output (the first underscore ignores the output from the command because there isn't supposed to be any) and if err is set, the function returns a 1 (remember, it's looking for a 0 as success back in main()).

There's a problem when it comes to the ulimit. Ulimit is typically set using a shell command of the invoking user; if I just "run" it, the application is running under sudo, which is to say it is not running as the person actually running port_listen. The file limit is altered for "root", so the user is still flooded with too many file open errors.

There is no way I can find to alter the limit using a command executed from the os/exec library. That means falling down one more level deeper...syscall. We'll alter the soft file limit with a system call. Fortunately it's not too hard to read through the code and divine what is happening.

The "err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)" gets the current file limit structure. If I don't have the call to Getrlimit, trying to set it later fails with an invalid argument error. If the attempt has an error, it returns a value of 3 to the caller.

Next I set the limit to the number I have been successfully using, 1400. The .Cur attribute is the current soft limit.

The limit is actually set with the line "err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)" and if err has a value other than nil, the function returns a value of 2 to the caller.

If everything gets set without an issue, the last line is executed returning 0 to the caller.

At this point the application can be run with sudo and it'll now properly set up its environment before opening all the network sockets. It's much easier to explain to even a novice user they must run "sudo ./port_limit" than step them through setting up system and shell limits first!

One thing notably missing in the list of information recorded about connections is when the event(s) occurred. It would be helpful to prepend a timestamp to output.

Go has a library for that; add to the import list "time". I then alter the LogEvent function to read:


func LogEvent(strFrom string, strMessage string) {
 fmt.Println(time.Now().String() + " From port " + strFrom + ": " + strMessage)
}

Now the output is prepended with the time when an event occurs!

Next up, I want to try setting up a directory for storing the logfile. I want to make port_listen as self-contained as possible,  which will help make it easy to use. After mulling over a few options I decided to create a directory in the current working directory. This, of course, operates under the assumption that it will be run from a location owned by the user and they didn't try to install it to an application folder or a system binary folder.

I made the setup changes in the SetupEnvironment function since, I figured, this would be run every time and it sounds like it makes sense to make this part of setting up the environment in which to run this application. Here's the modified function:


func SetupEnvironment() int {

 var strSudoUserUid string
 var strSudoUserGid string
 var intSudoUserUid int
 var intSudoUserGid int  
 var strWorkDir string
 var err error
 var fiDirectory os.FileInfo
 var rLimit syscall.Rlimit

 if os.Getuid() == 0 {
  strSudoUserUid = os.Getenv("SUDO_UID")
  strSudoUserGid = os.Getenv("SUDO_GID")
 } else {
  fmt.Println("Please use sudo to run application (sudo ./port_listen)")
  return 4
 }
 strWorkDir, err = os.Getwd()
 if err != nil {
 fmt.Println("Error getting working directory: " + err.Error())
 }
 strWorkDir = strWorkDir + "/port_listen_logs"
 fiDirectory, err = os.Stat(strWorkDir)
 if err != nil {
  fmt.Println("Logs directory does not exist. Creating directory " + strWorkDir)
  err = os.Mkdir(strWorkDir, 0700)
  if err != nil {
   fmt.Println("Error creating directory; " + err.Error())
   return 5
  }
  intSudoUserUid, _ = strconv.Atoi(strSudoUserUid)
  intSudoUserGid, _ = strconv.Atoi(strSudoUserGid)
  err = os.Chown(strWorkDir, intSudoUserUid, intSudoUserGid)
  if err != nil {
   fmt.Println("Error changing permissions on directory: " + err.Error())
   return 7
  }
 }
 fiDirectory, err = os.Stat(strWorkDir)
 if !fiDirectory.IsDir() {
  fmt.Println(strWorkDir + " is not a directory!")
  return 6
 }
 command := exec.Command("/bin/launchctl", "limit", "maxfiles", "1000000","1000000")
 _ , err = command.Output()
 if err != nil {
  return 1
 }
 err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return 3
 }
 rLimit.Cur = 1400
 err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return 2
 }
 return 0
}

It looks a little daunting, but the logic isn't a bad as it looks. In the declarations I added four new variables, two int and two string, in which to store a UID and a GID (User ID and Group ID). It's something that could be simplified in execution but I did it the long way for clarity.

I also added an explicit err of type Error to be defined. That means in the already existing part of the function the first declaration of err with a := can now be changed to just an equal sign (well...not accurate to say it can be changed. It has to be changed or the compiler will throw an error back at you.)

You can also see a string for storing the working directory and another for a "FileInfo" type variable. More on that shortly.

The first bit of added code is asking what the current UserID is, with a comparison checking if it's 0. 0 is, of course, root; the application should be running as root in order to monitor privileged ports below 1,024. But I also need to know who invoked sudo for reasons I'll show shortly. Unfortunately any application running under Sudo will be convinced it's root.

After poking around online for a bit I discovered that Sudo sets three environment variables specifically for this purpose; I need to use two of them. They're called SUDO_UID and SUDO GID.

The os.Getuid(), if it equals "root", will then grab the strings returned by the two environment variables.

This also serves a second purpose. If the current userid isn't 0, then the user forgot to invoke the application with Sudo. So the else statement will inform the user they must use sudo, and then exit the program by returning a status of 4 to main()...since it's a non-zero return status, the application will exit.

The next question is, where is the application running from? I suppose I could just create a new directory in dot notation, but to be safe, I decided to work with explicit paths rather than relative ones. The call to os.Getwd assigns a string with the current working directory to the strWorkDir variable. If it fails, the application prints an error and returns a non-zero status to main().

Now that I have that, it's trivial to add on the directory I want to use for my logs. I just append "/port_listen_logs" to the working directory variable. Now that variable holds the location where I want to store the logs...and the directory that must first exist in order to create those logs there.

That's where the fiDirectory comes in. os.Stat, fed the working directory variable, checks if the directory (or file) exists, and if it does it returns an interface with some methods for getting information about that file or directory. If the directory doesn't exist, it spits back an error.

That's what the following if statement checks; if an error is returned, the directory doesn't exist. The application then tells the user that the directory doesn't exist (along with the full path, for useful feedback purposes) and proceeds to try creating it.

The directory is created using the call to os.Mkdir, taking the working directory string and a permissions string as arguments (and here I set it to the owner having full control.) If this fails for some reason, it throws an error message to the user and again spits a non-zero status back to main().

The next block of magic is the reason I needed to know the userid and groupid of the person that invoked the application (hidden behind Sudo.) The directory is being created by an application running as root, meaning the directory it created is owned by and given permissions to root. This section fixes it so the directory is owned by the user.

That needs to be done by the os.Chown() function, which takes integers for the user and group ID's. I take the strSudoUserUid and Gid and use strconv.Atoi to convert them from strings to integers, assigning them over to intSudoUserUid and Gid. Then plug them into os.Chown with the directory whose ownership I want to change and the two ID's and everything should be set; the only thing left to do is check if an error is thrown and if so spit the error to the user along with a nonzero return to main().

New milestone complete...the application now creates a directory in which to save logs in the current working directory of the application, and it detects if you forgot to run it under Sudo.

If the call to isDir on that FileInfo interface is false, then for reasons unknown there's a file called port_listen_logs in the current directory. This should, in theory, be a waste of space, but I figured it's a simple check for a weird error condition and checking for it is stupid simple...better off doing it.

Here's where I'll end part 4!

No comments:

Post a Comment