Wednesday, May 13, 2015

Creating a Simple Golang Network Application, Part 6

Uh-oh...

I deployed the application to the test user and soon they reported they couldn't print. "It's set up as an IP printer," she said. "It just gets stuck at 'connecting to printer...'"

I use printers configured as network LPD printers so I tried to reproduce the error and sure enough, it got stuck. After some research (and a question on Apple.stackexchange.com) I discovered that LPD uses the 721-731 range of ports for printing, even if it's not configured as a server.

Weird, but I tried remove those from being monitored and printing worked again!

I changed the ListenToPort call loop to this:


fmt.Println("Port_Listen version 1.01, 4/24/2015")
LogEvent("0", "Port_Listen version 1.01, 4/24/2015")
LogEvent("0", "Application initializing ports to monitor, skipping ports 721-731 (LPD) for printing...")
for i = 1; i < 1025; i++ {
 if (i < 721 || i > 731) {
//   fmt.Println("Listening to " + strconv.FormatInt(int64(i), 10))
  go ListenToPort(i, chanCommLine)
 }
}

I also had to change the line with "if i == 1013 {" under the channels because there are now eleven less ports being monitored. Without this change the application gets stuck in a loop waiting for nonexistent goroutines to reply.

I also added code that noted the version number (since I had kind of "released" it to someone, I thought it apropos to increment by .01) as well as a modified logging message for the application launch demarcation of a fresh launch in the logfile.

The commented out line is there because I wanted to verify, in standard output, that the ports I didn't want listened to had nothing bound to them. It uses the same int-to-string conversion logic used in ListenToPort().

Now for one more tweak...

It seems fairly obvious that the channel in the application was unneeded. Initially I had a vague notion of using it for something more; as the application took shape, it was little more than a wasteful way of saying, "Yup, I'm here!" and counting replies from goroutines as verification that the program was doing what I expected.

As troubleshooting/diagnostics, the channels were kind of helpful but also redundant. In the end they don't really do much.

So I removed the channel code. This involved removing a block in main() and a few references in ListenToPort(). It wouldn't be helpful to describe them in detail here, as they were already explained in earlier parts. I can say that on recompile the application size shrank from 3274088 to 3269992 bytes, a savings of 4kb (hmm...interesting number to have shaved off...)

Here's version 1.02 of the port_listen application:


package main

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

