diff --git a/README.md b/README.md index 0ed5d41..df3edb6 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Enable TLS: ```sh curl -s --anyauth -u "root:$DEVICE_PASSWORD" \ - "http://$DEVICE_IP/axis-cgi/param.cgi?action=update&root.dockerdwrapper.UseTLS=yes" + "http://$DEVICE_IP/axis-cgi/param.cgi?action=update&root.dockerdwrapperwithcompose.UseTLS=yes" ``` Enable TCP Socket: @@ -116,34 +116,43 @@ Running the ACAP without TLS requires no further setup. ### TLS Setup -TLS requires a few keys and certificates to work, which are listed in the -subsections below. For more information on how to generate these files, please -consult the official [Docker documentation](https://docs.docker.com/engine/security/protect-access/). -Most of these keys and certificates need to be moved to the Axis device. There are multiple ways to -achieve this, for example by using `scp` to copy the files from a remote machine onto the device. -This can be done by running the following command on the remote machine: +TLS requires the following keys and certificates on the device: -```sh -scp ca.pem server-cert.pem server-key.pem root@:/usr/local/packages/dockerdwrapperwithcompose/localdata/ -``` +* Certificate Authority certificate `ca.pem` +* Server certificate `server-cert.pem` +* Private server key `server-key.pem` -#### The Certificate Authority (CA) certificate +For more information on how to generate these files, please consult the official +[Docker documentation](https://docs.docker.com/engine/security/protect-access/). -This certificate needs to be present in the dockerdwrapperwithcompose package folder on the -Axis device and be named `ca.pem`. The full path of the file should be -`/usr/local/packages/dockerdwrapperwithcompose/localdata/ca.pem`. +The files can be uploaded to the device using HTTP. -#### The server certificate +```sh +curl --anyauth -u "root:$DEVICE_PASSWORD" -F file=@ca.pem -X POST \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/ca.pem +curl --anyauth -u "root:$DEVICE_PASSWORD" -F file=@server-cert.pem -X POST \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/server-cert.pem +curl --anyauth -u "root:$DEVICE_PASSWORD" -F file=@server-key.pem -X POST \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/server-key.pem +``` -This certificate needs to be present in the dockerdwrapperwithcompose package folder on the -Axis device and be named `server-cert.pem`. The full path of the file should be -`/usr/local/packages/dockerdwrapperwithcompose/localdata/server-cert.pem`. +If desired, they can be deleted from the device using: -#### The private server key +```sh +curl --anyauth -u "root:$DEVICE_PASSWORD" -X DELETE \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/ca.pem +curl --anyauth -u "root:$DEVICE_PASSWORD" -X DELETE \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/server-cert.pem +curl --anyauth -u "root:$DEVICE_PASSWORD" -X DELETE \ + http://$DEVICE_IP/local/dockerdwrapperwithcompose/server-key.pem +``` + +They can also be copied to the `/usr/local/packages/dockerdwrapperwithcompose/localdata` +directory of the device using `scp`. -This key needs to be present in the dockerdwrapperwithcompose package folder on the Axis device -and be named `server-key.pem`. The full path of the file should be -`/usr/local/packages/dockerdwrapperwithcompose/localdata/server-key.pem`. +```sh +scp ca.pem server-cert.pem server-key.pem root@:/usr/local/packages/dockerdwrapperwithcompose/localdata/ +``` #### Client key and certificate diff --git a/app/Makefile b/app/Makefile index eb937d4..43474d1 100644 --- a/app/Makefile +++ b/app/Makefile @@ -1,7 +1,7 @@ PROG1 = dockerdwrapperwithcompose -OBJS1 = $(PROG1).o log.o sd_disk_storage.o tls.o +OBJS1 = $(PROG1).o fcgi_server.o fcgi_write_file_from_stream.o http_request.o log.o sd_disk_storage.o tls.o -PKGS = gio-2.0 glib-2.0 axparameter axstorage +PKGS = gio-2.0 glib-2.0 axparameter axstorage fcgi CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) @@ -16,7 +16,10 @@ $(PROG1): $(OBJS1) $(CC) $(CFLAGS) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@ $(PROG1).o tls.o: app_paths.h -$(PROG1).o log.o sd_disk_storage.o tls.o: log.h +$(PROG1).o fcgi_server.o: fcgi_server.h +fcgi_server.o fcgi_write_file_from_stream.o: fcgi_write_file_from_stream.h +$(PROG1).o fcgi_server.o http_request.o log.o sd_disk_storage.o tls.o: log.h +$(PROG1).o http_request.o: http_request.h $(PROG1).o sd_disk_storage.o: sd_disk_storage.h $(PROG1).o tls.o: tls.h diff --git a/app/dockerdwrapperwithcompose.c b/app/dockerdwrapperwithcompose.c index 1dd92a6..8ba7f73 100644 --- a/app/dockerdwrapperwithcompose.c +++ b/app/dockerdwrapperwithcompose.c @@ -16,6 +16,8 @@ #define _GNU_SOURCE // For sigabbrev_np() #include "app_paths.h" +#include "fcgi_server.h" +#include "http_request.h" #include "log.h" #include "sd_disk_storage.h" #include "tls.h" @@ -793,6 +795,10 @@ int main(int argc, char** argv) { init_signals(); + int fcgi_error = fcgi_start(http_request_callback); + if (fcgi_error) + return fcgi_error; + struct sd_disk_storage* sd_disk_storage = sd_disk_storage_init(sd_card_callback, &app_state); while (application_exit_code == EX_KEEP_RUNNING) { @@ -807,6 +813,8 @@ int main(int argc, char** argv) { } main_loop_unref(); + fcgi_stop(); + set_status_parameter(app_state.param_handle, STATUS_NOT_STARTED); if (app_state.param_handle != NULL) { diff --git a/app/fcgi_server.c b/app/fcgi_server.c new file mode 100644 index 0000000..c6488f1 --- /dev/null +++ b/app/fcgi_server.c @@ -0,0 +1,84 @@ +#include "fcgi_server.h" +#include "log.h" +#include +#include +#include +#include +#include +#include +#include + +#define FCGI_SOCKET_NAME "FCGI_SOCKET_NAME" + +static const char* g_socket_path = NULL; +static int g_socket = -1; +static GThread* g_thread = NULL; + +static void* handle_fcgi(void* request_callback) { + GThreadPool* workers = g_thread_pool_new((GFunc)request_callback, NULL, -1, FALSE, NULL); + while (workers) { + FCGX_Request* request = g_malloc0(sizeof(FCGX_Request)); + FCGX_InitRequest(request, g_socket, FCGI_FAIL_ACCEPT_ON_INTR); + if (FCGX_Accept_r(request) < 0) { + log_info("FCGX_Accept_r: %s", strerror(errno)); + g_free(request); + break; + } + g_thread_pool_push(workers, request, NULL); + } + log_info("Stopping FCGI server"); + g_thread_pool_free(workers, true, false); + return NULL; +} + +int fcgi_start(fcgi_request_callback request_callback) { + log_debug("Starting FCGI server"); + + g_socket_path = getenv(FCGI_SOCKET_NAME); + if (!g_socket_path) { + log_error("Failed to get environment variable FCGI_SOCKET_NAME"); + return EX_SOFTWARE; + } + + if (FCGX_Init() != 0) { + log_error("FCGX_Init failed: %s", strerror(errno)); + return EX_SOFTWARE; + } + + if ((g_socket = FCGX_OpenSocket(g_socket_path, 5)) < 0) { + log_error("FCGX_OpenSocket failed: %s", strerror(errno)); + return EX_SOFTWARE; + } + chmod(g_socket_path, S_IRWXU | S_IRWXG | S_IRWXO); + + /* Create a thread for request handling */ + if ((g_thread = g_thread_new("fcgi_server", &handle_fcgi, request_callback)) == NULL) { + log_error("Failed to launch FCGI server thread"); + return EX_SOFTWARE; + } + + log_debug("Launched FCGI server thread."); + return EX_OK; +} + +void fcgi_stop(void) { + log_debug("Stopping FCGI server."); + FCGX_ShutdownPending(); + + if (g_socket != -1) { + log_debug("Closing and removing FCGI socket."); + if (shutdown(g_socket, SHUT_RD) != 0) { + log_warning("Could not shutdown socket, err: %s", strerror(errno)); + } + if (unlink(g_socket_path) != 0) { + log_warning("Could not unlink socket, err: %s", strerror(errno)); + } + } + log_debug("Joining FCGI server thread."); + g_thread_join(g_thread); + + g_socket_path = NULL; + g_socket = -1; + g_thread = NULL; + log_debug("FCGI server has stopped."); +} diff --git a/app/fcgi_server.h b/app/fcgi_server.h new file mode 100644 index 0000000..6d72f68 --- /dev/null +++ b/app/fcgi_server.h @@ -0,0 +1,6 @@ +#pragma once + +typedef void (*fcgi_request_callback)(void* request_void_ptr, void* userdata); + +int fcgi_start(fcgi_request_callback request_callback); +void fcgi_stop(void); diff --git a/app/fcgi_write_file_from_stream.c b/app/fcgi_write_file_from_stream.c new file mode 100644 index 0000000..02895fd --- /dev/null +++ b/app/fcgi_write_file_from_stream.c @@ -0,0 +1,167 @@ +#include "fcgi_write_file_from_stream.h" +#include "fcgi_server.h" +#include "log.h" +#include + +static int request_content_length(const FCGX_Request* request) { + const char* content_length_str = FCGX_GetParam("CONTENT_LENGTH", request->envp); + if (!content_length_str) + return 0; + return strtol(content_length_str, NULL, 10); +} + +char* fcgi_write_file_from_stream(FCGX_Request request) { + char* temp_file = NULL; + const int content_length = request_content_length(&request); + const char* content_type = FCGX_GetParam("CONTENT_TYPE", request.envp); + + log_debug("Content-Type: %s", content_type); + + const char* MULTIPART_FORM_DATA = "multipart/form-data"; + if (strncmp(content_type, MULTIPART_FORM_DATA, sizeof(MULTIPART_FORM_DATA) - 1) != 0) { + log_error("Content type \"%s\" is not supported. Use \"%s\" instead.", + content_type, + MULTIPART_FORM_DATA); + return NULL; + } + + const char* BOUNDARY_KEY = "boundary="; + const char* boundary_text = strstr(content_type, BOUNDARY_KEY); + if (!boundary_text) { + log_error("No multipart boundary found in content-type \"%s\".", content_type); + return NULL; + } + boundary_text += strlen(BOUNDARY_KEY); + const int boundary_len = strlen(boundary_text); + + temp_file = g_strdup_printf("/tmp/fcgi_upload.XXXXXX"); + int file_des = mkstemp(temp_file); + if (file_des == -1) { + log_error("Failed to create %s, err %s.", temp_file, strerror(errno)); + return NULL; + } + log_debug("Opened %s for writing.", temp_file); + + bool remove_temp_file = true; // Clear this to return the filename to the caller. + + const int bufferLen = 2048; + char buffer[bufferLen + 1 /* Allow for NULL termination */]; + + const char* data_start = "\r\n\r\n"; + const char* data_end = "\r\n--"; + + int total_bytes_processed = 0; + bool pre_boundary_found = false; + bool post_boundary_found = false; + + int loop_counter = 0; // First iteration is special. + char* p_payload = buffer; + char* p_payload_end = NULL; + + while (total_bytes_processed < content_length) { + loop_counter++; + const int available_len = bufferLen - (p_payload - buffer); + + const int bytes_read = FCGX_GetStr(p_payload, available_len, request.in); + log_debug("FCGX_GetStr: bytes_read %d, p_payload %p(%d), available_len %d(%d)", + bytes_read, + p_payload, + (int)(p_payload - buffer), + available_len, + available_len - bufferLen); + if (bytes_read < 0) { + log_error("Failed to read from FCGI stream: %s", strerror(errno)); + break; + } + + /* Look for pre boundary */ + if (!pre_boundary_found) { + buffer[bytes_read] = 0; /* NULL terminate */ + p_payload = strstr(buffer + boundary_len + 1, data_start); + if (p_payload == NULL) { + log_error("Failed to find boundary in uploaded data."); + } + pre_boundary_found = true; + p_payload += strlen(data_start); + } else { + log_debug("Pre boundary already found"); + p_payload = buffer; + } + + /* Look for post boundary */ + if (!post_boundary_found) { + char* pchar; + for (pchar = (loop_counter == 1) ? p_payload : buffer; + pchar < buffer + bytes_read - ((loop_counter == 1) ? boundary_len : 0); + pchar++) { + if (memcmp(pchar, boundary_text, boundary_len) == 0) { + log_debug("Post boundary found for %.*s", boundary_len, pchar); + pchar -= strlen(data_end); + if (memcmp(pchar, data_end, strlen(data_end)) != 0) { + log_error("Post boundary data end not found"); + log_debug("Found %02x%02x%02x%02x at post boundary", + pchar[0], + pchar[1], + pchar[2], + pchar[3]); + goto end; + } + post_boundary_found = true; + break; + } + } + p_payload_end = pchar; + } + + int to_write = p_payload_end - p_payload; + int written = 0; + while (to_write > 0) { + if ((written = write(file_des, p_payload + written, to_write)) < 0) { + log_error("Failed to write %d bytes to %s: %s", + to_write, + temp_file, + strerror(errno)); + goto end; + } + total_bytes_processed += written; + to_write -= written; + } + log_debug("write: p_payload %p, %d bytes", p_payload, (int)(p_payload_end - p_payload)); + log_debug("loop %d, bytes_read %d, done %d", + loop_counter, + bytes_read, + total_bytes_processed); + + if (post_boundary_found) { + total_bytes_processed = content_length; + remove_temp_file = false; // File has been successfully received. + } else { + if (!pre_boundary_found) { + log_error("No pre boundary found"); + goto end; + } + if (bytes_read != available_len) { + log_error("No post boundary found"); + goto end; + } + + /* Post boundary may have been partial at payload end. Ensure possible rematch */ + p_payload = buffer + boundary_len; + memcpy(buffer, p_payload_end, boundary_len); + } + } + +end: + if (file_des != -1) { + log_debug("Closing %s after writing %ld bytes.", temp_file, lseek(file_des, 0, SEEK_CUR)); + if (close(file_des) == -1) + log_warning("Failed to close %s: %s", temp_file, strerror(errno)); + } + if (remove_temp_file && temp_file) { + if (unlink(temp_file) != 0) + log_error("Failed to remove %s: %s", temp_file, strerror(errno)); + g_free(temp_file); + temp_file = NULL; + } + return temp_file; +} diff --git a/app/fcgi_write_file_from_stream.h b/app/fcgi_write_file_from_stream.h new file mode 100644 index 0000000..a6506d4 --- /dev/null +++ b/app/fcgi_write_file_from_stream.h @@ -0,0 +1,7 @@ +#pragma once +#include + +// Given a request with multipart/form-data, store incoming data in a file on /tmp. On success, +// return the filename and let the caller do all cleanup. On failure, log the error, clean up the +// file and return NULL. +char* fcgi_write_file_from_stream(FCGX_Request request); diff --git a/app/html/index.html b/app/html/index.html new file mode 100644 index 0000000..60412d6 --- /dev/null +++ b/app/html/index.html @@ -0,0 +1,19 @@ + + + Docker Daemon Usage + +

