cmds: Add a simple syslog daemon.

This will let us extend telemetry gathering to mobile apps. The initial
use case will be isoping tests run by the Android Fiber TV app.

Change-Id: I155fec280ebf4a544aa5cb31a36dc88ad27ff1be
diff --git a/Makefile b/Makefile
index 146023b..f42685c 100644
--- a/Makefile
+++ b/Makefile
@@ -16,11 +16,12 @@
 BUILD_SIGNING?=     # default off: needs libgtest
 BUILD_JSONPOLL?=n
 BUILD_BOUNCER?=     # default off: costly
+BUILD_SYSLOG?=      # default off: new
 BUILD_PRESTERASTATS?=n
 export BUILD_HNVRAM BUILD_SSDP BUILD_DNSSD BUILD_LOGUPLOAD \
 	BUILD_BLUETOOTH BUILD_WAVEGUIDE BUILD_DVBUTILS BUILD_SYSMGR \
 	BUILD_STATUTILS BUILD_CRYPTDEV BUILD_SIGNING BUILD_JSONPOLL \
-	BUILD_PRESTERASTATS BUILD_CACHE_WARMING BUILD_BOUNCER
+	BUILD_PRESTERASTATS BUILD_CACHE_WARMING BUILD_BOUNCER BUILD_SYSLOG
 
 # note: libgpio is not built here.  It's conditionally built
 # via buildroot/packages/google/google_platform/google_platform.mk
diff --git a/cmds/.gitignore b/cmds/.gitignore
index fcc39f5..f881e18 100644
--- a/cmds/.gitignore
+++ b/cmds/.gitignore
@@ -1,6 +1,7 @@
 *.o
 *.tmp.*
 alivemonitor
+anonid
 asus_hosts
 balloon
 bsa2bluez
@@ -43,6 +44,7 @@
 statcatcher
 statpitcher
 stun
+syslogd
 test_dir
 test_file
 udpburst
diff --git a/cmds/Makefile b/cmds/Makefile
index b8ff87e..848b417 100644
--- a/cmds/Makefile
+++ b/cmds/Makefile
@@ -56,7 +56,6 @@
 	wait-until-created \
 	watch-dir
 
-HOST_TARGETS=$(addprefix host-,$(TARGETS)) host-isoping_fuzz
 LIB_TARGETS=\
 	stdoutline.so
 HOST_TEST_TARGETS=\
@@ -94,6 +93,12 @@
 TARGETS += statpitcher statcatcher
 endif
 
+ifeq ($(BUILD_SYSLOG),y)
+TARGETS += syslogd
+endif
+
+HOST_TARGETS=$(addprefix host-,$(TARGETS)) host-isoping_fuzz
+
 AS=$(CROSS_COMPILE)as
 CC=$(CROSS_COMPILE)gcc
 CXX=$(CROSS_COMPILE)g++
