diff mbox series

[6/7] suricatta/lua: General HTTP Server example suricatta Lua module

Message ID 20220602091707.53883-1-christian.storm@siemens.com
State Accepted
Delegated to: Stefano Babic
Headers show
Series [1/7] channel_curl: Map response code for file:// protocol | expand

Commit Message

Storm, Christian June 2, 2022, 9:17 a.m. UTC
The example suricatta Lua module implementing support for a server
mock implementation modeled after the "General Purpose HTTP Server"
showcases how a suricatta Lua module could look like.

It is documented extensively supporting and extending the suricatta
Lua module documentation.

Signed-off-by: Christian Storm <christian.storm@siemens.com>
---
 examples/suricatta/server_general.py      | 190 +++++++
 examples/suricatta/swupdate_suricatta.lua | 613 ++++++++++++++++++++++
 2 files changed, 803 insertions(+)
 create mode 100755 examples/suricatta/server_general.py
 create mode 100644 examples/suricatta/swupdate_suricatta.lua
diff mbox series

Patch

diff --git a/examples/suricatta/server_general.py b/examples/suricatta/server_general.py
new file mode 100755
index 0000000..17c0455
--- /dev/null
+++ b/examples/suricatta/server_general.py
@@ -0,0 +1,190 @@ 
+#!/usr/bin/python -B
+#
+# Author: Christian Storm <christian.storm@siemens.com>
+# Copyright (C) 2022, Siemens AG
+#
+# SPDX-License-Identifier:     GPL-2.0-or-later
+
+"""
+Suricatta General HTTP Server Mock
+
+Server mock implementation modeled after the 'General Purpose HTTP Server'.
+"""
+
+import os
+import sys
+import argparse
+import hashlib
+import pathlib
+import logging
+
+try:
+    import bottle
+except ModuleNotFoundError:
+    server_dir = os.path.dirname(os.path.abspath(__file__))
+    sys.exit(f"Install bottle, e.g., 'cd {server_dir}; wget http://bottlepy.org/bottle.py'")
+
+BOTTLE_SCHEME = "http"
+RETRY_BUSY = 10
+
+
+class server(object):
+    firmware_dir = None
+    url = None
+    debug = False
+
+
+def log(message, loglevel=logging.INFO):
+    logcolor = {
+        logging.ERROR: 31,
+        logging.WARN: 33,
+        logging.INFO: 32,
+        logging.DEBUG: 30,
+    }
+    loglevel = logging.ERROR if str(message).startswith(("400", "500")) else loglevel
+    color = logcolor.get(loglevel, 1)
+    print(f"\033[{color}m{message}\033[0m")
+    return message
+
+
+def logresult(func):
+    def decorator(*args, **kwargs):
+        return log(func(*args, **kwargs))
+
+    return decorator
+
+
+def extract_device_name(query):
+    device_name = bottle.request.headers.get("name")
+    if device_name is None and len(query) > 0:
+        device_name = "".join(ch for ch in "".join(map("".join, sorted(query.items()))) if ch.isalnum())
+    return device_name
+
+
+@bottle.error(500)
+@logresult
+def error500(error):
+    bottle.response.set_header("Content-Type", "text/plain; charset=utf-8")
+    bottle.response.status = "500 Oops."
+    return bottle.response.status
+
+
+@bottle.error(404)
+@logresult
+def error404(error):
+    bottle.response.set_header("Content-Type", "text/plain; charset=utf-8")
+    bottle.response.status = "404 Not found."
+    return bottle.response.status
+
+
+@bottle.route("/log", method="PUT")
+@logresult
+def route_log():
+    bottle.response.set_header("Content-Type", "text/plain; charset=utf-8")
+    bottle.response.status = "200 Log received."
+    log(">>> Log received: {}".format((bottle.request.body.read()).decode("UTF-8")))
+    return bottle.response.status
+
+
+@bottle.route("/", method="GET")
+@logresult
+def route_main():
+    if server.debug:
+        headers = ["{}: {}".format(h, bottle.request.headers.get(h)) for h in bottle.request.headers.keys()]
+        log(
+            ">>> {} {}\n{}".format(bottle.request.method, bottle.request.url, "\n".join(headers)),
+            logging.DEBUG,
+        )
+
+    bottle.response.set_header("Content-Type", "text/plain; charset=utf-8")
+
+    device_name = extract_device_name(bottle.request.query)
+    if device_name is None:
+        bottle.response.status = "400 HTTP Header 'name' or 'identify' cfg section error."
+        return bottle.response.status
+
+    firmware = os.path.join(server.firmware_dir, device_name)
+    firmware_relative_path = pathlib.Path(firmware).relative_to(os.getcwd())
+    if not os.path.isfile(firmware) or not os.access(firmware, os.R_OK):
+        log(f">>> Place firmware file at '{firmware_relative_path}' to update device {device_name}.")
+        # Send back the client name ID served just for the client's information.
+        bottle.response.set_header("Served-Client", device_name)
+        bottle.response.status = f"404 No update is available for {device_name}"
+        return bottle.response.status
+    log(f">>> Firmware file found at {firmware_relative_path}.")
+
+    try:
+        with open(firmware, "rb") as file:
+            bottle.response.set_header("Content-Md5", hashlib.md5(file.read()).hexdigest())
+    except FileNotFoundError:
+        bottle.response.status = f"500 Firmware file not found: {firmware_relative_path}"
+        return bottle.response.status
+
+    bottle.response.status = "302 Update available."
+    bottle.response.set_header("Location", f"{server.url}/firmware/{device_name}")
+    return bottle.response.status
+
+
+@bottle.route("/firmware/<filepath:path>")
+def route_download(filepath):
+    if bottle.request.method == "GET":
+        log(f">>> Serving firmware file: {filepath}")
+    return bottle.static_file(filepath, root=server.firmware_dir)
+
+
+def runserver():
+    parser = argparse.ArgumentParser(
+        add_help=True,
+        description="""SWUpdate Suricatta 'General HTTP Server' Mock.""",
+        epilog="""""",
+    )
+    parser.add_argument(
+        "-x",
+        metavar="BOTTLE_HOST",
+        dest="BOTTLE_HOST",
+        help="host to bind to (default: %(default)s)",
+        default="localhost",
+    )
+    parser.add_argument(
+        "-p",
+        metavar="BOTTLE_PORT",
+        dest="BOTTLE_PORT",
+        help="port to bind to (default: %(default)s)",
+        default="8080",
+    )
+    parser.add_argument(
+        "-d",
+        dest="debug",
+        help="Enable debug logging",
+        default=False,
+        action="store_true",
+    )
+    parser.add_argument(
+        "-f",
+        metavar="FIRMWARE_DIR",
+        dest="FIRMWARE_DIR",
+        help="path to firmware files" " directory (default: %(default)s)",
+        default=os.path.join(os.getcwd(), "firmwares"),
+    )
+    args = parser.parse_args()
+
+    global server
+    server.debug = args.debug
+    server.url = f"{BOTTLE_SCHEME}://{args.BOTTLE_HOST}:{args.BOTTLE_PORT}"
+    server.firmware_dir = args.FIRMWARE_DIR
+
+    if not pathlib.Path(server.firmware_dir).is_dir():
+        sys.exit(f"Path {server.firmware_dir} is not an existing directory.")
+
+    log(f"Debug logging: {server.debug}.", logging.INFO)
+    log(f"Serving firmware files from {server.firmware_dir}")
+    bottle.run(host=args.BOTTLE_HOST, port=args.BOTTLE_PORT, debug=False)
+
+
+if __name__ == "__main__":
+    try:
+        runserver()
+    except OSError as e:
+        if e.errno != 98:
+            raise
+        print("ERROR: Address already in use, server already running?")
diff --git a/examples/suricatta/swupdate_suricatta.lua b/examples/suricatta/swupdate_suricatta.lua
new file mode 100644
index 0000000..75a35a8
--- /dev/null
+++ b/examples/suricatta/swupdate_suricatta.lua
@@ -0,0 +1,613 @@ 
+--[[
+
+    SWUpdate Suricatta Example Lua Module for the 'General Purpose HTTP Server'.
+
+    Author: Christian Storm <christian.storm@siemens.com>
+    Copyright (C) 2022, Siemens AG
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+--]]
+
+--luacheck: no max line length
+
+local suricatta = require("suricatta")
+
+--[[ >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ]]
+--[[ Library Functions                                                         ]]
+--[[ <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< ]]
+
+--- Simplistic getopt()
+--
+-- Only short options (one dash, one character) are supported.
+-- Unknown options are (silently) ignored.
+-- If an option's required argument is missing, (':', option) is returned.
+--
+--- @param  argv       string  Integer-keyed arguments table
+--- @param  optstring  string  GETOPT(3)-like option string
+--- @return function           # Iterator, returning the next (option, optarg) pair
+function getopt(argv, optstring)
+    if type(argv) ~= "table" or type(optstring) ~= "string" then
+        return function() end
+    end
+    for index, value in ipairs(argv) do
+        argv[value] = index
+    end
+    local f = string.gmatch(optstring, "%l:?")
+    return function()
+        while true do
+            local option = f()
+            if not option then
+                return
+            end
+            local optchar = option:sub(1, 1)
+            local param = "-" .. optchar
+            if argv[param] then
+                if option:sub(2, 2) ~= ":" then
+                    return optchar, nil
+                end
+                local value = argv[argv[param] + 1]
+                if not value then
+                    return ":", optchar
+                end
+                if value:sub(1, 1) == "-" then
+                    return ":", optchar
+                end
+                return optchar, value
+            end
+        end
+    end
+end
+
+
+--- Merge two tables' data into one.
+--
+--- @param  dest    table  Destination table, modified
+--- @param  source  table  Table to merge into dest, overruling `dest`'s data if existing
+--- @return table          # Merged table
+function table.merge(dest, source)
+    local function istable(t)
+        return type(t) == "table"
+    end
+    for k, v in pairs(source) do
+        if istable(v) and istable(dest[k]) then
+            table.merge(dest[k], v)
+        else
+            dest[k] = v
+        end
+    end
+    return dest
+end
+
+
+--- Escape and trim a String.
+--
+-- The default substitutions table is suitable for escaping
+-- to proper JSON.
+--
+--- @param  str      string                The JSON string to be escaped
+--- @param  substs?  table<string,string>  Substitutions to apply
+--- @return string                         # The escaped JSON string
+function escape(str, substs)
+    local escapes = '[%z\1-\31"\\]'
+    if not substs then
+        substs = {
+            ['"'] = '"',
+            ["\\"] = "\\\\",
+            ["\b"] = "",
+            ["\f"] = "",
+            ["\n"] = "",
+            ["\r"] = "",
+            ["\t"] = "",
+        }
+    end
+    substs.__index = function(_, c)
+        return string.format("\\u00%02X", string.byte(c))
+    end
+    setmetatable(substs, substs)
+    return ((string.gsub(str, escapes, substs)):match("^%s*(.-)%s*$"):gsub("%s+", " "))
+end
+
+
+--[[ >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ]]
+--[[ Suricatta General Purpose HTTP Server Module                              ]]
+--[[ <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< ]]
+
+--- Device state and information.
+--
+--- @class device
+--- @field pstate  suricatta.pstate  Persistent state ID number
+--- @field id      string            Device ID
+device = {
+    pstate = nil,
+    id     = nil
+}
+
+
+--- Job type "enum".
+--
+--- @class job.type
+--- @type table<string, number>
+jobtype = {
+    INSTALL = 1,
+    DOWNLOAD = 2,
+    BOTH = 3,
+}
+
+
+--- A Job.
+--
+--- @class job
+--- @field md5  string    MD5 sum of the artifact
+--- @field url  string    URL of the artifact
+--- @field typ  job.type  Type of job
+
+
+--- Configuration for the General Purpose HTTP Server.
+--
+--- @class gs
+--- @field channel         table<string, channel>     Channels used in this module
+--- @field channel_config  suricatta.channel.options  Channel options (defaults, shadowed by config file, shadowed by command line arguments)
+--- @field job             job                        Current job information
+--- @field polldelay       table                      Default and temporary delay between two server poll operations in seconds
+gs = {
+    channel        = {},
+    channel_config = {},
+    job            = {},
+    polldelay      = { default = 0, current = 0 },
+}
+
+
+--- Query and handle pending actions on server.
+--
+-- Lua counterpart of `server_op_res_t server_has_pending_action(int *action_id)`.
+--
+-- The suricatta.status return values UPDATE_AVAILABLE and ID_REQUESTED
+-- are handled in `suricatta/suricatta.c`, the others result in SWUpdate
+-- sleeping again.
+--
+--- @param  action_id  number  Current Action ID [unused]
+--- @return number             # Action ID [optional]
+--- @return suricatta.status   # Suricatta return code
+function has_pending_action(action_id)
+    action_id = action_id
+    gs.polldelay.current = gs.polldelay.default
+    local _, pstate = suricatta.pstate.get()
+    if pstate == suricatta.pstate.INSTALLED then
+        suricatta.notify.warn("An installed update is pending testing, not querying server.")
+        return suricatta.status.NO_UPDATE_AVAILABLE
+    end
+
+    suricatta.notify.trace("Querying %q", gs.channel_config.url)
+    local _, _, data = gs.channel.default.get({
+        url = gs.channel_config.url,
+        format = suricatta.channel.content.NONE,
+        nocheckanswer = true, -- Don't print 404 ERROR() message, see comment below.
+        headers_to_send = { ["name"] = device.id },
+    })
+
+    if data.http_response_code == 404 then
+        -- Note: Returning 404 on no update available is a bit unfortunate
+        -- printing an error message if `nocheckanswer` isn't true.
+        -- Maybe '204 No Content' as not repurposing a general error condition
+        -- would've been a better choice. Then, 204 has to be introduced in
+        -- channel_map_http_code() as known though.
+        if math.random(3) == 1 then
+            -- Deliberately update the server from time to time with my health status.
+            suricatta.notify.trace(
+                "Server queries client data for: %s",
+                data.received_headers["Served-Client"] or "<Unknown>"
+            )
+            return suricatta.status.ID_REQUESTED
+        end
+        suricatta.notify.trace("Server served request for: %s", data.received_headers["Served-Client"] or "<Unknown>")
+        return suricatta.status.NO_UPDATE_AVAILABLE
+    end
+
+    if data.http_response_code == 503 then
+        -- Server is busy serving updates to another client.
+        -- Try again after seconds announced in HTTP header or default value.
+        gs.polldelay.current = tonumber(data.received_headers["Retry-After"]) or gs.polldelay.default
+        suricatta.notify.debug("Server busy, waiting for %ds.", gs.polldelay.current)
+        return suricatta.status.NO_UPDATE_AVAILABLE
+    end
+
+    if data.http_response_code == 302 then
+        -- Returning 302 is, like 404 above, also a bit unfortunate as it
+        -- requires telling curl not to follow the redirection but instead
+        -- treat this as the artifact's URL (see channel initialization
+        -- in server_start()).
+        suricatta.notify.info("Update available, update job enqueued.")
+        gs.job.md5 = data.received_headers["Content-Md5"]
+        gs.job.url = data.received_headers["Location"]
+        return suricatta.status.UPDATE_AVAILABLE
+    end
+
+    suricatta.notify.trace("Unhandled HTTP status code %d.", data.http_response_code)
+    return suricatta.status.NO_UPDATE_AVAILABLE
+end
+suricatta.server.register(has_pending_action, suricatta.server.HAS_PENDING_ACTION)
+
+
+--- Callback to check for update cancellation on server while downloading.
+--
+-- Some servers, e.g., hawkBit, support (remote) update cancellation while
+-- the download phase. This (optional) callback function is registered
+-- as `channel_data_t`'s `dwlwrdata` function for this purpose.
+--
+-- Note: The CALLBACK_PROGRESS and CALLBACK_CHECK_CANCEL callback functions
+-- are both executed under mutual exclusion in suricatta's Lua state which
+-- is suspended in the call to suricatta.{install,download}(). While this is
+-- safe, both callback functions should take care not to starve each other.
+--
+-- WARNING: This function is called as part of the CURLOPT_WRITEFUNCTION
+-- callback as soon as there is data received, i.e., usually *MANY* times.
+-- Since this function and CALLBACK_PROGRESS are contending for the Lua
+-- state lock (and by extension for some others), register and use this
+-- function only if really necessary.
+--
+--- @return suricatta.status  # OK to continue downloading, UPDATE_CANCELED to cancel
+function check_cancel_callback()
+    return suricatta.status.OK
+end
+-- suricatta.server.register(check_cancel_callback, suricatta.server.CALLBACK_CHECK_CANCEL)
+
+
+--- Lua equivalent of `sourcetype` as in `include/swupdate_status.h`.
+--
+--- @type table<string, number>
+--- @class sourcetype
+--- @field SOURCE_UNKNOWN            number  0
+--- @field SOURCE_WEBSERVER          number  1
+--- @field SOURCE_SURICATTA          number  2
+--- @field SOURCE_DOWNLOADER         number  3
+--- @field SOURCE_LOCAL              number  4
+--- @field SOURCE_CHUNKS_DOWNLOADER  number  5
+
+
+--- Lua equivalent of `RECOVERY_STATUS` as in `include/swupdate_status.h`.
+--
+--- @type table<string, number>
+--- @class RECOVERY_STATUS
+--- @field IDLE        number  0
+--- @field START       number  1
+--- @field RUN         number  2
+--- @field SUCCESS     number  3
+--- @field FAILURE     number  4
+--- @field DOWNLOAD    number  5
+--- @field DONE        number  6
+--- @field SUBPROCESS  number  7
+--- @field PROGRESS    number  8
+
+
+--- Lua equivalent of `progress_msg` as in `include/progress_ipc.h`.
+--
+--- @class progress_msg
+--- @field magic        number           SWUpdate IPC magic number
+--- @field status       RECOVERY_STATUS  Update status
+--- @field dwl_percent  number           Percent of downloaded data
+--- @field nsteps       number           Total steps count
+--- @field cur_step     number           Current step
+--- @field cur_percent  number           Percent in current step
+--- @field cur_image    string           Name of the current image to be installed
+--- @field hnd_name     string           Name of the running handler
+--- @field source       sourcetype       The source that has triggered the update
+--- @field info         string           Additional information about the installation
+--- @field jsoninfo     table            If `info` is JSON, according Lua Table
+
+
+--- Progress thread callback handling progress reporting to remote.
+--
+-- Deliberately just uploading the JSON content while not respecting
+-- the log configuration of the real `server_general.c` implementation.
+-- This callback function is optional.
+--
+-- Note: The CALLBACK_PROGRESS and CALLBACK_CHECK_CANCEL callback functions
+-- are both executed under mutual exclusion in suricatta's Lua state which
+-- is suspended in the call to suricatta.{install,download}(). While this is
+-- safe, both callback functions should take care not to starve each other.
+--
+--- @param  message  progress_msg  The progress message
+--- @return suricatta.status       # Suricatta return code
+function progress_callback(message)
+    if not gs.channel.progress then
+        return suricatta.status.OK
+    end
+    local logmessage
+    if message.dwl_percent > 0 and message.dwl_percent <= 100 and message.cur_step == 0 then
+        -- Rate limit progress messages sent to server.
+        if message.dwl_percent % 5 ~= 0 then
+            return suricatta.status.OK
+        end
+        if gs.job.typ == jobtype.INSTALL then
+            logmessage = escape(
+                string.format([[{"message": "File Processing...", "percent": %d}]], message.dwl_percent or 0)
+            )
+        else
+            logmessage = escape(
+                string.format([[{"message": "Downloading...", "percent": %d}]], message.dwl_percent or 0)
+            )
+        end
+    elseif message.dwl_percent == 100 and message.cur_step > 0 then
+        -- Rate limit progress messages sent to server.
+        if message.cur_percent % 5 ~= 0 then
+            return suricatta.status.OK
+        end
+        logmessage = escape(
+            string.format(
+                [[{"message": "Installing artifact %d/%d: '%s' with '%s'...", "percent": %d}]],
+                message.cur_step,
+                message.nsteps or 0,
+                message.cur_image or "<UNKNOWN>",
+                message.hnd_name or "<UNKNOWN>",
+                message.cur_percent or 0
+            )
+        )
+    end
+    if logmessage ~= nil then
+        local res, _, data = gs.channel.progress.put({
+            url = string.format("%s/%s", gs.channel_config.url, "log"),
+            content_type = "application/json",
+            method = suricatta.channel.method.PUT,
+            format = suricatta.channel.content.NONE,
+            request_body = logmessage,
+        })
+        if not res then
+            suricatta.notify.error("HTTP Error %d while uploading log.", tonumber(data.http_response_code) or -1)
+        end
+    end
+    return suricatta.status.OK
+end
+suricatta.server.register(progress_callback, suricatta.server.CALLBACK_PROGRESS)
+
+
+--- Install an update.
+--
+-- Lua counterpart of `server_op_res_t server_install_update(void)`.
+--
+--- @return suricatta.status  # Suricatta return code
+function install_update()
+    local res
+
+    suricatta.notify.info("Installing artifact from %q", gs.job.url)
+
+    -- Open progress reporting channel with default options.
+    res, gs.channel.progress = suricatta.channel.open({})
+    if not res then
+        suricatta.notify.error("Cannot initialize progress reporting channel, will not send progress.")
+        gs.channel.progress = nil
+    end
+
+    -- Chose an installation mode as I please...
+    -- Note: `drain_messages` is false, i.e., do not upload strictly all
+    -- progress messages to not artificially lengthen the installation
+    -- process due to progress message network I/O. Hence, only while
+    -- the update operation is in flight, progress messages are offloaded.
+    -- Once the operation has finished, the possibly remaining progress
+    -- messages are discarded. Instead of all progress messages, send a
+    -- final notification for the server's information.
+    res = suricatta.status.OK
+    local url = gs.job.url
+    local destfile
+    if math.random(2) == 2 then
+        suricatta.notify.info(">> Running in download + installation mode.")
+    else
+        suricatta.notify.info(">> Running in download and then local installation mode.")
+        -- Note: suricatta.get_tmpdir() returned path is '/' terminated.
+        destfile = ("%s%s"):format(suricatta.get_tmpdir(), "update.swu")
+        gs.job.typ = jobtype.DOWNLOAD
+        res, _, _ = suricatta.download({ channel = gs.channel.default, url = url, drain_messages = false }, destfile)
+        url = ("file://%s"):format(destfile)
+        gs.job.typ = jobtype.INSTALL
+        if not res then
+            suricatta.notify.error("Error downloading artifact!")
+        end
+    end
+    if res then
+        if not gs.job.typ then
+            gs.job.typ = jobtype.BOTH
+        end
+        res, _, _ = suricatta.install({ channel = gs.channel.default, url = url, drain_messages = false })
+        if destfile then
+            os.remove(destfile)
+        end
+    end
+
+    if gs.channel.progress then
+        local finres, _, findata = gs.channel.progress.put({
+            url = string.format("%s/%s", gs.channel_config.url, "log"),
+            content_type = "application/json",
+            method = suricatta.channel.method.PUT,
+            format = suricatta.channel.content.NONE,
+            request_body = escape([[{"message": "Final Note: Installation done :)"}]]),
+        })
+        if not finres then
+            suricatta.notify.error(
+                "HTTP Error %d while uploading final notification log.",
+                tonumber(findata.http_response_code) or -1
+            )
+        end
+        gs.channel.progress.close()
+        gs.channel.progress = nil
+    end
+
+    gs.job = {}
+
+    if not res then
+        suricatta.notify.error("Error installing artifact!")
+        return suricatta.status.EERR
+    end
+
+    suricatta.notify.info("Update artifact installed successfully.")
+    return suricatta.status.OK
+end
+suricatta.server.register(install_update, suricatta.server.INSTALL_UPDATE)
+
+
+--- Print the help text.
+--
+-- Lua counterpart of `void server_print_help(void)`.
+--
+--- @param  defaults  suricatta.channel.options  Compile-time channel default options ∪ { polldelay = CHANNEL_DEFAULT_POLLING_INTERVAL }
+--- @return suricatta.status                     # Suricatta return code
+function print_help(defaults)
+    defaults = defaults or {}
+    io.stdout:write(string.format("\t  -u <URL>          * URL to the server instance, e.g., http://localhost:8080\n"))
+    io.stdout:write(string.format("\t  -i <string>       * The device ID.\n"))
+    io.stdout:write(string.format("\t  -p <int>          Polling delay (default: %ds).\n", defaults.polldelay))
+    return suricatta.status.OK
+end
+suricatta.server.register(print_help, suricatta.server.PRINT_HELP)
+
+
+--- Start the Suricatta server.
+--
+-- Lua counterpart of `server_op_res_t server_start(char *fname, int argc, char *argv[])`.
+--
+--- @param  defaults  table<string, any>  Lua suricatta module channel default options
+--- @param  argv      table[]             C's `argv` as Lua Table
+--- @param  fconfig   table<string, any>  SWUpdate configuration file's [suricatta] section as Lua Table ∪ { polldelay = CHANNEL_DEFAULT_POLLING_INTERVAL }
+--- @return suricatta.status  # Suricatta return code
+function server_start(defaults, argv, fconfig)
+    -- Use defaults,
+    -- ... shadowed by configuration file values,
+    -- ... shadowed by command line arguments.
+    local configuration = defaults or {}
+    table.merge(configuration, fconfig or {})
+    device.id, configuration.id = configuration.id, nil
+    gs.polldelay.default, configuration.polldelay = configuration.polldelay, nil
+    gs.channel_config = configuration
+    for opt, arg in getopt(argv or {}, "u:i:p:") do
+        if opt == "u" then
+            gs.channel_config.url = tostring(arg)
+        elseif opt == "i" then
+            device.id = tostring(arg)
+        elseif opt == "p" then
+            gs.polldelay.default = tonumber(arg)
+        elseif opt == ":" then
+            io.stderr:write("Missing argument.")
+            print_help(defaults)
+            return suricatta.status.EINIT
+        end
+    end
+    gs.polldelay.current = gs.polldelay.default
+
+    if not gs.channel_config.url or not device.id then
+        suricatta.notify.error("Mandatory configuration parameter missing.")
+        return suricatta.status.EINIT
+    end
+
+    local res
+    res, gs.channel.default = suricatta.channel.open({ url = gs.channel_config.url, nofollow = true })
+    if not res then
+        suricatta.notify.error("Cannot initialize channel.")
+        return suricatta.status.EINIT
+    end
+
+    suricatta.notify.info("Running with device ID %s on %q.", device.id, gs.channel_config.url)
+    return suricatta.status.OK
+end
+suricatta.server.register(server_start, suricatta.server.SERVER_START)
+
+
+--- Stop the Suricatta server.
+--
+-- Lua counterpart of `server_op_res_t server_stop(void)`.
+--
+--- @return suricatta.status  # Suricatta return code
+function server_stop()
+    gs.channel_config = {}
+    for channel, _ in pairs(gs.channel) do
+        gs.channel[channel].close()
+        gs.channel[channel] = {}
+    end
+    return suricatta.status.OK
+end
+suricatta.server.register(server_stop, suricatta.server.SERVER_STOP)
+
+
+--- Query the polling interval from remote.
+--
+-- Lua counterpart of `unsigned int server_get_polling_interval(void)`.
+--
+--- @return number  # Polling interval in seconds
+function get_polling_interval()
+    -- Not implemented at server side, hence return device-local polling
+    -- interval that is configurable via IPC or the server-announced wait
+    -- time after having received a 503: busy while serving another client.
+    return gs.polldelay.current
+end
+suricatta.server.register(get_polling_interval, suricatta.server.GET_POLLING_INTERVAL)
+
+
+--- Send device configuration/data to remote.
+--
+-- Lua counterpart of `server_op_res_t server_send_target_data(void)`.
+--
+--- @return suricatta.status  # Suricatta return code
+function send_target_data()
+    local res, _, data = gs.channel.default.put({
+        url = string.format("%s/%s", gs.channel_config.url, "log"),
+        content_type = "application/json",
+        method = suricatta.channel.method.PUT,
+        format = suricatta.channel.content.NONE,
+        request_body = string.format([[{"message": "I'm %s and I'm fine."}]], tostring(device.id)),
+    })
+    if not res then
+        suricatta.notify.error("HTTP Error %d while uploading target data.", tonumber(data.http_response_code) or -1)
+    end
+    return suricatta.status.OK
+end
+suricatta.server.register(send_target_data, suricatta.server.SEND_TARGET_DATA)
+
+
+--- Lua-alike of `ipc_message` as in `include/network_ipc.h`
+--
+-- Note: Some members are deliberately not passed through to the Lua realm
+-- such as `ipc_message.data.len` as that's handled by the C-to-Lua bridge
+-- transparently.
+-- Also, this is not a direct equivalent as, e.g., the `json` field is not
+-- present in `struct ipc_message`, but rather it's a "sensible" selection.
+-- As another example, CMD_ENABLE is also not passed through and hence not
+-- in `ipc_commands` as it's handled directly in `suricatta/suricatta.c`.
+--
+--- @type  table<string, number>
+--- @class ipc_commands
+--- @field ACTIVATION  number  0
+--- @field CONFIG      number  1
+--- @field GET_STATUS  number  3
+--
+--- @class ipc_message
+--- @field magic     number        SWUpdate IPC magic number
+--- @field commands  ipc_commands  IPC commands
+--- @field cmd       number        Command number, one of `ipc_commands`'s values
+--- @field msg       string        String data sent via IPC
+--- @field json      string        If `msg` is JSON, JSON as Lua Table
+
+
+--- Handle IPC messages sent to Suricatta Lua module.
+--
+-- Lua counterpart of `server_op_res_t server_ipc(ipc_message *msg)`.
+--
+--- @param  message  ipc_message  The IPC message sent
+--- @return string                # IPC reply string
+--- @return suricatta.status      # Suricatta return code
+function ipc(message)
+    if not (message or {}).json then
+        return escape([[{ "request": "IPC requests must be JSON formatted" }]], { ['"'] = '"' }),
+            suricatta.status.EBADMSG
+    end
+    message.msg = message.msg or "<NONE>"
+    if message.cmd == message.commands.CONFIG then
+        suricatta.notify.debug("Got IPC configuration message: %s", message.msg)
+        if message.json.polling then
+            gs.polldelay.default = tonumber(message.json.polling) or gs.polldelay.default
+            return escape([[{ "request": "applied" }]], { ['"'] = '"' }), suricatta.status.OK
+        end
+    elseif message.cmd == message.commands.ACTIVATION then
+        suricatta.notify.debug("Got IPC activation message: %s", message.msg)
+        return escape([[{ "request": "inapplicable" }]], { ['"'] = '"' }), suricatta.status.OK
+    end
+    suricatta.notify.warn("Got unknown IPC message: %s", message.msg)
+    return escape([[{ "request": "unknown" }]], { ['"'] = '"' }), suricatta.status.EBADMSG
+end
+suricatta.server.register(ipc, suricatta.server.IPC)