func main() {

 var i uint16
 var strUserInput string
 var intEnvironmentReady int

 intEnvironmentReady = SetupEnvironment()
 if intEnvironmentReady != 0 {
  fmt.Println("Something has gone wrong setting up the system environment, error " + strconv.Itoa(intEnvironmentReady))
  os.Exit(1)
 }
 fmt.Println("Port_Listen version 1.02, 4/25/2015")
 LogEvent("0", "Port_Listen version 1.02, 4/25/2015")
 LogEvent("0", "Application initializing ports to monitor, skipping ports 721-731 (LPD) for printing...")
 for i = 1; i < 1025; i++ {
  if i < 721 || i > 731 {
   //fmt.Println("Listening to " + strconv.FormatInt(int64(i), 10))
   go ListenToPort(i)
  }
 }
 for {
  LogEvent("0", "Application started and monitoring")
  fmt.Println("Type 'quit' and hit enter to exit the program...")
  fmt.Scanf("%s", &strUserInput)
  strUserInput = strings.ToLower(strUserInput)
  if strUserInput == "quit" {
   os.Exit(0)
  }
 }
}
func ListenToPort(uint16Port uint16) {

 var strPortToListenOn string
 var strRemoteAddr string
 var strRemoteAddrSansPort string

 strPortToListenOn = strconv.FormatInt(int64(uint16Port), 10)

 netListenFor, err := net.Listen("tcp", ":"+strPortToListenOn)
 if err != nil {
  LogEvent(strPortToListenOn, err.Error())
  return
 }
 defer netListenFor.Close()

 for {
  conn, err := netListenFor.Accept()
  if err != nil {
   LogEvent(strPortToListenOn, err.Error())
  }
  strRemoteAddr = conn.RemoteAddr().String()
  strRemoteAddrSansPort, _, _ = net.SplitHostPort(strRemoteAddr)
  LogEvent(strPortToListenOn, "Connection attempt made from "+strRemoteAddrSansPort)

  go GrabInput(conn, strPortToListenOn, strRemoteAddrSansPort)
 }
}
func GrabInput(conn net.Conn, strFromListenerNumber string, strClientIP string) {

 var strMessage string

 bufIncoming := make([]byte, 1024)

 for {
  bytesRead, err := conn.Read(bufIncoming)
  if err != nil {
   if err.Error() == "EOF" {
    LogEvent(strFromListenerNumber, "Client located at "+strClientIP+" has disconnected.")
   } else {
    LogEvent(strFromListenerNumber+": Remote IP "+strClientIP, err.Error())
   }
   return
  }
  strMessage = string(bufIncoming[0 : bytesRead-1])
  LogEvent(strFromListenerNumber+": Remote IP "+strClientIP, strMessage)
 }
}
func LogEvent(strFrom string, strMessage string) {

 var strLogFile string
 var err error
 var fileLogFile *os.File

 strLogFile, _ = os.Getwd()
 strLogFile = strLogFile + "/port_listen_logs/port_listen.log"
 if _, err = os.Stat(strLogFile); err == nil {
  fileLogFile, err = os.OpenFile(strLogFile, os.O_APPEND|os.O_RDWR, 0644)
  if err != nil {
   fmt.Println("Log file could not be opened! Error " + err.Error())
   os.Exit(1)
  }
 } else {
  fileLogFile, err = os.Create(strLogFile)
  if err != nil {
   fmt.Println("Log file could not be created! Error " + err.Error())
   os.Exit(1)
  }
 }
 defer fileLogFile.Close()
 fileLogFile.WriteString(time.Now().String() + " From port " + strFrom + ":: " + strMessage + "\n")
}
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
}

Despite better judgement I decided to tinker with it a little more.

I had a chance to transfer the executable application to the person that was testing it, and the thought occurred to me that I didn't know if the version I was sending was the most recent compilation (Did I recompile before sending it? Or did I get distracted before "go build?"). I could run the program and view the logfile since it writes the version number there but rebuilding the executable takes only moments, so just to be safe I quickly rebuilt the executable and transferred it.

Then I thought, "Wouldn't it have been simpler if I had a command line flag to pass so it just told me the version? That sort of thing couldn't be all that hard to implement. Just take a look at the arguments passed to the program and if it matches "-v", print a version number and exit. How hard is that?"

First, I decided to centralize the version number into a variable. Change it there, it gets changed wherever else that variable is used.

I declared a variable in main() using "const strVERSION string = "1.03, 4/26/2015"". Yes, I bumped up the version again since this is a new version and I still include the date on which I last saved the source code. Then I changed the lines that separately listed the version to use the variable:

fmt.Println("Port_Listen version " + strVERSION)
LogEvent("0", "Port_Listen version " + strVERSION)

I recompiled the program and re-ran it; the new version appeared on the command line and in the log.

Next, I want to play with the passed command line arguments. It turns out the os package has the ability to read arguments using os.Args[].

Just under the declaration of variables in main(), but above the setup of the environment, I added this block of code:

if len(os.Args) > 1 {
 if os.Args[1] == "-v" {
  fmt.Println("Version " + strVERSION)
  os.Exit(0)
 } else {
  fmt.Println("Invalid command line argument, \"" + os.Args[1] + "\" is not a recognized option.")
  os.Exit(0)
 }
}

The len() checks the number of arguments that are passed. If the length of "Args" is 1, that means it's just the executable. The first element is the executable name. If the number of arguments is more than one, then something else was passed at the command line along with the executable name. If I didn't check this, if I only checked what was in os.Args[1] (remember, Args[0] is the executable name), you can have a crash because you're poking at addresses that are out of bounds. This length check isolates the possibility of that crash.