Docker Daemon Usage

+

Upload TLS certificates and keys

+ + curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -F file=@ca.pem -X POST http://$DEVICE_IP/local/dockerdwrapper/ca.pem
+ curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -F file=@server-cert.pem -X POST http://$DEVICE_IP/local/dockerdwrapper/server-cert.pem
+ curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -F file=@server-key.pem -X POST http://$DEVICE_IP/local/dockerdwrapper/server-key.pem
+
+

Remove TLS certificates and keys

+ + curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -X DELETE http://$DEVICE_IP/local/dockerdwrapper/ca.pem
+ curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -X DELETE http://$DEVICE_IP/local/dockerdwrapper/server-cert.pem
+ curl --anyauth -u $DEVICE_USER:$DEVICE_PASSWORD -X DELETE http://$DEVICE_IP/local/dockerdwrapper/server-key.pem
+
+ + diff --git a/app/http_request.c b/app/http_request.c new file mode 100644 index 0000000..d3946f8 --- /dev/null +++ b/app/http_request.c @@ -0,0 +1,134 @@ +#include "http_request.h" +#include "app_paths.h" +#include "fcgi_write_file_from_stream.h" +#include "log.h" +#include +#include + +#define HTTP_200_OK "200 OK" +#define HTTP_204_NO_CONTENT "204 No Content" +#define HTTP_400_BAD_REQUEST "400 Bad Request" +#define HTTP_404_NOT_FOUND "404 Not Found" +#define HTTP_405_METHOD_NOT_ALLOWED "405 Method Not Allowed" +#define HTTP_422_UNPROCESSABLE_CONTENT "422 Unprocessable Content" +#define HTTP_500_INTERNAL_SERVER_ERROR "500 Internal Server Error" + +static char* localdata_full_path(const char* filename) { + return g_strdup_printf("%s/%s", APP_LOCALDATA, filename); +} + +static bool copy_to_localdata(const char* source_path, const char* destination_filename) { + g_autofree char* full_path = localdata_full_path(destination_filename); + log_debug("Copying %s to %s.", source_path, full_path); + + GFile* source = g_file_new_for_path(source_path); + GFile* destination = g_file_new_for_path(full_path); + GError* error = NULL; + bool success = + g_file_copy(source, destination, G_FILE_COPY_OVERWRITE, NULL, NULL, NULL, &error); + if (!success) + log_error("Failed to copy %s to %s: %s.", source_path, full_path, error->message); + g_object_unref(source); + g_object_unref(destination); + g_clear_error(&error); + + return success; +} + +static bool exists_in_localdata(const char* filename) { + g_autofree char* full_path = localdata_full_path(filename); + struct stat sb; + return stat(full_path, &sb) == 0; +} + +static bool remove_from_localdata(const char* filename) { + g_autofree char* full_path = localdata_full_path(filename); + log_debug("Removing %s.", full_path); + bool success = !unlink(full_path); + if (!success) + // Log as warning rather than error, since 'No such file' is also treated as a failure. + log_warning("Failed to remove %s: %s.", filename, strerror(errno)); + return success; +} + +static void +response(FCGX_Request* request, const char* status, const char* content_type, const char* body) { + FCGX_FPrintF(request->out, + "Status: %s\r\n" + "Content-Type: %s\r\n\r\n" + "%s", + status, + content_type, + body); +} + +static void response_204_no_content(FCGX_Request* request) { + const char* status = HTTP_204_NO_CONTENT; + log_debug("Send response %s", status); + FCGX_FPrintF(request->out, "Status: %s\r\n\r\n", status); +} + +static void response_msg(FCGX_Request* request, const char* status, const char* message) { + log_debug("Send response %s: %s", status, message); + g_autofree char* body = g_strdup_printf("%s\r\n", message); + response(request, status, "text/plain", body); +} + +static void post_request(FCGX_Request* request, const char* filename) { + g_autofree char* temp_file = fcgi_write_file_from_stream(*request); + if (!temp_file) { + response_msg(request, HTTP_422_UNPROCESSABLE_CONTENT, "Upload to temporary file failed."); + return; + } + if (!copy_to_localdata(temp_file, filename)) + response_msg(request, HTTP_500_INTERNAL_SERVER_ERROR, "Failed to copy file to localdata"); + else + response_204_no_content(request); + + if (unlink(temp_file) != 0) + log_error("Failed to remove %s: %s", temp_file, strerror(errno)); +} + +static void delete_request(FCGX_Request* request, const char* filename) { + if (!exists_in_localdata(filename)) + response_msg(request, HTTP_404_NOT_FOUND, "File not found in localdata"); + else if (!remove_from_localdata(filename)) + response_msg(request, + HTTP_500_INTERNAL_SERVER_ERROR, + "Failed to remove file from localdata"); + else + response_204_no_content(request); +} + +static void unsupported_request(FCGX_Request* request, const char* method, const char* filename) { + log_error("Unsupported request %s %s", method, filename); + response_msg(request, HTTP_405_METHOD_NOT_ALLOWED, "Unsupported request method"); +} + +static void malformed_request(FCGX_Request* request, const char* method, const char* uri) { + log_error("Malformed request %s %s", method, uri); + response_msg(request, HTTP_400_BAD_REQUEST, "Malformed request"); +} + +void http_request_callback(void* request_void_ptr, __attribute__((unused)) void* userdata) { + FCGX_Request* request = (FCGX_Request*)request_void_ptr; + const char* method = FCGX_GetParam("REQUEST_METHOD", request->envp); + const char* uri = FCGX_GetParam("REQUEST_URI", request->envp); + + log_info("Processing HTTP request %s %s", method, uri); + + const char* filename = strrchr(uri, '/'); + if (!filename) { + malformed_request(request, method, uri); + } else { + filename++; // Strip leading '/' + + if (strcmp(method, "POST") == 0) + post_request(request, filename); + else if (strcmp(method, "DELETE") == 0) + delete_request(request, filename); + else + unsupported_request(request, method, filename); + } + FCGX_Finish_r(request); +} diff --git a/app/http_request.h b/app/http_request.h new file mode 100644 index 0000000..7e84012 --- /dev/null +++ b/app/http_request.h @@ -0,0 +1,4 @@ +#pragma once + +// Callback function called from a thread by the FCGI server +void http_request_callback(void* request_void_ptr, void* userdata); diff --git a/app/manifest.json b/app/manifest.json index 6e7caae..fdd9e9e 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -58,6 +58,24 @@ "default": "-1 No Status", "type": "hidden:string" } + ], + "settingPage": "index.html", + "httpConfig": [ + { + "access": "admin", + "name": "ca.pem", + "type": "fastCgi" + }, + { + "access": "admin", + "name": "server-cert.pem", + "type": "fastCgi" + }, + { + "access": "admin", + "name": "server-key.pem", + "type": "fastCgi" + } ] } }