diff --git a/cmds/syslogd.py b/cmds/syslogd.py
new file mode 100755
index 0000000..97f5628
--- /dev/null
+++ b/cmds/syslogd.py
@@ -0,0 +1,86 @@
+#!/usr/bin/python
+"""syslogd: a simple syslog daemon."""
+
+import errno
+import os
+import re
+import socket
+import sys
+
+import options
+
+optspec = """
+syslogd [-f FILTERFILE] [-l listenip] [-p port]
+--
+f,filters=   path to a file containing filters [/etc/syslog.conf]
+l,listenip=  IP address to listen on [::]
+p,port=      UDP port to listen for syslog messages on [5514]
+v,verbose    increase log output
+"""
+
+
+MAX_LENGTH = 1180
+
+
+def serve(sock, filters, verbose=False):
+  """Handle a raw syslog message on the wire."""
+  while True:
+    raw_message, address = sock.recvfrom(4096)
+    client_ip = address[0]
+
+    # util-linux `logger` writes a trailing NUL;
+    # /dev/log required this long ago.
+    message = raw_message.strip('\x00\r\n')
+
+    # Require only printable 7-bit ASCII to remain (RFC3164).
+    if not all(32 <= ord(c) <= 126 for c in message):
+      print >>sys.stderr, ('[%s]: discarded message with unprintable characters'
+                           % client_ip)
+      continue
+
+    if len(message) > MAX_LENGTH:
+      print >>sys.stderr, ('[%s]: discarded %dB message over max length %dB'
+                           % (client_ip, len(message), MAX_LENGTH))
+      continue
+
+    for f in filters:
+      if f.search(message):
+        if verbose:
+          print >>sys.stderr, 'matched by filter: %r' % f.pattern
+
+        print '[%s]: %s' % (client_ip, message)
+        sys.stdout.flush()
+        break
+    else:
+      if verbose:
+        print >>sys.stderr, ('[%s]: discarded unrecognized message: %s'
+                             % (client_ip, message))
+      else:
+        print >>sys.stderr, '[%s]: discarded unrecognized message' % client_ip
+
+
+def main(argv):
+  o = options.Options(optspec)
+  opt, unused_flags, unused_extra = o.parse(argv)
+
+  if opt.filters:
+    filter_patterns = open(opt.filters).read().splitlines()
+    filters = [re.compile(p) for p in filter_patterns]
+    print >>sys.stderr, 'using filters: %r' % filter_patterns
+
+  sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
+  sock.bind((opt.listenip, opt.port))
+
+  print >>sys.stderr, 'listening on UDP [%s]:%d' % (opt.listenip, opt.port)
+  try:
+    os.makedirs('/tmp/syslogd')
+  except OSError as e:
+    if e.errno != errno.EEXIST:
+      raise
+
+  open('/tmp/syslogd/ready', 'w')
+  serve(sock, filters, opt.verbose)
+
+
+if __name__ == '__main__':
+  main(sys.argv)
diff --git a/cmds/test-syslogd.py b/cmds/test-syslogd.py
new file mode 100755
index 0000000..7235aaf
--- /dev/null
+++ b/cmds/test-syslogd.py
@@ -0,0 +1,100 @@
+#!/usr/bin/python
+
+"""Tests for the syslogd program."""
+
+import errno
+import os
+import re
+import select
+import socket
+import subprocess
+import tempfile
+import time
+
+from wvtest.wvtest import WVFAIL
+from wvtest.wvtest import WVPASS
+from wvtest.wvtest import wvtest
+from wvtest.wvtest import wvtest_main
+
+
+def ChompLeadingIP(line):
+  _, message = re.split(r'^\[.*\]: ', line)
+  return message
+
+
+@wvtest
+def TestSyslogd():
+  """spin up and test a syslogd server."""
+  subprocess.call(['pkill', '-f', 'python syslogd.py'])
+  try:
+    os.remove('/tmp/syslogd/ready')
+  except OSError as e:
+    if e.errno != errno.ENOENT: raise
+
+  filters = tempfile.NamedTemporaryFile(bufsize=0, suffix='.conf', delete=False)
+  print >>filters, 'PASS'
+  filters.close()
+
+  out_r, out_w = os.pipe()
+  err_r, err_w = os.pipe()
+  subprocess.Popen(['python', 'syslogd.py', '-f', filters.name, '-v'],
+                   stdout=out_w, stderr=err_w)
+
+  while True:
+    try:
+      if 'ready' in os.listdir('/tmp/syslogd'): break
+      time.sleep(0.1)
+    except OSError as e:
+      if e.errno != errno.ENOENT: raise
+
+  def _Read():
+    r, unused_w, unused_x = select.select([out_r, err_r], [], [], 30)
+    out = ''
+    err = ''
+
+    if out_r in r: out = os.read(out_r, 4096)
+    if err_r in r: err = os.read(err_r, 4096)
+
+    if out or err:
+      return out, err
+    else:
+      raise Exception('read timed out')
+
+  _Read()  # discard syslogd startup messages
+
+  addr = ('::', 5514)
+  s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
+
+  s.sendto('a\nErROR: b\nw: c', addr)
+  out, err = _Read()
+  WVFAIL(out)
+  WVPASS(ChompLeadingIP(err).startswith('discarded'))
+
+  s.sendto('a\tb\r\nabba\tbbb\naa\t\tb\tc\n', addr)
+  out, err = _Read()
+  WVFAIL(out)
+  WVPASS(ChompLeadingIP(err).startswith('discarded'))
+
+  s.sendto(''.join(chr(i) for i in range(33)) + '\n', addr)
+  out, err = _Read()
+  WVFAIL(out)
+  WVPASS(ChompLeadingIP(err).startswith('discarded'))
+
+  s.sendto('Test PASSes', addr)
+  time.sleep(1)  # make sure both streams update at once
+  out, err = _Read()
+  WVPASS(ChompLeadingIP(out).startswith('Test PASSes'))
+
+  s.sendto('TooLongToPASS' * 100, addr)
+  out, err = _Read()
+  WVFAIL(out)
+  WVPASS(ChompLeadingIP(err).startswith('discarded'))
+
+  s.sendto('NoMatchFAILS', addr)
+  out, err = _Read()
+  WVFAIL(out)
+  WVPASS(ChompLeadingIP(err).startswith('discarded'))
+
+
+if __name__ == '__main__':
+  wvtest_main()