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();
};