From d938bd2fcbb5bf3768e3386a60d0c71dd62378fa Mon Sep 17 00:00:00 2001 From: Nathaniel Wesley Filardo Date: Fri, 5 Jul 2019 12:01:30 +0100 Subject: [PATCH] DNM: WIP: Rewrite sntp in Lua with only a little C Currently known defects: * Lots of print() debugging still present * No real handling of LI * No RTC rate support * Docs light, and none for sntppkt Doubtless plenty of others, too. --- app/modules/sntppkt.c | 315 ++++++++++++++++++++++++++++++++++++++ docs/lua-modules/sntp.md | 96 ++++++++++++ lua_modules/sntp/sntp.lua | 226 +++++++++++++++++++++++++++ 3 files changed, 637 insertions(+) create mode 100644 app/modules/sntppkt.c create mode 100644 docs/lua-modules/sntp.md create mode 100644 lua_modules/sntp/sntp.lua diff --git a/app/modules/sntppkt.c b/app/modules/sntppkt.c new file mode 100644 index 0000000000..7458c6d2e6 --- /dev/null +++ b/app/modules/sntppkt.c @@ -0,0 +1,315 @@ +/* + * Copyright 2015 Dius Computing Pty Ltd. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the + * distribution. + * - Neither the name of the copyright holders nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @author Johny Mattsson + * @author Nathaniel Wesley Filardo + */ + +// Module for Simple Network Time Protocol (SNTP) packet processing; +// see lua_modules/sntp/sntp.lua for the user-friendly bits of this. + +#include "module.h" +#include "lauxlib.h" +#include "lmem.h" +#include "os_type.h" +#include "osapi.h" +#include "lwip/udp.h" +#include "c_stdlib.h" +#include "user_modules.h" +#include "lwip/dns.h" +#include "task/task.h" +#include "user_interface.h" + +#define max(a,b) ((a < b) ? b : a) + +#define NTP_PORT 123 +#define NTP_ANYCAST_ADDR(dst) IP4_ADDR(dst, 224, 0, 1, 1) + +#if 0 +# define sntppkt_dbg(...) dbg_printf(__VA_ARGS__) +#else +# define sntppkt_dbg(...) +#endif + +typedef struct +{ + uint32_t sec; + uint32_t frac; +} ntp_timestamp_t; + +static const uint32_t NTP_TO_UNIX_EPOCH = 2208988800ul; + +typedef struct +{ + uint8_t mode : 3; + uint8_t ver : 3; + uint8_t LI : 2; + uint8_t stratum; + uint8_t poll; + uint8_t precision; + uint32_t root_delay; + uint32_t root_dispersion; + uint32_t refid; + ntp_timestamp_t ref; + ntp_timestamp_t origin; + ntp_timestamp_t recv; + ntp_timestamp_t xmit; +} __attribute__((packed)) ntp_frame_t; + +typedef struct { + int64_t delta; + uint32_t cached_delay; + uint32_t txsec; + uint32_t delay_frac; + uint32_t root_delay; + uint32_t root_dispersion; + uint8_t LI; + uint8_t stratum; +} ntp_response_t; + +static uint64_t +sntppkt_div1m(uint64_t n) { + uint64_t q1 = (n >> 5) + (n >> 10); + uint64_t q2 = (n >> 12) + (q1 >> 1); + uint64_t q3 = (q2 >> 11) - (q2 >> 23); + + uint64_t q = n + q1 + q2 - q3; + + q = q >> 20; + + // Ignore the error term -- it is measured in pico seconds + return q; +} + +static uint32_t +sntppkt_us_to_frac(uint64_t us) { + return sntppkt_div1m(us << 32); +} + +static const uint32_t MICROSECONDS = 1000000; + +static uint32_t +sntppkt_frac16_to_us(uint64_t frac) { + return (frac * MICROSECONDS) >> 16; +} + +/* + * Convert sec/usec to a Lua string suitable for depositing into a SNTP packet + * buffer. This is a little gross, but it's not the worst thing a C + * programmer's ever done, I'm sure. + */ +static int +sntppkt_make_ts(lua_State *L) { + ntp_timestamp_t ts; + uint32_t usec; + + ts.sec = htonl(luaL_checkinteger(L, 1) + NTP_TO_UNIX_EPOCH) ; + usec = luaL_checkinteger(L, 2) ; + ts.frac = htonl(sntppkt_us_to_frac(usec)); + + lua_pushlstring(L, (const char *)&ts, sizeof(ts)); + return 1; +} + +/* + * Process a SNTP packet as contained in a Lua string, given a cookie timestamp + * and local clock second*usecond pair. Generates a ntp_response_t userdata + * for later processing or a string if the server is telling us to go away. + */ +static int +sntppkt_proc_pkt(lua_State *L) { + const char *pkts; + size_t pkts_len; + + uint32_t now_sec; + uint32_t now_usec; + + ntp_timestamp_t *cookie; + size_t cookie_len; + + ntp_response_t *ntpr; + + // make sure we have an aligned copy to work from + // XXX nwf: is this necessary? + ntp_frame_t pktb; + + now_usec = luaL_checkinteger(L, 4); + now_sec = luaL_checkinteger(L, 3); + + luaL_checktype(L, 2, LUA_TSTRING); + cookie = (ntp_timestamp_t*) lua_tolstring(L, 2, &cookie_len); + if (cookie_len != sizeof(*cookie)) { + luaL_error(L, "Bad cookie"); + } + + luaL_checktype(L, 1, LUA_TSTRING); + pkts = lua_tolstring(L, 1, &pkts_len); + if (pkts_len != sizeof(pktb)) { + luaL_error(L, "Bad packet length"); + } + os_memcpy (&pktb, pkts, sizeof(pktb)); + + if (memcmp((const char *)cookie, (const char *)&pktb.origin, sizeof (*cookie))) { + /* bad cookie; return nil */ + return 0; + } + + /* KOD? */ + if (pktb.LI == 3) { + lua_pushlstring(L, (const char *)&pktb.refid, 4); + return 1; + } + + ntpr = lua_newuserdata(L, sizeof(ntp_response_t)); + luaL_getmetatable(L, "sntppkt.resp"); + lua_setmetatable(L, -2); + + ntpr->LI = pktb.LI; + ntpr->stratum = pktb.stratum; + ntpr->root_delay = ntohl(pktb.root_delay); + ntpr->root_dispersion = ntohl(pktb.root_dispersion); + + /* Heavy time lifting time */ + + pktb.origin.sec = ntohl(pktb.origin.sec); + pktb.origin.frac = ntohl(pktb.origin.frac); + pktb.recv.sec = ntohl(pktb.recv.sec); + pktb.recv.frac = ntohl(pktb.recv.frac); + pktb.xmit.sec = ntohl(pktb.xmit.sec); + pktb.xmit.frac = ntohl(pktb.xmit.frac); + + ntpr->txsec = pktb.xmit.sec - NTP_TO_UNIX_EPOCH; + + uint64_t ntp_recv = (((uint64_t) pktb.recv.sec ) << 32) + + pktb.recv.frac; + uint64_t ntp_origin = (((uint64_t) pktb.origin.sec ) << 32) + + pktb.origin.frac; + uint64_t ntp_xmit = (((uint64_t) pktb.xmit.sec ) << 32) + + pktb.xmit.frac; + uint64_t ntp_dest = (((uint64_t) now_sec + NTP_TO_UNIX_EPOCH ) << 32) + + sntppkt_us_to_frac(now_usec); + + ntpr->delta = ((int64_t) ntp_recv - ntp_origin) / 2 + + ((int64_t) ntp_xmit - ntp_dest ) / 2; + + ntpr->delay_frac = ((int64_t)ntp_dest - ntp_origin - ntp_xmit + ntp_recv) >> 16; + + ntpr->cached_delay = ntpr->root_delay * 2 + ntpr->delay_frac; + + return 1; +} + +/* + * Left-biased selector of a "preferred" NTP response. Note that preference + * is rather subjective! + * + * Lua does not make it straightforward to return an existing userdata + * object, so instead we merely return a boolean indicating whether the + * second argument is superior to the first. + */ + +static int +sntppkt_pick_resp(lua_State *L) { + + ntp_response_t *a = luaL_checkudata(L, 1, "sntppkt.resp"); + ntp_response_t *b = luaL_checkudata(L, 2, "sntppkt.resp"); + int biased = 0; + + biased = luaL_toboolean(L, 3); + + /* + * If we're "biased", prefer the second structure if the delay less than + * 3/4ths of the delay in the first. An unbiased comparison just uses + * the raw delay values. + */ + if (biased) { + lua_pushboolean(L, a->cached_delay * 3 > b->cached_delay * 4); + } else { + lua_pushboolean(L, a->cached_delay > b->cached_delay ); + } + return 1; +} + +/* + * Inflate a NTP response into a Lua table + */ +static int +sntppkt_read_resp(lua_State *L) { + ntp_response_t *r = luaL_checkudata(L, 1, "sntppkt.resp"); + + lua_createtable(L, 0, 6); + + /* For large corrections, don't bother exposing fine values */ + int d40 = r->delta >> 40; + if (d40 != 0 && d40 != -1) { + lua_pushnumber(L, r->delta >> 32); + lua_setfield(L, -2, "offset_s"); + } else { + lua_pushnumber(L, (r->delta * MICROSECONDS) >> 32); + lua_setfield(L, -2, "offset_us"); + } + + lua_pushnumber(L, sntppkt_frac16_to_us(r->delay_frac)); + lua_setfield(L, -2, "delay_us"); + + lua_pushnumber(L, sntppkt_frac16_to_us(r->root_delay)); + lua_setfield(L, -2, "root_delay_us"); + + lua_pushnumber(L, r->root_dispersion); + lua_setfield(L, -2, "root_dispersion"); + + lua_pushnumber(L, r->LI); + lua_setfield(L, -2, "leapind"); + + lua_pushnumber(L, r->stratum); + lua_setfield(L, -2, "stratum"); + + return 1; +} + +LROT_BEGIN(sntppkt_resp) +LROT_END(sntppkt_resp, sntppkt_resp, 0) + +static int +sntppkt_init(lua_State *L) +{ + luaL_rometatable(L, "sntppkt.resp" , LROT_TABLEREF(sntppkt_resp )); + return 0; +} + +// Module function map +LROT_BEGIN(sntppkt) + LROT_FUNCENTRY( make_ts , sntppkt_make_ts ) + LROT_FUNCENTRY( proc_pkt , sntppkt_proc_pkt ) + LROT_FUNCENTRY( pick_resp, sntppkt_pick_resp ) + LROT_FUNCENTRY( read_resp, sntppkt_read_resp ) +LROT_END( sntppkt, NULL, 0 ) + +NODEMCU_MODULE(SNTPPKT, "sntppkt", sntppkt, sntppkt_init); diff --git a/docs/lua-modules/sntp.md b/docs/lua-modules/sntp.md new file mode 100644 index 0000000000..3ed1f91f0c --- /dev/null +++ b/docs/lua-modules/sntp.md @@ -0,0 +1,96 @@ +# SNTP Module +| Since | Origin / Contributor | Maintainer | Source | +| :----- | :-------------------- | :---------- | :------ | +| 2019-07-01 | [nwf](https://github.com/nwf) | [nwf](https://github.com/nwf) | [sntp.lua](../../lua_modules/sntp/sntp.lua) | + +This is a user-friendly, Lua wrapper around the `sntppkt` module to facilitate +the use of SNTP. + +## Constructor +```lua +sntp = (require "sntp").new(servers, success_cb, [failure_cb], [clock]) +``` + +where + +* `servers` specifies the name(s) of the (S)NTP server(s) to use; it may be... + + * a string, either a DNS name or an IPv4 address in dotted quad form, + * an array of the above + * `nil` to use some default `*.nodemcu.pool.ntp.org` servers. + +* `success_cb` is called back at the end of a synchronization when at least one + server replied to us. It will be given two arguments: the preferred SNTP + result and the name of the server whence that result came. + +* `failure_cb` may be `nil` but, otherwise, is called back in two circumstances: + + * at the end of a pass during which no server could be reached. In this case, + the first argument will be the string "all" and the second will be the + number of servers tried. + + * an individual server has failed in some way. In this case, the first + argument will be one of: + + * "dns" (if name resolution failed), + * "timeout" (if the server failed to reply in time), + * "goaway" (if the server refused to answer), or + * "kod" ("kiss of death", if the server told us to stop contacting it entirely). + + In all cases, the name of the server is the second argument; in the + "goaway" case, the third argument will contain the refusal string (e.g., + "RATE" for rate-limiting or "DENY" for kiss-of-death warnings + +* `clock`, if given, should return two values describing the local clock in + seconds and microseconds (between 0 and 1000000). If not given, the module + will fall back on `rtctime.get`; if `rtctime` is not available, a clock must + be provided. + +## SNTP object methods + +### sntp.sync() +#### Syntax +`sntp:sync()` + +Run a pass through the specified servers and call back as described above. + +### sntp.stop() +#### Syntax +`sntp:stop()` + +Abort any pass in progress; no more continuations will be called. The current +preferred response and server name (i.e., the arguments to the success +callback, should the pass end now) are returned. + +## Other module functions + +The module contains some other utility functions beyond the SNTP object +constructor. + +### update_rtc() +#### Syntax +`update_rtc(res)` + +Given a result from a SNTP `sync` pass, update the local RTC through `rtctime`. +Attempting to use this function without `rtctime` support will raise an error. + +## Example usage + +```lua +sntpm = require "sntp" +sntp = sntpm.new(nil, + function(res, serv) + print("SNTP OK", serv) + sntpm.update_rtc(res) + end, + function(err, srv, rply) + if err == "all" then print("SNTP FAIL", #srv) + elif err == "goaway" then print("SNTP server rejected us", srv, rply) + else print("SNTP server unreachable", srv, err) + end + end) + +-- Every five minutes, re-run SNTP +sntptmr = tmr.create() +sntptmr:alarm(3000000, tmr.ALARM_AUTO, sntp.sync) +``` diff --git a/lua_modules/sntp/sntp.lua b/lua_modules/sntp/sntp.lua new file mode 100644 index 0000000000..f9cc4e8381 --- /dev/null +++ b/lua_modules/sntp/sntp.lua @@ -0,0 +1,226 @@ +local MAX_SERVER_ATTEMPTS = 2 +local SNTP_TIMEOUT = 5000 + +local defserv = { + "0.nodemcu.pool.ntp.org", + "1.nodemcu.pool.ntp.org", + "2.nodemcu.pool.ntp.org", + "3.nodemcu.pool.ntp.org", +} + + -- sk and fk are our Success and Failure Kontinuations, resp. +return { +new = function(serv, sk, fk, now) + + if type(serv) == "string" then serv = {serv} + elseif serv == nil then serv = defserv + elseif type(serv) ~= "table" then error "Bad server table" + end + + if type(sk) ~= "function" then + error "Bad success continuation type" + end + if fk ~= nil and type(fk) ~= "function" then + error "Bad failure continuation type" + end + if now ~= nil and type(now) ~= "function" then + error "Bad clock type" + end + now = now or (rtctime and rtctime.get) + if now == nil then error "Need clock function" end + + local _tmr -- contains the currently running timer, if any + local _udp -- the socket we're using to talk to the world + + local _kod = {} -- kiss of death flags accumulated accoss syncs + local _pbest -- best server from prior pass + + local _tok -- a token held to ensure callbacks are for this sync + local _six -- index of the server in serv to whom we are speaking + local _sat -- number of times we've tried to reach this server + local _res -- the best result we've got so far + + local _best -- best server this pass, for updating _pbest + + -- Shut down the state machine + local function stop() + print("sntp", "stop") + + local res, best = _res, _best + + _six = nil + _sat = nil + _res = nil + _tok = nil + _best = nil + + -- stop any time-based callbacks and drop tmr + if _tmr then + _tmr:unregister() + _tmr = nil + end + + -- stop any UDP callbacks and drop the socket + if _udp then + _udp:on("receive", nil) + _udp:on("sent" , nil) + _udp:on("dns" , nil) + _udp = nil + end + + return res and sntppkt.read_resp(res), best + end + + local nextServer + local doserver + + -- Try communicating with the current server + local function hail(ip) + print("sntp", "hail", ip) + + _tmr:alarm(SNTP_TIMEOUT, tmr.ALARM_SINGLE, function() + print("sntp", "hail-tmr") + _udp:on("sent", nil) + _udp:on("receive", nil) + return doserver("timeout") + end) + + -- XXX merely for diagnostics + _udp:on("sent", function() + print("sntp", "udp-on-sent") + _udp:on("sent", nil) + end) + + local txts = sntppkt.make_ts(now()) + + _udp:on("receive", function(skt, d, port, rxip) + print("sntp", "udp-on-recv", rxip, port) + + -- many things constitute bad packets; drop with tmr running + if rxip ~= ip and rxip ~= "224.0.1.1" then return end -- wrong peer + if port ~= 123 then return end -- wrong port + if #d ~= 48 then return end -- too short + + local pkt = sntppkt.proc_pkt(d, txts, now()) + + if pkt == nil then return -- sntppkt can also reject the packet + elseif type(pkt) == "string" then + print("sntp", "udp-on-recv", "goaway", pkt) + if pkt == "DENY" then -- KoD packet + if _kod[_six] then + if _fk then _fk("kod", serv[_six]) end + table.remove(_kod, _six) + table.remove(serv, _six) + _six = _six - 1 -- nextServer will add one + else + if _fk then _fk("goaway", serv[_six], pkt) end + end + else + if _fk then _fk("goaway", serv[_six]) end + end + return nextServer() + end + + _kod[_six] = nil + + if _pbest == serv[_six] then + -- this was our favorite server last time; if we don't have a + -- result or if we'd rather this one than the result we have... + if not _res or not sntppkt.pick_resp(pkt, _res, true) then + _res = pkt + _best = _pbest + end + else + -- this was not our favorite server; take this result if we have no + -- other option or if it compares favorably to the one we have, which + -- might be from our favorite from last pass. + if not _res or sntppkt.pick_resp(_res, pkt, _pbest == _best) then + _res = pkt + _best = serv[_six] + end + end + + _tmr:unregister() + _udp:on("receive", nil) + _udp:on("sent", nil) + return nextServer() + end) + + return _udp:send(123, ip, + -- '#' == 0x23: version 4, mode 3 (client), no LI + "#\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0" + .. txts) + end + + function doserver(err) + if _sat == MAX_SERVER_ATTEMPTS then + if _fk then _fk(err, serv[_six]) end + return nextServer() + end + _sat = _sat + 1 + local tok = _tok + return _udp:dns(serv[_six], function(skt, ip) + if tok ~= _tok then + print("sntp", "udp-on-dns", "stale token") + return + end + print("sntp", "udp-on-dns", ip) + _udp:on("dns", nil) + if ip == nil then return doserver("dns") else return hail(ip) end + end) + end + + -- Move on to the next server + function nextServer() + if _six >= #serv then + -- XXX Finished the entire pass; call success or failure as indicated + if _res then + _pbest = _best + local res = _res + local best = _best + stop() + return sk(sntppkt.read_resp(res), best) + else + stop() + if fk then return fk("all", #serv) else return end + end + end + + print("sntp", "next", _six) + + _six = _six + 1 + _sat = 0 + return doserver() + end + + local function sync() + stop() + _udp = net.createUDPSocket() + _tmr = tmr.create() + _udp:listen() -- on random port + _tok = {} + _six = 0 + nextServer() + end + + return { sync = sync, stop = stop } + +end, + +update_rtc = function(res) + local off_s, off_us = 0, 0 + if res.offset_s then + off_s = res.offset_s + elseif res.offset_us then + off_s, off_us = res.offset_us / 1000000, res.offset_us % 1000000 + end + local now_s, now_us = rtctime.get() + local new_s, new_us = now_s + off_s, now_us + off_us + if new_us > 1000000 then + new_s = new_s + 1 + new_us = new_us - 1000000 + end + rtctime.set(new_s, new_us) +end + +}