Next I check for the existence of the "-v" as the argument. If found, it prints the version and exits. If it's anything else, it spits out an error message and exits.

I'll also point out in the "invalid command" part that the output encloses the arguments passed at the command line in quotes; since the string in the code is delimited with quotes, you have to use escape characters to tell the Go compiler that no, you don't want to cut off the string with this particular quotation mark, I want you to print it instead. That's why you see the two \" marks in the quoted string around os.Args in the fmt.Println statement.

If nothing was passed (the number of arguments is just 1), the application continues with the environment setup. This way if the application is run just to get the version, it won't create the subdirectory and logfile.

I'll note there's a separate flags package that can be imported and used to parse command line arguments. Flags is great if I had keywords I wanted to handle, but here I only wanted to handle a simple "-v", so the use of os.Args[] would be more than adequate. If I were to handle things like ranges of port numbers to monitor or create a way to bypass the printed logfile and instead opt to print everything to the console or if I had multiple options that could be specified in any order at the command line, then the flags package would be a worthwhile alternative.

At this point I'm very close to calling it done. I've already gone beyond the original goal...a quick and dirty port monitoring application. But before I released it into the wild (or discussed it on a series of blog posts) I asked an experienced developer to glance at the code and see if there were any glaring mistakes, just to reduce the snark from more experienced people who may see this online and reply just to tell me how stupid I am.

He pointed out two items; one I'll list here, the other I'll list in the next blog post. He said that in the areas returning integers as errors (mostly in the function that sets up the environment) it wasn't necessarily wrong to return a numerical code, but it was considered non-idiomatic of Go.

"Go gives you an error, so why not use it? You can just return that error to the caller."

Once that was pointed out it seemed like a rather obvious difference between Go-think and my previous experience in C++-think. The "GoLang Way" would be to just return the error to the caller. For times when an error wasn't returned by the operating system, there's is a function that crafts an error message for you; fmt.Errorf, as used in the os.Getuid() if block in SetupEnvironment():


if os.Getuid() == 0 {
 strSudoUserUid = os.Getenv("SUDO_UID")
 strSudoUserGid = os.Getenv("SUDO_GID")
} else {
 return fmt.Errorf("Please use sudo to run application (sudo ./port_listen)")
}

I also had to change the caller in main() for SetupEnvironment() to reflect that it'll get back an error, using:


err = SetupEnvironment()

...after having declared a variable in my declaration block of


var err error

...and the SetupEnvironment() needed the definition changed to


