luaOTAserver.lua 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. --------------------------------------------------------------------------------
  2. -- LuaOTA provisioning system for ESPs using NodeMCU Lua
  3. -- LICENCE: http://opensource.org/licenses/MIT
  4. -- TerryE 15 Jul 2017
  5. --
  6. -- See luaOTA.md for description
  7. --------------------------------------------------------------------------------
  8. --[[ luaOTAserver.lua - an example provisioning server
  9. This module implements an example server-side implementation of LuaOTA provisioning
  10. system for ESPs used the SPI Flash FS (SPIFFS) on development and production modules.
  11. This implementation is a simple TCP listener which can have one active provisioning
  12. client executing the luaOTA module at a time. It will synchronise the client's FS
  13. with the content of the given directory on the command line.
  14. ]]
  15. local socket = require "socket"
  16. local lfs = require "lfs"
  17. local md5 = require "md5"
  18. local json = require "cjson"
  19. require "etc.strict" -- see http://www.lua.org/extras/5.1/strict.lua
  20. -- Local functions (implementation see below) ------------------------------------------
  21. local get_inventory -- function(root_directory, CPU_ID)
  22. local send_command -- function(esp, resp, buffer)
  23. local receive_and_parse -- function(esp)
  24. local provision -- function(esp, config, files, inventory, fingerprint)
  25. local read_file -- function(fname)
  26. local save_file -- function(fname, data)
  27. local compress_lua -- function(lua_file)
  28. local hmac -- function(data)
  29. -- Function-wide locals (can be upvalues)
  30. local unpack = table.unpack or unpack
  31. local concat = table.concat
  32. local load = loadstring or load
  33. local format = string.format
  34. -- use string % operators as a synomyn for string.format
  35. getmetatable("").__mod =
  36. function(a, b)
  37. return not b and a or
  38. (type(b) == "table" and format(a, unpack(b)) or format(a, b))
  39. end
  40. local ESPport = 8266
  41. local ESPtimeout = 15
  42. local src_dir = arg[1] or "."
  43. -- Main process ------------------------ do encapsulation to prevent name-clash upvalues
  44. local function main ()
  45. local server = assert(socket.bind("*", ESPport))
  46. local ip, port = server:getsockname()
  47. print("Lua OTA service listening on %s:%u\n After connecting, the ESP timeout is %u s"
  48. % {ip, port, ESPtimeout})
  49. -- Main loop forever waiting for ESP clients then processing each request ------------
  50. while true do
  51. local esp = server:accept() -- wait for ESP connection
  52. esp:settimeout(ESPtimeout) -- set session timeout
  53. -- receive the opening request
  54. local config = receive_and_parse(esp)
  55. if config and config.a == "HI" then
  56. print ("Processing provision check from ESP-"..config.id)
  57. local inventory, fingerprint = get_inventory(src_dir, config.id)
  58. -- Process the ESP request
  59. if config.chk and config.chk == fingerprint then
  60. send_command(esp, {r = "OK!"}) -- no update so send_command with OK
  61. esp:receive("*l") -- dummy receive to allow client to close
  62. else
  63. local status, msg = pcall(provision, esp, config, inventory, fingerprint)
  64. if not status then print (msg) end
  65. end
  66. end
  67. pcall(esp.close, esp)
  68. print ("Provisioning complete")
  69. end
  70. end
  71. -- Local Function Implementations ------------------------------------------------------
  72. local function get_hmac_md5(key)
  73. if key:len() > 64 then
  74. key = md5.sum(key)
  75. elseif key:len() < 64 then
  76. key = key .. ('\0'):rep(64-key:len())
  77. end
  78. local ki = md5.exor(('\54'):rep(64),key)
  79. local ko = md5.exor(('\92'):rep(64),key)
  80. return function (data) return md5.sumhexa(ko..md5.sum(ki..data)) end
  81. end
  82. -- Enumerate the sources directory and load the relevent inventory
  83. ------------------------------------------------------------------
  84. get_inventory = function(dir, cpuid)
  85. if (not dir or lfs.attributes(dir).mode ~= "directory") then
  86. error("Cannot open directory, aborting %s" % arg[0], 0)
  87. end
  88. -- Load the CPU's (or the default) inventory
  89. local invtype, inventory = "custom", read_file("%s/ESP-%s.json" % {dir, cpuid})
  90. if not inventory then
  91. invtype, inventory = "default", read_file(dir .. "/default.json")
  92. end
  93. -- tolerate and remove whitespace formatting, then decode
  94. inventory = (inventory or ""):gsub("[ \t]*\n[ \t]*","")
  95. inventory = inventory:gsub("[ \t]*:[ \t]*",":")
  96. local ok; ok,inventory = pcall(json.decode, inventory)
  97. if ok and inventory.files then
  98. print( "Loading %s inventory for ESP-%s" % {invtype, cpuid})
  99. else
  100. error( "Invalid inventory for %s :%s" % {cpuid,inventory}, 0)
  101. end
  102. -- Calculate the current fingerprint of the inventory
  103. local fp,f = {},inventory.files
  104. for i= 1,#f do
  105. local name, fullname = f[i], "%s/%s" % {dir, f[i]}
  106. local fa = lfs.attributes(fullname)
  107. assert(fa, "File %s is required but not in sources directory" % name)
  108. fp[#fp+1] = name .. ":" .. fa.modification
  109. f[i] = {name = name, mtime = fa.modification,
  110. size = fa.size, content = read_file(fullname) }
  111. assert (f[i].size == #(f[i].content or ''), "File %s unreadable" % name )
  112. end
  113. assert(#f == #fp, "Aborting provisioning die to missing fies",0)
  114. assert(type(inventory.secret) == "string",
  115. "Aborting, config must contain a shared secret")
  116. hmac = get_hmac_md5(inventory.secret)
  117. return inventory, md5.sumhexa(concat(fp,":"))
  118. end
  119. -- Encode a response buff, add a signature and any optional buffer
  120. ------------------------------------------------------------------
  121. send_command = function(esp, resp, buffer)
  122. if type(buffer) == "string" then
  123. resp.data = #buffer
  124. else
  125. buffer = ''
  126. end
  127. local rec = json.encode(resp)
  128. rec = rec .. hmac(rec):sub(-6) .."\n"
  129. -- print("requesting ", rec:sub(1,-2), #(buffer or ''))
  130. esp:send(rec .. buffer)
  131. end
  132. -- Decode a response buff, check the signature and any optional buffer
  133. ----------------------------------------------------------------------
  134. receive_and_parse = function(esp)
  135. local line = esp:receive("*l")
  136. local packed_cmd, sig = line:sub(1,#line-6),line:sub(-6)
  137. -- print("reply:", packed_cmd, sig)
  138. local status, cmd = pcall(json.decode, packed_cmd)
  139. if not hmac or hmac(packed_cmd):sub(-6) == sig then
  140. if cmd and cmd.data == "number" then
  141. local data = esp:receive(cmd.data)
  142. return cmd, data
  143. end
  144. return cmd
  145. end
  146. end
  147. provision = function(esp, config, inventory, fingerprint)
  148. if type(config.files) ~= "table" then config.files = {} end
  149. local cf = config.files
  150. for _, f in ipairs(inventory.files) do
  151. local name, size, mtime, content = f.name, f.size, f.mtime, f.content
  152. if not cf[name] or cf[name] ~= mtime then
  153. -- Send the file
  154. local func, action, cmd, buf
  155. if f.name:sub(-4) == ".lua" then
  156. assert(load(content, f.name)) -- check that the contents can compile
  157. if content:find("--SAFETRIM\n",1,true) then
  158. -- if the source is tagged with SAFETRIM then its safe to remove "--"
  159. -- comments, leading and trailing whitespace. Not as good as LuaSrcDiet,
  160. -- but this simple source compression algo preserves line numbering in
  161. -- the generated lc files, which helps debugging.
  162. content = content:gsub("\n[ \t]+","\n")
  163. content = content:gsub("[ \t]+\n","\n")
  164. content = content:gsub("%-%-[^\n]*","")
  165. size = #content
  166. end
  167. action = "cm"
  168. else
  169. action = "dl"
  170. end
  171. print ("Sending file ".. name)
  172. for i = 1, size, 1024 do
  173. if i+1023 < size then
  174. cmd = {a = "pu", data = 1024}
  175. buf = content:sub(i, i+1023)
  176. else
  177. cmd = {a = action, data = size - i + 1, name = name}
  178. buf = content:sub(i)
  179. end
  180. send_command(esp, cmd, buf)
  181. local resp = receive_and_parse(esp)
  182. assert(resp and resp.s == "OK", "Command to ESP failed")
  183. if resp.lcsize then
  184. print("Compiled file size %s bytes" % resp.lcsize)
  185. end
  186. end
  187. end
  188. cf[name] = mtime
  189. end
  190. config.chk = fingerprint
  191. config.id = nil
  192. config.a = "restart"
  193. send_command(esp, config)
  194. end
  195. -- Load contents of the given file (or null if absent/unreadable)
  196. -----------------------------------------------------------------
  197. read_file = function(fname)
  198. local file = io.open(fname, "rb")
  199. if not file then return end
  200. local data = file and file:read"*a"
  201. file:close()
  202. return data
  203. end
  204. -- Save contents to the given file
  205. ----------------------------------
  206. save_file = function(fname, data)
  207. local file = io.open(fname, "wb")
  208. file:write(data)
  209. file:close()
  210. end
  211. --------------------------------------------------------------------------------------
  212. main() -- now that all functions have been bound to locals, we can start the show :-)