diff --git a/CMakeLists.txt b/CMakeLists.txt index d76196a..d079303 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(go-plugin-cpp VERSION 0.2.0 LANGUAGES CXX) +project(go-plugin-cpp VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 44b6f0e..f6c831c 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,20 @@ using **gRPC** as the transport (net/RPC is not supported). ## How it works -go-plugin launches a plugin as a child process and communicates via gRPC over loopback TCP. The child process (plugin) -signals readiness by printing a single *handshake line* to stdout: +go-plugin launches a plugin as a child process and communicates via gRPC over a Unix socket, as go-plugin's own Go +plugins do on Linux. The child process (plugin) signals readiness by printing a single *handshake line* to stdout: ``` -CORE-PROTO|APP-PROTO|tcp|127.0.0.1:PORT|grpc|SERVER-CERT +CORE-PROTO|APP-PROTO|unix|SOCKET-PATH|grpc|SERVER-CERT ``` This library handles: - **Magic-cookie validation** – aborts startup if the binary is run directly rather than by a go-plugin host. -- **Port selection** – respects `PLUGIN_MIN_PORT`/`PLUGIN_MAX_PORT` if set by the host. +- **The socket** – created in the directory the host names in `PLUGIN_UNIX_SOCKET_DIR` (or the system's temporary + directory), opened to the group it names in `PLUGIN_UNIX_SOCKET_GROUP` if any, and removed when the server goes. No + other local process reaches it unless the directory and the group let it, unlike a loopback TCP port, which any + process on the machine can connect to. - **Auto-mTLS** – when the host sets `PLUGIN_CLIENT_CERT`, a fresh server certificate is generated and mutual TLS is configured automatically. - **Handshake line output** – writes the correctly formatted line to stdout so the host can connect. diff --git a/include/go_plugin/server.hpp b/include/go_plugin/server.hpp index 8b1e231..4549654 100644 --- a/include/go_plugin/server.hpp +++ b/include/go_plugin/server.hpp @@ -78,10 +78,12 @@ class PluginServer { PluginServer& operator=(const PluginServer&) = delete; /** - * Validates the magic cookie, picks a port, starts the gRPC server, - * registers services, the built-in health-check service and go-plugin's - * controller, then writes the go-plugin handshake line to the configured - * output stream. The controller answers the host's request to stop by + * Validates the magic cookie, starts the gRPC server on a Unix socket in + * the directory the host names in PLUGIN_UNIX_SOCKET_DIR (the system's + * temporary directory when it names none), opened to the group it names in + * PLUGIN_UNIX_SOCKET_GROUP if any, registers services, the built-in + * health-check service and go-plugin's controller, then writes the + * go-plugin handshake line to the configured output stream. The controller answers the host's request to stop by * shutting the server down, which returns Wait(). * * @param error If non-null, receives a human-readable error message on @@ -100,8 +102,8 @@ class PluginServer { /** Initiates an orderly shutdown; safe to call from any thread. */ void Shutdown(); - /** Returns the TCP port the server is listening on (valid after Start()). */ - int port() const { return port_; } + /** Returns the path of the socket the server listens on (valid after Start()). */ + const std::string& socket_path() const { return socket_path_; } private: // Watches for the host (parent) process disappearing. When the host dies @@ -121,7 +123,7 @@ class PluginServer { ServeConfig config_; std::unique_ptr controller_; std::unique_ptr server_; - int port_ = 0; + std::string socket_path_; std::thread parent_watchdog_; std::mutex watchdog_mu_; diff --git a/src/server.cpp b/src/server.cpp index 3b1e5e3..ecd57ac 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -2,22 +2,20 @@ #include "go_plugin/tls.hpp" #include +#include #include #include #include #include #include #include -#include -#include #include #include #include -#include -#include -#include -#include +#include +#include +#include #include #include @@ -57,55 +55,56 @@ bool ValidateMagicCookie(const HandshakeConfig &h) { return val && std::string(val) == h.magic_cookie_value; } -// Returns true if we can bind a TCP socket on 127.0.0.1:port right now. -bool IsPortAvailable(int port) { - int fd = ::socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) +// Reserves a path for the plugin's socket in the directory the host made for +// it, as go-plugin's own servers do: a file of a unique name is created and +// removed again, since a socket can only be bound where nothing is. +bool ReserveSocketPath(std::string *path, std::string *error) { + const char *dir = std::getenv("PLUGIN_UNIX_SOCKET_DIR"); + if (!dir || !*dir) + dir = std::getenv("TMPDIR"); + if (!dir || !*dir) + dir = "/tmp"; + + std::string name = std::string(dir) + "/pluginXXXXXX"; + if (name.size() >= sizeof(sockaddr_un{}.sun_path)) { + *error = "socket directory " + std::string(dir) + " is too long a path for a Unix socket"; return false; - - int opt = 1; - ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = htons(static_cast(port)); - - bool ok = ::bind(fd, reinterpret_cast(&addr), sizeof(addr)) == 0; + } + int fd = ::mkstemp(name.data()); + if (fd < 0) { + *error = "reserve a socket path in " + std::string(dir) + ": " + std::strerror(errno); + return false; + } ::close(fd); - return ok; + ::unlink(name.c_str()); + *path = std::move(name); + return true; } -/** - * Returns a port in [PLUGIN_MIN_PORT, PLUGIN_MAX_PORT] that appears to be - * available, or 0 to let the OS choose (when the env vars are absent). - * - * Ports are tried in a random order to reduce collision probability when - * multiple plugin processes are started simultaneously. - */ -int PickPort() { - const char *min_str = std::getenv("PLUGIN_MIN_PORT"); - const char *max_str = std::getenv("PLUGIN_MAX_PORT"); - if (!min_str || !max_str) - return 0; - - int min_port = std::stoi(min_str); - int max_port = std::stoi(max_str); - if (min_port < 1 || max_port > 65535 || min_port > max_port) - return 0; - - std::vector ports(static_cast(max_port - min_port + 1)); - std::iota(ports.begin(), ports.end(), min_port); - - std::random_device rd; - std::mt19937 gen(rd()); - std::shuffle(ports.begin(), ports.end(), gen); - - for (int p : ports) { - if (IsPortAvailable(p)) - return p; +// Opens the socket to a group the host names, by name or number, as go-plugin's +// own servers do. +bool SetGroupWritable(const std::string &path, const std::string &group, std::string *error) { + gid_t gid; + const bool numeric = std::all_of(group.begin(), group.end(), [](unsigned char c) { return std::isdigit(c); }); + if (numeric) { + errno = 0; + unsigned long long number = std::strtoull(group.c_str(), nullptr, 10); + if (errno != 0 || number > static_cast(-1)) { + *error = "group " + group + " is out of range"; + return false; + } + gid = static_cast(number); + } else if (const struct group *found = ::getgrnam(group.c_str())) { + gid = found->gr_gid; + } else { + *error = "no group " + group; + return false; } - return 0; // fallback: let gRPC pick + if (::chown(path.c_str(), static_cast(-1), gid) != 0 || ::chmod(path.c_str(), 0660) != 0) { + *error = "open the socket to group " + group + ": " + std::strerror(errno); + return false; + } + return true; } } // namespace @@ -120,6 +119,8 @@ PluginServer::~PluginServer() { server_->Shutdown(); server_->Wait(); } + if (!socket_path_.empty()) + ::unlink(socket_path_.c_str()); } bool PluginServer::Start(std::string *out_error) { @@ -160,10 +161,15 @@ bool PluginServer::Start(std::string *out_error) { // ── 3. Build gRPC server ───────────────────────────────────────────── grpc::EnableDefaultHealthCheckService(true); + std::string socket_path; + std::string error; + if (!ReserveSocketPath(&socket_path, &error)) { + return fail(std::move(error)); + } + grpc::ServerBuilder builder; int selected_port = 0; - int hint_port = PickPort(); - std::string listen_addr = "127.0.0.1:" + std::to_string(hint_port); + std::string listen_addr = "unix:" + socket_path; builder.AddListeningPort(listen_addr, creds, &selected_port); for (auto *svc : config_.services) { @@ -179,16 +185,24 @@ bool PluginServer::Start(std::string *out_error) { if (selected_port == 0) { server_->Shutdown(); server_.reset(); - return fail("gRPC server started but reported port 0 (bind failed?)"); + return fail("gRPC server started but did not bind " + listen_addr); + } + socket_path_ = std::move(socket_path); + + if (const char *group = std::getenv("PLUGIN_UNIX_SOCKET_GROUP"); group && *group) { + if (!SetGroupWritable(socket_path_, group, &error)) { + server_->Shutdown(); + server_.reset(); + return fail(std::move(error)); + } } - port_ = selected_port; // ── 4. Write go-plugin handshake ───────────────────────────────────── // Format: CORE-PROTO|APP-PROTO|NETWORK|ADDR|PROTOCOL|SERVER-CERT\n std::ostream &out = config_.output ? *config_.output : std::cout; out << "1" - << "|" << config_.handshake.protocol_version << "|tcp" - << "|127.0.0.1:" << port_ << "|grpc" + << "|" << config_.handshake.protocol_version << "|unix" + << "|" << socket_path_ << "|grpc" << "|" << server_cert_b64 << "\n"; out.flush(); diff --git a/tests/plugin_fixture.hpp b/tests/plugin_fixture.hpp index 674a9d1..a76dcfb 100644 --- a/tests/plugin_fixture.hpp +++ b/tests/plugin_fixture.hpp @@ -46,8 +46,8 @@ class PluginFixture : public ::testing::Test { server_->Wait(); } UnsetEnv(kCookieKey); - UnsetEnv("PLUGIN_MIN_PORT"); - UnsetEnv("PLUGIN_MAX_PORT"); + UnsetEnv("PLUGIN_UNIX_SOCKET_DIR"); + UnsetEnv("PLUGIN_UNIX_SOCKET_GROUP"); } static void SetEnv(const char* key, const char* value) { ::setenv(key, value, 1); } @@ -64,7 +64,7 @@ class PluginFixture : public ::testing::Test { std::string handshake() const { return handshake_.str(); } - std::string target() const { return "127.0.0.1:" + std::to_string(server_->port()); } + std::string target() const { return "unix:" + server_->socket_path(); } go_plugin::ServeConfig config_; diff --git a/tests/test_handshake.cpp b/tests/test_handshake.cpp index 063bde5..88c9713 100644 --- a/tests/test_handshake.cpp +++ b/tests/test_handshake.cpp @@ -1,7 +1,11 @@ #include #include +#include #include +#include +#include +#include #include "plugin_fixture.hpp" @@ -49,7 +53,7 @@ TEST_F(Handshake, NamesTheTransportAndProtocol) { std::string error; ASSERT_TRUE(Start(&error)) << error; - EXPECT_NE(handshake().find("|tcp|"), std::string::npos) << handshake(); + EXPECT_NE(handshake().find("|unix|"), std::string::npos) << handshake(); EXPECT_NE(handshake().find("|grpc|"), std::string::npos) << handshake(); EXPECT_EQ(handshake().back(), '\n') << "the host reads the line, so it has to be terminated"; } @@ -58,8 +62,8 @@ TEST_F(Handshake, AdvertisesTheListeningAddress) { std::string error; ASSERT_TRUE(Start(&error)) << error; - EXPECT_GT(server().port(), 0); - EXPECT_NE(handshake().find(target()), std::string::npos) << handshake(); + ASSERT_FALSE(server().socket_path().empty()); + EXPECT_NE(handshake().find("|" + server().socket_path() + "|"), std::string::npos) << handshake(); } TEST_F(Handshake, HasTheSixFieldsTheHostSplitsOn) { @@ -72,15 +76,56 @@ TEST_F(Handshake, HasTheSixFieldsTheHostSplitsOn) { EXPECT_EQ(std::count(line.begin(), line.end(), '|'), 5) << line; } -TEST_F(Handshake, RespectsThePortRangeTheHostAsksFor) { - SetEnv("PLUGIN_MIN_PORT", "19900"); - SetEnv("PLUGIN_MAX_PORT", "19999"); +// The host makes a directory for the plugin's socket and expects it there. +TEST_F(Handshake, PutsTheSocketInTheDirectoryTheHostNames) { + char dir[] = "/tmp/go-plugin-cpp-XXXXXX"; + ASSERT_NE(::mkdtemp(dir), nullptr); + SetEnv("PLUGIN_UNIX_SOCKET_DIR", dir); std::string error; ASSERT_TRUE(Start(&error)) << error; - EXPECT_GE(server().port(), 19900); - EXPECT_LE(server().port(), 19999); + const std::string& path = server().socket_path(); + EXPECT_EQ(path.substr(0, path.rfind('/')), dir); + struct stat st {}; + ASSERT_EQ(::stat(path.c_str(), &st), 0); + EXPECT_TRUE(S_ISSOCK(st.st_mode)); + + server().Shutdown(); + server().Wait(); + ::unlink(path.c_str()); + ::rmdir(dir); +} + +// A host that names a group, by number or by name, reaches the socket through it, as with +// go-plugin's own servers. +TEST_F(Handshake, OpensTheSocketToTheGroupTheHostNames) { + std::vector groups{std::to_string(::getgid())}; + const struct group* own = ::getgrgid(::getgid()); + if (own && std::string(own->gr_name).find_first_not_of("0123456789") != std::string::npos) { + groups.emplace_back(own->gr_name); + } + for (const std::string& named : groups) { + SetEnv("PLUGIN_UNIX_SOCKET_GROUP", named.c_str()); + go_plugin::PluginServer server(config_); + std::string error; + ASSERT_TRUE(server.Start(&error)) << named << ": " << error; + + struct stat st {}; + ASSERT_EQ(::stat(server.socket_path().c_str(), &st), 0); + EXPECT_EQ(st.st_gid, ::getgid()) << named; + EXPECT_EQ(st.st_mode & 0777, 0660u) << named; + } +} + +// A group the system does not know fails the start, rather than leave the host locked out. +TEST_F(Handshake, RefusesAGroupThatDoesNotExist) { + SetEnv("PLUGIN_UNIX_SOCKET_GROUP", "no-such-group-for-go-plugin-cpp"); + + std::string error; + EXPECT_FALSE(Start(&error)); + EXPECT_NE(error.find("no-such-group-for-go-plugin-cpp"), std::string::npos) << error; + EXPECT_TRUE(handshake().empty()) << "a plugin that cannot be reached must advertise nothing"; } } // namespace diff --git a/tests/test_server.cpp b/tests/test_server.cpp index 2acec94..4d66ee7 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "plugin_fixture.hpp" @@ -14,15 +15,15 @@ namespace { using Server = PluginFixture; -TEST_F(Server, ReportsNoPortUntilStarted) { +TEST_F(Server, ReportsNoSocketUntilStarted) { go_plugin::PluginServer server(config_); - EXPECT_EQ(server.port(), 0); + EXPECT_TRUE(server.socket_path().empty()); } TEST_F(Server, StartsAndShutsDown) { std::string error; ASSERT_TRUE(Start(&error)) << error; - EXPECT_GT(server().port(), 0); + EXPECT_FALSE(server().socket_path().empty()); server().Shutdown(); server().Wait(); @@ -49,6 +50,19 @@ TEST_F(Server, AnswersACallOnTheAdvertisedAddress) { EXPECT_EQ(reply.text(), "hello"); } +// A plugin leaves no socket behind for the next one to trip over. +TEST_F(Server, RemovesItsSocketWhenItGoes) { + std::string path; + { + go_plugin::PluginServer server(config_); + std::string error; + ASSERT_TRUE(server.Start(&error)) << error; + path = server.socket_path(); + ASSERT_EQ(::access(path.c_str(), F_OK), 0); + } + EXPECT_NE(::access(path.c_str(), F_OK), 0); +} + TEST_F(Server, ServeRefusesTheWrongCookie) { UnsetEnv(kCookieKey); diff --git a/vcpkg.json b/vcpkg.json index df48bd6..a19ca16 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "go-plugin-cpp", - "version": "0.2.0", + "version": "0.3.0", "dependencies": [ "abseil", "grpc",