func SetupEnvironment() error {

I also bumped up the version from my experimentation and alterations and added a big ol' comment block at the top to give rudimentary instructions on what this application was. Here's the final version...at least as far as I'm planning on taking it for now...of port_listen.go:


/*
 Port_listen is a small application that monitors incoming TCP connections
to your computer and logs what is sent to the connection in a logfile under the
current working directory/port_listen_logs directory.

It must be run using sudo so it can access ports under 1024.

To exit, type "quit" at the command line.

"-v" at the command line invocation gives the application version and build date
*/
package main

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

func main() {

 var i uint16
 var strUserInput string
 var err error
 const strVERSION string = "1.06, 4/28/2015"

 if len(os.Args) > 1 {
  if os.Args[1] == "-v" {
   fmt.Println("Version " + strVERSION)
  } else {
   fmt.Println("Invalid command line argument, \"" + os.Args[1] + "\" is not a recognized option.")
  }
  os.Exit(0)
 }
 err = SetupEnvironment()
 if err != nil {
  fmt.Println("Something has gone wrong setting up the system environment, error: " + err.Error())
  os.Exit(1)
 }
 fmt.Println("Port_Listen version " + strVERSION)
 LogEvent("0", "Port_Listen version "+strVERSION)
 LogEvent("0", "Application initializing ports to monitor, skipping ports 721-731 (LPD) for printing...")
 for i = 1; i < 1025; i++ {
  if i < 721 || i > 731 {
   //fmt.Println("Listening to " + strconv.FormatInt(int64(i), 10))
   go ListenToPort(i)
  }
 }
 for {
  LogEvent("0", "Application started and monitoring")
  fmt.Println("Type 'quit' and hit enter to exit the program...")
  fmt.Scanf("%s", &strUserInput)
  strUserInput = strings.ToLower(strUserInput)
  if strUserInput == "quit" {
   os.Exit(0)
  }
 }
}
func ListenToPort(uint16Port uint16) {

 var strPortToListenOn string
 var strRemoteAddr string
 var strRemoteAddrSansPort string

 strPortToListenOn = strconv.FormatInt(int64(uint16Port), 10)

 netListenFor, err := net.Listen("tcp", ":"+strPortToListenOn)
 if err != nil {
  LogEvent(strPortToListenOn, err.Error())
  return
 }
 defer netListenFor.Close()

 for {
  conn, err := netListenFor.Accept()
  if err != nil {
   LogEvent(strPortToListenOn, err.Error())
  }
  strRemoteAddr = conn.RemoteAddr().String()
  strRemoteAddrSansPort, _, _ = net.SplitHostPort(strRemoteAddr)
  LogEvent(strPortToListenOn, "Connection attempt made from "+strRemoteAddrSansPort)

  go GrabInput(conn, strPortToListenOn, strRemoteAddrSansPort)
 }
}
func GrabInput(conn net.Conn, strFromListenerNumber string, strClientIP string) {

 var strMessage string

 bufIncoming := make([]byte, 1024)

 for {
  bytesRead, err := conn.Read(bufIncoming)
  if err != nil {
   if err.Error() == "EOF" {
    LogEvent(strFromListenerNumber, "Client located at "+strClientIP+" has disconnected.")
   } else {
    LogEvent(strFromListenerNumber+": Remote IP "+strClientIP, err.Error())
   }
   return
  }
  strMessage = string(bufIncoming[0 : bytesRead-1])
  LogEvent(strFromListenerNumber+": Remote IP "+strClientIP, strMessage)
 }
}
func LogEvent(strFrom string, strMessage string) {

 var strLogFile string
 var err error
 var fileLogFile *os.File

 strLogFile, _ = os.Getwd()
 strLogFile = strLogFile + "/port_listen_logs/port_listen.log"
 if _, err = os.Stat(strLogFile); err == nil {
  fileLogFile, err = os.OpenFile(strLogFile, os.O_APPEND|os.O_RDWR, 0644)
  if err != nil {
   fmt.Println("Log file could not be opened! Error " + err.Error())
   os.Exit(1)
  }
 } else {
  fileLogFile, err = os.Create(strLogFile)
  if err != nil {
   fmt.Println("Log file could not be created! Error " + err.Error())
   os.Exit(1)
  }
 }
 defer fileLogFile.Close()
 fileLogFile.WriteString(time.Now().String() + " From port " + strFrom + ":: " + strMessage + "\n")
}
func SetupEnvironment() error {

 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 {
  return fmt.Errorf("Please use sudo to run application (sudo ./port_listen)")
 }
 strWorkDir, err = os.Getwd()
 if err != nil {
  return err
 }
 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 {
   return err
  }
  intSudoUserUid, _ = strconv.Atoi(strSudoUserUid)
  intSudoUserGid, _ = strconv.Atoi(strSudoUserGid)
  err = os.Chown(strWorkDir, intSudoUserUid, intSudoUserGid)
  if err != nil {
   return err
  }
 }
 fiDirectory, err = os.Stat(strWorkDir)
 if !fiDirectory.IsDir() {
  return fmt.Errorf(strWorkDir + " is not a directory!")
 }

 command := exec.Command("/bin/launchctl", "limit", "maxfiles", "1000000", "1000000")
 _, err = command.Output()
 if err != nil {
  return err
 }
 err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return err
 }
 rLimit.Cur = 1400
 err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
 if err != nil {
  return err
 }
 return nil
}

Here I'll end part 6. In part 7 I'll go through a wrap-up of lessons learned!

No comments:

Post a Comment