Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
devgianlu marked this conversation as resolved.

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions include/go_plugin/server.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -121,7 +123,7 @@ class PluginServer {
ServeConfig config_;
std::unique_ptr<grpc::Service> controller_;
std::unique_ptr<grpc::Server> server_;
int port_ = 0;
std::string socket_path_;

std::thread parent_watchdog_;
std::mutex watchdog_mu_;
Expand Down
126 changes: 70 additions & 56 deletions src/server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
#include "go_plugin/tls.hpp"

#include <algorithm>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <iostream>
#include <numeric>
#include <random>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <grp.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>

#include <grpcpp/grpcpp.h>
Expand Down Expand Up @@ -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<uint16_t>(port));

bool ok = ::bind(fd, reinterpret_cast<sockaddr *>(&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<int> ports(static_cast<size_t>(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<gid_t>(-1)) {
*error = "group " + group + " is out of range";
return false;
}
gid = static_cast<gid_t>(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<uid_t>(-1), gid) != 0 || ::chmod(path.c_str(), 0660) != 0) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*error = "open the socket to group " + group + ": " + std::strerror(errno);
return false;
}
return true;
}

} // namespace
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();

Expand Down
6 changes: 3 additions & 3 deletions tests/plugin_fixture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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_;

Expand Down
61 changes: 53 additions & 8 deletions tests/test_handshake.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#include <algorithm>
#include <string>
#include <vector>

#include <gtest/gtest.h>
#include <grp.h>
#include <sys/stat.h>
#include <unistd.h>

#include "plugin_fixture.hpp"

Expand Down Expand Up @@ -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";
}
Expand All @@ -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) {
Expand All @@ -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<std::string> 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
Expand Down
Loading
Loading