This is an automated email from the ASF dual-hosted git repository. acassis pushed a commit to branch releases/13.0 in repository https://gitbox.apache.org/repos/asf/nuttx-apps.git
commit ced7c261cc54a6c2a5ac465d3ca74ea90eab7ac9 Author: Tiago Medicci <[email protected]> AuthorDate: Wed Jun 10 08:49:35 2026 -0300 examples/webpanel: Add a web panel application to configure NuttX Initial development of the NuttX's Web Panel application. This is a self-hosted web page application that enables retrieving system info, provides a simple NSH terminal and enables uploading files. Signed-off-by: Tiago Medicci <[email protected]> --- .codespellrc | 3 +- examples/webpanel/.gitignore | 2 + examples/webpanel/CMakeLists.txt | 25 ++ examples/webpanel/Kconfig | 109 +++++ examples/webpanel/Make.defs | 25 ++ examples/webpanel/Makefile | 94 +++++ examples/webpanel/cgi_files.c | 314 ++++++++++++++ examples/webpanel/cgi_renew.c | 72 ++++ examples/webpanel/cgi_sysinfo.c | 414 +++++++++++++++++++ examples/webpanel/cgi_upload.c | 637 +++++++++++++++++++++++++++++ examples/webpanel/content/www/index.html | 454 ++++++++++++++++++++ examples/webpanel/content/www/xterm.css | 218 ++++++++++ examples/webpanel/content/www/xterm.min.js | 8 + examples/webpanel/webpanel_main.c | 207 ++++++++++ examples/webpanel/ws_terminal.c | 350 ++++++++++++++++ examples/webpanel/ws_terminal.h | 63 +++ 16 files changed, 2994 insertions(+), 1 deletion(-) diff --git a/.codespellrc b/.codespellrc index 8bcdf2bc7..651916c6a 100644 --- a/.codespellrc +++ b/.codespellrc @@ -8,6 +8,7 @@ exclude-file = .codespell-ignore-lines # Ignore complete files (e.g. legal text or other immutable material). skip = LICENSE, + examples/webpanel/content/www/xterm.min.js, # Ignore words list (FTP protocol commands and technical terms) -ignore-words-list = ALLO, ARCHTYPE, parm +ignore-words-list = ALLO, ARCHTYPE, parm, shiftIn diff --git a/examples/webpanel/.gitignore b/examples/webpanel/.gitignore new file mode 100644 index 000000000..583a0928d --- /dev/null +++ b/examples/webpanel/.gitignore @@ -0,0 +1,2 @@ +# ROMFS build artifacts (generated by Makefile) +/content diff --git a/examples/webpanel/CMakeLists.txt b/examples/webpanel/CMakeLists.txt new file mode 100644 index 000000000..ed47cabbd --- /dev/null +++ b/examples/webpanel/CMakeLists.txt @@ -0,0 +1,25 @@ +# ############################################################################## +# apps/examples/webpanel/CMakeLists.txt +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. The ASF licenses this +# file to you under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# +# ############################################################################## + +if(CONFIG_EXAMPLES_WEBPANEL) + nuttx_add_application(NAME ${CONFIG_EXAMPLES_WEBPANEL_PROGNAME}) +endif() diff --git a/examples/webpanel/Kconfig b/examples/webpanel/Kconfig new file mode 100644 index 000000000..b1392c725 --- /dev/null +++ b/examples/webpanel/Kconfig @@ -0,0 +1,109 @@ +# +# For a description of the syntax of this configuration file, +# see the file kconfig-language.txt in the NuttX tools repository. +# + +config EXAMPLES_WEBPANEL + tristate "Web Panel for device management" + default n + depends on NET_TCP + depends on NETUTILS_THTTPD + depends on NETUTILS_LIBWEBSOCKETS + depends on NETUTILS_LIBWEBSOCKETS_SERVER + depends on FS_BINFS + depends on FS_UNIONFS + depends on PSEUDOTERM + depends on PSEUDOTERM_SUSV1 + ---help--- + Enable the Web Panel application, a Tasmota-inspired web + interface for NuttX devices. Provides a landing page served + via THTTPD from ROMFS, CGI handlers for system info, file + management, and a WebSocket-based NSH terminal (powered + by libwebsockets). + +if EXAMPLES_WEBPANEL + +config EXAMPLES_WEBPANEL_PROGNAME + string "Program name" + default "webpanel" + +config EXAMPLES_WEBPANEL_PRIORITY + int "Web Panel task priority" + default 100 + +config EXAMPLES_WEBPANEL_STACKSIZE + int "Web Panel stack size" + default 4096 + +config EXAMPLES_WEBPANEL_NETIF + string "Network interface name" + default "eth0" + ---help--- + Name of the network interface used by the web panel for + system info queries and DHCP renew (e.g. eth0, wlan0). + +config EXAMPLES_WEBPANEL_CGI_SYSINFO_PROGNAME + string "CGI sysinfo program name" + default "sysinfo" + +config EXAMPLES_WEBPANEL_CGI_SYSINFO_PRIORITY + int "CGI sysinfo task priority" + default 100 + +config EXAMPLES_WEBPANEL_CGI_SYSINFO_STACKSIZE + int "CGI sysinfo stack size" + default 4096 + +config EXAMPLES_WEBPANEL_CGI_FILES_PROGNAME + string "CGI files program name" + default "files" + +config EXAMPLES_WEBPANEL_CGI_FILES_PRIORITY + int "CGI files task priority" + default 100 + +config EXAMPLES_WEBPANEL_CGI_FILES_STACKSIZE + int "CGI files stack size" + default 4096 + +config EXAMPLES_WEBPANEL_CGI_UPLOAD_PROGNAME + string "CGI upload program name" + default "upload" + +config EXAMPLES_WEBPANEL_CGI_UPLOAD_PRIORITY + int "CGI upload task priority" + default 100 + +config EXAMPLES_WEBPANEL_CGI_UPLOAD_STACKSIZE + int "CGI upload stack size" + default 8192 + +config EXAMPLES_WEBPANEL_CGI_RENEW_PROGNAME + string "CGI DHCP renew program name" + default "dhcprenew" + +config EXAMPLES_WEBPANEL_CGI_RENEW_PRIORITY + int "CGI DHCP renew task priority" + default 100 + +config EXAMPLES_WEBPANEL_CGI_RENEW_STACKSIZE + int "CGI DHCP renew stack size" + default 4096 + +config EXAMPLES_WEBPANEL_WS_PORT + int "WebSocket terminal TCP port" + default 8080 + +config EXAMPLES_WEBPANEL_WS_PRIORITY + int "WebSocket server task priority" + default 100 + +config EXAMPLES_WEBPANEL_WS_STACKSIZE + int "WebSocket daemon stack size" + default 8192 + +config EXAMPLES_WEBPANEL_WS_NSH_STACKSIZE + int "WebSocket NSH session stack size" + default 4096 + +endif diff --git a/examples/webpanel/Make.defs b/examples/webpanel/Make.defs new file mode 100644 index 000000000..bf880eb82 --- /dev/null +++ b/examples/webpanel/Make.defs @@ -0,0 +1,25 @@ +############################################################################ +# apps/examples/webpanel/Make.defs +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +ifneq ($(CONFIG_EXAMPLES_WEBPANEL),) +CONFIGURED_APPS += $(APPDIR)/examples/webpanel +endif diff --git a/examples/webpanel/Makefile b/examples/webpanel/Makefile new file mode 100644 index 000000000..12d6be8e6 --- /dev/null +++ b/examples/webpanel/Makefile @@ -0,0 +1,94 @@ +############################################################################ +# apps/examples/webpanel/Makefile +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +include $(APPDIR)/Make.defs + +# Web Panel application + CGI handlers +# NuttX multi-program build: space-separated lists, matched by position. + +CSRCS = romfs.c ws_terminal.c +MAINSRC = webpanel_main.c cgi_sysinfo.c cgi_files.c cgi_upload.c cgi_renew.c + +PROGNAME = $(CONFIG_EXAMPLES_WEBPANEL_PROGNAME) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_SYSINFO_PROGNAME) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_FILES_PROGNAME) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_UPLOAD_PROGNAME) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_RENEW_PROGNAME) +PRIORITY = $(CONFIG_EXAMPLES_WEBPANEL_PRIORITY) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_SYSINFO_PRIORITY) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_FILES_PRIORITY) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_UPLOAD_PRIORITY) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_RENEW_PRIORITY) +STACKSIZE = $(CONFIG_EXAMPLES_WEBPANEL_STACKSIZE) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_SYSINFO_STACKSIZE) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_FILES_STACKSIZE) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_UPLOAD_STACKSIZE) \ + $(CONFIG_EXAMPLES_WEBPANEL_CGI_RENEW_STACKSIZE) + +LWS_DIR = $(APPDIR)/netutils/libwebsockets +CFLAGS += -I$(LWS_DIR) \ + -I$(LWS_DIR)/libwebsockets/include + +MODULE = $(CONFIG_EXAMPLES_WEBPANEL) + +VPATH += content +DEPPATH += --dep-path content + +# ROMFS image generation +# Static web content goes directly into the ROMFS root (no www/ subdir) + +WEBPANEL_DIR = $(APPDIR)/examples/webpanel +CONTENT_DIR = $(WEBPANEL_DIR)/content +ROMFS_DIR = $(CONTENT_DIR)/romfs +ROMFS_IMG = $(CONTENT_DIR)/romfs.img +ROMFS_SRC = $(CONTENT_DIR)/romfs.c + +WWW_FILES = $(wildcard $(CONTENT_DIR)/www/*) + +$(CONTENT_DIR)/.romfs_stamp: $(WWW_FILES) + $(Q) mkdir -p $(ROMFS_DIR) + $(Q) cp -a $(CONTENT_DIR)/www/* $(ROMFS_DIR)/ + $(Q) touch $@ + +$(ROMFS_IMG): $(CONTENT_DIR)/.romfs_stamp + $(Q) genromfs -f $@ -d $(ROMFS_DIR) -V "WebPanelROMFS" + +$(ROMFS_SRC): $(ROMFS_IMG) + $(Q) (cd $(CONTENT_DIR) && \ + echo "#include <nuttx/compiler.h>" >$@ && \ + xxd -i romfs.img | \ + sed -e "s/romfs_img/webpanel_romfs_img/g" | \ + sed -e "s/^unsigned char/const unsigned char aligned_data(4)/g" >>$@) + +content/romfs.c: $(ROMFS_SRC) + +context:: + +clean:: + $(call DELFILE, $(ROMFS_SRC)) + $(call DELFILE, $(ROMFS_IMG)) + $(call DELFILE, $(CONTENT_DIR)/.romfs_stamp) + $(Q) rm -rf $(ROMFS_DIR) + +distclean:: clean + +include $(APPDIR)/Application.mk diff --git a/examples/webpanel/cgi_files.c b/examples/webpanel/cgi_files.c new file mode 100644 index 000000000..fdc67b868 --- /dev/null +++ b/examples/webpanel/cgi_files.c @@ -0,0 +1,314 @@ +/**************************************************************************** + * apps/examples/webpanel/cgi_files.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <dirent.h> +#include <sys/stat.h> +#include <unistd.h> + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#define FILES_DIR "/mnt" + +/**************************************************************************** + * Private Function Prototypes + ****************************************************************************/ + +static void url_decode(char *dst, const char *src, size_t dstsize); +static const char *get_query_param(const char *qs, const char *key, + char *val, size_t vallen); +static void handle_list(void); +static void handle_delete(const char *name); + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: url_decode + * + * Description: + * Decode a URL-encoded string into a destination buffer. + * + * Input Parameters: + * dst - Destination buffer. + * src - Source URL-encoded string. + * dstsize - Size of destination buffer. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void url_decode(char *dst, const char *src, size_t dstsize) +{ + size_t i = 0; + + while (*src && i < dstsize - 1) + { + if (*src == '%' && src[1] && src[2]) + { + char hex[3]; + + hex[0] = src[1]; + hex[1] = src[2]; + hex[2] = '\0'; + dst[i++] = (char)strtol(hex, NULL, 16); + src += 3; + } + else if (*src == '+') + { + dst[i++] = ' '; + src++; + } + else + { + dst[i++] = *src++; + } + } + + dst[i] = '\0'; +} + +/**************************************************************************** + * Name: get_query_param + * + * Description: + * Find and decode a key=value parameter from the QUERY_STRING. + * + * Input Parameters: + * qs - Query string. + * key - Parameter name to search for. + * val - Output buffer for decoded value. + * vallen - Size of output buffer. + * + * Returned Value: + * Pointer to val on success; NULL if not found or invalid input. + * + ****************************************************************************/ + +static const char *get_query_param(const char *qs, const char *key, + char *val, size_t vallen) +{ + const char *p; + size_t klen; + + if (qs == NULL || key == NULL) + { + return NULL; + } + + klen = strlen(key); + p = qs; + + while (*p) + { + if (strncmp(p, key, klen) == 0 && p[klen] == '=') + { + const char *start = p + klen + 1; + const char *end = strchr(start, '&'); + size_t len; + + if (end == NULL) + { + end = start + strlen(start); + } + + len = end - start; + if (len >= vallen) + { + len = vallen - 1; + } + + url_decode(val, start, len + 1); + return val; + } + + p = strchr(p, '&'); + if (p == NULL) + { + break; + } + + p++; + } + + return NULL; +} + +/**************************************************************************** + * Name: handle_list + * + * Description: + * Emit a JSON response listing files in FILES_DIR. + * + * Input Parameters: + * None. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void handle_list(void) +{ + DIR *dir; + struct dirent *ent; + struct stat st; + char path[128]; + int first = 1; + + puts("Content-type: application/json\r\n" + "\r\n"); + + printf("{\"files\":["); + + dir = opendir(FILES_DIR); + if (dir != NULL) + { + while ((ent = readdir(dir)) != NULL) + { + if (ent->d_name[0] == '.') + { + continue; + } + + snprintf(path, sizeof(path), "%s/%s", FILES_DIR, ent->d_name); + + if (!first) + { + printf(","); + } + + first = 0; + + if (stat(path, &st) == 0) + { + printf("{\"name\":\"%s\",\"size\":%ld}", + ent->d_name, (long)st.st_size); + } + else + { + printf("{\"name\":\"%s\",\"size\":0}", ent->d_name); + } + } + + closedir(dir); + } + + printf("]}\n"); +} + +/**************************************************************************** + * Name: handle_delete + * + * Description: + * Delete the requested file from FILES_DIR and emit a JSON response. + * + * Input Parameters: + * name - File name to delete. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void handle_delete(const char *name) +{ + char path[128]; + + if (name == NULL || name[0] == '\0' || strchr(name, '/') != NULL) + { + puts("Content-type: application/json\r\n" + "Status: 400\r\n" + "\r\n"); + printf("{\"error\":\"Invalid filename\"}\n"); + return; + } + + snprintf(path, sizeof(path), "%s/%s", FILES_DIR, name); + + if (unlink(path) == 0) + { + puts("Content-type: application/json\r\n" + "\r\n"); + printf("{\"ok\":true}\n"); + } + else + { + puts("Content-type: application/json\r\n" + "Status: 404\r\n" + "\r\n"); + printf("{\"error\":\"File not found\"}\n"); + } +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: files_main + * + * Description: + * Entry point for file list/delete CGI handling. + * + * Input Parameters: + * argc - Number of arguments. + * argv - Argument vector. + * + * Returned Value: + * Zero (OK). + * + ****************************************************************************/ + +int files_main(int argc, FAR char *argv[]) +{ + const char *qs; + char action[16]; + char name[64]; + + qs = getenv("QUERY_STRING"); + + if (qs != NULL && get_query_param(qs, "action", action, sizeof(action))) + { + if (strcmp(action, "delete") == 0) + { + get_query_param(qs, "name", name, sizeof(name)); + handle_delete(name); + return 0; + } + } + + handle_list(); + return 0; +} diff --git a/examples/webpanel/cgi_renew.c b/examples/webpanel/cgi_renew.c new file mode 100644 index 000000000..098995094 --- /dev/null +++ b/examples/webpanel/cgi_renew.c @@ -0,0 +1,72 @@ +/**************************************************************************** + * apps/examples/webpanel/cgi_renew.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <stdio.h> + +#include "netutils/netlib.h" + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: dhcprenew_main + * + * Description: + * CGI entry point that renews the IPv4 address on the configured + * interface and returns the result as JSON. + * + * Input Parameters: + * argc - Number of arguments. + * argv - Argument vector. + * + * Returned Value: + * Zero on success; one on failure. + * + ****************************************************************************/ + +int dhcprenew_main(int argc, FAR char *argv[]) +{ + int ret; + + ret = netlib_obtain_ipv4addr(CONFIG_EXAMPLES_WEBPANEL_NETIF); + + puts("Content-type: application/json\r\n" + "\r\n"); + + if (ret >= 0) + { + puts("{\"status\":\"ok\"}\n"); + } + else + { + printf("{\"status\":\"error\",\"code\":%d}\n", ret); + } + + return ret < 0 ? 1 : 0; +} diff --git a/examples/webpanel/cgi_sysinfo.c b/examples/webpanel/cgi_sysinfo.c new file mode 100644 index 000000000..619eb4cf1 --- /dev/null +++ b/examples/webpanel/cgi_sysinfo.c @@ -0,0 +1,414 @@ +/**************************************************************************** + * apps/examples/webpanel/cgi_sysinfo.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <dirent.h> +#include <time.h> +#include <sys/ioctl.h> +#include <sys/socket.h> +#include <sys/utsname.h> +#include <net/if.h> +#include <arpa/inet.h> +#include <netinet/in.h> + +/**************************************************************************** + * Private Function Prototypes + ****************************************************************************/ + +static void get_version(char *ver, size_t vlen, + char *build, size_t blen, + char *board, size_t boardlen); +static void get_arch(char *arch, size_t archlen); +static unsigned long get_uptime(char *buf, size_t len); +static void get_net_info(char *ip, size_t iplen, + char *mask, size_t masklen, + char *gw, size_t gwlen, + char *mac, size_t maclen); +static int count_files(const char *path); + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: get_version + * + * Description: + * Read version, build hash, and board information from /proc/version. + * + * Input Parameters: + * ver - Output buffer for version string. + * vlen - Size of ver buffer. + * build - Output buffer for build/hash string. + * blen - Size of build buffer. + * board - Output buffer for board string. + * boardlen - Size of board buffer. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void get_version(char *ver, size_t vlen, + char *build, size_t blen, + char *board, size_t boardlen) +{ + FILE *fp; + char line[256]; + char *p; + char *sp; + char *last; + char *tok; + size_t n; + + strncpy(ver, "unknown", vlen); + strncpy(build, "unknown", blen); + strncpy(board, "unknown", boardlen); + + fp = fopen("/proc/version", "r"); + if (fp == NULL) + { + return; + } + + if (fgets(line, sizeof(line), fp) != NULL) + { + /* Format: "NuttX version X.Y.Z HASH DATE TIME BOARD:CONFIG" */ + + p = strstr(line, "version "); + if (p != NULL) + { + p += 8; + + /* version token */ + + sp = strchr(p, ' '); + if (sp != NULL) + { + n = sp - p; + if (n >= vlen) + { + n = vlen - 1; + } + + memcpy(ver, p, n); + ver[n] = '\0'; + + /* build (git hash) token */ + + p = sp + 1; + sp = strchr(p, ' '); + if (sp != NULL) + { + n = sp - p; + if (n >= blen) + { + n = blen - 1; + } + + memcpy(build, p, n); + build[n] = '\0'; + } + } + } + + /* Board name is the last whitespace-separated token; trim :config */ + + last = NULL; + tok = strtok(line, " \t\r\n"); + while (tok != NULL) + { + last = tok; + tok = strtok(NULL, " \t\r\n"); + } + + if (last != NULL) + { + char *colon = strchr(last, ':'); + if (colon != NULL) + { + *colon = '\0'; + } + + strncpy(board, last, boardlen - 1); + board[boardlen - 1] = '\0'; + } + } + + fclose(fp); +} + +/**************************************************************************** + * Name: get_arch + * + * Description: + * Query architecture information via uname(). + * + * Input Parameters: + * arch - Output buffer for architecture string. + * archlen - Size of arch buffer. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void get_arch(char *arch, size_t archlen) +{ + struct utsname uts; + + strncpy(arch, "unknown", archlen); + + if (uname(&uts) == 0) + { + strncpy(arch, uts.machine, archlen - 1); + arch[archlen - 1] = '\0'; + } +} + +/**************************************************************************** + * Name: get_uptime + * + * Description: + * Read system uptime based on CLOCK_MONOTONIC and format it. + * + * Input Parameters: + * buf - Output buffer for formatted uptime. + * len - Size of buf. + * + * Returned Value: + * Uptime in seconds; zero on error. + * + ****************************************************************************/ + +static unsigned long get_uptime(char *buf, size_t len) +{ + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) + { + unsigned long secs = ts.tv_sec; + unsigned int days = secs / 86400; + unsigned int hrs = (secs % 86400) / 3600; + unsigned int mins = (secs % 3600) / 60; + unsigned int s = secs % 60; + + if (days > 0) + { + snprintf(buf, len, "%ud %02u:%02u:%02u", days, hrs, mins, s); + } + else + { + snprintf(buf, len, "%02u:%02u:%02u", hrs, mins, s); + } + + return secs; + } + + snprintf(buf, len, "unknown"); + return 0; +} + +/**************************************************************************** + * Name: get_net_info + * + * Description: + * Query IP, netmask, gateway, and MAC for the configured interface. + * + * Input Parameters: + * ip - Output buffer for IPv4 address. + * iplen - Size of ip buffer. + * mask - Output buffer for netmask. + * masklen - Size of mask buffer. + * gw - Output buffer for gateway. + * gwlen - Size of gw buffer. + * mac - Output buffer for MAC address. + * maclen - Size of mac buffer. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void get_net_info(char *ip, size_t iplen, + char *mask, size_t masklen, + char *gw, size_t gwlen, + char *mac, size_t maclen) +{ + int sockfd; + struct ifreq ifr; + + strncpy(ip, "N/A", iplen); + strncpy(mask, "N/A", masklen); + strncpy(gw, "N/A", gwlen); + strncpy(mac, "N/A", maclen); + + sockfd = socket(AF_INET, SOCK_DGRAM, 0); + if (sockfd < 0) + { + return; + } + + memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, CONFIG_EXAMPLES_WEBPANEL_NETIF, IFNAMSIZ); + + if (ioctl(sockfd, SIOCGIFADDR, &ifr) == 0) + { + struct sockaddr_in *sa = (struct sockaddr_in *)&ifr.ifr_addr; + inet_ntop(AF_INET, &sa->sin_addr, ip, iplen); + } + + if (ioctl(sockfd, SIOCGIFNETMASK, &ifr) == 0) + { + struct sockaddr_in *sa = (struct sockaddr_in *)&ifr.ifr_netmask; + inet_ntop(AF_INET, &sa->sin_addr, mask, masklen); + } + + if (ioctl(sockfd, SIOCGIFDSTADDR, &ifr) == 0) + { + struct sockaddr_in *sa = (struct sockaddr_in *)&ifr.ifr_dstaddr; + inet_ntop(AF_INET, &sa->sin_addr, gw, gwlen); + } + + if (ioctl(sockfd, SIOCGIFHWADDR, &ifr) == 0) + { + unsigned char *hw = (unsigned char *)ifr.ifr_hwaddr.sa_data; + snprintf(mac, maclen, "%02x:%02x:%02x:%02x:%02x:%02x", + hw[0], hw[1], hw[2], hw[3], hw[4], hw[5]); + } + + close(sockfd); +} + +/**************************************************************************** + * Name: count_files + * + * Description: + * Count visible files in a directory. + * + * Input Parameters: + * path - Directory path. + * + * Returned Value: + * Number of visible entries. + * + ****************************************************************************/ + +static int count_files(const char *path) +{ + DIR *dir; + struct dirent *ent; + int count = 0; + + dir = opendir(path); + if (dir == NULL) + { + return 0; + } + + while ((ent = readdir(dir)) != NULL) + { + if (ent->d_name[0] != '.') + { + count++; + } + } + + closedir(dir); + return count; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: sysinfo_main + * + * Description: + * CGI entry point that returns board and runtime information as JSON. + * + * Input Parameters: + * argc - Number of arguments. + * argv - Argument vector. + * + * Returned Value: + * Zero (OK). + * + ****************************************************************************/ + +int sysinfo_main(int argc, FAR char *argv[]) +{ + char version[16]; + char build[16]; + char board[64]; + char arch[32]; + char uptime[32]; + char ip[16]; + char mask[16]; + char gw[16]; + char mac[24]; + unsigned long uptime_sec; + int nfiles; + + get_version(version, sizeof(version), build, sizeof(build), + board, sizeof(board)); + get_arch(arch, sizeof(arch)); + uptime_sec = get_uptime(uptime, sizeof(uptime)); + get_net_info(ip, sizeof(ip), mask, sizeof(mask), + gw, sizeof(gw), mac, sizeof(mac)); + nfiles = count_files("/mnt"); + + puts("Content-type: application/json\r\n" + "\r\n"); + + printf("{" + "\"chip\":\"" CONFIG_ARCH_CHIP "\"," + "\"board\":\"%s\"," + "\"os\":\"NuttX\"," + "\"version\":\"%s\"," + "\"build\":\"%s\"," + "\"arch\":\"%s\"," + "\"ifname\":\"" CONFIG_EXAMPLES_WEBPANEL_NETIF "\"," + "\"ip\":\"%s\"," + "\"mask\":\"%s\"," + "\"gw\":\"%s\"," + "\"mac\":\"%s\"," + "\"uptime\":\"%s\"," + "\"uptime_sec\":%lu," + "\"files\":%d" + "}\n", + board, version, build, arch, + ip, mask, gw, mac, + uptime, uptime_sec, nfiles); + + return 0; +} diff --git a/examples/webpanel/cgi_upload.c b/examples/webpanel/cgi_upload.c new file mode 100644 index 000000000..4dfd137ad --- /dev/null +++ b/examples/webpanel/cgi_upload.c @@ -0,0 +1,637 @@ +/**************************************************************************** + * apps/examples/webpanel/cgi_upload.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <fcntl.h> +#include <errno.h> + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#define UPLOAD_DIR "/mnt" +#define BUF_SIZE 512 +#define MAX_FILENAME 64 +#define MAX_BOUNDARY 80 + +/**************************************************************************** + * Private Function Prototypes + ****************************************************************************/ + +static void cgi_error(int status, const char *msg); +static void cgi_ok(const char *filename); +static int extract_boundary(const char *content_type, char *boundary, + size_t blen); +static int extract_filename(const char *line, char *name, size_t nlen); +static size_t read_stdin(char *buf, size_t n); +static char *memmem_local(const char *haystack, size_t hlen, + const char *needle, size_t nlen); + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: cgi_error + * + * Description: + * Emit a JSON error response with HTTP status. + * + * Input Parameters: + * status - HTTP status code. + * msg - Error message string. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void cgi_error(int status, const char *msg) +{ + printf("Content-type: application/json\r\n" + "Status: %d\r\n" + "\r\n" + "{\"error\":\"%s\"}\n", status, msg); +} + +/**************************************************************************** + * Name: cgi_ok + * + * Description: + * Emit a JSON success response. + * + * Input Parameters: + * filename - Uploaded file name. + * + * Returned Value: + * None. + * + ****************************************************************************/ + +static void cgi_ok(const char *filename) +{ + printf("Content-type: application/json\r\n" + "\r\n" + "{\"ok\":true,\"name\":\"%s\"}\n", filename); +} + +/**************************************************************************** + * Name: extract_boundary + * + * Description: + * Extract multipart boundary from CONTENT_TYPE. + * + * Input Parameters: + * content_type - CONTENT_TYPE value. + * boundary - Output buffer for boundary token. + * blen - Size of boundary buffer. + * + * Returned Value: + * Zero on success; negated value on failure. + * + ****************************************************************************/ + +static int extract_boundary(const char *content_type, char *boundary, + size_t blen) +{ + const char *p; + char *q; + size_t len; + + p = strstr(content_type, "boundary="); + if (p == NULL) + { + return -1; + } + + p += 9; + + /* Skip optional quotes */ + + if (*p == '"') + { + p++; + } + + strncpy(boundary, p, blen - 1); + boundary[blen - 1] = '\0'; + + /* Remove trailing quote if present */ + + q = strchr(boundary, '"'); + if (q != NULL) + { + *q = '\0'; + } + + /* Remove trailing whitespace/CR/LF */ + + len = strlen(boundary); + while (len > 0 && + (boundary[len - 1] == '\r' || boundary[len - 1] == '\n' || + boundary[len - 1] == ' ')) + { + boundary[--len] = '\0'; + } + + return len > 0 ? 0 : -1; +} + +/**************************************************************************** + * Name: extract_filename + * + * Description: + * Extract a safe filename from a Content-Disposition header line. + * + * Input Parameters: + * line - Header line. + * name - Output buffer for filename. + * nlen - Size of name buffer. + * + * Returned Value: + * Zero on success; negated value on failure. + * + ****************************************************************************/ + +static int extract_filename(const char *line, char *name, size_t nlen) +{ + const char *p; + const char *end; + size_t len; + + p = strstr(line, "filename=\""); + if (p == NULL) + { + return -1; + } + + p += 10; + end = strchr(p, '"'); + if (end == NULL) + { + return -1; + } + + len = end - p; + if (len == 0 || len >= nlen) + { + return -1; + } + + /* Reject path separators in filename */ + + if (memchr(p, '/', len) != NULL || memchr(p, '\\', len) != NULL) + { + return -1; + } + + memcpy(name, p, len); + name[len] = '\0'; + return 0; +} + +/**************************************************************************** + * Name: read_stdin + * + * Description: + * Read exactly n bytes from standard input unless EOF/error occurs. + * + * Input Parameters: + * buf - Destination buffer. + * n - Requested number of bytes. + * + * Returned Value: + * Number of bytes actually read. + * + ****************************************************************************/ + +static size_t read_stdin(char *buf, size_t n) +{ + size_t total = 0; + while (total < n) + { + ssize_t r = read(STDIN_FILENO, buf + total, n - total); + if (r <= 0) + { + break; + } + + total += r; + } + + return total; +} + +/**************************************************************************** + * Name: memmem_local + * + * Description: + * Find a byte sequence inside a bounded memory region. + * + * Input Parameters: + * haystack - Input buffer. + * hlen - Size of haystack. + * needle - Pattern to search. + * nlen - Size of needle. + * + * Returned Value: + * Pointer to first match; NULL if not found. + * + ****************************************************************************/ + +static char *memmem_local(const char *haystack, size_t hlen, + const char *needle, size_t nlen) +{ + const char *p; + if (nlen == 0) + { + return (char *)haystack; + } + + if (hlen < nlen) + { + return NULL; + } + + p = haystack; + while (p <= haystack + hlen - nlen) + { + if (memcmp(p, needle, nlen) == 0) + { + return (char *)p; + } + + p++; + } + + return NULL; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: upload_main + * + * Description: + * CGI entry point for multipart file upload. + * + * Input Parameters: + * argc - Number of arguments. + * argv - Argument vector. + * + * Returned Value: + * Zero (OK). + * + ****************************************************************************/ + +int upload_main(int argc, FAR char *argv[]) +{ + const char *content_type; + const char *content_length_str; + char boundary_raw[MAX_BOUNDARY]; + char boundary[MAX_BOUNDARY + 4]; + size_t boundary_len; + char filename[MAX_FILENAME]; + char filepath[MAX_FILENAME + 8]; + char buf[BUF_SIZE]; + size_t content_length; + size_t total_read; + int fd = -1; + int state; + char linebuf[256]; + size_t linepos; + int headers_done; + + content_type = getenv("CONTENT_TYPE"); + content_length_str = getenv("CONTENT_LENGTH"); + + if (content_type == NULL || content_length_str == NULL) + { + cgi_error(400, "Missing Content-Type or Content-Length"); + return 0; + } + + content_length = strtoul(content_length_str, NULL, 10); + if (content_length == 0 || content_length > 1024 * 1024) + { + cgi_error(400, "Invalid content length (max 1MB)"); + return 0; + } + + if (extract_boundary(content_type, boundary_raw, sizeof(boundary_raw)) < 0) + { + cgi_error(400, "No boundary in Content-Type"); + return 0; + } + + /* Multipart boundaries in the body are prefixed with "--" */ + + snprintf(boundary, sizeof(boundary), "--%s", boundary_raw); + boundary_len = strlen(boundary); + + /* State machine for multipart parsing: + * 0 = looking for first boundary + * 1 = reading headers after boundary + * 2 = reading file data + * 3 = done + */ + + state = 0; + total_read = 0; + filename[0] = '\0'; + linepos = 0; + headers_done = 0; + + /* Read the entire POST body in chunks and process inline. + * We accumulate a line buffer for header parsing, and stream + * file data directly to disk. + */ + + while (total_read < content_length && state != 3) + { + size_t toread = content_length - total_read; + size_t nread; + + if (toread > sizeof(buf)) + { + toread = sizeof(buf); + } + + nread = read_stdin(buf, toread); + if (nread == 0) + { + break; + } + + total_read += nread; + + size_t i = 0; + while (i < nread && state != 3) + { + switch (state) + { + case 0: + + /* Accumulate until we find the first boundary line */ + + while (i < nread) + { + if (buf[i] == '\n') + { + linebuf[linepos] = '\0'; + + /* Strip trailing \r */ + + if (linepos > 0 && linebuf[linepos - 1] == '\r') + { + linebuf[--linepos] = '\0'; + } + + if (strncmp(linebuf, boundary, boundary_len) == 0) + { + state = 1; + headers_done = 0; + linepos = 0; + i++; + break; + } + + linepos = 0; + i++; + } + else + { + if (linepos < sizeof(linebuf) - 1) + { + linebuf[linepos++] = buf[i]; + } + + i++; + } + } + + break; + + case 1: + /* Parse headers after boundary, looking for + * Content-Disposition with filename. + * Headers end with an empty line. + */ + + while (i < nread && !headers_done) + { + if (buf[i] == '\n') + { + linebuf[linepos] = '\0'; + + if (linepos > 0 && linebuf[linepos - 1] == '\r') + { + linebuf[--linepos] = '\0'; + } + + if (linepos == 0) + { + /* Empty line = end of headers */ + + headers_done = 1; + + if (filename[0] == '\0') + { + cgi_error(400, "No filename in upload"); + if (fd >= 0) + { + close(fd); + } + + return 0; + } + + snprintf(filepath, sizeof(filepath), "%s/%s", + UPLOAD_DIR, filename); + + fd = open(filepath, + O_WRONLY | O_CREAT | O_TRUNC, + 0666); + if (fd < 0) + { + cgi_error(500, "Cannot create file"); + return 0; + } + + state = 2; + i++; + break; + } + + if (strstr(linebuf, "Content-Disposition") != NULL) + { + extract_filename(linebuf, filename, + sizeof(filename)); + } + + linepos = 0; + i++; + } + else + { + if (linepos < sizeof(linebuf) - 1) + { + linebuf[linepos++] = buf[i]; + } + + i++; + } + } + + break; + + case 2: + { + /* Write file data. We need to detect the closing boundary + * which appears as "\r\n--BOUNDARY" in the stream. + * Buffer the last boundary_len+4 bytes to check for the + * boundary. + */ + + size_t remaining = nread - i; + char *bnd; + + bnd = memmem_local(buf + i, remaining, + boundary, boundary_len); + if (bnd != NULL) + { + /* Found boundary. Write everything before it, + * minus the preceding \r\n. + */ + + size_t datalen = bnd - (buf + i); + if (datalen >= 2) + { + datalen -= 2; + } + + if (datalen > 0) + { + write(fd, buf + i, datalen); + } + + close(fd); + fd = -1; + state = 3; + break; + } + else + { + /* No boundary in this chunk. Write data but hold back + * enough bytes to avoid splitting a boundary across + * chunks. + */ + + size_t safe; + + if (remaining > boundary_len + 4) + { + safe = remaining - boundary_len - 4; + } + else + { + safe = 0; + } + + if (safe > 0) + { + write(fd, buf + i, safe); + i += safe; + } + else + { + /* Not enough data to be safe. This means we're near + * the end of a chunk and need more data. Just write + * what we have and hope the boundary comes in the + * next read. For simplicity, write it all - worst + * case we include a few extra bytes at end of file + * which will be fixed when the boundary is found. + */ + + write(fd, buf + i, remaining); + i = nread; + } + } + + break; + } + } + } + } + + /* Drain any remaining data from stdin */ + + while (total_read < content_length) + { + size_t toread = content_length - total_read; + size_t nread; + + if (toread > sizeof(buf)) + { + toread = sizeof(buf); + } + + nread = read_stdin(buf, toread); + if (nread == 0) + { + break; + } + + total_read += nread; + } + + if (fd >= 0) + { + close(fd); + } + + if (state == 3 && filename[0] != '\0') + { + cgi_ok(filename); + } + else if (filename[0] != '\0') + { + cgi_ok(filename); + } + else + { + cgi_error(400, "Upload incomplete or no file received"); + } + + return 0; +} diff --git a/examples/webpanel/content/www/index.html b/examples/webpanel/content/www/index.html new file mode 100644 index 000000000..7d4928e31 --- /dev/null +++ b/examples/webpanel/content/www/index.html @@ -0,0 +1,454 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>NuttX Web Panel</title> +<link rel="stylesheet" href="/xterm.css"> +<style> +*{box-sizing:border-box;margin:0;padding:0} +body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; + background:#1a1a2e;color:#e0e0e0;min-height:100vh} +a{color:#5dade2;text-decoration:none} +a:hover{text-decoration:underline} + +.hdr{background:#16213e;padding:12px 20px;border-bottom:2px solid #0f3460; + display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap} +.hdr h1{font-size:18px;color:#e94560;display:flex;align-items:center;gap:8px} +.hdr .chip{font-size:11px;background:#0f3460;color:#7ec8e3;padding:2px 8px; + border-radius:10px} +.hdr .uptime{font-size:11px;color:#8899aa} + +nav{background:#0f1b33;display:flex;gap:0;overflow-x:auto} +nav a{padding:10px 18px;font-size:13px;color:#8899aa;border-bottom:2px solid transparent; + white-space:nowrap;transition:all .2s} +nav a:hover,nav a.act{color:#e94560;border-bottom-color:#e94560;text-decoration:none; + background:rgba(233,69,96,.06)} + +.page{display:none;padding:16px 20px;max-width:1200px;margin:0 auto} +.page.vis{display:block} + +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px} +.card{background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:16px; + transition:border-color .2s} +.card:hover{border-color:#e94560} +.card h3{font-size:14px;color:#e94560;margin-bottom:10px;display:flex; + align-items:center;gap:6px} +.card .ico{font-size:18px} +.row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px; + border-bottom:1px solid rgba(255,255,255,.04)} +.row .lbl{color:#8899aa} +.row .val{color:#c0d0e0;font-family:monospace} + +.badge{display:inline-block;padding:1px 7px;border-radius:8px;font-size:10px; + font-weight:600} +.badge-ok{background:#1e4d2b;color:#4ade80} +.badge-warn{background:#4d3e1e;color:#fbbf24} + +.term-wrap{background:#0d1117;border:1px solid #0f3460;border-radius:8px; + padding:8px;height:360px;overflow:hidden;position:relative} +.term-placeholder{display:flex;align-items:center;justify-content:center; + height:100%;color:#556;font-size:13px;flex-direction:column;gap:8px} + +.fm-toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center} +.fm-path{background:#0d1117;border:1px solid #0f3460;border-radius:4px;padding:6px 10px; + color:#c0d0e0;font-family:monospace;font-size:12px;flex:1;min-width:120px} +.btn{background:#e94560;color:#fff;border:none;padding:7px 16px;border-radius:4px; + cursor:pointer;font-size:12px;font-weight:600;transition:background .2s} +.btn:hover{background:#c73852} +.btn-sec{background:#0f3460;color:#7ec8e3} +.btn-sec:hover{background:#1a4a80} + +.fm-list{background:#0d1117;border:1px solid #0f3460;border-radius:8px;overflow:hidden} +.fm-item{display:flex;align-items:center;padding:8px 12px;font-size:12px; + border-bottom:1px solid rgba(255,255,255,.04);gap:10px} +.fm-item:hover{background:rgba(233,69,96,.04)} +.fm-item .name{flex:1;font-family:monospace;color:#c0d0e0} +.fm-item .sz{color:#667;min-width:60px;text-align:right} +.fm-item .acts{display:flex;gap:6px} +.fm-item .acts button{background:none;border:1px solid #0f3460;color:#8899aa; + padding:2px 8px;border-radius:3px;cursor:pointer;font-size:11px} +.fm-item .acts button:hover{border-color:#e94560;color:#e94560} +.fm-empty{padding:24px;text-align:center;color:#556;font-size:13px} + +.upload-zone{border:2px dashed #0f3460;border-radius:8px;padding:24px;text-align:center; + color:#556;font-size:13px;cursor:pointer;transition:border-color .2s;margin-top:12px} +.upload-zone:hover,.upload-zone.drag{border-color:#e94560;color:#8899aa} +.upload-zone input{display:none} + +.net-if{margin-bottom:12px} +.net-if h4{font-size:12px;color:#7ec8e3;margin-bottom:6px} + +.toast{position:fixed;bottom:20px;right:20px;background:#16213e;border:1px solid #0f3460; + border-radius:8px;padding:10px 16px;font-size:12px;color:#c0d0e0; + transform:translateY(80px);opacity:0;transition:all .3s;z-index:999} +.toast.show{transform:translateY(0);opacity:1} +.toast.err{border-color:#e94560} +</style> +</head> +<body> + +<div class="hdr"> + <h1>NuttX Web Panel <span class="chip" id="chip">--</span></h1> + <span class="uptime" id="uptime">Uptime: --</span> +</div> + +<nav id="nav"> + <a href="#home" class="act" data-p="home">Home</a> + <a href="#terminal" data-p="terminal">Terminal</a> + <a href="#files" data-p="files">Files</a> + <a href="#network" data-p="network">Network</a> +</nav> + +<!-- HOME --> +<div class="page vis" id="p-home"> + <div class="grid"> + <div class="card"> + <h3><span class="ico">⚙</span> System</h3> + <div class="row"><span class="lbl">Board</span><span class="val" id="si-board">--</span></div> + <div class="row"><span class="lbl">OS</span><span class="val" id="si-os">NuttX</span></div> + <div class="row"><span class="lbl">Version</span><span class="val" id="si-ver">--</span></div> + <div class="row"><span class="lbl">Build</span><span class="val" id="si-build">--</span></div> + <div class="row"><span class="lbl">Arch</span><span class="val" id="si-arch">--</span></div> + </div> + <div class="card"> + <h3><span class="ico">🔌</span> Network</h3> + <div class="row"><span class="lbl">Interface</span><span class="val" id="si-ifname">--</span></div> + <div class="row"><span class="lbl">IP</span><span class="val" id="si-ip">--</span></div> + <div class="row"><span class="lbl">Mask</span><span class="val" id="si-mask">--</span></div> + <div class="row"><span class="lbl">Gateway</span><span class="val" id="si-gw">--</span></div> + <div class="row"><span class="lbl">MAC</span><span class="val" id="si-mac">--</span></div> + </div> + <div class="card"> + <h3><span class="ico">💾</span> Storage</h3> + <div class="row"><span class="lbl">SmartFS</span><span class="val">/mnt</span></div> + <div class="row"><span class="lbl">Web Root</span><span class="val">/data/www (ROMFS)</span></div> + <div class="row"><span class="lbl">Files</span><span class="val" id="si-files">--</span></div> + </div> + <div class="card"> + <h3><span class="ico">⚡</span> Quick Actions</h3> + <div style="display:flex;flex-direction:column;gap:8px;margin-top:4px"> + <button class="btn" onclick="showPage('terminal')">Open Terminal</button> + <button class="btn btn-sec" onclick="showPage('files')">Manage Files</button> + <button class="btn btn-sec" onclick="fetchSysInfo()">Refresh Info</button> + </div> + </div> + </div> +</div> + +<!-- TERMINAL --> +<div class="page" id="p-terminal"> + <div class="card" style="padding:12px"> + <h3><span class="ico">⌨</span> NSH Terminal</h3> + <div class="fm-toolbar" style="margin-bottom:8px"> + <button class="btn btn-sec" id="term-connect" onclick="connectTerminal()">Connect</button> + <button class="btn btn-sec" id="term-disc" onclick="disconnectTerminal()" style="display:none">Disconnect</button> + <span id="term-status" style="font-size:11px;color:#8899aa;margin-left:8px">Disconnected</span> + </div> + <div class="term-wrap" id="term-container"></div> + </div> +</div> + +<!-- FILES --> +<div class="page" id="p-files"> + <div class="card" style="padding:12px"> + <h3><span class="ico">📁</span> File Manager — /mnt</h3> + <div class="fm-toolbar"> + <span class="fm-path">/mnt</span> + <button class="btn btn-sec" onclick="fetchFiles()">Refresh</button> + </div> + <div class="fm-list" id="fm-list"> + <div class="fm-empty">Loading file list…</div> + </div> + <div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-in').click()"> + <input type="file" id="file-in" onchange="uploadFile(this.files[0])"> + Drop file here or click to upload to /mnt + </div> + </div> +</div> + +<!-- NETWORK --> +<div class="page" id="p-network"> + <div class="grid"> + <div class="card"> + <h3><span class="ico">🔌</span> <span id="net-ifname">--</span></h3> + <div class="net-if"> + <div class="row"><span class="lbl">Status</span><span class="val"><span class="badge badge-ok">RUNNING</span></span></div> + <div class="row"><span class="lbl">IP Address</span><span class="val" id="net-ip">--</span></div> + <div class="row"><span class="lbl">Netmask</span><span class="val" id="net-mask">--</span></div> + <div class="row"><span class="lbl">Gateway</span><span class="val" id="net-gw">--</span></div> + <div class="row"><span class="lbl">MAC</span><span class="val" id="net-mac">--</span></div> + <div class="row"><span class="lbl">MTU</span><span class="val" id="net-mtu">1500</span></div> + </div> + </div> + <div class="card"> + <h3><span class="ico">📜</span> DHCP</h3> + <div class="row"><span class="lbl">Mode</span><span class="val">Client (auto)</span></div> + <div class="row"><span class="lbl">Last Renew</span><span class="val" id="net-lease">--</span></div> + <div style="margin-top:10px;display:flex;align-items:center;gap:10px"> + <button class="btn btn-sec" id="renew-btn" onclick="renewDhcp()">Renew Lease</button> + <span id="renew-status" style="font-size:11px;color:#8899aa"></span> + </div> + </div> + </div> +</div> + +<div class="toast" id="toast"></div> + +<script src="/xterm.min.js"></script> +<script> +var term=null, termWs=null, termReady=false, termQueue=[], termDataSub=null; +var uptimeSec=0, uptimeRef=0; +function $(id){return document.getElementById(id)} +function showPage(p){ + document.querySelectorAll('.page').forEach(function(e){e.classList.remove('vis')}); + document.querySelectorAll('nav a').forEach(function(e){e.classList.remove('act')}); + $('p-'+p).classList.add('vis'); + document.querySelector('nav a[data-p="'+p+'"]').classList.add('act'); + if(p==='terminal')initTerminal(); +} +document.querySelectorAll('nav a').forEach(function(a){ + a.addEventListener('click',function(e){ + e.preventDefault();showPage(this.dataset.p); + }); +}); + +function toast(msg,err){ + var t=$('toast');t.textContent=msg; + t.className='toast'+(err?' err':'')+' show'; + setTimeout(function(){t.classList.remove('show')},3000); +} + +function fmtUptime(s){ + var d=Math.floor(s/86400); + var h=Math.floor((s%86400)/3600); + var m=Math.floor((s%3600)/60); + var sec=s%60; + var hh=('0'+h).slice(-2),mm=('0'+m).slice(-2),ss=('0'+sec).slice(-2); + return d>0?(d+'d '+hh+':'+mm+':'+ss):(hh+':'+mm+':'+ss); +} + +setInterval(function(){ + if(!uptimeRef)return; + var s=uptimeSec+Math.floor((Date.now()-uptimeRef)/1000); + $('uptime').textContent='Uptime: '+fmtUptime(s); +},1000); + +function fetchSysInfo(){ + var x=new XMLHttpRequest(); + x.open('GET','/cgi-bin/sysinfo'); + x.onload=function(){ + if(x.status===200){ + try{ + var d=JSON.parse(x.responseText); + if(d.version)$('si-ver').textContent=d.version; + if(d.build)$('si-build').textContent=d.build; + if(d.chip)$('chip').textContent=d.chip; + if(d.board)$('si-board').textContent=d.board; + if(d.ifname){ + $('si-ifname').textContent=d.ifname; + $('net-ifname').textContent=d.ifname; + } + if(d.arch)$('si-arch').textContent=d.arch; + if(d.ip){$('si-ip').textContent=d.ip;$('net-ip').textContent=d.ip;} + if(d.mask){$('si-mask').textContent=d.mask;$('net-mask').textContent=d.mask;} + if(d.gw){$('si-gw').textContent=d.gw;$('net-gw').textContent=d.gw;} + if(d.mac){$('si-mac').textContent=d.mac;$('net-mac').textContent=d.mac;} + if(d.uptime_sec!==undefined){ + uptimeSec=d.uptime_sec; + uptimeRef=Date.now(); + $('uptime').textContent='Uptime: '+fmtUptime(d.uptime_sec); + } else if(d.uptime){ + $('uptime').textContent='Uptime: '+d.uptime; + } + if(d.files!==undefined)$('si-files').textContent=d.files; + toast('System info updated'); + }catch(e){toast('Parse error','err');} + } + }; + x.onerror=function(){}; + x.send(); +} + +function fetchFiles(){ + var x=new XMLHttpRequest(); + x.open('GET','/cgi-bin/files'); + x.onload=function(){ + if(x.status===200){ + try{ + var d=JSON.parse(x.responseText); + renderFiles(d.files||[]); + }catch(e){renderFiles([]);} + } + }; + x.onerror=function(){ + $('fm-list').innerHTML='<div class="fm-empty">Could not load files (CGI not ready)</div>'; + }; + x.send(); +} + +function renderFiles(files){ + var el=$('fm-list'); + if(!files.length){el.innerHTML='<div class="fm-empty">No files in /mnt</div>';return;} + var h=''; + for(var i=0;i<files.length;i++){ + var f=files[i]; + h+='<div class="fm-item">' + +'<span class="name">'+esc(f.name)+'</span>' + +'<span class="sz">'+(f.size||'--')+'</span>' + +'<span class="acts">'; + if(f.name.match(/\.py$/i)) + h+='<button onclick="runScript(\''+esc(f.name)+'\')">Run</button>'; + h+='<button onclick="deleteFile(\''+esc(f.name)+'\')">Del</button>' + +'</span></div>'; + } + el.innerHTML=h; +} + +function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');} + +function uploadFile(file){ + if(!file)return; + toast('Uploading '+file.name+'...'); + var form=new FormData(); + form.append('file',file); + var x=new XMLHttpRequest(); + x.open('POST','/cgi-bin/upload'); + x.onload=function(){ + if(x.status===200){toast('Uploaded '+file.name);fetchFiles();} + else toast('Upload failed: '+x.status,true); + }; + x.onerror=function(){toast('Upload failed (CGI not ready)',true);}; + x.send(form); +} + +function deleteFile(name){ + if(!confirm('Delete '+name+'?'))return; + var x=new XMLHttpRequest(); + x.open('POST','/cgi-bin/files?action=delete&name='+encodeURIComponent(name)); + x.onload=function(){ + if(x.status===200){toast('Deleted '+name);fetchFiles();} + else toast('Delete failed',true); + }; + x.send(); +} + +function initTerminal(){ + if(term)return; + term=new Terminal({cursorBlink:true,fontSize:13,theme:{background:'#0d1117',foreground:'#c0d0e0'}}); + term.open($('term-container')); + term.writeln('NuttX Web Terminal - click Connect to start NSH session'); + termDataSub=term.onData(function(data){ + if(termWs&&termWs.readyState===WebSocket.OPEN)termWs.send(data); + else termQueue.push(data); + }); +} + +function setTermStatus(s,ok){ + $('term-status').textContent=s; + $('term-status').style.color=ok?'#4ade80':'#8899aa'; +} + +function connectTerminal(){ + initTerminal(); + if(termWs&&termWs.readyState===WebSocket.OPEN)return; + var host=window.location.hostname||'127.0.0.1'; + var url='ws://'+host+':8080'; + setTermStatus('Connecting...',false); + termWs=new WebSocket(url); + termWs.onopen=function(){ + termReady=true; + setTermStatus('Connected',true); + $('term-connect').style.display='none'; + $('term-disc').style.display=''; + while(termQueue.length)termWs.send(termQueue.shift()); + }; + termWs.onmessage=function(ev){term.write(ev.data);}; + termWs.onclose=function(){ + termReady=false; + setTermStatus('Disconnected',false); + $('term-connect').style.display=''; + $('term-disc').style.display='none'; + termWs=null; + }; + termWs.onerror=function(){ + toast('WebSocket connection failed',true); + setTermStatus('Error',false); + }; +} + +function disconnectTerminal(){ + if(termWs)termWs.close(); +} + +function sendTerminalCmd(cmd){ + showPage('terminal'); + if(!term)initTerminal(); + if(!termWs||termWs.readyState!==WebSocket.OPEN){ + connectTerminal(); + setTimeout(function(){sendTerminalCmd(cmd);},800); + return; + } + termWs.send(cmd); +} + +function runScript(name){ + var cmd='python /mnt/'+name; + sendTerminalCmd(cmd+'\r'); + toast('Running: '+cmd); +} + +function renewDhcp(){ + var btn=$('renew-btn'), st=$('renew-status'); + btn.disabled=true; + st.textContent='Renewing...'; + st.style.color='#fbbf24'; + var x=new XMLHttpRequest(); + x.open('GET','/cgi-bin/dhcprenew'); + x.timeout=30000; + x.onload=function(){ + btn.disabled=false; + try{ + var d=JSON.parse(x.responseText); + if(d.status==='ok'){ + var now=new Date(); + var ts=('0'+now.getHours()).slice(-2)+':'+('0'+now.getMinutes()).slice(-2)+':'+('0'+now.getSeconds()).slice(-2); + $('net-lease').textContent='Renewed at '+ts; + st.textContent='OK'; + st.style.color='#4ade80'; + toast('DHCP lease renewed'); + setTimeout(fetchSysInfo,1000); + } else { + st.textContent='Failed (code '+d.code+')'; + st.style.color='#e94560'; + toast('DHCP renew failed',true); + } + }catch(e){ + st.textContent='Error'; + st.style.color='#e94560'; + toast('DHCP renew error',true); + } + setTimeout(function(){st.textContent='';},5000); + }; + x.onerror=x.ontimeout=function(){ + btn.disabled=false; + st.textContent='Timeout'; + st.style.color='#e94560'; + toast('DHCP renew timed out',true); + }; + x.send(); +} + +var uz=$('upload-zone'); +uz.addEventListener('dragover',function(e){e.preventDefault();uz.classList.add('drag');}); +uz.addEventListener('dragleave',function(){uz.classList.remove('drag');}); +uz.addEventListener('drop',function(e){ + e.preventDefault();uz.classList.remove('drag'); + if(e.dataTransfer.files.length)uploadFile(e.dataTransfer.files[0]); +}); + +fetchSysInfo(); +fetchFiles(); +</script> +</body> +</html> diff --git a/examples/webpanel/content/www/xterm.css b/examples/webpanel/content/www/xterm.css new file mode 100644 index 000000000..e97b64390 --- /dev/null +++ b/examples/webpanel/content/www/xterm.css @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + user-select: text; + white-space: pre; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/examples/webpanel/content/www/xterm.min.js b/examples/webpanel/content/www/xterm.min.js new file mode 100644 index 000000000..0a51bfb69 --- /dev/null +++ b/examples/webpanel/content/www/xterm.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/@xterm/[email protected]/lib/xterm.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.d [...] ",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="",e.HOP="",e.BPH="",e.NBH="",e.IND="",e.NEL=" ",e.SSA="",e.ESA="",e.HTS="",e.HTJ="",e.VTS="",e.PLD="",e.PLU="",e.RI="",e.SS2="",e.SS3="",e.DCS="",e.PU1="",e.PU2="",e.STS="",e.CCH="",e.MW="",e.SPA="",e.EPA="",e.SOS="",e.SGCI="",e.SCI="",e.CSI="",e.ST="",e.OSC="",e.PM="",e.APC=""}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3", [...] +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/examples/webpanel/webpanel_main.c b/examples/webpanel/webpanel_main.c new file mode 100644 index 000000000..7831808f3 --- /dev/null +++ b/examples/webpanel/webpanel_main.c @@ -0,0 +1,207 @@ +/**************************************************************************** + * apps/examples/webpanel/webpanel_main.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <sys/mount.h> +#include <sys/boardctl.h> +#include <sys/stat.h> +#include <sys/statfs.h> +#include <stdio.h> +#include <stdlib.h> +#include <errno.h> + +#include <nuttx/drivers/ramdisk.h> + +#include "netutils/thttpd.h" +#include "fsutils/mksmartfs.h" +#include "ws_terminal.h" + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#define SECTORSIZE 64 +#define NSECTORS(b) (((b) + SECTORSIZE - 1) / SECTORSIZE) +#define WEBPANEL_RAMDISK_MINOR 2 +#define ROMFSDEV "/dev/ram2" + +#define ROMFS_MOUNTPT "/data/tmp_romfs" +#define BINFS_MOUNTPT "/data/tmp_binfs" +#define BINFS_PREFIX "cgi-bin" +#define UNIONFS_MOUNTPT CONFIG_THTTPD_PATH + +#define SMARTFS_DEV "/dev/smart0" +#define SMARTFS_MNT "/mnt" + +/**************************************************************************** + * External References + ****************************************************************************/ + +extern const unsigned char webpanel_romfs_img[]; +extern const unsigned int webpanel_romfs_img_len; + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: main + * + * Description: + * Initialize WebPanel filesystem mounts, start the websocket daemon, + * then launch the THTTPD server. + * + * Input Parameters: + * argc - Number of arguments. + * argv - Argument vector. + * + * Returned Value: + * EXIT_SUCCESS on success; EXIT_FAILURE on setup error. + * + ****************************************************************************/ + +int main(int argc, FAR char *argv[]) +{ + struct boardioc_romdisk_s desc; + char *thttpd_argv = "thttpd"; + struct statfs check_fs; + struct statfs sfs; + int ret; + + /* Prevent duplicate startup (e.g. rcS runs again in child NSH) */ + + if (statfs(UNIONFS_MOUNTPT, &check_fs) == 0) + { + return 0; + } + + /* Register the ROMFS image as a ramdisk */ + + printf("WebPanel: Registering ROMFS ramdisk\n"); + + desc.minor = WEBPANEL_RAMDISK_MINOR; + desc.nsectors = NSECTORS(webpanel_romfs_img_len); + desc.sectsize = SECTORSIZE; + desc.image = (FAR uint8_t *)webpanel_romfs_img; + + ret = boardctl(BOARDIOC_ROMDISK, (uintptr_t)&desc); + if (ret < 0 && errno != EEXIST) + { + printf("WebPanel: ERROR romdisk_register failed: %d\n", ret); + return EXIT_FAILURE; + } + + /* Create mount point directories */ + + mkdir("/data", 0777); + mkdir(ROMFS_MOUNTPT, 0777); + mkdir(BINFS_MOUNTPT, 0777); + + /* Mount ROMFS at temp location */ + + printf("WebPanel: Mounting ROMFS at %s\n", ROMFS_MOUNTPT); + + ret = mount(ROMFSDEV, ROMFS_MOUNTPT, "romfs", MS_RDONLY, NULL); + if (ret < 0 && errno != EBUSY) + { + printf("WebPanel: ERROR mount ROMFS failed: %d\n", errno); + return EXIT_FAILURE; + } + + /* Mount BINFS at temp location (exposes built-in apps as CGI) */ + + printf("WebPanel: Mounting BINFS at %s\n", BINFS_MOUNTPT); + + ret = mount(NULL, BINFS_MOUNTPT, "binfs", MS_RDONLY, NULL); + if (ret < 0 && errno != EBUSY) + { + printf("WebPanel: ERROR mount BINFS failed: %d\n", errno); + return EXIT_FAILURE; + } + + /* Create UNIONFS: ROMFS content + BINFS under cgi-bin/ prefix */ + + printf("WebPanel: Creating UNIONFS at %s\n", UNIONFS_MOUNTPT); + + ret = mount(NULL, UNIONFS_MOUNTPT, "unionfs", 0, + "fspath1=" ROMFS_MOUNTPT ",prefix1=" + ",fspath2=" BINFS_MOUNTPT ",prefix2=" BINFS_PREFIX); + if (ret < 0 && errno != EBUSY) + { + printf("WebPanel: ERROR mount UNIONFS failed: %d\n", errno); + return EXIT_FAILURE; + } + + /* Ensure SmartFS is formatted and mounted at /mnt for user files */ + + if (statfs(SMARTFS_MNT, &sfs) != 0) + { + printf("WebPanel: SmartFS not mounted, formatting %s\n", + SMARTFS_DEV); + ret = mksmartfs(SMARTFS_DEV, 0); + if (ret < 0) + { + printf("WebPanel: WARNING mksmartfs failed: %d\n", errno); + } + else + { + mkdir(SMARTFS_MNT, 0777); + ret = mount(SMARTFS_DEV, SMARTFS_MNT, "smartfs", 0, NULL); + if (ret < 0) + { + printf("WebPanel: WARNING mount SmartFS failed: %d\n", + errno); + } + else + { + printf("WebPanel: SmartFS mounted at %s\n", SMARTFS_MNT); + } + } + } + else + { + printf("WebPanel: SmartFS already mounted at %s\n", SMARTFS_MNT); + } + + /* Start WebSocket terminal server */ + + ret = ws_terminal_start(); + if (ret < 0) + { + printf("WebPanel: WARNING WebSocket server failed to start\n"); + } + + /* Start THTTPD */ + + printf("WebPanel: Starting THTTPD (serving from %s)\n", UNIONFS_MOUNTPT); + fflush(stdout); + + thttpd_main(1, &thttpd_argv); + + printf("WebPanel: THTTPD terminated\n"); + return 0; +} diff --git a/examples/webpanel/ws_terminal.c b/examples/webpanel/ws_terminal.c new file mode 100644 index 000000000..7db8d7392 --- /dev/null +++ b/examples/webpanel/ws_terminal.c @@ -0,0 +1,350 @@ +/**************************************************************************** + * apps/examples/webpanel/ws_terminal.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include <nuttx/config.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <fcntl.h> +#include <errno.h> +#include <sched.h> +#include <spawn.h> + +#include <libwebsockets.h> + +#include "ws_terminal.h" + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#ifndef CONFIG_EXAMPLES_WEBPANEL_WS_PORT +# define CONFIG_EXAMPLES_WEBPANEL_WS_PORT 8080 +#endif + +#ifndef CONFIG_EXAMPLES_WEBPANEL_WS_PRIORITY +# define CONFIG_EXAMPLES_WEBPANEL_WS_PRIORITY 100 +#endif + +#ifndef CONFIG_EXAMPLES_WEBPANEL_WS_STACKSIZE +# define CONFIG_EXAMPLES_WEBPANEL_WS_STACKSIZE 8192 +#endif + +#ifndef CONFIG_EXAMPLES_WEBPANEL_WS_NSH_STACKSIZE +# define CONFIG_EXAMPLES_WEBPANEL_WS_NSH_STACKSIZE 4096 +#endif + +#define WS_IOBUF_SIZE 512 + +/**************************************************************************** + * Private Types + ****************************************************************************/ + +struct ws_session +{ + int masterfd; + pid_t nshpid; + unsigned char txbuf[LWS_PRE + WS_IOBUF_SIZE]; + size_t txlen; +}; + +/**************************************************************************** + * Private Function Prototypes + ****************************************************************************/ + +static int ws_spawn_nsh(int masterfd, pid_t *pid); +static int ws_callback(struct lws *wsi, + enum lws_callback_reasons reason, + void *user, void *in, size_t len); +static int ws_daemon(int argc, FAR char *argv[]); + +/**************************************************************************** + * Private Data + ****************************************************************************/ + +static const struct lws_protocols g_protocols[] = +{ + { + "", + ws_callback, + sizeof(struct ws_session), + WS_IOBUF_SIZE, + 0, NULL, 0 + }, + LWS_PROTOCOL_LIST_TERM +}; + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: ws_spawn_nsh + * + * Description: + * Spawn an NSH instance attached to a PTY slave. + * + * Input Parameters: + * masterfd - PTY master descriptor. + * pid - Location to receive spawned task PID. + * + * Returned Value: + * Zero on success; negated errno on failure. + * + ****************************************************************************/ + +static int ws_spawn_nsh(int masterfd, pid_t *pid) +{ + char slavepath[32]; + FAR char *nshargv[] = + { + "nsh", NULL + }; + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attr; + struct sched_param param; + int ret; + + ret = grantpt(masterfd); + if (ret < 0) + { + return ret; + } + + ret = unlockpt(masterfd); + if (ret < 0) + { + return ret; + } + + ret = ptsname_r(masterfd, slavepath, sizeof(slavepath)); + if (ret < 0) + { + return ret; + } + + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_addopen(&actions, 0, slavepath, + O_RDWR, 0); + posix_spawn_file_actions_adddup2(&actions, 0, 1); + posix_spawn_file_actions_adddup2(&actions, 0, 2); + + posix_spawnattr_init(&attr); + param.sched_priority = CONFIG_EXAMPLES_WEBPANEL_WS_PRIORITY; + posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSCHEDPARAM); + posix_spawnattr_setschedparam(&attr, ¶m); + posix_spawnattr_setstacksize(&attr, + CONFIG_EXAMPLES_WEBPANEL_WS_NSH_STACKSIZE); + + ret = posix_spawn(pid, "nsh", &actions, &attr, nshargv, NULL); + + posix_spawn_file_actions_destroy(&actions); + posix_spawnattr_destroy(&attr); + + return ret == 0 ? 0 : -ret; +} + +/**************************************************************************** + * Name: ws_callback + * + * Description: + * libwebsockets protocol callback for the NSH terminal relay. + * + ****************************************************************************/ + +static int ws_callback(struct lws *wsi, + enum lws_callback_reasons reason, + void *user, void *in, size_t len) +{ + struct ws_session *sess = (struct ws_session *)user; + ssize_t n; + + switch (reason) + { + case LWS_CALLBACK_ESTABLISHED: + sess->masterfd = open("/dev/ptmx", O_RDWR | O_NOCTTY); + if (sess->masterfd < 0) + { + printf("ws_terminal: failed to open PTY\n"); + return -1; + } + + sess->nshpid = -1; + if (ws_spawn_nsh(sess->masterfd, &sess->nshpid) < 0) + { + printf("ws_terminal: failed to spawn NSH\n"); + close(sess->masterfd); + sess->masterfd = -1; + return -1; + } + + /* Make PTY master non-blocking for polling */ + + fcntl(sess->masterfd, F_SETFL, + fcntl(sess->masterfd, F_GETFL) | O_NONBLOCK); + + sess->txlen = 0; + printf("ws_terminal: client connected\n"); + + lws_set_timer_usecs(wsi, 50 * LWS_USEC_PER_SEC / 1000); + break; + + case LWS_CALLBACK_RECEIVE: + if (sess->masterfd >= 0 && len > 0) + { + write(sess->masterfd, in, len); + } + + break; + + case LWS_CALLBACK_SERVER_WRITEABLE: + if (sess->masterfd < 0) + { + break; + } + + n = read(sess->masterfd, + &sess->txbuf[LWS_PRE], + WS_IOBUF_SIZE); + if (n > 0) + { + lws_write(wsi, &sess->txbuf[LWS_PRE], n, LWS_WRITE_TEXT); + lws_set_timer_usecs(wsi, 10 * LWS_USEC_PER_SEC / 1000); + } + else + { + lws_set_timer_usecs(wsi, 50 * LWS_USEC_PER_SEC / 1000); + } + + break; + + case LWS_CALLBACK_TIMER: + lws_callback_on_writable(wsi); + break; + + case LWS_CALLBACK_CLOSED: + printf("ws_terminal: client disconnected\n"); + if (sess->nshpid > 0) + { + task_delete(sess->nshpid); + sess->nshpid = -1; + } + + if (sess->masterfd >= 0) + { + close(sess->masterfd); + sess->masterfd = -1; + } + + break; + + default: + break; + } + + return 0; +} + +/**************************************************************************** + * Name: ws_daemon + * + * Description: + * WebSocket terminal daemon using libwebsockets. + * + ****************************************************************************/ + +static int ws_daemon(int argc, FAR char *argv[]) +{ + struct lws_context_creation_info info; + struct lws_context *context; + int n; + + lws_set_log_level(LLL_ERR | LLL_WARN, NULL); + + memset(&info, 0, sizeof(info)); + info.port = CONFIG_EXAMPLES_WEBPANEL_WS_PORT; + info.protocols = g_protocols; + info.vhost_name = "localhost"; + info.options = 0; + + context = lws_create_context(&info); + if (context == NULL) + { + printf("ws_terminal: ERROR creating lws context\n"); + return EXIT_FAILURE; + } + + printf("WebPanel: WebSocket terminal listening on port %d\n", + CONFIG_EXAMPLES_WEBPANEL_WS_PORT); + + n = 0; + while (n >= 0) + { + n = lws_service(context, 100); + } + + lws_context_destroy(context); + return EXIT_FAILURE; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +/**************************************************************************** + * Name: ws_terminal_start + * + * Description: + * Start the websocket terminal daemon task. + * + * Input Parameters: + * None. + * + * Returned Value: + * Task PID on success; negated errno value on failure. + * + ****************************************************************************/ + +int ws_terminal_start(void) +{ + int pid; + + pid = task_create("ws_daemon", + CONFIG_EXAMPLES_WEBPANEL_WS_PRIORITY, + CONFIG_EXAMPLES_WEBPANEL_WS_STACKSIZE, + ws_daemon, NULL); + if (pid < 0) + { + printf("WebPanel: ERROR failed to start WebSocket server: %d\n", + errno); + return -errno; + } + + return pid; +} diff --git a/examples/webpanel/ws_terminal.h b/examples/webpanel/ws_terminal.h new file mode 100644 index 000000000..91cc637d9 --- /dev/null +++ b/examples/webpanel/ws_terminal.h @@ -0,0 +1,63 @@ +/**************************************************************************** + * apps/examples/webpanel/ws_terminal.h + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +#ifndef __APPS_EXAMPLES_WEBPANEL_WS_TERMINAL_H +#define __APPS_EXAMPLES_WEBPANEL_WS_TERMINAL_H + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#ifdef __cplusplus +#define EXTERN extern "C" +extern "C" +{ +#else +#define EXTERN extern +#endif + +/**************************************************************************** + * Public Function Prototypes + ****************************************************************************/ + +/**************************************************************************** + * Name: ws_terminal_start + * + * Description: + * Start the websocket terminal daemon task. + * + * Input Parameters: + * None. + * + * Returned Value: + * Task PID on success; negated errno value on failure. + * + ****************************************************************************/ + +int ws_terminal_start(void); + +#undef EXTERN +#ifdef __cplusplus +} +#endif + +#endif /* __APPS_EXAMPLES_WEBPANEL_WS_TERMINAL_H */
