hostname.rkt
#lang racket/base
;; For legal info, see file "info.rkt".

(require racket/system
         (planet neil/mcfly))

(module+ test
  (require (planet neil/overeasy:2)))

(doc (section "Introduction")

     (para "This package permits Racket programs to determined the host
machine's hostname and internal IP addresses.")

     (para "This package currently relies on the "
           (filepath "/bin/hostname")
           " and "
           (filepath "/sbin/ifconfig")
           " programs, available on various Unix-like systems, like GNU/Linux,
FreeBSD, OpenBSD, and Mac OS X."))

(doc (section "Interface"))

(define %hostname:null-input-port
  ;; Note: "%hostname:null-input-port" was taken from an example in Racket 5.3
  ;; documentation.
  (make-input-port 'null
                   (lambda (s) eof)
                   (lambda (skip s progress-evt) eof)
                   void
                   (lambda () never-evt)
                   (lambda (k progress-evt done-evt)
                     (error "no successful peeks!"))))

(define (%hostname:system*/string #:error-name    error-name
                                  #:use-exn?      use-exn?
                                  #:trim-newline? trim-newline?
                                  #:command       command
                                  #:args          args)
  (let* ((stdout-os  (open-output-string))
         (stderr-os  (open-output-string))
         (ok?        (parameterize ((current-output-port stdout-os)
                                    (current-error-port  stderr-os)
                                    (current-input-port  %hostname:null-input-port))
                       (apply system* command args)))
         (stdout-str (get-output-string stdout-os))
         (stderr-str (get-output-string stderr-os)))
    (if ok?
        (if (equal? "" stderr-str)
            (if trim-newline?
                (regexp-replace #rx"\r?\n$" stdout-str "")
                stdout-str)
            (if use-exn?
                (error error-name
                       "shell command ~S had stderr ~S and stdout ~S"
                       (cons command args)
                       stderr-str
                       stdout-str)
                #f))
        (if use-exn?
            (error error-name
                   "shell command ~S failed with stderr ~S and stdout ~S"
                   (cons command args)
                   stderr-str
                   stdout-str)
            #f))))

(doc (defproc (get-full-hostname)
         (or/c #f string?)
       (para "Gets the full hostname (aka, fully-qualified domain name, or
FQDN) of the host, or "
             (racket #f)
             " if unknown.")
       (racketinput (get-full-hostname)
                    #,(racketresult "computer.lan"))))
(provide get-full-hostname)
(define (get-full-hostname)
  (or (getenv "HOSTNAME")
      (%hostname:system*/string #:error-name    'get-full-hostname
                                #:use-exn?      #f
                                #:trim-newline? #t
                                #:command       "/bin/hostname"
                                #:args          '("-f"))
      (%hostname:system*/string #:error-name    'get-short-hostname
                                #:use-exn?      #f
                                #:trim-newline? #t
                                #:command       "hostname"
                                #:args          '("-f"))))

(doc (defproc (get-short-hostname)
         (or/c #f string?)
       (para "Gets the short hostname (i.e., just the hostname of the immediate
host, not qualified with any parent domain names), or "
             (racket #f)
             " if unknown.")
       (racketinput (get-short-hostname)
                    #,(racketresult "computer"))))
(provide get-short-hostname)
(define (get-short-hostname)
  ;; TODO: Possibly use (getenv "HOSTNAME") when available, and check whether
  ;; it has any dots in it.
  (or (%hostname:system*/string #:error-name    'get-short-hostname
                                #:use-exn?      #f
                                #:trim-newline? #t
                                #:command       "/bin/hostname"
                                #:args          '("-s"))
      (%hostname:system*/string #:error-name    'get-short-hostname
                                #:use-exn?      #f
                                #:trim-newline? #t
                                #:command       "hostname"
                                #:args          '())))

(define %hostname:parse-ipv4-addrs-from-ifconfig-rx
  (let* ((octet "[0-9](?:[0-9](?:[0-9])?)?"))
    (regexp (string-append "[ \t]"
                           "inet"
                           "[ \t]+"
                           "(?:"        ; <A?
                           "addr:"
                           "[ \t]*"
                           ")?"         ; >A?
                           "("          ; <1
                           "(?:"        ; <B
                           "(127)"      ; =2
                           "|"          ; |B
                           octet
                           ")"          ; >B
                           "\\."
                           octet
                           "\\."
                           octet
                           "\\."
                           octet
                           ")"          ; >1
                           ))))

(define (%hostname:parse-ipv4-addrs-from-ifconfig
         in
         #:normal?    (normal?    #t)
         #:localhost? (localhost? #f))
  (let loop ((reverse-results '()))
    (cond ((regexp-try-match %hostname:parse-ipv4-addrs-from-ifconfig-rx
                             in)
           => (lambda (m)
                (apply (lambda (whole addr onetwoseven)
                         (if (if onetwoseven
                                 localhost?
                                 normal?)
                             (loop (cons (bytes->string/latin-1 addr) reverse-results))
                             (loop reverse-results)))
                       m)))
          (else (reverse reverse-results)))))

(module+ test

  ;; Debian GNU/Linux 6.0.5
  (test (let ((str (string-append
                    "eth0      Link encap:Ethernet  HWaddr 00:11:22:33:44:55  \n"
                    "          UP BROADCAST MULTICAST  MTU:1500  Metric:1\n"
                    "          RX packets:0 errors:0 dropped:0 overruns:0 frame:0\n"
                    "          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0\n"
                    "          collisions:0 txqueuelen:1000 \n"
                    "          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)\n"
                    "          Interrupt:16 Memory:ee000000-ee020000 \n"
                    "\n"
                    "lo        Link encap:Local Loopback  \n"
                    "          inet addr:127.0.0.1  Mask:255.0.0.0\n"
                    "          inet6 addr: ::1/128 Scope:Host\n"
                    "          UP LOOPBACK RUNNING  MTU:16436  Metric:1\n"
                    "          RX packets:50 errors:0 dropped:0 overruns:0 frame:0\n"
                    "          TX packets:50 errors:0 dropped:0 overruns:0 carrier:0\n"
                    "          collisions:0 txqueuelen:0 \n"
                    "          RX bytes:3204 (3.1 KiB)  TX bytes:3204 (3.1 KiB)\n"
                    "\n"
                    "wlan0     Link encap:Ethernet  HWaddr 00:11:22:33:44:56  \n"
                    "          inet addr:123.123.1.234  Bcast:123.123.1.255  Mask:255.255.255.0\n"
                    "          inet6 addr: 1111::222:3333:4444:5555/64 Scope:Link\n"
                    "          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1\n"
                    "          RX packets:20281 errors:0 dropped:0 overruns:0 frame:0\n"
                    "          TX packets:19318 errors:0 dropped:0 overruns:0 carrier:0\n"
                    "          collisions:0 txqueuelen:1000 \n"
                    "          RX bytes:19514369 (18.6 MiB)  TX bytes:2709526 (2.5 MiB)\n")))
          (values (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str))
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:localhost? #t)
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:normal? #f #:localhost? #t)))
        (values '("123.123.1.234")
                '("127.0.0.1"
                  "123.123.1.234")
                '("127.0.0.1")))

  ;; MacOS X http://apple.stackexchange.com/questions/25895/how-do-i-unset-an-ip-address-set-with-ifconfig
  (test (let ((str (string-append
                    "en1: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n"
                    "    ether 00:23:xx:xx:xx:xx \n"
                    "    inet 192.168.141.99 netmask 0xffffff00 broadcast 192.168.141.255\n"
                    "    inet 192.168.1.112 netmask 0xffffff00 broadcast 192.168.1.255\n"
                    "    media: autoselect\n"
                    "    status: active\n")))
          (values (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str))
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:localhost? #t)))
        (values '("192.168.141.99"
                  "192.168.1.112")
                '("192.168.141.99"
                  "192.168.1.112")))

  ;; Mac OS X http://pastebin.com/nbDmmWY7
  (test (let ((str (string-append
                    "lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n"
                    "\tinet6 ::1 prefixlen 128 \n"
                    "\tinet6 fe80::1%lo0 prefixlen 64 scopeid 0x1 \n"
                    "\tinet 127.0.0.1 netmask 0xff000000 \n"
                    "gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280\n"
                    "stf0: flags=0<> mtu 1280\n"
                    "en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n"
                    "\tether 00:11:22:33:44:55 \n"
                    "\tinet 192.168.2.1 netmask 0xffffff00 broadcast 192.168.2.255\n"
                    "\tmedia: autoselect\n"
                    "\tstatus: inactive\n"
                    "fw0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 4078\n"
                    "\tlladdr 00:11:22:33:44:55:66:77 \n"
                    "\tmedia: autoselect <full-duplex>\n"
                    "\tstatus: inactive\n"
                    "en1: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n"
                    "\tether 00:11:22:33:44:56 \n"
                    "\tinet6 1111::222:33:4444:555%en1 prefixlen 64 scopeid 0x6 \n"
                    "\tinet 123.123.1.234 netmask 0xffffff00 broadcast 123.123.1.255\n"
                    "\tmedia: autoselect\n"
                    "\tstatus: active\n"
                    "vboxnet0: flags=8842<BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n"
                    "\tether 0a:00:27:00:00:00\n")))
          (values (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str))
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:localhost? #t)))
        (values '("192.168.2.1"
                  "123.123.1.234")
                '("127.0.0.1"
                  "192.168.2.1"
                  "123.123.1.234")))

  ;; FreeBSD http://lists.freebsd.org/pipermail/freebsd-hackers/2011-April/035015.html
  (test (let ((str (string-append
                    "em0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> metric 0 mtu 1500\n"
                    "\toptions=219b<RXCSUM,TXCSUM,VLAN_MTU,VLAN_HWTAGGING,VLAN_HWCSUM,TSO4,WOL_MAGIC>\n"
                    "\tether xx:xx:xx:xx:xx:xx\n"
                    "\tinet 10.0.5.2 netmask 0xff00ff00 broadcast 10.255.5.255\n"
                    "\tmedia: Ethernet autoselect (1000baseT <full-duplex>)\n"
                    "\tstatus: active\n")))
          (values (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str))
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:localhost? #t)))
        (values '("10.0.5.2")
                '("10.0.5.2")))

  ;; OpenBSD http://old.nabble.com/ifconfig-output-for-nfe-td12789606.html
  (test (let ((str (string-append
                    "nfe0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n"
                    "         lladdr 00:14:4f:7d:91:ea\n"
                    "         media: Ethernet autoselect (1000baseSX full-duplex)\n"
                    "         status: active\n"
                    "         inet 192.168.100.77 netmask 0xffffff00 broadcast 192.168.100.255\n"
                    "         inet6 fe80::214:4fff:fe7d:91ea%nfe0 prefixlen 64 scopeid 0x1\n")))
          (values (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str))
                  (%hostname:parse-ipv4-addrs-from-ifconfig (open-input-string str) #:localhost? #t)))
        (values '("192.168.100.77")
                '("192.168.100.77"))))

(doc (defproc (get-ipv4-addrs (#:normal?    normal?    boolean? #t)
                              (#:localhost? localhost? boolean? #f))
         (listof string?)
       (para "Get a list of IPv4 addresses for this machine, such as gotten
from "
             (filepath "/sbin/ifconfig")
             ".")
       (para "If "
             (racket normal?)
             " is true, then non-localhost addresses are included.  If "
             (racket localhost?)
             " is true, then localhost addresses are included.")
       (para "The ordering of the list is unspecified.  In event of error,
generally an empty list will be returned (and a "
             (racket 'warning)
             " message will be posted to "
             (racket current-logger)
             ").")
       (racketinput (get-ipv4-addrs)
                    #,(racketresult '("192.168.141.99"
                                      "192.168.1.112")))

       (racketinput (get-ipv4-addrs #:localhost? #t)
                    #,(racketresult '("127.0.0.1"
                                      "192.168.141.99"
                                      "192.168.1.112")))))
(provide get-ipv4-addrs)
(define (get-ipv4-addrs #:normal?    (normal?    #t)
                        #:localhost? (localhost? #f))
  (let ((command "/sbin/ifconfig"))
    (cond ((with-handlers ((exn:fail?
                            (lambda (e)
                              (log-warning (format "get-ipv4-addrs: command ~S failed: ~S"
                                                   command
                                                   (exn-message e)))
                              #f)))
             (process* command))
           => (lambda (lst)
                (apply (lambda (stdout-in stdin-out pid stderr-in proc)
                         (dynamic-wind
                           void
                           (lambda ()
                             (with-handlers ((exn:fail?
                                              (lambda (e)
                                                (log-warning (format "get-ipv4-addrs: error while parsing output of command ~S: ~S"
                                                                     command
                                                                     (exn-message e)))
                                                '())))
                               (%hostname:parse-ipv4-addrs-from-ifconfig
                                stdout-in
                                #:normal?    normal?
                                #:localhost? localhost?)))
                           (lambda ()
                             (with-handlers ((exn:fail? void))
                               (proc 'kill)))))
                       lst)))
          (else '()))))

(doc history

     (#:planet 1:2 #:date "2012-10-05"
               (itemlist

                (item
                 "Added "
                 (racket get-ipv4-addrs)
                 ".")))

     (#:planet 1:1 #:date "2012-09-29"
               (itemlist

                (item "Fixed problem with "
                      (filepath "main.rkt")
                      ".")))

     (#:planet 1:0 #:date "2012-09-29"
               (itemlist

                (item "Initial release."))))