diff --git a/makefiles/pseudomodules.inc.mk b/makefiles/pseudomodules.inc.mk index 491b8e00c8..5a312839f9 100644 --- a/makefiles/pseudomodules.inc.mk +++ b/makefiles/pseudomodules.inc.mk @@ -65,6 +65,7 @@ PSEUDOMODULES += evtimer_on_ztimer PSEUDOMODULES += fatfs_vfs_format PSEUDOMODULES += fmt_% PSEUDOMODULES += gcoap_forward_proxy +PSEUDOMODULES += gcoap_fileserver PSEUDOMODULES += gcoap_dtls PSEUDOMODULES += fido2_tests PSEUDOMODULES += gnrc_dhcpv6_% diff --git a/sys/Makefile.dep b/sys/Makefile.dep index 019c00483f..9de53e026a 100644 --- a/sys/Makefile.dep +++ b/sys/Makefile.dep @@ -623,6 +623,12 @@ ifneq (,$(filter l2filter_%,$(USEMODULE))) USEMODULE += l2filter endif +ifneq (,$(filter gcoap_fileserver,$(USEMODULE))) + USEMODULE += gcoap + USEMODULE += checksum + USEMODULE += vfs +endif + ifneq (,$(filter gcoap_forward_proxy,$(USEMODULE))) USEMODULE += gcoap USEMODULE += uri_parser diff --git a/sys/include/net/gcoap.h b/sys/include/net/gcoap.h index 2ab21acc33..92006c045a 100644 --- a/sys/include/net/gcoap.h +++ b/sys/include/net/gcoap.h @@ -627,8 +627,19 @@ extern "C" { #define GCOAP_DTLS_EXTRA_STACKSIZE (0) #endif +/** + * @brief Extra stack for VFS operations + */ +#if IS_USED(MODULE_GCOAP_FILESERVER) +#include "vfs.h" +#define GCOAP_VFS_EXTRA_STACKSIZE (VFS_DIR_BUFFER_SIZE + VFS_FILE_BUFFER_SIZE) +#else +#define GCOAP_VFS_EXTRA_STACKSIZE (0) +#endif + #define GCOAP_STACK_SIZE (THREAD_STACKSIZE_DEFAULT + DEBUG_EXTRA_STACKSIZE \ - + sizeof(coap_pkt_t) + GCOAP_DTLS_EXTRA_STACKSIZE) + + sizeof(coap_pkt_t) + GCOAP_DTLS_EXTRA_STACKSIZE \ + + GCOAP_VFS_EXTRA_STACKSIZE) #endif /** @} */ diff --git a/sys/include/net/gcoap/fileserver.h b/sys/include/net/gcoap/fileserver.h new file mode 100644 index 0000000000..1258ff7ea0 --- /dev/null +++ b/sys/include/net/gcoap/fileserver.h @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2020 chrysn + * + * This file is subject to the terms and conditions of the GNU Lesser + * General Public License v2.1. See the file LICENSE in the top level + * directory for more details. + */ + +/** + * @defgroup net_gcoap_fileserver GCoAP file server + * @ingroup net_gcoap + * @brief Library for serving files from the VFS to CoAP clients + * + * # About + * + * This maps files in the local file system onto a resources in CoAP. In that, + * it is what is called a static web server in the unconstrained web. + * + * As usual, GET operations are used to read files. + * + * Directories are expressed to URIs with trailing slashes. + * + * @note The file server uses ETag for cache validation. The ETags are built + * from the file system stat values. As clients rely on the ETag to differ when + * the file changes, it is important that file modification times are set. The + * precise time values do not matter, but if a file is changed in place and + * neither its length nor its modification time is varied, then clients will + * not become aware of the change or may even mix up the versions half way + * through if they have a part of the old version cached. + * + * # Usage + * + * * ``USEMODULE += gcoap_fileserver`` + * + * * Have a @ref gcoap_fileserver_entry_t populated with the path you want to serve, + * and the number of path components to strip from incoming requests: + * + * ``` + * static const gcoap_fileserver_entry_t files_sd = { + * .root = "/sd0", + * .resource = "/files/sd" + * }; + * ``` + * + * * Enter a @ref gcoap_fileserver_handler handler into your CoAP server's + * resource list like this: + * + * ``` + * static const coap_resource_t _resources[] = { + * ... + * { "/files/sd", COAP_GET | COAP_MATCH_SUBTREE, gcoap_fileserver_handler, (void*)&files_sd }, + * ... + * } + * ``` + * + * The allowed methods dictate whether it's read-only (``COAP_GET``) or (in the + * future) read-write (``COAP_GET | COAP_PUT | COAP_DELETE``). + * + * @{ + * + * @file + * @brief Resource handler for the CoAP file system server + * + * @author chrysn + */ + +#ifndef NET_GCOAP_FILESERVER_H +#define NET_GCOAP_FILESERVER_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "net/nanocoap.h" + +/** + * @brief File server starting point + * + * This struct needs to be present at the ctx of a gcoap_fileserver_handler entry + * in a resource list. + * + */ +typedef struct { + /** + * @brief Path in the VFS that should be served. + * + * This does not need a trailing slash. + */ + const char *root; + /** + * @brief The associated CoAP resource path + */ + const char *resource; +} gcoap_fileserver_entry_t; + +/** + * @brief File server handler + * + * Serve a directory from the VFS as a CoAP resource tree. + * @see net_gcoap_fileserver + * + * @param[in] pdu CoAP request package + * @param[out] buf Buffer for the response + * @param[in] len Response buffer length + * @param[in] ctx pointer to a @ref gcoap_fileserver_entry_t + * + * @return size of the response on success + * negative error + */ +ssize_t gcoap_fileserver_handler(coap_pkt_t *pdu, uint8_t *buf, size_t len, void *ctx); + +#ifdef __cplusplus +} +#endif + +#endif /* NET_GCOAP_FILESERVER_H */ + +/** @} */ diff --git a/sys/net/application_layer/gcoap/fileserver.c b/sys/net/application_layer/gcoap/fileserver.c new file mode 100644 index 0000000000..e4d09fb5e9 --- /dev/null +++ b/sys/net/application_layer/gcoap/fileserver.c @@ -0,0 +1,373 @@ +/* + * Copyright (C) 2020 chrysn + * + * This file is subject to the terms and conditions of the GNU Lesser + * General Public License v2.1. See the file LICENSE in the top level + * directory for more details. + */ +/** + * @{ + * + * @file + * @brief CoAP file server implementation + * + * @author Christian Amsüss + * @author Benjamin Valentin + */ + +#include +#include +#include +#include +#include + +#include "checksum/fletcher32.h" +#include "net/gcoap/fileserver.h" +#include "net/gcoap.h" +#include "vfs.h" + +#define ENABLE_DEBUG 0 +#include "debug.h" + +/** Maximum length of an expressible path, including the trailing 0 character. */ +#define COAPFILESERVER_PATH_MAX (64) + +/** Data extracted from a request on a file */ +struct requestdata { + /** 0-terminated expanded file name in the VFS */ + char namebuf[COAPFILESERVER_PATH_MAX]; + uint32_t blocknum2; + unsigned int szx2; /* would prefer uint8_t but that's what coap_get_blockopt gives */ + uint32_t etag; + bool etag_sent; +}; + +/** + * @brief Return true if path/name is a directory. + */ +static bool entry_is_dir(char *path, const char *name) +{ + struct stat stat; + size_t path_len = strlen(path); + size_t name_len = strlen(name); + + if (path_len + name_len + 1 >= COAPFILESERVER_PATH_MAX) { + return false; + } + + /* re-use path buffer, it is already COAPFILESERVER_PATH_MAX bytes long */ + path[path_len] = '/'; + path[path_len + 1] = 0; + strncat(path, name, COAPFILESERVER_PATH_MAX - 1); + + if (vfs_stat(path, &stat)) { + DEBUG("vfs_stat(%s) failed\n", path); + } + + path[path_len] = 0; + return (stat.st_mode & S_IFMT) == S_IFDIR; +} + +static unsigned _count_char(const char *s, char c) +{ + unsigned count = 0; + while (*s) { + if (*s == c) { + ++count; + } + ++s; + } + + return count; +} + +/** Build an ETag based on the given file's VFS stat. If the stat fails, + * returns the error and leaves etag in any state; otherwise there's an etag + * in the stattag's field */ +static int stat_etag(const char *filename, uint32_t *etag) +{ + struct stat stat; + int err = vfs_stat(filename, &stat); + if (err < 0) { + return err; + } + + /* Normalizing fields whose value can change without affecting the ETag */ + stat.st_nlink = 0; +#if defined(CPU_ESP32) || defined(CPU_ESP8266) || defined(CPU_MIPS_PIC32MX) || defined(CPU_MIPS_PIC32MZ) + memset(&stat.st_atime, 0, sizeof(stat.st_atime)); +#else + memset(&stat.st_atim, 0, sizeof(stat.st_atim)); +#endif + + *etag = fletcher32((void *)&stat, sizeof(stat) / 2); + + return 0; +} + +/** Create a CoAP response for a given errno (eg. EACCESS -> 4.03 Forbidden + * etc., defaulting to 5.03 Internal Server Error) */ +static size_t gcoap_fileserver_errno_handler(coap_pkt_t *pdu, uint8_t *buf, size_t len, int err) +{ + uint8_t code; + switch (err) { + case -EACCES: + code = COAP_CODE_FORBIDDEN; + break; + case -ENOENT: + code = COAP_CODE_PATH_NOT_FOUND; + break; + default: + code = COAP_CODE_INTERNAL_SERVER_ERROR; + }; + DEBUG("gcoap_fileserver: Rejecting error %d (%s) as %d.%02d\n", err, strerror(err), + code >> 5, code & 0x1f); + return gcoap_response(pdu, buf, len, code); +} + +static void _calc_szx2(coap_pkt_t *pdu, size_t reserve, struct requestdata *request) +{ + assert(pdu->payload_len > reserve); + size_t remaining_length = pdu->payload_len - reserve; + /* > 0: To not wrap around; if that still won't fit that's later caught in + * an assertion */ + while ((coap_szx2size(request->szx2) > remaining_length) && (request->szx2 > 0)) { + request->szx2--; + request->blocknum2 <<= 1; + } +} + +static ssize_t gcoap_fileserver_file_handler(coap_pkt_t *pdu, uint8_t *buf, size_t len, + struct requestdata *request) +{ + uint32_t etag; + int err = stat_etag(request->namebuf, &etag); + if (err < 0) { + return gcoap_fileserver_errno_handler(pdu, buf, len, err); + } + + if (request->etag_sent && etag == request->etag) { + gcoap_resp_init(pdu, buf, len, COAP_CODE_VALID); + coap_opt_add_opaque(pdu, COAP_OPT_ETAG, &etag, sizeof(etag)); + return coap_opt_finish(pdu, COAP_OPT_FINISH_NONE); + } + + int fd = vfs_open(request->namebuf, O_RDONLY, 0); + if (fd < 0) { + return gcoap_fileserver_errno_handler(pdu, buf, len, fd); + } + + gcoap_resp_init(pdu, buf, len, COAP_CODE_CONTENT); + coap_opt_add_opaque(pdu, COAP_OPT_ETAG, &etag, sizeof(etag)); + coap_block_slicer_t slicer; + _calc_szx2(pdu, + 5 + 1 + 1 /* reserve BLOCK2 size + payload marker + more */, + request); + coap_block_slicer_init(&slicer, request->blocknum2, coap_szx2size(request->szx2)); + coap_opt_add_block2(pdu, &slicer, true); + size_t resp_len = coap_opt_finish(pdu, COAP_OPT_FINISH_PAYLOAD); + + err = vfs_lseek(fd, slicer.start, SEEK_SET); + if (err < 0) { + goto late_err; + } + + /* That'd only happen if the buffer is too small for even a 16-byte block, + * or if the above calculations were wrong. + * + * Not using payload_len here as that's needlessly underestimating the + * space by CONFIG_GCOAP_RESP_OPTIONS_BUF + * */ + assert(pdu->payload + slicer.end - slicer.start <= buf + len); + bool more = 1; + int read = vfs_read(fd, pdu->payload, slicer.end - slicer.start + more); + if (read < 0) { + goto late_err; + } + more = (unsigned)read > slicer.end - slicer.start; + read -= more; + + vfs_close(fd); + + slicer.cur = slicer.end + more; + coap_block2_finish(&slicer); + + if (read == 0) { + /* Rewind to clear payload marker */ + read -= 1; + } + + return resp_len + read; + +late_err: + vfs_close(fd); + coap_hdr_set_code(pdu->hdr, COAP_CODE_INTERNAL_SERVER_ERROR); + return coap_get_total_hdr_len(pdu); +} + +static ssize_t gcoap_fileserver_directory_handler(coap_pkt_t *pdu, uint8_t *buf, size_t len, + struct requestdata *request, + gcoap_fileserver_entry_t *resource) +{ + vfs_DIR dir; + coap_block_slicer_t slicer; + + int err = vfs_opendir(&dir, request->namebuf); + if (err != 0) { + return gcoap_fileserver_errno_handler(pdu, buf, len, err); + } + DEBUG("gcoap_fileserver: Serving directory listing\n"); + + gcoap_resp_init(pdu, buf, len, COAP_CODE_CONTENT); + coap_opt_add_format(pdu, COAP_FORMAT_LINK); + _calc_szx2(pdu, + 5 + 1 /* reserve BLOCK2 size + payload marker */, + request); + coap_block_slicer_init(&slicer, request->blocknum2, coap_szx2size(request->szx2)); + coap_opt_add_block2(pdu, &slicer, true); + buf += coap_opt_finish(pdu, COAP_OPT_FINISH_PAYLOAD); + + size_t root_len = resource->root ? strlen(resource->root) : 0; + const char *root_dir = &request->namebuf[root_len]; + const char *resource_dir = resource->resource; + size_t root_dir_len = strlen(root_dir); + size_t resource_dir_len = strlen(resource_dir); + + vfs_dirent_t entry; + while (vfs_readdir(&dir, &entry) > 0) { + const char *entry_name = entry.d_name; + size_t entry_len = strlen(entry_name); + if (entry_len <= 2 && memcmp(entry_name, "..", entry_len) == 0) { + /* Up pointers don't work the same way in URI semantics */ + continue; + } + bool is_dir = entry_is_dir(request->namebuf, entry_name); + + if (slicer.cur) { + buf += coap_blockwise_put_char(&slicer, buf, ','); + } + buf += coap_blockwise_put_char(&slicer, buf, '<'); + + buf += coap_blockwise_put_bytes(&slicer, buf, resource_dir, resource_dir_len); + buf += coap_blockwise_put_bytes(&slicer, buf, root_dir, root_dir_len); + buf += coap_blockwise_put_char(&slicer, buf, '/'); + buf += coap_blockwise_put_bytes(&slicer, buf, entry_name, entry_len); + if (is_dir) { + buf += coap_blockwise_put_char(&slicer, buf, '/'); + } + buf += coap_blockwise_put_char(&slicer, buf, '>'); + } + + vfs_closedir(&dir); + coap_block2_finish(&slicer); + + return (uintptr_t)buf - (uintptr_t)pdu->hdr; +} + +ssize_t gcoap_fileserver_handler(coap_pkt_t *pdu, uint8_t *buf, size_t len, void *ctx) { + gcoap_fileserver_entry_t *entry = ctx; + struct requestdata request = { + .etag_sent = false, + .blocknum2 = 0, + .szx2 = CONFIG_NANOCOAP_BLOCK_SIZE_EXP_MAX, + }; + + /** Index in request.namebuf. Must not point at the last entry as that will be + * zeroed to get a 0-terminated string. */ + size_t namelength = 0; + uint8_t errorcode = COAP_CODE_INTERNAL_SERVER_ERROR; + uint8_t strip_remaining = _count_char(entry->resource, '/'); + + /* If a root directory for the server was specified, use that */ + if (entry->root && strlen(entry->root) > 1) { + strncpy(request.namebuf, entry->root, sizeof(request.namebuf)); + namelength = strlen(entry->root); + } + + bool is_directory = true; /* either no path component at all or trailing '/' */ + coap_optpos_t opt = { + .offset = coap_get_total_hdr_len(pdu), + }; + uint8_t *value; + ssize_t optlen; + while ((optlen = coap_opt_get_next(pdu, &opt, &value, 0)) != -ENOENT) { + + if (optlen < 0) { + errorcode = COAP_CODE_BAD_REQUEST; + goto error; + } + + switch (opt.opt_num) { + case COAP_OPT_URI_PATH: + if (strip_remaining != 0) { + strip_remaining -= 1; + continue; + } + if ((is_directory = (optlen == 0))) { /* '/' */ + continue; + } + if (memchr(value, '\0', optlen) != NULL || + memchr(value, '/', optlen) != NULL) { + /* Path can not be expressed in the file system */ + errorcode = COAP_CODE_PATH_NOT_FOUND; + goto error; + } + size_t newlength = namelength + 1 + optlen; + if (newlength > sizeof(request.namebuf) - 1) { + /* Path too long, therefore can't exist in this mapping */ + errorcode = COAP_CODE_PATH_NOT_FOUND; + goto error; + } + request.namebuf[namelength] = '/'; + memcpy(&request.namebuf[namelength] + 1, value, optlen); + namelength = newlength; + break; + case COAP_OPT_ETAG: + if (optlen != sizeof(request.etag)) { + /* Can't be a matching tag, no use in carrying that */ + continue; + } + if (request.etag_sent) { + /* We can reasonably only check for a limited sized set, + * and it size is 1 here (sending multiple ETags is + * possible but rare) */ + continue; + } + request.etag_sent = true; + memcpy(&request.etag, value, sizeof(request.etag)); + break; + case COAP_OPT_BLOCK2: + /* Could be more efficient now that we already know where it + * is, but meh */ + coap_get_blockopt(pdu, COAP_OPT_BLOCK2, &request.blocknum2, &request.szx2); + break; + default: + if (opt.opt_num & 1) { + errorcode = COAP_CODE_BAD_REQUEST; + goto error; + } + else { + /* Ignoring elective option */ + } + } + } + + request.namebuf[namelength] = '\0'; + + DEBUG("request: '%s'\n", request.namebuf); + + /* Note to self: As we parse more options than just Uri-Path, we'll likely + * pass a struct pointer later. So far, those could even be hooked into the + * resource list, but that'll go away once we parse more options */ + if (is_directory) { + return gcoap_fileserver_directory_handler(pdu, buf, len, &request, entry); + } + else { + return gcoap_fileserver_file_handler(pdu, buf, len, &request); + } + +error: + return gcoap_response(pdu, buf, len, errorcode); +} + +/** @} */