gfch100: add https and digest authentication

	* https requires signed certs; will add that later
	* support admin and guest accounts
	* guest can read, can't write

Change-Id: I2b2bbc5002678770286d16ab07edaece9b3e33e3
diff --git a/craftui/.gitignore b/craftui/.gitignore
index 1a5f7ef..7b8a4bf 100644
--- a/craftui/.gitignore
+++ b/craftui/.gitignore
@@ -1,4 +1,6 @@
+*.pyo
 *.swp
 .started
 .sim.extracted
 sim
+LOG
diff --git a/craftui/HOW.restart_if_changed b/craftui/HOW.restart_if_changed
index 8c17154..0bfd11f 100644
--- a/craftui/HOW.restart_if_changed
+++ b/craftui/HOW.restart_if_changed
@@ -2,6 +2,8 @@
 
 # developer tool to restart server when file source changes
 
+export PATH="$(pwd)/../../../../out.gfch100_defconfig/host/usr/bin:$PATH"
+
 pid=
 
 restart() {
diff --git a/craftui/HOW.updatesim b/craftui/HOW.updatesim
index 8ae3f04..04e4cce 100644
--- a/craftui/HOW.updatesim
+++ b/craftui/HOW.updatesim
@@ -20,6 +20,7 @@
 	etc/version \
 	tmp/glaukus \
 	tmp/serial \
+	tmp/ssl \
 	tmp/platform \
 	tmp/gpio/ledstate \
 	tmp/sim \
diff --git a/craftui/Makefile b/craftui/Makefile
index b946953..782ca22 100644
--- a/craftui/Makefile
+++ b/craftui/Makefile
@@ -16,6 +16,7 @@
 	@echo "No libs to install."
 
 .sim.extracted: sim.tgz
+	-chmod -R +w sim
 	rm -rf sim
 	rsync -av sim-tools/ sim
 	tar xf sim.tgz -C sim
diff --git a/craftui/craftui b/craftui/craftui
index 2f5e143..527eba7 100755
--- a/craftui/craftui
+++ b/craftui/craftui
@@ -13,7 +13,7 @@
 # if running from developer desktop, use simulated data
 if [ "$isdev" = 1 ]; then
   cw="$devcw"
-  args="$args --port=8888 --sim=./sim"
+  args="$args --http-port=8888 --https-port=8889 --sim=./sim"
   pycode=./craftui_fortesting.py
   export PATH="$PWD/sim/bin:$PATH"
 fi
@@ -23,10 +23,25 @@
   args="$args --www=$localwww"
 fi
 
-# enable debugger
-if [ "$1" = -d ]; then
-  debug="-m pdb"
-fi
+# command line parsing
+while [ $# -gt 0 ]; do
+  # enable debugger
+  if [ "$1" = -d ]; then
+    debug="-m pdb"
+    shift
+    continue
+  fi
 
-export PYTHONPATH="$cw/tr/vendor/tornado:$PYTHONPATH"
-exec python -u $debug $pycode $args
+  # enable https
+  if [ "$1" = -S ]; then
+    httpsmode="-S"
+    shift
+    continue
+  fi
+
+  echo "$0: '$1': unknown command line option" >&2
+  exit 1
+done
+
+export PYTHONPATH="$cw/tr/vendor/tornado:$cw/tr/vendor/curtain:$PYTHONPATH"
+exec python -u $debug $pycode $args $httpsmode
diff --git a/craftui/craftui.py b/craftui/craftui.py
index d25b40b..59441b3 100755
--- a/craftui/craftui.py
+++ b/craftui/craftui.py
@@ -17,6 +17,7 @@
 
 __author__ = 'edjames@google.com (Ed James)'
 
+import base64
 import getopt
 import json
 import os
@@ -24,6 +25,8 @@
 import subprocess
 import sys
 import urllib2
+import digest
+import tornado.httpserver
 import tornado.ioloop
 import tornado.web
 
@@ -138,7 +141,7 @@
   """Validate as gain index."""
 
   def __init__(self):
-    super(VGainIndex, self).__init__(0, 5)
+    super(VGainIndex, self).__init__(1, 5)
 
 
 class VDict(Validator):
@@ -163,6 +166,22 @@
   dict = {'true': 'true', 'false': 'false'}
 
 
+class VPassword(Validator):
+  """Validate as base64 encoded and reasonable length."""
+  example = '******'
+
+  def Validate(self, value):
+    super(VPassword, self).Validate(value)
+    pw = ''
+    try:
+      pw = base64.b64decode(value)
+    except TypeError:
+      raise ConfigError('passwords must be base64 encoded')
+    # TODO(edjames) ascii decodes legally; how to check it's really base64?
+    if len(pw) < 5 or len(pw) > 16:
+      raise ConfigError('passwords should be 5-16 characters')
+
+
 class Config(object):
   """Configure the device after validation."""
 
@@ -247,6 +266,9 @@
   """A web server that configures and displays Chimera data."""
 
   handlers = {
+      'password_admin': PtpConfig(VPassword, 'password_admin'),
+      'password_guest': PtpConfig(VPassword, 'password_guest'),
+
       'craft_ipaddr': PtpConfig(VSlash, 'craft_ipaddr'),
       'link_ipaddr': PtpConfig(VSlash, 'local_ipaddr'),
       'peer_ipaddr': PtpConfig(VSlash, 'peer_ipaddr'),
@@ -300,11 +322,14 @@
       'tx_errors',
       'tx_dropped'
   ]
+  realm = 'gfch100'
 
-  def __init__(self, wwwroot, port, sim):
+  def __init__(self, wwwroot, http_port, https_port, use_https, sim):
     """initialize."""
     self.wwwroot = wwwroot
-    self.port = port
+    self.http_port = http_port
+    self.https_port = https_port
+    self.use_https = use_https
     self.sim = sim
     self.data = {}
     self.data['refreshCount'] = 0
@@ -466,40 +491,85 @@
         print 'Connection to %s failed: %s' % (url, ex.reason)
     return response
 
-  class MainHandler(tornado.web.RequestHandler):
+  def GetUserCreds(self, user):
+    if user not in ('admin', 'guest'):
+      return None
+    b64 = self.ReadFile('%s/config/settings/password_%s' % (self.sim, user))
+    pw = base64.b64decode(b64)
+    return {'auth_username': user, 'auth_password': pw}
+
+  def GetAdminCreds(self, user):
+    if user != 'admin':
+      return None
+    return self.GetUserCreds(user)
+
+  def Authenticate(self, request):
+    """Check if user is authenticated (sends challenge if not)."""
+    if not request.get_authenticated_user(self.GetUserCreds, self.realm):
+      return False
+    return True
+
+  def AuthenticateAdmin(self, request):
+    """Check if user is authenticated (sends challenge if not)."""
+    if not request.get_authenticated_user(self.GetAdminCreds, self.realm):
+      return False
+    return True
+
+  class RedirectHandler(tornado.web.RequestHandler):
+    """Redirect to the https_port."""
+
+    def get(self):
+      ui = self.settings['ui']
+      print 'GET craft redirect page'
+      host = re.sub(r':.*', '', self.request.host)
+      port = ui.https_port
+      self.redirect('https://%s:%d/' % (host, port))
+
+  class MainHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Displays the Craft UI."""
 
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET craft HTML page'
       self.render(ui.wwwroot + '/index.thtml', peerurl='/?peer=1')
 
-  class ConfigHandler(tornado.web.RequestHandler):
+  class ConfigHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Displays the Config page."""
 
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET config HTML page'
       self.render(ui.wwwroot + '/config.thtml', peerurl='/config/?peer=1')
 
-  class RestartHandler(tornado.web.RequestHandler):
+  class RestartHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Restart the box."""
 
     def get(self):
+      ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'displaying restart interstitial screen'
       self.render('restarting.html')
 
     def post(self):
+      ui = self.settings['ui']
+      if not ui.AuthenticateAdmin(self):
+        return
       print 'user requested restart'
       self.redirect('/restart')
       os.system('(sleep 5; reboot) &')
 
-  class JsonHandler(tornado.web.RequestHandler):
+  class JsonHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Provides JSON-formatted content to be displayed in the UI."""
 
-    @tornado.web.asynchronous
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET JSON data for craft page'
       jsonstring = ui.GetData()
       self.set_header('Content-Type', 'application/json')
@@ -507,6 +577,9 @@
       self.finish()
 
     def post(self):
+      ui = self.settings['ui']
+      if not ui.AuthenticateAdmin(self):
+        return
       print 'POST JSON data for craft page'
       request = self.request.body
       result = {}
@@ -519,7 +592,6 @@
         except ValueError as e:
           print e
           raise ConfigError('json format error')
-        ui = self.settings['ui']
         ui.ApplyChanges(json_args)
       except ConfigError as e:
         print e
@@ -534,8 +606,13 @@
       self.finish()
 
   def RunUI(self):
-    """Create the web server and run forever."""
-    handlers = [
+    """Create the http redirect and https web server and run forever."""
+    sim = self.sim
+
+    redirect_handlers = [
+        (r'.*', self.RedirectHandler),
+    ]
+    craftui_handlers = [
         (r'/', self.MainHandler),
         (r'/config', self.ConfigHandler),
         (r'/content.json', self.JsonHandler),
@@ -543,9 +620,22 @@
         (r'/static/([^/]*)$', tornado.web.StaticFileHandler,
          {'path': self.wwwroot + '/static'}),
     ]
-    app = tornado.web.Application(handlers)
-    app.settings['ui'] = self
-    app.listen(self.port)
+
+    http_handlers = redirect_handlers if self.use_https else craftui_handlers
+
+    http_app = tornado.web.Application(http_handlers)
+    http_app.settings['ui'] = self
+    http_app.listen(self.http_port)
+
+    if self.use_https:
+      https_app = tornado.web.Application(craftui_handlers)
+      https_app.settings['ui'] = self
+      https_server = tornado.httpserver.HTTPServer(https_app, ssl_options={
+          'certfile': sim + '/tmp/ssl/certs/device.pem',
+          'keyfile': sim + '/tmp/ssl/private/device.key'
+      })
+      https_server.listen(self.https_port)
+
     ioloop = tornado.ioloop.IOLoop.instance()
     ioloop.start()
 
@@ -558,11 +648,14 @@
 
 def main():
   www = '/usr/craftui/www'
-  port = 80
+  http_port = 80
+  https_port = 443
+  use_https = False
   sim = ''
   try:
-    opts, args = getopt.getopt(sys.argv[1:], 's:p:w:',
-                               ['sim=', 'port=', 'www='])
+    opts, args = getopt.getopt(sys.argv[1:], 's:p:P:w:S',
+                               ['sim=', 'http-port=', 'https-port=', 'www=',
+                                'use-https='])
   except getopt.GetoptError as err:
     # print help information and exit:
     print str(err)
@@ -571,8 +664,12 @@
   for o, a in opts:
     if o in ('-s', '--sim'):
       sim = a
-    elif o in ('-p', '--port'):
-      port = int(a)
+    elif o in ('-p', '--http-port'):
+      http_port = int(a)
+    elif o in ('-P', '--https-port'):
+      https_port = int(a)
+    elif o in ('-S', '--use-https'):
+      use_https = True
     elif o in ('-w', '--www'):
       www = a
     else:
@@ -583,7 +680,7 @@
     assert False, 'extra args'
     Usage()
     sys.exit(1)
-  craftui = CraftUI(www, port, sim)
+  craftui = CraftUI(www, http_port, https_port, use_https, sim)
   craftui.RunUI()
 
 
diff --git a/craftui/craftui_test.sh b/craftui/craftui_test.sh
index 9147945..dc71fe2 100755
--- a/craftui/craftui_test.sh
+++ b/craftui/craftui_test.sh
@@ -4,6 +4,7 @@
 
 # save stdout to 3, dup stdout to a file
 log=.testlog.$$
+ln -sf LOG $log
 exec 3>&1
 exec >$log 2>&1
 
@@ -11,80 +12,204 @@
 passcount=0
 
 fail() {
-	echo "FAIL: $*" >&3
-	echo "FAIL: $*"
-	((failcount++))
+  echo "FAIL: $*" >&3
+  echo "FAIL: $*"
+  ((failcount++))
 }
 
 pass() {
-	echo "PASS: $*" >&3
-	echo "PASS: $*"
-	((passcount++))
+  echo "PASS: $*" >&3
+  echo "PASS: $*"
+  ((passcount++))
 }
 
 testname() {
-	test="$*"
-	echo "---------------------------------------------------------"
-	echo "starting test $test"
+  test="$*"
+  echo ""
+  echo "---------------------------------------------------------"
+  echo "starting test '$test'"
 }
 
 check_success() {
-	status=$?
-	echo "check_success: last return code was $status, wanted 0"
-	if [ $status = 0 ]; then
-		pass $test
-	else
-		fail $test
-	fi
+  status=$?
+  echo "check_success: last return code was $status, wanted 0"
+  if [ $status = 0 ]; then
+    pass $test
+  else
+    fail $test
+  fi
 }
 
 check_failure() {
-	status=$?
-	echo "check_failure: last return code was $status, wanted not-0"
-	if [ $status != 0 ]; then
-		pass $test
-	else
-		fail $test
-	fi
+  status=$?
+  echo "check_failure: last return code was $status, wanted not-0"
+  if [ $status != 0 ]; then
+    pass $test
+  else
+    fail $test
+  fi
 }
 
 onexit() {
-	testname "process running at exit"
-	kill -0 $pid
-	check_success
+  testname "process not running at exit"
+  kill -0 $pid
+  check_failure
 
-	# cleanup
-	kill -9 $pid
+  testname "end of script reached"
+  test "$eos" = 1
+  check_success
 
-	exec 1>&3
-	echo "SUMMARY: pass=$passcount fail=$failcount"
-	if [ $failcount -eq 0 ]; then
-		echo "SUCCESS: $passcount tests passed."
-	else
-		echo "FAILURE: $failcount tests failed."
-		echo "details follow:"
-		cat $log
-	fi
-	rm -f $log
+  exec 1>&3
+  echo "SUMMARY: pass=$passcount fail=$failcount"
+  if [ $failcount -eq 0 ]; then
+    echo "SUCCESS: $passcount tests passed."
+  else
+    echo "FAILURE: $failcount tests failed."
+    echo "details follow:"
+    cat $log
+  fi
+  rm -f $log
 
-	exit $failcount
+  exit $failcount
 }
 
+run_tests() {
+  local use_https http https url curl n arg secure_arg curl_arg
+  use_https=$1
+
+  http=8888
+  https=8889
+  url=http://localhost:$http
+
+  if [ "$use_https" = 1 ]; then
+    url=https://localhost:$https
+    secure_arg=-S
+    curl_arg=-k
+
+    # not really testing here, just showing the mode change
+    testname "INFO: https mode"
+    true
+    check_success
+  else
+    # not really testing here, just showing the mode change
+    testname "INFO: http mode"
+    true
+    check_success
+  fi
+
+  testname "server not running"
+  curl -s http://localhost:8888/
+  check_failure
+
+  ./craftui $secure_arg &
+  pid=$!
+
+  testname "process running"
+  kill -0 $pid
+  check_success
+
+  sleep 1
+
+  curl="curl -v -s -m 1 $curl_arg"
+
+  if [ "$use_https" = 1 ]; then
+    for n in localhost 127.0.0.1; do
+      testname "redirect web page ($n)"
+      $curl "http://$n:8888/anything" |& grep "Location: https://$n:8889/"
+      check_success
+    done
+  fi
+
+  testname "404 not found"
+  $curl $url/notexist |& grep '404: Not Found'
+  check_success
+
+  baduser_auth="--digest --user root:admin"
+  badpass_auth="--digest --user guest:admin"
+
+  for auth in "" "$baduser_auth" "$badpass_auth"; do
+    for n in / /config /content.json; do
+      testname "page $n bad auth ($auth)"
+      $curl -v $auth $url/ |& grep 'WWW-Authenticate: Digest'
+      check_success
+    done
+  done
+
+  admin_auth="--digest --user admin:admin"
+  guest_auth="--digest --user guest:guest"
+
+  for auth in "$admin_auth" "$guest_auth"; do
+    testname "main web page ($auth)"
+    $curl $auth $url/ |& grep index.thtml
+    check_success
+
+    testname "config web page ($auth)"
+    $curl $auth $url/config |& grep config.thtml
+    check_success
+
+    testname "json ($auth)"
+    $curl $auth $url/content.json |& grep '"platform": "GFCH100"'
+    check_success
+  done
+
+  testname "bad json to config page"
+  $curl $admin_auth -d 'duck' $url/content.json | grep "json format error"
+  check_success
+
+  testname "good json config"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 0}'
+  check_success
+
+  testname "good json config, bad value"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/240"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 1}'
+  check_success
+
+  testname "good json config, guest access"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl $guest_auth -d $d $url/content.json |& grep '401 Unauthorized'
+  check_success
+
+  testname "good json config, no auth"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl -d $d $url/content.json |& grep '401 Unauthorized'
+  check_success
+
+  testname "password is base64"
+  d='{"config":[{"password_guest":"ZHVja3k="}]}'	# ducky
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 0}'
+  check_success
+
+  # TODO(edjames): duckduck does not fail.  Need to catch that.
+  testname "password not base64"
+  d='{"config":[{"password_guest":"abc123XXX"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 1}'
+  check_success
+
+  testname "process still running at end of test sequence"
+  kill -0 $pid
+  check_success
+
+  # cleanup
+  t0=$(date +%s)
+  kill $pid
+  wait
+  t1=$(date +%s)
+  dt=$((t1 - t0))
+
+  testname "process stopped on TERM reasonably fast"
+  echo "process stopped in $dt seconds"
+  test "$dt" -lt 3
+  check_success
+}
+
+#
+# main()
+#
 trap onexit 0 1 2 3
 
-testname "server not running"
-curl -s http://localhost:8888/
-check_failure
-
-./craftui > /tmp/LOG 2>&1 &
-pid=$!
-
-testname "process running"
-kill -0 $pid
-check_success
-
-sleep 1
-
+# sanity tests
 testname true
 true
 check_success
@@ -93,15 +218,13 @@
 false
 check_failure
 
-testname "main web page"
-curl -s http://localhost:8888/ > /dev/null
-check_success
+# run without https
+run_tests 0
 
-testname "404 not found"
-curl -s http://localhost:8888/notexist | grep '404: Not Found'
-check_success
+# run with https
+run_tests 1
 
-testname "json"
-curl -s http://localhost:8888/content.json | grep '"platform": "GFCH100"'
-check_success
-
+# If there's a syntax error in this script, trap 0 will call onexit,
+# so indicate we really hit the end of the script.
+eos=1
+# end of script, add more tests before this section
diff --git a/craftui/sim.tgz b/craftui/sim.tgz
index 136253f..d3b157d 100644
--- a/craftui/sim.tgz
+++ b/craftui/sim.tgz
Binary files differ
diff --git a/craftui/www/config.thtml b/craftui/www/config.thtml
index ddca423..aa81c3e 100644
--- a/craftui/www/config.thtml
+++ b/craftui/www/config.thtml
@@ -204,3 +204,4 @@
   <script src="static/craft.js"></script>
 </body>
 </html>
+<!-- end of config.thtml (used by unit test) -->
diff --git a/craftui/www/index.thtml b/craftui/www/index.thtml
index 20bba69..54a5ccb 100644
--- a/craftui/www/index.thtml
+++ b/craftui/www/index.thtml
@@ -493,3 +493,4 @@
   <script src="static/craft.js"></script>
 </body>
 </html>
+<!-- end of index.thtml (used by unit test) -->
diff --git a/craftui/www/static/craft.js b/craftui/www/static/craft.js
index 81501aa..f93df89 100644
--- a/craftui/www/static/craft.js
+++ b/craftui/www/static/craft.js
@@ -16,6 +16,7 @@
 };
 
 CraftUI.info = {checksum: 0};
+CraftUI.am_sending = false
 
 CraftUI.updateField = function(key, val) {
   var el = document.getElementById(key);
@@ -60,6 +61,9 @@
 
 CraftUI.getInfo = function() {
   // Request info, set the connected status, and update the fields.
+  if (CraftUI.am_sending) {
+    return;
+  }
   var xhr = new XMLHttpRequest();
   xhr.onreadystatechange = function() {
     self.unhandled = '';
@@ -68,11 +72,13 @@
       CraftUI.flattenAndUpdateFields(list, '');
     }
     CraftUI.updateField('unhandled', self.unhandled);
+    CraftUI.am_sending = false
   };
   var payload = [];
   payload.push('checksum=' + encodeURIComponent(CraftUI.info.checksum));
   payload.push('_=' + encodeURIComponent((new Date()).getTime()));
   xhr.open('get', 'content.json?' + payload.join('&'), true);
+  CraftUI.am_sending = true
   xhr.send();
 };