diff --git a/Makefile b/Makefile index 9a05849..280ebac 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,13 @@ CFLAGS += -DGHASHPREC=$(GHASHPREC) ifeq ($(HAVE_PING),yes) CFLAGS += -DHAVE_PING=1 endif + +ifeq ($(WITH_LUA),yes) + CFLAGS += -DWITH_LUA=1 $(LUA_CFLAGS) + LIBS += $(LUA_LIBS) + OTR_OBJS += hooks.o +endif + ifeq ($(HAVE_KILL),yes) CFLAGS += -DHAVE_KILL=1 endif @@ -60,7 +67,7 @@ ocat: ocat.o $(OTR_OBJS) $(CC) $(CFLAGS) -o ocat ocat.o $(OTR_OBJS) $(LIBS) -ot-recorder.o: ot-recorder.c storage.h util.h Makefile geo.h udata.h json.h http.h gcache.h config.mk +ot-recorder.o: ot-recorder.c storage.h util.h Makefile geo.h udata.h json.h http.h gcache.h config.mk hooks.h geo.o: geo.h geo.c udata.h Makefile config.mk geohash.o: geohash.h geohash.c udata.h Makefile config.mk base64.o: base64.h base64.c @@ -71,6 +78,7 @@ util.o: util.c util.h Makefile config.mk mongoose.o: mongoose.c mongoose.h ocat.o: ocat.c storage.h util.h config.mk version.h storage.o: storage.c storage.h util.h gcache.h config.mk +hooks.o: hooks.c udata.h hooks.h util.h version.h clean: diff --git a/README.md b/README.md index ee5aa42..36eceb9 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ You will require: * [libmosquitto](http://mosquitto.org) * [libCurl](http://curl.haxx.se/libcurl/) * [lmdb](http://symas.com/mdb) included +* Optionally [Lua](http://lua.org) Obtain and download the software, either [as a package, if available](https://packagecloud.io/owntracks/ot-recorder), via [our Homebrew Tap](https://github.com/owntracks/homebrew-recorder) on Mac OS X, directly as a clone of the repository, or as a [tar ball](https://github.com/owntracks/recorder/releases) which you unpack. Copy the included `config.mk.in` file to `config.mk` and edit that. You specify the features or tweaks you need. (The file is commented.) Pay particular attention to the installation directory and the value of the _store_ (`STORAGEDEFAULT`): that is where the recorder will store its files. `DOCROOT` is the root of the directory from which the _recorder_'s HTTP server will serve files. @@ -417,6 +418,73 @@ curl 'http://127.0.0.1:8083/api/0/kill?user=ngin&device=ojo' ``` The response contains a list of removed `.rec` files, and file system operations are logged to syslog. +## Lua hook + +If _recorder_ is compiled with Lua support, a Lua script you provide is launched at startup. Lua is _a powerful, fast, lightweight, embeddable scripting language_. You can use this to process location publishes in any way you desire: your imagination (and Lua-scripting knowhow) set the limits. Some examples: + +* insert publishes into a database of your choice +* switch on the coffee machine when your OwnTracks device reports you're entering home (but see also [mqttwarn](http://jpmens.net/2014/02/17/introducing-mqttwarn-a-pluggable-mqtt-notifier/) +* write a file with data in a format of your choice (see `etc/example.lua`) + +Run the _recorder_ with the path to your Lua script specified in its `--lua-script` option (there is no default). If the script cannot be loaded (e.g. because it cannot be read or contains syntax errors), the _recorder_ unloads Lua and continues *without* your script. + +Your Lua program is automatically provided with a table which contains the following members: + +* `otr.version` is a read-only string with the _recorder_ version (example: `"0.3.2"`) +* `otr.log()` is a function which takes a string which is logged to syslog at the _recorder_'s facility and log level INFO. +* `otr.strftime(fmt, t)` is a function which takes a format string (see `strftime(3)`) and an integer number of seconds and returns a string with the formatted UTC time. If `t` is 0 or negative, the current system time is used. + +Your Lua script must provide the following functions: + +### `otr_init` + +This is invoked at start of _recorder_. If the function returns a non-zero value, _recorder_ unloads Lua and disables its processing; i.e. the `hook()` will *not* be invoked on location publishes. + +### `otr_exit` + +This is invoked when the _recorder_ stops, which it doesn't really do unless you CTRL-C it. + +### `otr_hook` + +This function is invoked at every location publish processed by the _recorder_. Your function is passed three arguments: + +1. _topic_ is the topic published to (e.g. `owntracks/jane/phone`) +2. _type_ is the type of MQTT message. This is the `_type` in our JSON messages (e.g. `location`) or `"unknown"`. +3. _location_ is a [Lua table](http://www.lua.org/pil/2.5.html) (associative array) with all the elements obtained in the JSON message. In the case of _type_ being `location`, we also add country code (`cc`) and the location's address (`addr`) unless reverse-geo lookups have been disabled in _recorder_. + +Assume the following small example Lua script in `example.lua`: + +```lua +local file + +function otr_init() + otr.log("example.lua starting; writing to /tmp/lua.out") + file = io.open("/tmp/lua.out", "a") + file:write("written by OwnTracks Recorder version " .. otr.version .. "\n") +end + +function otr_hook(topic, _type, data) + local timestr = otr.strftime("It is %T in the year %Y", 0) + print("L: " .. topic .. " -> " .. _type) + file:write(timestr .. " " .. topic .. " lat=" .. data['lat'] .. data['addr'] .. "\n") +end +``` + +When _recorder_ is launched with `--lua-script example.lua` it invokes `otr_init()` which opens a file. Then, for each location received, it calls `otr_hook()` which updates the file. + +Assuming an OwnTracks device publishes this payload + +```json +{"cog":-1,"batt":-1,"lon":2.29513,"acc":5,"vel":-1,"vac":-1,"lat":48.85833,"t":"u","tst":1441984413,"alt":0,"_type":"location","tid":"JJ"} +``` + +the file `/tmp/lua.out` would contain + +```txt +written by OwnTracks Recorder version 0.3.0 +It is 14:10:01 in the year 2015 owntracks/jane/phone lat=48.858339 Avenue Anatole France, 75007 Paris, France +``` + #### Environment The following environment variables control _ocat_'s behaviour: diff --git a/assets/ot-recorder.png b/assets/ot-recorder.png index c3eac9c..7367fd8 100644 Binary files a/assets/ot-recorder.png and b/assets/ot-recorder.png differ diff --git a/config.mk.in b/config.mk.in index 62200d8..39c7a5c 100644 --- a/config.mk.in +++ b/config.mk.in @@ -8,6 +8,9 @@ HAVE_HTTP ?= yes # Do you want to use reverse-geo caching? (Highly recommended) HAVE_LMDB ?= yes +# Do you have Lua libraries installed and want the Lua hook integration? +WITH_LUA ?= no + # Do you want support for the `pingping' monitoring feature? HAVE_PING ?= yes @@ -51,3 +54,6 @@ APIKEY ?= MOSQUITTO_INC = -I/usr/include MOSQUITTO_LIB = -L/usr/lib MORELIBS = # -lssl + +LUA_CFLAGS = +LUA_LIBS = -llua diff --git a/etc/example.lua b/etc/example.lua new file mode 100644 index 0000000..404effa --- /dev/null +++ b/etc/example.lua @@ -0,0 +1,13 @@ +local file + +function otr_init() + otr.log("example.lua starting; writing to /tmp/lua.out") + file = io.open("/tmp/lua.out", "a") + file:write("written by OwnTracks Recorder version " .. otr.version .. "\n") +end + +function otr_hook(topic, _type, data) + local timestr = otr.strftime("It is %T in the year %Y", 0) + print("L: " .. topic .. " -> " .. _type) + file:write(timestr .. " " .. topic .. " lat=" .. data['lat'] .. data['addr'] .. "\n") +end diff --git a/hooks.c b/hooks.c new file mode 100644 index 0000000..91fda0d --- /dev/null +++ b/hooks.c @@ -0,0 +1,201 @@ +#include +#include +#include +#include + +#ifdef WITH_LUA +# include +# include +# include "util.h" +# include "udata.h" +# include "hooks.h" +# include "lua.h" +# include "lualib.h" +# include "lauxlib.h" +# include "json.h" +# include "version.h" + +static int otr_log(lua_State *lua); +static int otr_strftime(lua_State *lua); + +/* + * Invoke the function `name' in the Lua script, which _may_ return + * an integer that we, in turn, return to caller. + */ + +static int l_function(lua_State *L, char *name) +{ + int rc = 0; + + lua_getglobal(L, name); + if (lua_type(L, -1) != LUA_TFUNCTION) { + olog(LOG_ERR, "Cannot invoke Lua function %s: %s\n", name, lua_tostring(L, -1)); + rc = 1; + } else { + lua_call(L, 0, 1); + rc = (int)lua_tonumber(L, -1); + } + lua_settop(L, 0); + return (rc); +} + +struct luadata *hooks_init(char *script) +{ + struct luadata *ld; + int rc; + + if ((ld = malloc(sizeof(struct luadata))) == NULL) + return (NULL); + + ld->script = strdup(script); + // ld->L = lua_open(); + ld->L = luaL_newstate(); + luaL_openlibs(ld->L); + + /* + * Set up a global with some values. + */ + + lua_newtable(ld->L); + lua_pushstring(ld->L, VERSION); + lua_setfield(ld->L, -2, "version"); + + // lua_pushstring(ld->L, "/Users/jpm/Auto/projects/on-github/owntracks/recorder/lua"); + // lua_setfield(ld->L, -2, "luapath"); + + lua_pushcfunction(ld->L, otr_log); + lua_setfield(ld->L, -2, "log"); + + lua_pushcfunction(ld->L, otr_strftime); + lua_setfield(ld->L, -2, "strftime"); + + lua_setglobal(ld->L, "otr"); + + + olog(LOG_DEBUG, "initializing Lua hooks at %s", script); + + /* Load the Lua script */ + if (luaL_dofile(ld->L, ld->script)) { + olog(LOG_ERR, "Cannot load Lua from %s: %s", ld->script, lua_tostring(ld->L, -1)); + hooks_exit(ld, "failed to load script"); + return (NULL); + } + + rc = l_function(ld->L, "otr_init"); + if (rc != 0) { + + /* + * After all this work (sigh), the Script has decided we shouldn't use + * hooks, so unload the whole Lua stuff and NULL back. + */ + + hooks_exit(ld, "otr_init() returned non-zero"); + ld = NULL; + } + + + return (ld); +} + +void hooks_exit(struct luadata *ld, char *reason) +{ + if (ld) { + olog(LOG_NOTICE, "unloading Lua: %s", reason); + free(ld->script); + lua_close(ld->L); + free(ld); + } +} + +void hooks_hook(struct udata *ud, char *topic, JsonNode *fullo) +{ + struct luadata *ld = ud->luadata; + char *_type = "unknown"; + JsonNode *j; + + lua_getglobal(ld->L, "otr_hook"); + if (lua_type(ld->L, -1) != LUA_TFUNCTION) { + olog(LOG_NOTICE, "cannot invoke otr_hook in Lua script"); + return; + } + + lua_pushstring(ld->L, topic); /* arg1: topic */ + + if ((j = json_find_member(fullo, "_type")) != NULL) { + if (j->tag == JSON_STRING) + _type = j->string_; + } + + lua_pushstring(ld->L, _type); /* arg2: record type */ + + lua_newtable(ld->L); /* arg3: table */ + json_foreach(j, fullo) { + lua_pushstring(ld->L, j->key); /* table key */ + if (j->tag == JSON_STRING) { + lua_pushstring(ld->L, j->string_); + } else if (j->tag == JSON_NUMBER) { + lua_pushnumber(ld->L, j->number_); + } else if (j->tag == JSON_NULL) { + lua_pushnil(ld->L); + } else if (j->tag == JSON_BOOL) { + lua_pushboolean(ld->L, j->bool_); + } + lua_rawset(ld->L, -3); + + } + + /* Invoke `hook' function in Lua with our args */ + if (lua_pcall(ld->L, 3, 1, 0)) { + olog(LOG_ERR, "Failed to run script: %s", lua_tostring(ld->L, -1)); + exit(1); + } + + // rc = (int)lua_tonumber(ld->L, -1); + // printf("C: FILTER returns %d\n", rc); +} + +/* + * --- Here come the functions we provide to Lua scripts. + */ + +static int otr_log(lua_State *lua) +{ + const char *str; + + if (lua_gettop(lua) >= 1) { + str = lua_tostring(lua, 1); + olog(LOG_INFO, "%s", str); + } + return 0; +} + +/* + * otr.strftime(format, seconds) + * Perform a strtime(3) for Lua with the specified format and + * seconds, and return the string result to Lua. As a special + * case, if `seconds' is negative, use current time. + */ + +static int otr_strftime(lua_State *lua) +{ + const char *fmt; + long secs; + struct tm *tm; + char buf[BUFSIZ]; + + if (lua_gettop(lua) >= 1) { + fmt = lua_tostring(lua, 1); + if ((secs = lua_tonumber(lua, 2)) < 1) + secs = time(0); + + if ((tm = gmtime(&secs)) != NULL) { + strftime(buf, sizeof(buf), fmt, tm); + + lua_pushstring(lua, buf); + return (1); + } + } + return (0); +} + +#endif /* WITH_LUA */ diff --git a/hooks.h b/hooks.h new file mode 100644 index 0000000..0c6acfe --- /dev/null +++ b/hooks.h @@ -0,0 +1,19 @@ +#ifndef HOOKS_H_INCLUDED +# define HOOKS_H_INCLUDED + +#ifdef WITH_LUA +# include "lua.h" + + +struct luadata { + char *script; /* Path to Lua script in --lua-script */ + lua_State *L; /* The Lua machine */ +}; + +struct luadata *hooks_init(char *luascript); +void hooks_exit(struct luadata *, char *reason); +void hooks_hook(struct udata *ud, char *topic, JsonNode *obj); + +#endif /* WITH_LUA */ + +#endif diff --git a/ocat.c b/ocat.c index b050f9f..b056139 100644 --- a/ocat.c +++ b/ocat.c @@ -173,6 +173,9 @@ void print_versioninfo() #ifdef HAVE_LMDB printf("\tHAVE_LMDB = yes\n"); #endif +#ifdef WITH_LUA + printf("\tWITH_LUA = yes\n"); +#endif #ifdef HAVE_HTTP printf("\tHAVE_HTTP = yes\n"); #endif diff --git a/ot-recorder.c b/ot-recorder.c index 079bef3..55909a1 100644 --- a/ot-recorder.c +++ b/ot-recorder.c @@ -45,6 +45,9 @@ #ifdef HAVE_HTTP # include "http.h" #endif +#ifdef WITH_LUA +# include "hooks.h" +#endif #define SSL_VERIFY_PEER (1) @@ -357,7 +360,7 @@ JsonNode *csv(char *payload, char *tid, char *t, double *lat, double *lon, long void on_message(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *m) { - JsonNode *json; + JsonNode *json, *fullo, *geo = NULL; char tid[BUFSIZ], t[BUFSIZ], *p; double lat, lon; long tst; @@ -510,7 +513,7 @@ void on_message(struct mosquitto *mosq, void *userdata, const struct mosquitto_m cached = FALSE; if (ud->revgeo == TRUE) { - JsonNode *geo, *j; + JsonNode *j; if ((geo = gcache_json_get(ud->gc, utstring_body(ghash))) != NULL) { /* Habemus cached data */ @@ -523,11 +526,9 @@ void on_message(struct mosquitto *mosq, void *userdata, const struct mosquitto_m if ((j = json_find_member(geo, "addr")) != NULL) { utstring_printf(addr, "%s", j->string_); } - json_delete(geo); } else { if ((geo = revgeo(lat, lon, addr, cc)) != NULL) { gcache_json_put(ud->gc, utstring_body(ghash), geo); - json_delete(geo); } else { /* We didn't obtain reverse Geo, maybe because of over * quota; make a note of the missing geohash */ @@ -551,45 +552,51 @@ void on_message(struct mosquitto *mosq, void *userdata, const struct mosquitto_m * We have exactly three topic parts (owntracks/user/device), and valid JSON. */ + /* + * Create a new location object containing all the bits and + * pieces we need and push that into connected Websockets. + * and/or into Lua hooks. + */ + + fullo = json_mkobject(); + json_copy_to_object(fullo, json, TRUE); + + if (geo != NULL) { + json_copy_to_object(fullo, geo, FALSE); + json_delete(geo); + } + + /* + * I need a unique "key" in the Websocket clients to keep track + * of which device is being updated; use topic. + */ + + json_append_member(fullo, "topic", json_mkstring(m->topic)); + + /* + * We have to know which user/device this is for in order to + * determine whether a connected Websocket client is authorized + * to see this. Add user/device + */ + + json_append_member(fullo, "user", json_mkstring(utstring_body(username))); + json_append_member(fullo, "device", json_mkstring(utstring_body(device))); + #ifdef HAVE_HTTP if (ud->mgserver && !pingping) { - /* - * Create a new location object containing all the bits and - * pieces we need and push that into connected Websockets. - * TODO: clean up - */ - JsonNode *geo, *wso = json_mkobject(); - - json_copy_to_object(wso, json, TRUE); - - if ((geo = gcache_json_get(ud->gc, utstring_body(ghash))) != NULL) { - json_copy_to_object(wso, geo, FALSE); - json_delete(geo); - } - - /* - * I need a unique "key" in the Websocket clients to keep track - * of which device is being updated; use topic. - */ - - json_append_member(wso, "topic", json_mkstring(m->topic)); - - /* - * We have to know which user/device this is for in order to - * determine whether a connected Websocket client is authorized - * to see this. Add user/device - */ - - json_append_member(wso, "user", json_mkstring(utstring_body(username))); - json_append_member(wso, "device", json_mkstring(utstring_body(device))); - - http_ws_push_json(ud->mgserver, wso); - json_delete(wso); + http_ws_push_json(ud->mgserver, fullo); } #endif +#ifdef WITH_LUA + if (ud->luadata && !pingping) { + hooks_hook(ud, m->topic, fullo); + } +#endif + json_delete(fullo); + if ((jsonstring = json_stringify(json, NULL)) != NULL) { char *js; @@ -685,8 +692,7 @@ void on_disconnect(struct mosquitto *mosq, void *userdata, int reason) static void catcher(int sig) { fprintf(stderr, "Going down on signal %d\n", sig); - - exit(1); + run = 0; } void usage(char *prog) @@ -707,6 +713,9 @@ void usage(char *prog) printf(" --http-host HTTP addr to bind to (localhost)\n"); printf(" --http-port -A HTTP port (8083); 0 to disable HTTP\n"); printf(" --doc-root document root (%s)\n", DOCROOT); +#endif +#ifdef WITH_LUA + printf(" --lua-script path to Lua script. If unset, no Lua hooks\n"); #endif printf(" --precision ghash precision (dflt: %d)\n", GHASHPREC); printf(" --hosted use OwnTracks Hosted\n"); @@ -732,6 +741,9 @@ int main(int argc, char **argv) struct mosquitto *mosq = NULL; char err[1024], *p, *username, *password, *cafile, *device; char *hostname = "localhost", *logfacility = "local0"; +#ifdef WITH_LUA + char *luascript = NULL; +#endif int port = 1883; int rc, i, ch, hosted = FALSE; static struct udata udata, *ud = &udata; @@ -755,6 +767,9 @@ int main(int argc, char **argv) #ifdef HAVE_HTTP udata.mgserver = NULL; #endif +#ifdef WITH_LUA + udata.luadata = NULL; +#endif if ((p = getenv("OTR_HOST")) != NULL) { hostname = strdup(p); @@ -790,6 +805,9 @@ int main(int argc, char **argv) { "logfacility", required_argument, 0, 4}, { "precision", required_argument, 0, 5}, { "hosted", no_argument, 0, 6}, +#ifdef WITH_LUA + { "lua-script", required_argument, 0, 7}, +#endif #ifdef HAVE_HTTP { "http-host", required_argument, 0, 3}, { "http-port", required_argument, 0, 'A'}, @@ -804,6 +822,12 @@ int main(int argc, char **argv) break; switch (ch) { +#ifdef WITH_LUA + case 7: + /* FIXME: check existence of script file */ + luascript = strdup(optarg); + break; +#endif case 6: hosted = TRUE; break; @@ -911,6 +935,18 @@ int main(int argc, char **argv) openlog("ot-recorder", LOG_PID | LOG_PERROR, syslog_facility_code(logfacility)); +#ifdef WITH_LUA + /* + * If option for lua-script has not been given, ignore all hooks. + */ + + if (luascript) { + if ((udata.luadata = hooks_init(luascript)) == NULL) + olog(LOG_NOTICE, "proceeding sans Lua"); + free(luascript); + } +#endif + #ifdef HAVE_HTTP if (http_port) { if (!is_directory(doc_root)) { @@ -946,6 +982,7 @@ int main(int argc, char **argv) signal(SIGINT, catcher); + signal(SIGTERM, catcher); mosq = mosquitto_new(utstring_body(clientid), CLEAN_SESSION, (void *)&udata); if (!mosq) { @@ -1059,6 +1096,10 @@ int main(int argc, char **argv) #ifdef HAVE_HTTP mg_destroy_server(&udata.mgserver); #endif + +#ifdef WITH_LUA + hooks_exit(ud->luadata, "recorder stops"); +#endif mosquitto_disconnect(mosq); mosquitto_destroy(mosq); diff --git a/udata.h b/udata.h index 6206ad1..6c23a35 100644 --- a/udata.h +++ b/udata.h @@ -24,6 +24,9 @@ struct udata { #ifdef HAVE_HTTP struct mg_server *mgserver; /* Mongoose */ #endif +#ifdef WITH_LUA + struct luadata *luadata; /* Lua stuff */ +#endif }; #endif