From 709f5cdeb5964b963c4984354e7676cd333afc01 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Mon, 18 May 2026 01:02:21 +0900 Subject: [PATCH 1/3] socketd: expose initial TCP Docker-compatible discovery API Extend droidspaces-socketd from a backend bridge probe into the first public Socket API daemon usable by Portainer. The daemon now: - performs backend PING and CAPABILITIES handshake at startup - listens on unencrypted TCP via --listen-tcp - defaults to 0.0.0.0:2375 for early integration testing - serves Docker-compatible discovery endpoints: - GET/HEAD /_ping - GET /version - GET /info - versioned /vX.Y/... forms - emits compatibility headers such as Server, Api-Version, and Ostype - logs received HTTP request headers for bring-up debugging /version reports truthful Droidspaces version/platform metadata without fabricating Docker-specific Go build fields. /info is intentionally synthesized in socketd for now so Portainer can complete environment discovery. A clear TODO marks the path to replace or enrich it through DS_SOCKETD_OP_INFO once backend-side information marshalling is added. This is sufficient for Portainer to register Droidspaces as a connected standalone environment over localhost:2375. --- src/socketd/api_server.cpp | 418 +++++++++++++++++++++++++++++++++++-- 1 file changed, 406 insertions(+), 12 deletions(-) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp index 3cbb00ed..ba0ffe66 100644 --- a/src/socketd/api_server.cpp +++ b/src/socketd/api_server.cpp @@ -1,4 +1,5 @@ #include "api_server.h" +#include "../droidspace.h" #include #include @@ -9,15 +10,193 @@ #include #include #include +#include +#include +#include #include #include +#include #include namespace droidspaces::socketd { namespace { constexpr std::size_t kMaxRequestHeaderBytes = 16 * 1024; +constexpr const char* kSocketApiVersion = "1.40"; +constexpr const char* kSocketMinApiVersion = "1.40"; +constexpr const char* kSocketOsType = "linux"; + +std::string socketd_arch_name() { +#if defined(__x86_64__) + return "amd64"; +#elif defined(__i386__) + return "386"; +#elif defined(__aarch64__) + return "arm64"; +#elif defined(__arm__) + return "arm"; +#elif defined(__riscv) && (__riscv_xlen == 64) + return "riscv64"; +#else + return "unknown"; +#endif +} + +std::string socketd_kernel_version() { + struct utsname uts {}; + if (::uname(&uts) != 0) { + return {}; + } + + return uts.release; +} + +std::uint64_t socketd_mem_total_bytes() { + std::ifstream meminfo("/proc/meminfo"); + if (!meminfo.is_open()) { + return 0; + } + + std::string key; + std::uint64_t value_kib = 0; + std::string unit; + + while (meminfo >> key >> value_kib >> unit) { + if (key == "MemTotal:") { + /* + * /proc/meminfo reports MemTotal in KiB. + */ + return value_kib * 1024ULL; + } + } + + return 0; +} + +unsigned int socketd_ncpu() { + const long value = ::sysconf(_SC_NPROCESSORS_ONLN); + if (value <= 0) { + return 0; + } + + if (static_cast(value) > + std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + + return static_cast(value); +} + +std::string socketd_hostname() { + char hostname[256] {}; + if (::gethostname(hostname, sizeof(hostname) - 1) != 0) { + return "droidspaces"; + } + + hostname[sizeof(hostname) - 1] = '\0'; + + if (hostname[0] == '\0') { + return "droidspaces"; + } + + return hostname; +} + +std::string socketd_system_time_utc() { + std::time_t now = std::time(nullptr); + if (now == static_cast(-1)) { + return {}; + } + + std::tm tm {}; +#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) + if (::gmtime_r(&now, &tm) == nullptr) { + return {}; + } +#else + const std::tm* tmp = std::gmtime(&now); + if (tmp == nullptr) { + return {}; + } + tm = *tmp; +#endif + + char buffer[64] {}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { + return {}; + } + + return buffer; +} + +std::string json_escape(const std::string& input) { + std::string out; + out.reserve(input.size()); + + constexpr char kHex[] = "0123456789abcdef"; + + for (unsigned char ch : input) { + switch (ch) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (ch < 0x20) { + out += "\\u00"; + out += kHex[(ch >> 4) & 0x0f]; + out += kHex[ch & 0x0f]; + } else { + out += static_cast(ch); + } + break; + } + } + + return out; +} + +void debug_log_request_headers(const std::string& request) { + const std::size_t header_end = request.find("\r\n\r\n"); + + const std::size_t visible_len = + header_end == std::string::npos + ? request.size() + : header_end + 4; + + std::cerr << "socketd: received HTTP request headers\n"; + std::cerr << "----- BEGIN REQUEST -----\n"; + std::cerr.write(request.data(), static_cast(visible_len)); + + /* + * HTTP headers already end with CRLF CRLF, but ensure the terminal output + * does not visually run into the separator if a malformed request arrives. + */ + if (visible_len == 0 || + request[visible_len - 1] != '\n') { + std::cerr << '\n'; + } + + std::cerr << "----- END REQUEST -----\n"; +} bool send_all(int fd, const void* data, std::size_t len, std::string& error) { const auto* p = static_cast(data); @@ -72,6 +251,14 @@ bool send_http_response(int fd, header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; + header += "Api-Version: "; + header += kSocketApiVersion; + header += "\r\n"; + + header += "Ostype: "; + header += kSocketOsType; + header += "\r\n"; + header += "Connection: close\r\n"; header += "\r\n"; @@ -136,27 +323,30 @@ bool is_ascii_digit(char c) { return c >= '0' && c <= '9'; } -bool is_versioned_ping_path(const std::string& path) { - constexpr const char* kSuffix = "/_ping"; - constexpr std::size_t kSuffixLen = 6; +bool is_versioned_api_path(const std::string& path, + const char* endpoint_path) { + const std::size_t endpoint_len = std::strlen(endpoint_path); - if (path.size() <= kSuffixLen) { + if (path.size() <= endpoint_len) { return false; } - if (path.compare(path.size() - kSuffixLen, kSuffixLen, kSuffix) != 0) { + if (path.compare(path.size() - endpoint_len, + endpoint_len, + endpoint_path) != 0) { return false; } - const std::string prefix = path.substr(0, path.size() - kSuffixLen); + const std::string prefix = + path.substr(0, path.size() - endpoint_len); /* - * Accept forms like: + * Accept: * * /v1.40/_ping - * /v1.51/_ping + * /v1.40/version * - * Prefix must be exactly: + * Prefix must be: * * /v. */ @@ -197,12 +387,14 @@ bool is_versioned_ping_path(const std::string& path) { return i == prefix.size(); } -bool is_ping_target(const std::string& target) { +bool is_api_target(const std::string& target, + const char* endpoint_path) { const std::size_t query_pos = target.find('?'); const std::string path = query_pos == std::string::npos ? target : target.substr(0, query_pos); - return path == "/_ping" || is_versioned_ping_path(path); + return path == endpoint_path || + is_versioned_api_path(path, endpoint_path); } bool parse_port(const std::string& value, @@ -233,6 +425,198 @@ bool parse_port(const std::string& value, return true; } +std::string build_version_json() { + const std::string arch = socketd_arch_name(); + const std::string kernel_version = socketd_kernel_version(); + + std::string body; + body.reserve(512); + + body += "{"; + + body += "\"Platform\":{\"Name\":\""; + body += json_escape(DS_PROJECT_NAME); + body += "\"},"; + + body += "\"Components\":[{"; + body += "\"Name\":\"Engine\","; + body += "\"Version\":\""; + body += json_escape(DS_VERSION); + body += "\","; + body += "\"Details\":{}"; + body += "}],"; + + body += "\"Version\":\""; + body += json_escape(DS_VERSION); + body += "\","; + + body += "\"ApiVersion\":\""; + body += kSocketApiVersion; + body += "\","; + + body += "\"MinAPIVersion\":\""; + body += kSocketMinApiVersion; + body += "\","; + + body += "\"Os\":\""; + body += kSocketOsType; + body += "\","; + + body += "\"Arch\":\""; + body += json_escape(arch); + body += "\""; + + if (!kernel_version.empty()) { + body += ",\"KernelVersion\":\""; + body += json_escape(kernel_version); + body += "\""; + } + + body += "}\n"; + return body; +} + +bool send_version_ok(int fd, bool suppress_body, std::string& error) { + const std::string body = build_version_json(); + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +std::string build_info_json() { + /* + * TODO(socketd): + * This /info response is intentionally synthesized locally so that early + * Portainer integration can proceed and reveal the next compatibility + * requirements. Replace or enrich this with DS_SOCKETD_OP_INFO once the + * privileged backend bridge has a stable information payload. + */ + const std::string arch = socketd_arch_name(); + const std::string kernel_version = socketd_kernel_version(); + const std::string hostname = socketd_hostname(); + const std::string system_time = socketd_system_time_utc(); + const unsigned int ncpu = socketd_ncpu(); + const std::uint64_t mem_total = socketd_mem_total_bytes(); + + std::string body; + body.reserve(1400); + + body += "{"; + + /* + * Container and image counters are placeholders until INFO is backed by + * the privileged daemon. + */ + body += "\"ID\":\"\","; + body += "\"Containers\":0,"; + body += "\"ContainersRunning\":0,"; + body += "\"ContainersPaused\":0,"; + body += "\"ContainersStopped\":0,"; + body += "\"Images\":0,"; + + body += "\"Driver\":\"droidspaces\","; + body += "\"DriverStatus\":[],"; + body += "\"Plugins\":{"; + body += "\"Volume\":[],"; + body += "\"Network\":[],"; + body += "\"Authorization\":[],"; + body += "\"Log\":[]"; + body += "},"; + + body += "\"MemoryLimit\":false,"; + body += "\"SwapLimit\":false,"; + body += "\"CpuCfsPeriod\":false,"; + body += "\"CpuCfsQuota\":false,"; + body += "\"CPUShares\":false,"; + body += "\"CPUSet\":false,"; + body += "\"PidsLimit\":false,"; + body += "\"IPv4Forwarding\":false,"; + body += "\"Debug\":false,"; + body += "\"NFd\":0,"; + body += "\"OomKillDisable\":false,"; + body += "\"NGoroutines\":0,"; + + body += "\"SystemTime\":\""; + body += json_escape(system_time); + body += "\","; + + body += "\"LoggingDriver\":\"\","; + body += "\"CgroupDriver\":\"\","; + body += "\"NEventsListener\":0,"; + + body += "\"KernelVersion\":\""; + body += json_escape(kernel_version); + body += "\","; + + body += "\"OperatingSystem\":\"Droidspaces\","; + body += "\"OSVersion\":\"\","; + body += "\"OSType\":\"linux\","; + + body += "\"Architecture\":\""; + body += json_escape(arch); + body += "\","; + + body += "\"IndexServerAddress\":\"\","; + body += "\"RegistryConfig\":null,"; + + body += "\"NCPU\":"; + body += std::to_string(ncpu); + body += ","; + + body += "\"MemTotal\":"; + body += std::to_string(mem_total); + body += ","; + + body += "\"GenericResources\":[],"; + body += "\"DockerRootDir\":\"\","; + body += "\"HttpProxy\":\"\","; + body += "\"HttpsProxy\":\"\","; + body += "\"NoProxy\":\"\","; + + body += "\"Name\":\""; + body += json_escape(hostname); + body += "\","; + + body += "\"Labels\":[],"; + body += "\"ExperimentalBuild\":false,"; + + body += "\"ServerVersion\":\""; + body += json_escape(DS_VERSION); + body += "\","; + + body += "\"Runtimes\":{},"; + body += "\"DefaultRuntime\":\"\","; + body += "\"Swarm\":{\"NodeID\":\"\"},"; + body += "\"LiveRestoreEnabled\":false,"; + body += "\"Isolation\":\"\","; + body += "\"InitBinary\":\"\","; + body += "\"ContainerdCommit\":{\"ID\":\"\"},"; + body += "\"RuncCommit\":{\"ID\":\"\"},"; + body += "\"InitCommit\":{\"ID\":\"\"},"; + body += "\"SecurityOptions\":[],"; + body += "\"Warnings\":[]"; + + body += "}\n"; + return body; +} + +bool send_info_ok(int fd, bool suppress_body, std::string& error) { + const std::string body = build_info_json(); + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + } // namespace bool parse_tcp_listen_endpoint(const std::string& value, @@ -354,6 +738,8 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { return false; } } +// DEBUG FEATURE: perhaps remove later; do NOT expect this to stay. + debug_log_request_headers(request); const std::size_t line_end = request.find("\r\n"); if (line_end == std::string::npos) { @@ -380,9 +766,17 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { const bool is_head = method == "HEAD"; const bool is_get = method == "GET"; - if ((is_get || is_head) && is_ping_target(target)) { + if ((is_get || is_head) && is_api_target(target, "/_ping")) { return send_ping_ok(client_fd, is_head, error); } + + if (is_get && is_api_target(target, "/version")) { + return send_version_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/info")) { + return send_info_ok(client_fd, false, error); + } return send_not_found(client_fd, is_head, error); } From 8f2baf4b3d2b9a3e805c996b37c32b0977f9746a Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Mon, 18 May 2026 11:00:43 +0900 Subject: [PATCH 2/3] socketd: add dummy container list endpoint for Portainer probing Expose Docker-compatible container list routes through the socket extension: - GET /containers/json - GET /vX.Y/containers/json The extension parses the currently observed `all` query parameter and routes the request through a socketd-owned container-list seam. For now, the seam deliberately returns an empty JSON list. This keeps the core engine untouched while validating Portainer behavior at the public API boundary. Portainer accepts the endpoint and advances to subsequent snapshot probes for images, volumes, and networks, confirming that the next development step can replace this dummy seam with a core-backed DS_SOCKETD_OP_LIST_CONTAINERS implementation. --- src/socketd/Makefile | 1 + src/socketd/api_server.cpp | 77 ++++++++++++++++++++++++++++++++++ src/socketd/container_list.cpp | 25 +++++++++++ src/socketd/container_list.h | 32 ++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 src/socketd/container_list.cpp create mode 100644 src/socketd/container_list.h diff --git a/src/socketd/Makefile b/src/socketd/Makefile index b3e1b8f7..adc85bc5 100644 --- a/src/socketd/Makefile +++ b/src/socketd/Makefile @@ -42,6 +42,7 @@ endif SRCS := \ main.cpp \ backend_client.cpp \ + container_list.cpp \ api_server.cpp OBJS := $(SRCS:%.cpp=$(OBJ_DIR)/%.o) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp index ba0ffe66..2e73791f 100644 --- a/src/socketd/api_server.cpp +++ b/src/socketd/api_server.cpp @@ -1,4 +1,5 @@ #include "api_server.h" +#include "container_list.h" #include "../droidspace.h" #include @@ -397,6 +398,57 @@ bool is_api_target(const std::string& target, is_versioned_api_path(path, endpoint_path); } +bool is_truthy_query_value(const std::string& value) { + return value.empty() || + value == "1" || + value == "true"; +} + +ContainerListRequest parse_container_list_request( + const std::string& target) { + ContainerListRequest request {}; + + const std::size_t query_pos = target.find('?'); + if (query_pos == std::string::npos || + query_pos + 1 >= target.size()) { + return request; + } + + /* + * This is intentionally a very small query parser for the bring-up phase. + * It extracts only the public API semantic that socketd currently cares + * about: ?all=1. Unknown query parameters are ignored. + */ + std::size_t pos = query_pos + 1; + + while (pos <= target.size()) { + const std::size_t amp = target.find('&', pos); + const std::size_t end = + amp == std::string::npos ? target.size() : amp; + + const std::string item = target.substr(pos, end - pos); + const std::size_t eq = item.find('='); + + const std::string key = + eq == std::string::npos ? item : item.substr(0, eq); + + const std::string value = + eq == std::string::npos ? "" : item.substr(eq + 1); + + if (key == "all") { + request.include_all = is_truthy_query_value(value); + } + + if (amp == std::string::npos) { + break; + } + + pos = amp + 1; + } + + return request; +} + bool parse_port(const std::string& value, std::uint16_t& port_out, std::string& error) { @@ -617,6 +669,27 @@ bool send_info_ok(int fd, bool suppress_body, std::string& error) { error); } +bool send_container_list_ok(int fd, + const std::string& target, + bool suppress_body, + std::string& error) { + const ContainerListRequest request = + parse_container_list_request(target); + + std::string body; + if (!request_container_list_json_from_core(request, body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + } // namespace bool parse_tcp_listen_endpoint(const std::string& value, @@ -777,6 +850,10 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { if (is_get && is_api_target(target, "/info")) { return send_info_ok(client_fd, false, error); } + + if (is_get && is_api_target(target, "/containers/json")) { + return send_container_list_ok(client_fd, target, false, error); + } return send_not_found(client_fd, is_head, error); } diff --git a/src/socketd/container_list.cpp b/src/socketd/container_list.cpp new file mode 100644 index 00000000..084351a9 --- /dev/null +++ b/src/socketd/container_list.cpp @@ -0,0 +1,25 @@ +#include "container_list.h" + +namespace droidspaces::socketd { + +bool request_container_list_json_from_core( + const ContainerListRequest& request, + std::string& json_out, + std::string& error) { + (void)request; + error.clear(); + + /* + * TODO(socketd-core-bridge): + * Replace this dummy payload with a request to the Droidspaces core through + * the private socketd backend protocol once Portainer-side behavior for the + * public /containers/json endpoint has been confirmed. + * + * The HTTP layer must continue to depend on this socketd-owned seam, not on + * core runtime internals directly. + */ + json_out = "[]\n"; + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.h b/src/socketd/container_list.h new file mode 100644 index 00000000..436c9d57 --- /dev/null +++ b/src/socketd/container_list.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace droidspaces::socketd { + +struct ContainerListRequest { + /* + * Docker-compatible public API spelling: + * + * GET /containers/json?all=1 + * + * The extension keeps this as a semantic flag rather than exposing the + * raw HTTP query string to any eventual core-side implementation. + */ + bool include_all = false; +}; + +/* + * Socketd-owned seam for obtaining the container-list payload. + * + * For the current extension-only bring-up step this is deliberately a dummy: + * it returns an empty Docker-shaped JSON list so Portainer can advance to its + * next probe. Once that behavior is confirmed, this function becomes the + * place where socketd requests real container data from the core bridge. + */ +bool request_container_list_json_from_core( + const ContainerListRequest& request, + std::string& json_out, + std::string& error); + +} // namespace droidspaces::socketd From 8d137200b7ff844ce49f52c1a3c6a006db425d90 Mon Sep 17 00:00:00 2001 From: Yoshinobu Date Date: Mon, 18 May 2026 12:18:29 +0900 Subject: [PATCH 3/3] socketd: satisfy Portainer snapshot probes and expose local events Extend the Socket API compatibility layer with additional extension-local endpoints observed during Portainer integration. Add dummy discovery responses for: - GET /images/json - GET /volumes - GET /networks These return structurally valid empty payloads so Portainer can complete its periodic snapshot pass without emitting image, volume, or network snapshot warnings. With these responses in place, the Portainer dashboard settles correctly. Add an initial /events implementation: - accepts since/until query parameters - returns Docker-compatible JSON object streams - maintains a small socketd-local event journal - records extension lifecycle events such as: - backend bridge connection - public TCP listener startup This keeps the implementation within src/socketd/ only. No core-engine changes are introduced; real core-backed inventory and lifecycle events remain deferred until the socket extension itself requires them. Portainer now: - loads the dashboard without the previous UI glitch - shows an empty event list before events exist - renders socketd lifecycle events once recorded --- src/socketd/Makefile | 2 + src/socketd/api_server.cpp | 156 +++++++++++++++++++- src/socketd/event_log.cpp | 257 +++++++++++++++++++++++++++++++++ src/socketd/event_log.h | 40 +++++ src/socketd/main.cpp | 25 +++- src/socketd/snapshot_lists.cpp | 48 ++++++ src/socketd/snapshot_lists.h | 28 ++++ 7 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/socketd/event_log.cpp create mode 100644 src/socketd/event_log.h create mode 100644 src/socketd/snapshot_lists.cpp create mode 100644 src/socketd/snapshot_lists.h diff --git a/src/socketd/Makefile b/src/socketd/Makefile index adc85bc5..0f074fc4 100644 --- a/src/socketd/Makefile +++ b/src/socketd/Makefile @@ -43,6 +43,8 @@ SRCS := \ main.cpp \ backend_client.cpp \ container_list.cpp \ + snapshot_lists.cpp \ + event_log.cpp \ api_server.cpp OBJS := $(SRCS:%.cpp=$(OBJ_DIR)/%.o) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp index 2e73791f..95f6aae5 100644 --- a/src/socketd/api_server.cpp +++ b/src/socketd/api_server.cpp @@ -1,5 +1,7 @@ #include "api_server.h" #include "container_list.h" +#include "snapshot_lists.h" +#include "event_log.h" #include "../droidspace.h" #include @@ -449,6 +451,51 @@ ContainerListRequest parse_container_list_request( return request; } +EventsRequest parse_events_request(const std::string& target) { + EventsRequest request {}; + + const std::size_t query_pos = target.find('?'); + if (query_pos == std::string::npos || + query_pos + 1 >= target.size()) { + return request; + } + + /* + * Small, deliberate parser for only the fields observed from Portainer's + * event-log request. Unknown parameters are ignored for now. + */ + std::size_t pos = query_pos + 1; + + while (pos <= target.size()) { + const std::size_t amp = target.find('&', pos); + const std::size_t end = + amp == std::string::npos ? target.size() : amp; + + const std::string item = target.substr(pos, end - pos); + const std::size_t eq = item.find('='); + + const std::string key = + eq == std::string::npos ? item : item.substr(0, eq); + + const std::string value = + eq == std::string::npos ? "" : item.substr(eq + 1); + + if (key == "since") { + request.since = value; + } else if (key == "until") { + request.until = value; + } + + if (amp == std::string::npos) { + break; + } + + pos = amp + 1; + } + + return request; +} + bool parse_port(const std::string& value, std::uint16_t& port_out, std::string& error) { @@ -690,6 +737,82 @@ bool send_container_list_ok(int fd, error); } +bool send_image_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_image_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_volume_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_volume_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_network_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_network_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_events_ok(int fd, + const std::string& target, + bool suppress_body, + std::string& error) { + const EventsRequest request = parse_events_request(target); + + std::string body; + if (!request_event_log_stream_from_core(request, body, error)) { + return false; + } + + /* + * API v1.40-compatible behavior: + * Moby used application/json for event streams at this API level. + * An empty body is intentional and accepted by Portainer's event-log parser. + */ + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + } // namespace bool parse_tcp_listen_endpoint(const std::string& value, @@ -854,6 +977,22 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { if (is_get && is_api_target(target, "/containers/json")) { return send_container_list_ok(client_fd, target, false, error); } + + if (is_get && is_api_target(target, "/images/json")) { + return send_image_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/volumes")) { + return send_volume_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/networks")) { + return send_network_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/events")) { + return send_events_ok(client_fd, target, false, error); + } return send_not_found(client_fd, is_head, error); } @@ -863,12 +1002,27 @@ bool ApiServer::run(std::string& error) { if (!create_listener(listener_fd, error)) { return false; } - +// To tty std::cerr << "socketd: listening on http://" << config_.bind_address << ':' << config_.port << '\n'; +// To API + const std::string listen_target = + "tcp://" + config_.bind_address + ":" + std::to_string(config_.port); + + const SocketdEventAttributes attrs[] = { + {"name", "droidspaces-socketd"}, + {"component", "socketd"}, + {"listen", listen_target}, + }; + + record_socketd_event("daemon", + "start", + "droidspaces-socketd", + attrs, + sizeof(attrs) / sizeof(attrs[0])); for (;;) { const int client_fd = ::accept(listener_fd, nullptr, nullptr); diff --git a/src/socketd/event_log.cpp b/src/socketd/event_log.cpp new file mode 100644 index 00000000..306c095b --- /dev/null +++ b/src/socketd/event_log.cpp @@ -0,0 +1,257 @@ +#include "event_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace droidspaces::socketd { +namespace { + +constexpr std::size_t kMaxSocketdEvents = 128; + +struct EventAttribute { + std::string key; + std::string value; +}; + +struct SocketdEvent { + std::string type; + std::string action; + std::string actor_id; + std::vector attributes; + std::int64_t time = 0; + std::int64_t time_nano = 0; +}; + +std::deque g_socketd_events; + +std::int64_t unix_time_seconds_now() { + using clock = std::chrono::system_clock; + const auto now = clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch()) + .count(); +} + +std::int64_t unix_time_nanos_now() { + using clock = std::chrono::system_clock; + const auto now = clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch()) + .count(); +} + +std::string json_escape(const std::string& input) { + std::string out; + out.reserve(input.size()); + + constexpr char kHex[] = "0123456789abcdef"; + + for (unsigned char ch : input) { + switch (ch) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (ch < 0x20) { + out += "\\u00"; + out += kHex[(ch >> 4) & 0x0f]; + out += kHex[ch & 0x0f]; + } else { + out += static_cast(ch); + } + break; + } + } + + return out; +} + +bool parse_optional_epoch_seconds(const std::string& value, + std::int64_t& out, + std::string& error) { + out = 0; + + if (value.empty()) { + return true; + } + + char* end = nullptr; + errno = 0; + const long long parsed = std::strtoll(value.c_str(), &end, 10); + + if (errno != 0 || end == value.c_str() || *end != '\0') { + error = "invalid event timestamp: "; + error += value; + return false; + } + + if (parsed < 0) { + error = "negative event timestamp is unsupported: "; + error += value; + return false; + } + + out = static_cast(parsed); + return true; +} + +bool event_in_window(const SocketdEvent& event, + std::int64_t since, + std::int64_t until) { + if (since > 0 && event.time < since) { + return false; + } + + if (until > 0 && event.time > until) { + return false; + } + + return true; +} + +std::string encode_event_json_line(const SocketdEvent& event) { + std::string out; + out.reserve(512); + + out += "{"; + + out += "\"Type\":\""; + out += json_escape(event.type); + out += "\","; + + out += "\"Action\":\""; + out += json_escape(event.action); + out += "\","; + + out += "\"Actor\":{"; + out += "\"ID\":\""; + out += json_escape(event.actor_id); + out += "\","; + out += "\"Attributes\":{"; + + for (std::size_t i = 0; i < event.attributes.size(); ++i) { + if (i != 0) { + out += ","; + } + + out += "\""; + out += json_escape(event.attributes[i].key); + out += "\":\""; + out += json_escape(event.attributes[i].value); + out += "\""; + } + + out += "}"; + out += "},"; + + out += "\"scope\":\"local\","; + + out += "\"time\":"; + out += std::to_string(event.time); + out += ","; + + out += "\"timeNano\":"; + out += std::to_string(event.time_nano); + + out += "}\n"; + return out; +} + +} // namespace + +void record_socketd_event(const std::string& type, + const std::string& action, + const std::string& actor_id, + const SocketdEventAttributes* attributes, + std::size_t attribute_count) { + SocketdEvent event; + event.type = type; + event.action = action; + event.actor_id = actor_id; + event.time = unix_time_seconds_now(); + event.time_nano = unix_time_nanos_now(); + + event.attributes.reserve(attribute_count); + for (std::size_t i = 0; i < attribute_count; ++i) { + event.attributes.push_back(EventAttribute{ + attributes[i].key, + attributes[i].value, + }); + } + + if (g_socketd_events.size() >= kMaxSocketdEvents) { + g_socketd_events.pop_front(); + } + + g_socketd_events.push_back(std::move(event)); +} + +bool request_event_log_stream_from_core( + const EventsRequest& request, + std::string& stream_out, + std::string& error) { + error.clear(); + stream_out.clear(); + + /* + * TODO(socketd-core-events): + * This endpoint currently exposes extension-owned daemon events from the + * socketd-local event journal. When the socket extension has a validated + * need for core-runtime lifecycle events, merge in a backend-provided event + * stream behind this same socketd-owned seam. + */ + std::int64_t since = 0; + std::int64_t until = 0; + + if (!parse_optional_epoch_seconds(request.since, since, error)) { + return false; + } + + if (!parse_optional_epoch_seconds(request.until, until, error)) { + return false; + } + + if (since > 0 && until > 0 && since > until) { + error = "event 'since' timestamp is after 'until'"; + return false; + } + + for (const SocketdEvent& event : g_socketd_events) { + if (!event_in_window(event, since, until)) { + continue; + } + + stream_out += encode_event_json_line(event); + } + + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.h b/src/socketd/event_log.h new file mode 100644 index 00000000..13e3f7ba --- /dev/null +++ b/src/socketd/event_log.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace droidspaces::socketd { + +struct EventsRequest { + std::string since; + std::string until; +}; + +struct SocketdEventAttributes { + std::string key; + std::string value; +}; + +/* + * Record a socketd-owned engine-style event in the extension-local journal. + * + * The public /events endpoint exposes this journal in Docker-compatible + * event-stream form. This deliberately covers only events owned by the socket + * extension itself; core-engine events remain a later, separate backend seam. + */ +void record_socketd_event(const std::string& type, + const std::string& action, + const std::string& actor_id, + const SocketdEventAttributes* attributes, + std::size_t attribute_count); + +/* + * Return historical socketd-owned events matching the requested time window + * as a Docker-compatible stream of JSON objects. + */ +bool request_event_log_stream_from_core( + const EventsRequest& request, + std::string& stream_out, + std::string& error); + +} // namespace droidspaces::socketd diff --git a/src/socketd/main.cpp b/src/socketd/main.cpp index 19572786..d9c5c316 100644 --- a/src/socketd/main.cpp +++ b/src/socketd/main.cpp @@ -1,9 +1,11 @@ #include "api_server.h" #include "backend_client.h" +#include "event_log.h" #include "socketd_protocol.h" #include +#include #include #include #include @@ -15,6 +17,8 @@ using droidspaces::socketd::BackendClient; using droidspaces::socketd::CapabilitiesResult; using droidspaces::socketd::TcpListenConfig; using droidspaces::socketd::parse_tcp_listen_endpoint; +using droidspaces::socketd::SocketdEventAttributes; +using droidspaces::socketd::record_socketd_event; constexpr std::uint32_t kRequiredBackendCapabilities = DS_SOCKETD_CAP_PROTOCOL_V1 | @@ -53,12 +57,31 @@ bool check_backend(std::string& error) { error = "backend is missing required base capabilities"; return false; } - +// To tty std::cerr << "socketd: backend handshake OK, capabilities mask: 0x" << std::hex << caps.mask << std::dec << '\n'; + +// To API +const std::string caps_text = "0x" + [&caps]() { + char buffer[32] {}; + std::snprintf(buffer, sizeof(buffer), "%x", caps.mask); + return std::string(buffer); +}(); + +const SocketdEventAttributes attrs[] = { + {"name", "droidspaces-backend"}, + {"component", "socketd"}, + {"capabilities", caps_text}, +}; + +record_socketd_event("daemon", + "connect", + "droidspaces-backend", + attrs, + sizeof(attrs) / sizeof(attrs[0])); return true; } diff --git a/src/socketd/snapshot_lists.cpp b/src/socketd/snapshot_lists.cpp new file mode 100644 index 00000000..8f32d389 --- /dev/null +++ b/src/socketd/snapshot_lists.cpp @@ -0,0 +1,48 @@ +#include "snapshot_lists.h" + +namespace droidspaces::socketd { + +bool request_image_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-images): + * This is a deliberate dummy response. Portainer's snapshot pass requests + * /images/json, but Droidspaces has not yet decided whether Docker-style + * image inventory is meaningful to expose. Keep this extension-local until + * a socketd-owned need for core data is established. + */ + json_out = "[]\n"; + return true; +} + +bool request_volume_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-volumes): + * This is a deliberate dummy response. The Docker-compatible /volumes route + * returns an object, not a bare array, so preserve that shape while exposing + * no volume inventory. + */ + json_out = "{\"Volumes\":[],\"Warnings\":[]}\n"; + return true; +} + +bool request_network_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-networks): + * This is a deliberate dummy response. Portainer requests /networks during + * its snapshot pass; return a structurally valid empty list and avoid + * projecting Docker network semantics into the Droidspaces core. + */ + json_out = "[]\n"; + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/snapshot_lists.h b/src/socketd/snapshot_lists.h new file mode 100644 index 00000000..579e699f --- /dev/null +++ b/src/socketd/snapshot_lists.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace droidspaces::socketd { + +/* + * Extension-owned seams for Portainer snapshot inventory probes. + * + * These functions intentionally return dummy payloads for now. Portainer has + * been observed requesting images, volumes, and networks during its Docker + * snapshot pass after /containers/json succeeds. + * + * No core-engine change is warranted at this stage: first we confirm that + * Portainer's UI settles when these discovery probes receive structurally + * valid empty responses. + */ + +bool request_image_list_json_from_core(std::string& json_out, + std::string& error); + +bool request_volume_list_json_from_core(std::string& json_out, + std::string& error); + +bool request_network_list_json_from_core(std::string& json_out, + std::string& error); + +} // namespace droidspaces::socketd