260 lines
8.8 KiB
Lua
260 lines
8.8 KiB
Lua
|
--------------------------------------------------------------------------------
|
||
|
-- LuaOTA provisioning system for ESPs using NodeMCU Lua
|
||
|
-- LICENCE: http://opensource.org/licenses/MIT
|
||
|
-- TerryE 15 Jul 2017
|
||
|
--
|
||
|
-- See luaOTA.md for description
|
||
|
--------------------------------------------------------------------------------
|
||
|
|
||
|
--[[ luaOTAserver.lua - an example provisioning server
|
||
|
|
||
|
This module implements an example server-side implementation of LuaOTA provisioning
|
||
|
system for ESPs used the SPI Flash FS (SPIFFS) on development and production modules.
|
||
|
|
||
|
This implementation is a simple TCP listener which can have one active provisioning
|
||
|
client executing the luaOTA module at a time. It will synchronise the client's FS
|
||
|
with the content of the given directory on the command line.
|
||
|
|
||
|
]]
|
||
|
|
||
|
-- luacheck: std max
|
||
|
|
||
|
local socket = require "socket"
|
||
|
local lfs = require "lfs"
|
||
|
local md5 = require "md5"
|
||
|
local json = require "cjson"
|
||
|
require "std.strict" -- see http://www.lua.org/extras/5.1/strict.lua
|
||
|
|
||
|
-- Local functions (implementation see below) ------------------------------------------
|
||
|
|
||
|
local get_inventory -- function(root_directory, CPU_ID)
|
||
|
local send_command -- function(esp, resp, buffer)
|
||
|
local receive_and_parse -- function(esp)
|
||
|
local provision -- function(esp, config, files, inventory, fingerprint)
|
||
|
local read_file -- function(fname)
|
||
|
local save_file -- function(fname, data)
|
||
|
local hmac -- function(data)
|
||
|
|
||
|
-- Function-wide locals (can be upvalues)
|
||
|
local unpack = table.unpack or unpack
|
||
|
local concat = table.concat
|
||
|
local load = loadstring or load
|
||
|
local format = string.format
|
||
|
-- use string % operators as a synomyn for string.format
|
||
|
getmetatable("").__mod =
|
||
|
function(a, b)
|
||
|
return not b and a or
|
||
|
(type(b) == "table" and format(a, unpack(b)) or format(a, b))
|
||
|
end
|
||
|
|
||
|
local ESPport = 8266
|
||
|
local ESPtimeout = 15
|
||
|
|
||
|
local src_dir = arg[1] or "."
|
||
|
|
||
|
-- Main process ------------------------ do encapsulation to prevent name-clash upvalues
|
||
|
local function main ()
|
||
|
local server = assert(socket.bind("*", ESPport))
|
||
|
local ip, port = server:getsockname()
|
||
|
|
||
|
print("Lua OTA service listening on %s:%u\n After connecting, the ESP timeout is %u s"
|
||
|
% {ip, port, ESPtimeout})
|
||
|
|
||
|
-- Main loop forever waiting for ESP clients then processing each request ------------
|
||
|
|
||
|
|
||
|
while true do
|
||
|
local esp = server:accept() -- wait for ESP connection
|
||
|
esp:settimeout(ESPtimeout) -- set session timeout
|
||
|
-- receive the opening request
|
||
|
local config = receive_and_parse(esp)
|
||
|
if config and config.a == "HI" then
|
||
|
print ("Processing provision check from ESP-"..config.id)
|
||
|
local inventory, fingerprint = get_inventory(src_dir, config.id)
|
||
|
-- Process the ESP request
|
||
|
if config.chk and config.chk == fingerprint then
|
||
|
send_command(esp, {r = "OK!"}) -- no update so send_command with OK
|
||
|
esp:receive("*l") -- dummy receive to allow client to close
|
||
|
else
|
||
|
local status, msg = pcall(provision, esp, config, inventory, fingerprint)
|
||
|
if not status then print (msg) end
|
||
|
|
||
|
end
|
||
|
end
|
||
|
pcall(esp.close, esp)
|
||
|
print ("Provisioning complete")
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Local Function Implementations ------------------------------------------------------
|
||
|
|
||
|
local function get_hmac_md5(key)
|
||
|
if key:len() > 64 then
|
||
|
key = md5.sum(key)
|
||
|
elseif key:len() < 64 then
|
||
|
key = key .. ('\0'):rep(64-key:len())
|
||
|
end
|
||
|
local ki = md5.exor(('\54'):rep(64),key)
|
||
|
local ko = md5.exor(('\92'):rep(64),key)
|
||
|
return function (data) return md5.sumhexa(ko..md5.sum(ki..data)) end
|
||
|
end
|
||
|
|
||
|
-- Enumerate the sources directory and load the relevent inventory
|
||
|
------------------------------------------------------------------
|
||
|
get_inventory = function(dir, cpuid)
|
||
|
if (not dir or lfs.attributes(dir).mode ~= "directory") then
|
||
|
error("Cannot open directory, aborting %s" % arg[0], 0)
|
||
|
end
|
||
|
|
||
|
-- Load the CPU's (or the default) inventory
|
||
|
local invtype, inventory = "custom", read_file("%s/ESP-%s.json" % {dir, cpuid})
|
||
|
if not inventory then
|
||
|
invtype, inventory = "default", read_file(dir .. "/default.json")
|
||
|
end
|
||
|
|
||
|
-- tolerate and remove whitespace formatting, then decode
|
||
|
inventory = (inventory or ""):gsub("[ \t]*\n[ \t]*","")
|
||
|
inventory = inventory:gsub("[ \t]*:[ \t]*",":")
|
||
|
local ok; ok,inventory = pcall(json.decode, inventory)
|
||
|
if ok and inventory.files then
|
||
|
print( "Loading %s inventory for ESP-%s" % {invtype, cpuid})
|
||
|
else
|
||
|
error( "Invalid inventory for %s :%s" % {cpuid,inventory}, 0)
|
||
|
end
|
||
|
|
||
|
-- Calculate the current fingerprint of the inventory
|
||
|
local fp,f = {},inventory.files
|
||
|
for i= 1,#f do
|
||
|
local name, fullname = f[i], "%s/%s" % {dir, f[i]}
|
||
|
local fa = lfs.attributes(fullname)
|
||
|
|
||
|
assert(fa, "File %s is required but not in sources directory" % name)
|
||
|
fp[#fp+1] = name .. ":" .. fa.modification
|
||
|
f[i] = {name = name, mtime = fa.modification,
|
||
|
size = fa.size, content = read_file(fullname) }
|
||
|
assert (f[i].size == #(f[i].content or ''), "File %s unreadable" % name )
|
||
|
end
|
||
|
|
||
|
assert(#f == #fp, "Aborting provisioning die to missing fies",0)
|
||
|
assert(type(inventory.secret) == "string",
|
||
|
"Aborting, config must contain a shared secret")
|
||
|
hmac = get_hmac_md5(inventory.secret)
|
||
|
return inventory, md5.sumhexa(concat(fp,":"))
|
||
|
end
|
||
|
|
||
|
|
||
|
-- Encode a response buff, add a signature and any optional buffer
|
||
|
------------------------------------------------------------------
|
||
|
send_command = function(esp, resp, buffer)
|
||
|
if type(buffer) == "string" then
|
||
|
resp.data = #buffer
|
||
|
else
|
||
|
buffer = ''
|
||
|
end
|
||
|
local rec = json.encode(resp)
|
||
|
rec = rec .. hmac(rec):sub(-6) .."\n"
|
||
|
-- print("requesting ", rec:sub(1,-2), #(buffer or ''))
|
||
|
esp:send(rec .. buffer)
|
||
|
end
|
||
|
|
||
|
|
||
|
-- Decode a response buff, check the signature and any optional buffer
|
||
|
----------------------------------------------------------------------
|
||
|
receive_and_parse = function(esp)
|
||
|
local line = esp:receive("*l")
|
||
|
if (not line) then
|
||
|
error( "Empty response from ESP, possible cause: file signature failure", 0)
|
||
|
--return nil
|
||
|
end
|
||
|
local packed_cmd, sig = line:sub(1,#line-6),line:sub(-6)
|
||
|
-- print("reply:", packed_cmd, sig)
|
||
|
local status, cmd = pcall(json.decode, packed_cmd)
|
||
|
if not status then error("JSON decode error") end
|
||
|
if not hmac or hmac(packed_cmd):sub(-6) == sig then
|
||
|
if cmd and cmd.data == "number" then
|
||
|
local data = esp:receive(cmd.data)
|
||
|
return cmd, data
|
||
|
end
|
||
|
return cmd
|
||
|
end
|
||
|
end
|
||
|
|
||
|
|
||
|
provision = function(esp, config, inventory, fingerprint)
|
||
|
|
||
|
if type(config.files) ~= "table" then config.files = {} end
|
||
|
local cf = config.files
|
||
|
|
||
|
for _, f in ipairs(inventory.files) do
|
||
|
local name, size, mtime, content = f.name, f.size, f.mtime, f.content
|
||
|
if not cf[name] or cf[name] ~= mtime then
|
||
|
-- Send the file
|
||
|
local action, cmd, buf
|
||
|
if f.name:sub(-4) == ".lua" then
|
||
|
assert(load(content, f.name)) -- check that the contents can compile
|
||
|
if content:find("--SAFETRIM\n",1,true) then
|
||
|
-- if the source is tagged with SAFETRIM then its safe to remove "--"
|
||
|
-- comments, leading and trailing whitespace. Not as good as LuaSrcDiet,
|
||
|
-- but this simple source compression algo preserves line numbering in
|
||
|
-- the generated lc files, which helps debugging.
|
||
|
content = content:gsub("\n[ \t]+","\n")
|
||
|
content = content:gsub("[ \t]+\n","\n")
|
||
|
content = content:gsub("%-%-[^\n]*","")
|
||
|
size = #content
|
||
|
end
|
||
|
action = "cm"
|
||
|
else
|
||
|
action = "dl"
|
||
|
end
|
||
|
print ("Sending file ".. name)
|
||
|
|
||
|
for i = 1, size, 1024 do
|
||
|
if i+1023 < size then
|
||
|
cmd = {a = "pu", data = 1024}
|
||
|
buf = content:sub(i, i+1023)
|
||
|
else
|
||
|
cmd = {a = action, data = size - i + 1, name = name}
|
||
|
buf = content:sub(i)
|
||
|
end
|
||
|
send_command(esp, cmd, buf)
|
||
|
local resp = receive_and_parse(esp)
|
||
|
assert(resp and resp.s == "OK", "Command to ESP failed")
|
||
|
if resp.lcsize then
|
||
|
print("Compiled file size %s bytes" % resp.lcsize)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
cf[name] = mtime
|
||
|
end
|
||
|
|
||
|
config.chk = fingerprint
|
||
|
config.id = nil
|
||
|
config.a = "restart"
|
||
|
send_command(esp, config)
|
||
|
|
||
|
end
|
||
|
|
||
|
-- Load contents of the given file (or null if absent/unreadable)
|
||
|
-----------------------------------------------------------------
|
||
|
read_file = function(fname)
|
||
|
local file = io.open(fname, "rb")
|
||
|
if not file then return end
|
||
|
local data = file and file:read"*a"
|
||
|
file:close()
|
||
|
return data
|
||
|
end
|
||
|
|
||
|
-- Save contents to the given file
|
||
|
----------------------------------
|
||
|
save_file = function(fname, data) -- luacheck: ignore
|
||
|
local file = io.open(fname, "wb")
|
||
|
file:write(data)
|
||
|
file:close()
|
||
|
end
|
||
|
--------------------------------------------------------------------------------------
|
||
|
|
||
|
main() -- now that all functions have been bound to locals, we can start the show :-)
|
||
|
|
||
|
|