merge: lane/daemon
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# cli/ produces the `velox` binary — the scriptable RPC client. Owned by lane DAEMON.
|
||||
# Built early (AGENT-DAEMON.md build step 8, pulled forward): it is how the daemon is
|
||||
# tested before the GUI exists.
|
||||
#
|
||||
# Links velox::proto for the wire types and error codes. It speaks the same NDJSON Unix
|
||||
# socket veloxd listens on; no daemon code is linked.
|
||||
|
||||
if(NOT TARGET nlohmann_json::nlohmann_json)
|
||||
find_package(nlohmann_json 3.11 REQUIRED)
|
||||
endif()
|
||||
|
||||
add_executable(velox
|
||||
src/main.cpp
|
||||
src/client.cpp
|
||||
)
|
||||
target_include_directories(velox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_compile_features(velox PRIVATE cxx_std_23)
|
||||
target_compile_options(velox PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(velox PRIVATE velox::proto nlohmann_json::nlohmann_json)
|
||||
|
||||
if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "client.hpp"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::cli {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string read_error_message(int e) { return std::strerror(e); }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string default_socket_path() {
|
||||
std::string base;
|
||||
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') {
|
||||
base = xdg;
|
||||
} else {
|
||||
base = "/run/user/" + std::to_string(::geteuid());
|
||||
}
|
||||
if (!base.empty() && base.back() == '/') base.pop_back();
|
||||
return base + "/velox/velox.sock";
|
||||
}
|
||||
|
||||
Client::~Client() {
|
||||
if (fd_ >= 0) ::close(fd_);
|
||||
}
|
||||
|
||||
std::optional<CallError> Client::connect() {
|
||||
socket_path_ = default_socket_path();
|
||||
if (socket_path_.size() + 1 > sizeof(sockaddr_un::sun_path)) {
|
||||
return CallError{CallError::kConnect, "socket path too long: " + socket_path_, {}};
|
||||
}
|
||||
|
||||
fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd_ < 0) return CallError{CallError::kConnect, read_error_message(errno), {}};
|
||||
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
std::memcpy(addr.sun_path, socket_path_.c_str(), socket_path_.size());
|
||||
if (::connect(fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
const int e = errno;
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
return CallError{CallError::kConnect,
|
||||
"cannot reach veloxd at " + socket_path_ + ": " + read_error_message(e),
|
||||
{}};
|
||||
}
|
||||
|
||||
nlohmann::json hello = {
|
||||
{"clientType", "cli"},
|
||||
{"clientName", std::string("velox ") + std::string(velox::proto::kProtocolVersion)},
|
||||
{"protocolVersion", std::string(velox::proto::kProtocolVersion)},
|
||||
};
|
||||
auto r = call("session.hello", hello);
|
||||
if (!r) return r.error();
|
||||
hello_result_ = *r;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::expected<nlohmann::json, CallError> Client::call(const std::string& method,
|
||||
const nlohmann::json& params) {
|
||||
const nlohmann::json request = {
|
||||
{"jsonrpc", "2.0"},
|
||||
{"id", next_id_++},
|
||||
{"method", method},
|
||||
{"params", params},
|
||||
};
|
||||
return round_trip(request);
|
||||
}
|
||||
|
||||
std::expected<nlohmann::json, CallError> Client::round_trip(const nlohmann::json& request) {
|
||||
if (fd_ < 0) return std::unexpected(CallError{CallError::kConnect, "not connected", {}});
|
||||
|
||||
std::string out = request.dump();
|
||||
out.push_back('\n');
|
||||
std::size_t off = 0;
|
||||
while (off < out.size()) {
|
||||
const ssize_t n = ::write(fd_, out.data() + off, out.size() - off);
|
||||
if (n > 0) {
|
||||
off += static_cast<std::size_t>(n);
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
return std::unexpected(CallError{CallError::kConnect,
|
||||
"write to daemon failed: " + read_error_message(errno), {}});
|
||||
}
|
||||
|
||||
// Read until a newline completes a frame.
|
||||
for (;;) {
|
||||
if (const auto nl = inbuf_.find('\n'); nl != std::string::npos) {
|
||||
const std::string line = inbuf_.substr(0, nl);
|
||||
inbuf_.erase(0, nl + 1);
|
||||
nlohmann::json reply = nlohmann::json::parse(line, nullptr, false);
|
||||
if (reply.is_discarded()) {
|
||||
return std::unexpected(
|
||||
CallError{CallError::kProtocol, "daemon sent a malformed reply", {}});
|
||||
}
|
||||
if (reply.contains("error")) {
|
||||
const auto& e = reply.at("error");
|
||||
return std::unexpected(CallError{e.value("code", 0), e.value("message", ""),
|
||||
e.contains("data") ? e.at("data") : nlohmann::json()});
|
||||
}
|
||||
return reply.contains("result") ? reply.at("result") : nlohmann::json(nullptr);
|
||||
}
|
||||
|
||||
char chunk[8192];
|
||||
const ssize_t n = ::read(fd_, chunk, sizeof(chunk));
|
||||
if (n > 0) {
|
||||
inbuf_.append(chunk, static_cast<std::size_t>(n));
|
||||
continue;
|
||||
}
|
||||
if (n == 0) {
|
||||
return std::unexpected(CallError{CallError::kConnect,
|
||||
"daemon closed the connection", {}});
|
||||
}
|
||||
if (errno == EINTR) continue;
|
||||
return std::unexpected(CallError{CallError::kConnect,
|
||||
"read from daemon failed: " + read_error_message(errno), {}});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace velox::cli
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
// A synchronous, blocking RPC client for the Unix socket. The CLI does one request at a
|
||||
// time and waits for the reply, so none of the daemon's async machinery is needed here —
|
||||
// just connect, session.hello, call, read one NDJSON frame back.
|
||||
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace velox::cli {
|
||||
|
||||
struct CallError {
|
||||
int code; // JSON-RPC error code, or a negative transport code below
|
||||
std::string message;
|
||||
nlohmann::json data; // may be null
|
||||
|
||||
static constexpr int kConnect = -1000; // could not reach the daemon
|
||||
static constexpr int kProtocol = -1001; // malformed reply / framing error
|
||||
};
|
||||
|
||||
class Client {
|
||||
public:
|
||||
~Client();
|
||||
|
||||
// Resolve the socket path ($XDG_RUNTIME_DIR/velox/velox.sock), connect, and complete
|
||||
// session.hello with clientType "cli". On failure returns the error and leaves the
|
||||
// client unusable.
|
||||
std::optional<CallError> connect();
|
||||
|
||||
// Send one request and return its result, or the error. `params` is passed through
|
||||
// verbatim as the JSON-RPC params.
|
||||
std::expected<nlohmann::json, CallError> call(const std::string& method,
|
||||
const nlohmann::json& params);
|
||||
|
||||
const std::string& socket_path() const noexcept { return socket_path_; }
|
||||
const nlohmann::json& hello_result() const noexcept { return hello_result_; }
|
||||
|
||||
private:
|
||||
std::expected<nlohmann::json, CallError> round_trip(const nlohmann::json& request);
|
||||
|
||||
int fd_ = -1;
|
||||
int next_id_ = 1;
|
||||
std::string socket_path_;
|
||||
std::string inbuf_;
|
||||
nlohmann::json hello_result_;
|
||||
};
|
||||
|
||||
// $XDG_RUNTIME_DIR/velox/velox.sock, or /run/user/<uid>/velox/velox.sock when the env var
|
||||
// is unset. Mirrors daemon/src/rpc/runtime_dir.cpp; kept in sync by being trivial.
|
||||
std::string default_socket_path();
|
||||
|
||||
} // namespace velox::cli
|
||||
@@ -0,0 +1,179 @@
|
||||
// velox — the command-line client for veloxd.
|
||||
//
|
||||
// velox add <url> [--dir D] [--out NAME] [--segments N] [--json]
|
||||
// velox ls [--json]
|
||||
// velox pause <id>... velox resume <id>...
|
||||
// velox rm <id>... [--delete-file]
|
||||
//
|
||||
// Exit codes: 0 ok, 1 daemon returned an error, 2 usage error, 3 cannot reach the daemon.
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "client.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
using nlohmann::json;
|
||||
using velox::cli::CallError;
|
||||
using velox::cli::Client;
|
||||
|
||||
constexpr int kOk = 0;
|
||||
constexpr int kRpcError = 1;
|
||||
constexpr int kUsage = 2;
|
||||
constexpr int kNoDaemon = 3;
|
||||
|
||||
struct Args {
|
||||
std::vector<std::string> positional;
|
||||
bool json = false;
|
||||
bool delete_file = false;
|
||||
std::string dir;
|
||||
std::string out;
|
||||
long segments = 0;
|
||||
};
|
||||
|
||||
[[noreturn]] void usage(int code) {
|
||||
std::fprintf(code == kOk ? stdout : stderr,
|
||||
"usage: velox <command> [options]\n\n"
|
||||
" add <url> [--dir DIR] [--out NAME] [--segments N]\n"
|
||||
" ls\n"
|
||||
" pause <id>...\n"
|
||||
" resume <id>...\n"
|
||||
" rm <id>... [--delete-file]\n\n"
|
||||
" --json print the raw JSON-RPC result\n");
|
||||
std::exit(code);
|
||||
}
|
||||
|
||||
Args parse_args(int argc, char** argv) {
|
||||
Args a;
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
const std::string_view arg = argv[i];
|
||||
if (arg == "--json") {
|
||||
a.json = true;
|
||||
} else if (arg == "--delete-file") {
|
||||
a.delete_file = true;
|
||||
} else if (arg == "--dir" && i + 1 < argc) {
|
||||
a.dir = argv[++i];
|
||||
} else if (arg == "--out" && i + 1 < argc) {
|
||||
a.out = argv[++i];
|
||||
} else if (arg == "--segments" && i + 1 < argc) {
|
||||
a.segments = std::strtol(argv[++i], nullptr, 10);
|
||||
} else if (arg == "-h" || arg == "--help") {
|
||||
usage(kOk);
|
||||
} else if (!arg.empty() && arg.front() == '-') {
|
||||
std::fprintf(stderr, "velox: unknown option %s\n", argv[i]);
|
||||
usage(kUsage);
|
||||
} else {
|
||||
a.positional.emplace_back(arg);
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
int report_error(const CallError& e, bool as_json) {
|
||||
if (as_json) {
|
||||
json j = {{"error", {{"code", e.code}, {"message", e.message}}}};
|
||||
if (!e.data.is_null()) j["error"]["data"] = e.data;
|
||||
std::printf("%s\n", j.dump(2).c_str());
|
||||
} else {
|
||||
std::fprintf(stderr, "velox: %s\n", e.message.c_str());
|
||||
}
|
||||
return e.code == CallError::kConnect ? kNoDaemon : kRpcError;
|
||||
}
|
||||
|
||||
void print_task_table(const json& items) {
|
||||
std::printf("%-38s %-10s %-9s %s\n", "ID", "STATE", "PROGRESS", "NAME");
|
||||
for (const auto& t : items) {
|
||||
const std::string id = t.value("taskId", "");
|
||||
const std::string state = t.value("state", "");
|
||||
const std::string name = t.value("filename", "");
|
||||
const long long total = t.value("sizeBytes", 0LL);
|
||||
const long long done = t.value("downloadedBytes", 0LL);
|
||||
char pct[12] = "-";
|
||||
if (total > 0) std::snprintf(pct, sizeof(pct), "%lld%%", done * 100 / total);
|
||||
std::printf("%-38s %-10s %-9s %s\n", id.c_str(), state.c_str(), pct, name.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
int cmd_ls(Client& c, const Args& a) {
|
||||
auto r = c.call("download.list", json::object());
|
||||
if (!r) return report_error(r.error(), a.json);
|
||||
if (a.json) {
|
||||
std::printf("%s\n", r->dump(2).c_str());
|
||||
return kOk;
|
||||
}
|
||||
const auto& items = r->contains("items") ? r->at("items") : json::array();
|
||||
if (items.empty()) {
|
||||
std::printf("no downloads\n");
|
||||
return kOk;
|
||||
}
|
||||
print_task_table(items);
|
||||
return kOk;
|
||||
}
|
||||
|
||||
int cmd_add(Client& c, const Args& a) {
|
||||
if (a.positional.empty()) {
|
||||
std::fprintf(stderr, "velox add: a URL is required\n");
|
||||
return kUsage;
|
||||
}
|
||||
json params = {{"url", a.positional.front()}};
|
||||
if (!a.dir.empty()) params["saveDir"] = a.dir;
|
||||
if (!a.out.empty()) params["filename"] = a.out;
|
||||
if (a.segments > 0) params["segments"] = a.segments;
|
||||
|
||||
auto r = c.call("download.add", params);
|
||||
if (!r) return report_error(r.error(), a.json);
|
||||
if (a.json) {
|
||||
std::printf("%s\n", r->dump(2).c_str());
|
||||
} else {
|
||||
std::printf("added %s\n", r->value("taskId", "?").c_str());
|
||||
}
|
||||
return kOk;
|
||||
}
|
||||
|
||||
int cmd_bulk(Client& c, const Args& a, const char* method) {
|
||||
if (a.positional.empty()) {
|
||||
std::fprintf(stderr, "velox: at least one task id is required\n");
|
||||
return kUsage;
|
||||
}
|
||||
json params = {{"taskIds", a.positional}};
|
||||
if (std::string_view(method) == "download.remove" && a.delete_file) params["deleteFile"] = true;
|
||||
|
||||
auto r = c.call(method, params);
|
||||
if (!r) return report_error(r.error(), a.json);
|
||||
if (a.json) {
|
||||
std::printf("%s\n", r->dump(2).c_str());
|
||||
} else {
|
||||
std::printf("ok\n");
|
||||
}
|
||||
return kOk;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) usage(kUsage);
|
||||
const std::string command = argv[1];
|
||||
if (command == "-h" || command == "--help") usage(kOk);
|
||||
|
||||
const Args args = parse_args(argc, argv);
|
||||
|
||||
Client client;
|
||||
if (const auto err = client.connect()) {
|
||||
return report_error(*err, args.json);
|
||||
}
|
||||
|
||||
if (command == "ls") return cmd_ls(client, args);
|
||||
if (command == "add") return cmd_add(client, args);
|
||||
if (command == "pause") return cmd_bulk(client, args, "download.pause");
|
||||
if (command == "resume") return cmd_bulk(client, args, "download.resume");
|
||||
if (command == "rm") return cmd_bulk(client, args, "download.remove");
|
||||
|
||||
std::fprintf(stderr, "velox: unknown command '%s'\n", command.c_str());
|
||||
usage(kUsage);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# CLI integration test: a real in-process UdsServer on a temp socket, the real Client
|
||||
# against it. Links veloxd_rpc for the server half.
|
||||
|
||||
add_executable(velox_client_test client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/client.cpp)
|
||||
target_include_directories(velox_client_test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../../daemon/tests
|
||||
)
|
||||
target_compile_features(velox_client_test PRIVATE cxx_std_23)
|
||||
target_compile_options(velox_client_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(velox_client_test PRIVATE veloxd_rpc velox::proto nlohmann_json::nlohmann_json)
|
||||
add_test(NAME velox.client COMMAND velox_client_test)
|
||||
set_tests_properties(velox.client PROPERTIES TIMEOUT 30)
|
||||
@@ -0,0 +1,78 @@
|
||||
// The CLI's Client against a real in-process daemon RPC server.
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "client.hpp"
|
||||
#include "rpc/dispatcher.hpp"
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/uds_server.hpp"
|
||||
|
||||
namespace rpc = velox::daemon::rpc;
|
||||
using velox::cli::CallError;
|
||||
using velox::cli::Client;
|
||||
|
||||
void run() {
|
||||
// connect() with no daemon -> a kConnect error, not a crash.
|
||||
{
|
||||
::setenv("XDG_RUNTIME_DIR", "/tmp/velox-cli-test-nonexistent-xyz", 1);
|
||||
Client c;
|
||||
auto err = c.connect();
|
||||
CHECK(err.has_value());
|
||||
if (err) CHECK_EQ(err->code, CallError::kConnect);
|
||||
}
|
||||
|
||||
// Point XDG_RUNTIME_DIR at a fresh temp dir; the client derives
|
||||
// <XDG_RUNTIME_DIR>/velox/velox.sock and the server binds exactly that.
|
||||
char tmpl[] = "/tmp/velox-cli-test-XXXXXX";
|
||||
const char* xdg = ::mkdtemp(tmpl);
|
||||
CHECK(xdg != nullptr);
|
||||
if (xdg == nullptr) return;
|
||||
::setenv("XDG_RUNTIME_DIR", xdg, 1);
|
||||
const std::string velox_dir = std::string(xdg) + "/velox";
|
||||
::mkdir(velox_dir.c_str(), 0700);
|
||||
const std::string server_sock = velox_dir + "/velox.sock";
|
||||
|
||||
rpc::EventLoop loop;
|
||||
rpc::VeloxDispatcher dispatcher;
|
||||
rpc::UdsServer server(loop, dispatcher, server_sock);
|
||||
const auto ec = server.start();
|
||||
CHECK(!ec);
|
||||
if (ec) return;
|
||||
std::thread th([&loop] { loop.run(); });
|
||||
|
||||
{
|
||||
Client c;
|
||||
auto err = c.connect();
|
||||
CHECK(!err.has_value());
|
||||
if (err) {
|
||||
loop.stop();
|
||||
th.join();
|
||||
return;
|
||||
}
|
||||
CHECK_EQ(c.hello_result().value("transport", ""), std::string("uds"));
|
||||
|
||||
auto ls = c.call("download.list", nlohmann::json::object());
|
||||
CHECK(ls.has_value());
|
||||
if (ls) {
|
||||
CHECK_EQ(ls->value("total", -1), 0);
|
||||
CHECK(ls->at("items").is_array());
|
||||
}
|
||||
|
||||
// A not-yet-implemented method surfaces the daemon's error, not a transport error.
|
||||
auto add = c.call("download.add", {{"url", "https://example.com/x"}});
|
||||
CHECK(!add.has_value());
|
||||
if (!add) CHECK_EQ(add.error().code, -32603);
|
||||
}
|
||||
|
||||
loop.stop();
|
||||
th.join();
|
||||
::unlink(server_sock.c_str());
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,75 @@
|
||||
# daemon/ produces the veloxd binary and the static libraries it is built from.
|
||||
# Owned by lane DAEMON. Wired in by PKG via add_subdirectory(daemon) in the root file,
|
||||
# guarded on this file existing.
|
||||
#
|
||||
# Layering (CLAUDE.md §3): depends on velox::core and velox::proto. No Qt. The engine
|
||||
# (velox::core) is not linked yet — it arrives when sched/ and the task glue land. This
|
||||
# drop is the RPC transports (Unix socket + loopback WebSocket), the SQLite store, and a
|
||||
# dispatcher skeleton so the CLI and GUI have a real server to talk to.
|
||||
|
||||
if(NOT TARGET nlohmann_json::nlohmann_json)
|
||||
find_package(nlohmann_json 3.11 REQUIRED)
|
||||
endif()
|
||||
find_package(Threads REQUIRED)
|
||||
find_package(SQLite3 REQUIRED)
|
||||
find_package(OpenSSL REQUIRED) # libcrypto: WebSocket accept hash, pairing token hash
|
||||
|
||||
# --- generated: migrations_embedded.hpp from src/store/migrations/*.sql ---------------
|
||||
set(_mig_dir ${CMAKE_CURRENT_SOURCE_DIR}/src/store/migrations)
|
||||
set(_mig_hdr ${CMAKE_CURRENT_BINARY_DIR}/generated/migrations_embedded.hpp)
|
||||
file(GLOB _mig_srcs ${_mig_dir}/*.sql)
|
||||
add_custom_command(
|
||||
OUTPUT ${_mig_hdr}
|
||||
COMMAND ${CMAKE_COMMAND} -DMIG_DIR=${_mig_dir} -DOUT=${_mig_hdr}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_migrations.cmake
|
||||
DEPENDS ${_mig_srcs} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_migrations.cmake
|
||||
COMMENT "Embedding SQL migrations"
|
||||
VERBATIM)
|
||||
add_custom_target(veloxd_migrations_hdr DEPENDS ${_mig_hdr})
|
||||
|
||||
# --- veloxd_store — SQLite store, migrations, crypto helpers --------------------------
|
||||
add_library(veloxd_store STATIC
|
||||
src/util/crypto.cpp
|
||||
src/store/sqlite.cpp
|
||||
src/store/migrations.cpp
|
||||
src/store/pairings.cpp
|
||||
${_mig_hdr}
|
||||
)
|
||||
add_library(velox::daemon_store ALIAS veloxd_store)
|
||||
target_include_directories(veloxd_store
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated
|
||||
)
|
||||
target_compile_features(veloxd_store PUBLIC cxx_std_23)
|
||||
target_compile_options(veloxd_store PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3 PRIVATE OpenSSL::Crypto)
|
||||
|
||||
# --- veloxd_rpc — the RPC transports + dispatcher ------------------------------------
|
||||
add_library(veloxd_rpc STATIC
|
||||
src/rpc/runtime_dir.cpp
|
||||
src/rpc/event_loop.cpp
|
||||
src/rpc/uds_server.cpp
|
||||
src/rpc/ws_frame.cpp
|
||||
src/rpc/ws_handshake.cpp
|
||||
src/rpc/ws_server.cpp
|
||||
src/rpc/pairing.cpp
|
||||
src/rpc/dispatcher.cpp
|
||||
)
|
||||
add_library(velox::daemon_rpc ALIAS veloxd_rpc)
|
||||
|
||||
target_include_directories(veloxd_rpc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_compile_features(veloxd_rpc PUBLIC cxx_std_23)
|
||||
target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(veloxd_rpc
|
||||
PUBLIC velox::proto veloxd_store nlohmann_json::nlohmann_json Threads::Threads
|
||||
)
|
||||
|
||||
# --- veloxd — the daemon binary -------------------------------------------------------
|
||||
add_executable(veloxd src/main.cpp)
|
||||
target_compile_features(veloxd PRIVATE cxx_std_23)
|
||||
target_compile_options(veloxd PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(veloxd PRIVATE veloxd_rpc)
|
||||
|
||||
if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generates migrations_embedded.hpp from daemon/src/store/migrations/*.sql.
|
||||
#
|
||||
# cmake -DMIG_DIR=<dir> -DOUT=<file> -P embed_migrations.cmake
|
||||
#
|
||||
# Each NNNN_name.sql becomes a Migration{ version = NNNN, name = "NNNN_name", sql = R"..." }.
|
||||
# The delimiter for the raw string literal is chosen to not collide with the file body.
|
||||
|
||||
file(GLOB _sql_files "${MIG_DIR}/*.sql")
|
||||
list(SORT _sql_files)
|
||||
|
||||
set(_entries "")
|
||||
foreach(_f ${_sql_files})
|
||||
get_filename_component(_stem "${_f}" NAME_WE) # 0001_initial
|
||||
string(REGEX MATCH "^([0-9]+)_" _m "${_stem}")
|
||||
if(NOT _m)
|
||||
message(FATAL_ERROR "migration file '${_f}' does not start with NNNN_")
|
||||
endif()
|
||||
string(REGEX REPLACE "^0*([0-9]+)_.*$" "\\1" _ver "${_stem}")
|
||||
|
||||
file(READ "${_f}" _body)
|
||||
# Pick a raw-string delimiter guaranteed absent from the body.
|
||||
set(_delim "MIGSQL")
|
||||
while(_body MATCHES "\\)${_delim}\"")
|
||||
set(_delim "${_delim}X")
|
||||
endwhile()
|
||||
|
||||
string(APPEND _entries
|
||||
" Migration{ ${_ver}, \"${_stem}\", R\"${_delim}(\n${_body}\n)${_delim}\" },\n")
|
||||
endforeach()
|
||||
|
||||
list(LENGTH _sql_files _count)
|
||||
|
||||
set(_out "// GENERATED by embed_migrations.cmake — do not edit. Source: src/store/migrations/*.sql
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include \"store/migrations.hpp\"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
inline constexpr std::array<Migration, ${_count}> kEmbeddedMigrations = {{
|
||||
${_entries}}};
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
")
|
||||
|
||||
if(EXISTS "${OUT}")
|
||||
file(READ "${OUT}" _existing)
|
||||
if(_existing STREQUAL "${_out}")
|
||||
return() # unchanged — do not rewrite, keeps the build stable
|
||||
endif()
|
||||
endif()
|
||||
file(WRITE "${OUT}" "${_out}")
|
||||
@@ -0,0 +1,94 @@
|
||||
# DAEMON review — CORE's engine API (`core/docs/engine-api-m1.md`, `lane/core@d6cf1fe`)
|
||||
|
||||
**Verdict: sign off.** Nothing here forces a `daemon/src/sched/` or RPC-dispatch rewrite.
|
||||
The value types are final enough to build `sched/` against now; `Engine` / `DownloadHandle`
|
||||
bodies landing in stage 8 is fine. The split matches ADR 0011 and ADR 0013 exactly.
|
||||
|
||||
Answers to the five open questions, then the small things to confirm.
|
||||
|
||||
## Answers
|
||||
|
||||
### Q1 — `probe_hint`: keep it optional, as sketched
|
||||
|
||||
DAEMON has a `ProbeResult` on exactly one path: the File Info dialog, where the user
|
||||
already waited for `download.probe` and then clicked Download. Every other entry —
|
||||
`capture.offer` → take, `velox add`, `download.addBatch`, the restart flow — has no probe
|
||||
in hand. Forcing the engine to always probe adds a round trip to the one case where the
|
||||
user just sat through one; forcing DAEMON to always probe first means reimplementing the
|
||||
engine's probe on the daemon side. Pass `probe_hint` when we have it, omit it otherwise —
|
||||
the two code paths are worth keeping.
|
||||
|
||||
### Q2 — one `cancel(discard_partial)`, not a separate `remove()`
|
||||
|
||||
The two wire methods map cleanly onto the one call:
|
||||
|
||||
| wire | live task | already terminal |
|
||||
|---|---|---|
|
||||
| `download.cancel` | `handle.cancel(discard_partial=false)` — keeps `.veloxpart` | no-op on the handle; DAEMON marks the row `cancelled` |
|
||||
| `download.remove {deleteFile}` | `handle.cancel(discard_partial=true)` | no-op on the handle; DAEMON deletes the row and, if `deleteFile`, the finished file |
|
||||
|
||||
`download.remove` on a completed task is pure DAEMON-side work (row + optional file); the
|
||||
handle is already terminal so `cancel()` no-ops, which is exactly what we want. No
|
||||
`handle.remove()` needed.
|
||||
|
||||
### Q3 — `{restart, keep_partial, abort}` is enough, if the engine owns the mechanical 416 retry
|
||||
|
||||
For `server_file_changed` the three options are right and complete. For
|
||||
`range_metadata_stale` (416): `docs/04` §7 already has the engine re-probe and re-split
|
||||
automatically. Keep that — DAEMON does not want to be in the loop for a routine 416. Only
|
||||
escalate to `on_decision_needed{range_metadata_stale}` when the automatic re-probe/re-split
|
||||
*also* fails to reconcile, and at that point "retry the same range once more" is not a
|
||||
useful fourth option (the engine already exhausted it). So: no fourth value, provided the
|
||||
engine handles the common 416 without a callback.
|
||||
|
||||
### Q4 — per-task 4 Hz `on_progress` is fine; `on_progress_batch` optional
|
||||
|
||||
DAEMON already has to coalesce across tasks: `event.task.progress` is "batched ≤ 4 Hz into
|
||||
a single array message" (AGENT-DAEMON.md item 5). So 80 per-task callbacks/s land in
|
||||
DAEMON's fan-out queue and are re-emitted as one array at 4 Hz regardless. Per-task keeps
|
||||
the handle↔callback correspondence simple and is not a bottleneck. If the engine's timer
|
||||
thread is already walking every task to build those callbacks, a
|
||||
`on_progress_batch(span<Progress>)` is strictly less work for both sides and we'd take it —
|
||||
but it is not needed for M1 and should not hold stage 8.
|
||||
|
||||
### Q5 — `refresh_url` while `downloading`: restart all segments on the new URL
|
||||
|
||||
`download.refreshUrl`'s contract is the signed-URL-expiry case: "re-probes and compares
|
||||
size and validator; if they still match, the transfer resumes from where it stopped." That
|
||||
wants consistency — every segment on the new URL once the re-probe validates. Mirror
|
||||
rotation (finish in-flight on the old host, new work elsewhere) is a different mechanism
|
||||
and it is already `DownloadSpec.mirrors` + the segmenter's 3-failure requeue, not
|
||||
`refresh_url`. So: on `refresh_url`, re-probe, and if size+validator match, move all
|
||||
segments to the new URL from their current offsets; if they don't match, surface it
|
||||
(`on_decision_needed` or a `refresh_url` error) rather than silently restarting.
|
||||
|
||||
## Confirmed by CORE (`lane/core@3da4cd6`)
|
||||
|
||||
1. **`vdm::TaskId`** — cheap-copy and `std::hash`-able; DAEMON never constructs one, only
|
||||
receives it from `start()` / callbacks and passes it back to `set_task_order()`.
|
||||
`sched/` keeps the `vdm::TaskId → wire taskId` map keyed off `handle.id()`.
|
||||
2. **Parent directory** — DAEMON `mkdir -p`s `save_path`'s parent before `start()`; the
|
||||
engine opens the file and fails the task with `Error::path_rejected` if it is missing.
|
||||
Now explicit on `DownloadSpec`'s doc comment.
|
||||
3. **`sha512`** — added as the fourth `Checksum::Algo`, matching the wire set. No `-32602`
|
||||
at the RPC edge; DAEMON passes it straight through.
|
||||
4. **Cancel ordering** — `on_state(_, cancelled, nullopt)` then `on_finished`, always in
|
||||
that order. Note the taxonomy value is spelled `canceled` (one L): the finish is
|
||||
`on_finished(Err{Error::canceled})`, `error.code == canceled`. `sched/` keys on that
|
||||
to write a `cancelled` history row rather than a user-facing failure. (The wire
|
||||
`TaskState` / `EngineState` spelling stays `cancelled`; only `vdm::Error` is one-L.)
|
||||
|
||||
## Related, landed the same pass
|
||||
|
||||
`rate/token_bucket` — the global → queue → task speed-limiter hierarchy (`docs/04` §6),
|
||||
reached via `Engine::rate_limiter()`. DAEMON's `limiter.set {globalBps, enabled}` maps to
|
||||
`rate_limiter().set_global_limit(...)`; `limiter.get` reads it back. Wire this alongside
|
||||
the `download.add` → `start()` glue.
|
||||
|
||||
## Integration timing
|
||||
|
||||
Wire it after `sched/` lands — the scheduler is what calls `start()` / `pause()` /
|
||||
`resume()` / `cancel()` and drives `segment_budget().set_task_order()`. Order:
|
||||
`sched/` (against these headers) → `download.add` → `start()` glue → the callback→wire
|
||||
projection. `sched/` does not need the `Engine` bodies, only the signatures in this doc,
|
||||
so stage 8 and `sched/` proceed in parallel.
|
||||
@@ -0,0 +1,92 @@
|
||||
# DAEMON → PROTO — requests against `contracts/` (and its codegen)
|
||||
|
||||
Status: **open**. Raised by lane DAEMON while building `rpc/` against `1.3.0`.
|
||||
PROTO owns `contracts/`, including `contracts/codegen/`. Ranking per
|
||||
`contracts/README.md` rule 4: a codegen output-shape change that every server must
|
||||
adopt is effectively **major for the C++ binding** even when the wire is untouched —
|
||||
it needs a version note and a regen, not a silent change.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
- **P1 — landed** on `lane/proto` as `contracts/` **1.4.0** (commit `5e3e215`), as the
|
||||
`HandlerError` / `HandlerResult<T>` sketch below. Wire is byte-identical; C++-binding
|
||||
bump only. `rpc/` adopts it (the predicted `Result<T>` → `HandlerResult<T>` swap on the
|
||||
`on_*` overrides) **once `lane/proto` merges to `main`** — not against the unmerged
|
||||
branch. `uds_roundtrip`'s `-32603`-collapse guard flips to `-32010` in the same change.
|
||||
- **P2 — resolved.** `session.hello.version-mismatch`'s `data.expected` is now `$any`;
|
||||
the error-fixture compare is on `code` only, so `rpc/` echoes `kProtocolVersion` there.
|
||||
PROTO's writeup: `contracts/proto-answers-daemon-m1.md`.
|
||||
|
||||
---
|
||||
|
||||
## P1. The generated `Dispatcher` has no error channel below `-32603` — **blocking a conformant server**
|
||||
|
||||
`velox::proto::Dispatcher`'s 39 methods each return `Result<T>` =
|
||||
`std::expected<T, ParseError>`, and `dispatch()` maps **every** handler error to
|
||||
`ErrorCode::InternalError` (`-32603`):
|
||||
|
||||
```cpp
|
||||
auto r = handler.on_download_get(*p);
|
||||
if (!r)
|
||||
return make_error(id, ErrorCode::InternalError, r.error().message,
|
||||
nlohmann::json{{"path", r.error().path}});
|
||||
```
|
||||
|
||||
So a handler cannot return any of the contract's own error codes. The error fixtures
|
||||
in `contracts/fixtures/errors/` that a live server must satisfy (DAEMON DoD: "passes
|
||||
the full conformance suite as a server, over both transports") include:
|
||||
|
||||
| Fixture | Expected code | `data` | Originates |
|
||||
|---|---|---|---|
|
||||
| `download.get.not-found` | `-32010` | `{taskId}` | inside the handler |
|
||||
| `download.add.invalid-path` | `-32011` | `{path}` | inside the handler (after canonicalization) |
|
||||
| `download.probe.probe-failed` | `-32013` | `{httpStatus}` | inside the handler |
|
||||
| `session.pair.rate-limited` | `-32014` | `{retryAfterSec}` | server-side gate, but cleanest expressed as a handler result |
|
||||
| `session.hello.version-mismatch` | `-32001` | `{expected, actual}` | can be done server-side around `dispatch()` |
|
||||
| `session.hello.not-paired` | `-32002` | — | server-side WS auth gate, around `dispatch()` |
|
||||
|
||||
`-32001`, `-32002`, `-32003` DAEMON can and will handle in the server layer that wraps
|
||||
`dispatch()` (`-32003` is already in `dispatch()` itself). But `-32010`, `-32011`,
|
||||
`-32013` are per-method **handler outcomes** — the daemon knows "no such task" only
|
||||
after the store lookup, "outside allowed roots" only after `realpath()`. There is no
|
||||
correct way to surface them today except misreporting as `-32603`, which the TS
|
||||
conformance replay will reject on the `code` compare.
|
||||
|
||||
**Requested:** give the generated handler methods an error return that carries an
|
||||
`ErrorCode`, a message, and a free-form `data` object. Shape is PROTO's call; a
|
||||
minimal one that keeps `ParseError` for the parse path and adds a handler-error type:
|
||||
|
||||
```cpp
|
||||
struct HandlerError {
|
||||
ErrorCode code{ErrorCode::InternalError};
|
||||
std::string message;
|
||||
nlohmann::json data{nullptr};
|
||||
};
|
||||
template <class T> using HandlerResult = std::expected<T, HandlerError>;
|
||||
// Dispatcher::on_* return HandlerResult<T>; dispatch() forwards code/message/data
|
||||
// straight into make_error() instead of hard-coding InternalError.
|
||||
```
|
||||
|
||||
`FixtureDispatcher` and `conformance_main.cpp` would need the trivial follow-on edit
|
||||
(they only ever return success today, so it is a type-name swap).
|
||||
|
||||
Until this lands, DAEMON's `rpc/` server layer handles `-3200x` around `dispatch()`
|
||||
where it can, and every genuine in-handler failure collapses to `-32603` with a clear
|
||||
message — visibly non-conformant on three error fixtures, tracked here, not worked
|
||||
around by inventing a side channel.
|
||||
|
||||
---
|
||||
|
||||
## P2. `SessionHelloResult.transport` and `-32001` `data.expected` — minor clarifications
|
||||
|
||||
- `session.hello.version-mismatch`'s `data.expected` is `"1.0.0"` in the fixture, i.e.
|
||||
the daemon's *current* protocol version string, not a bare major. DAEMON will echo
|
||||
`kProtocolVersion` (`"1.3.0"`) there unless PROTO wants the fixture's literal
|
||||
`"1.0.0"` preserved — flag if the conformance compare is exact on that field rather
|
||||
than structural.
|
||||
- `SessionHelloResult.transport` is `std::optional` — DAEMON intends to always populate
|
||||
it (`"uds"` / `"ws"`) so a client knows its privilege level up front, as the field's
|
||||
own description invites. No change requested; noting the intent so a later "why is
|
||||
this always set" review has the answer.
|
||||
@@ -0,0 +1,134 @@
|
||||
// veloxd — the Velox download-manager daemon.
|
||||
//
|
||||
// Wires up both RPC transports (Unix socket + loopback WebSocket), the SQLite store,
|
||||
// and a dispatcher skeleton so the CLI and GUI have a real server to speak to
|
||||
// (AGENT-DAEMON.md build order, steps 1 and 3). The scheduler and the engine link land
|
||||
// next.
|
||||
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "rpc/dispatcher.hpp"
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/pairing.hpp"
|
||||
#include "rpc/runtime_dir.hpp"
|
||||
#include "rpc/uds_server.hpp"
|
||||
#include "rpc/ws_server.hpp"
|
||||
#include "store/migrations.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
#include "version.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
velox::daemon::rpc::EventLoop* g_loop = nullptr;
|
||||
|
||||
void on_signal(int) {
|
||||
if (g_loop != nullptr) g_loop->stop(); // stop() is async-signal-safe (writes an eventfd)
|
||||
}
|
||||
|
||||
// Single-instance guard: bind an abstract-namespace Unix socket whose name is unique to
|
||||
// this user. A second daemon gets EADDRINUSE and exits. The kernel reclaims an
|
||||
// abstract-namespace address when the holding process dies, so a crash never wedges it
|
||||
// (docs/01 §2). Returns the held fd (kept open for the process lifetime) or -1.
|
||||
int acquire_single_instance_lock() {
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return -1;
|
||||
|
||||
const std::string name = std::string("velox-daemon-") + std::to_string(::geteuid());
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
// Leading NUL selects the abstract namespace; the name follows, not NUL-terminated.
|
||||
addr.sun_path[0] = '\0';
|
||||
std::memcpy(addr.sun_path + 1, name.c_str(), name.size());
|
||||
const socklen_t len =
|
||||
static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + 1 + name.size());
|
||||
|
||||
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), len) != 0) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
std::cout << "veloxd " << velox::daemon::kDaemonVersion << " (protocol "
|
||||
<< velox::proto::kProtocolVersion << ")\n";
|
||||
|
||||
const int lock_fd = acquire_single_instance_lock();
|
||||
if (lock_fd < 0) {
|
||||
std::cerr << "veloxd: another instance is already running for this user\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
velox::daemon::rpc::RuntimeDir rt;
|
||||
if (const auto ec = velox::daemon::rpc::resolve_runtime_dir(rt)) {
|
||||
std::cerr << "veloxd: cannot prepare runtime directory: " << ec.message() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
velox::daemon::rpc::EventLoop loop;
|
||||
g_loop = &loop;
|
||||
|
||||
struct sigaction sa{};
|
||||
sa.sa_handler = on_signal;
|
||||
::sigemptyset(&sa.sa_mask);
|
||||
::sigaction(SIGINT, &sa, nullptr);
|
||||
::sigaction(SIGTERM, &sa, nullptr);
|
||||
::signal(SIGPIPE, SIG_IGN); // a client vanishing mid-write is EPIPE, never a signal
|
||||
|
||||
std::string data_dir;
|
||||
if (const auto ec = velox::daemon::rpc::resolve_data_dir(data_dir)) {
|
||||
std::cerr << "veloxd: cannot prepare data directory: " << ec.message() << "\n";
|
||||
return 1;
|
||||
}
|
||||
auto db = velox::daemon::store::Db::open(data_dir + "/velox.db");
|
||||
if (!db) {
|
||||
std::cerr << "veloxd: cannot open " << data_dir << "/velox.db: "
|
||||
<< db.error().to_string() << "\n";
|
||||
return 1;
|
||||
}
|
||||
if (const auto m = velox::daemon::store::migrate_to_head(*db); !m) {
|
||||
std::cerr << "veloxd: schema migration failed: " << m.error().to_string() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
velox::daemon::rpc::VeloxDispatcher dispatcher;
|
||||
|
||||
velox::daemon::rpc::UdsServer uds(loop, dispatcher, rt.socket_path());
|
||||
if (const auto ec = uds.start()) {
|
||||
std::cerr << "veloxd: cannot listen on " << rt.socket_path() << ": " << ec.message()
|
||||
<< "\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "veloxd: listening on " << uds.socket_path() << "\n";
|
||||
|
||||
// The WebSocket transport is the extension's fallback (docs/05 §4); the Unix socket is
|
||||
// the primary. If every port in 52000-52016 is taken, log it and carry on rather than
|
||||
// refusing to start — capture must fail open, and the GUI/CLI still have the socket.
|
||||
// TODO(build step 7): replace EnvAutoApprover with a GUI-dialog / desktop-notification
|
||||
// approver. Until then pairing needs VELOX_PAIR_AUTO=1.
|
||||
velox::daemon::rpc::EnvAutoApprover approver;
|
||||
velox::daemon::rpc::WsServer ws(loop, dispatcher, *db, approver, rt);
|
||||
if (const auto ec = ws.start()) {
|
||||
std::cerr << "veloxd: WebSocket transport unavailable (" << ec.message()
|
||||
<< "); the extension fallback will not work this run\n";
|
||||
} else {
|
||||
std::cout << "veloxd: WebSocket transport on 127.0.0.1:" << ws.port() << "\n";
|
||||
}
|
||||
|
||||
loop.run();
|
||||
std::cout << "veloxd: shutting down\n";
|
||||
|
||||
g_loop = nullptr;
|
||||
::close(lock_fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#include "rpc/dispatcher.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace proto = velox::proto;
|
||||
|
||||
namespace {
|
||||
|
||||
// A method whose body arrives with the store / scheduler. Answers -32603 with a clear
|
||||
// message through the generated HandlerError channel (contracts/ 1.4.0, ADR 0014).
|
||||
template <class T>
|
||||
proto::HandlerResult<T> not_implemented(const char* method) {
|
||||
return std::unexpected(proto::HandlerError{
|
||||
proto::ErrorCode::InternalError,
|
||||
std::string("not implemented in this build: ") + method});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- session.* : handled in the server layer, unreachable here in the running daemon ---
|
||||
// Kept as explicit stubs so a direct dispatch() caller (a test, a future in-process client)
|
||||
// gets a clear answer rather than undefined behaviour from a missing override.
|
||||
|
||||
proto::HandlerResult<proto::SessionHelloResult>
|
||||
VeloxDispatcher::on_session_hello(const proto::SessionHelloParams&) {
|
||||
return not_implemented<proto::SessionHelloResult>("session.hello");
|
||||
}
|
||||
|
||||
proto::HandlerResult<proto::SessionPairResult>
|
||||
VeloxDispatcher::on_session_pair(const proto::SessionPairParams&) {
|
||||
return not_implemented<proto::SessionPairResult>("session.pair");
|
||||
}
|
||||
|
||||
proto::HandlerResult<proto::SessionSubscribeResult>
|
||||
VeloxDispatcher::on_session_subscribe(const proto::SessionSubscribeParams&) {
|
||||
return not_implemented<proto::SessionSubscribeResult>("session.subscribe");
|
||||
}
|
||||
|
||||
// --- download.list : an empty table, so a client can connect and render ---------------
|
||||
|
||||
proto::HandlerResult<proto::DownloadListResult>
|
||||
VeloxDispatcher::on_download_list(const proto::DownloadListParams&) {
|
||||
proto::DownloadListResult r;
|
||||
r.total = 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- everything else : not implemented until the store and scheduler land -------------
|
||||
|
||||
proto::HandlerResult<proto::CaptureRules>
|
||||
VeloxDispatcher::on_capture_getRules(const proto::CaptureGetRulesParams&) {
|
||||
return not_implemented<proto::CaptureRules>("capture.getRules");
|
||||
}
|
||||
proto::HandlerResult<proto::CaptureOfferResult>
|
||||
VeloxDispatcher::on_capture_offer(const proto::CaptureOfferParams&) {
|
||||
return not_implemented<proto::CaptureOfferResult>("capture.offer");
|
||||
}
|
||||
proto::HandlerResult<proto::CategoryListResult>
|
||||
VeloxDispatcher::on_category_list(const proto::CategoryListParams&) {
|
||||
return not_implemented<proto::CategoryListResult>("category.list");
|
||||
}
|
||||
proto::HandlerResult<proto::CategoryRemoveResult>
|
||||
VeloxDispatcher::on_category_remove(const proto::CategoryRemoveParams&) {
|
||||
return not_implemented<proto::CategoryRemoveResult>("category.remove");
|
||||
}
|
||||
proto::HandlerResult<proto::CategoryUpsertResult>
|
||||
VeloxDispatcher::on_category_upsert(const proto::CategoryUpsertParams&) {
|
||||
return not_implemented<proto::CategoryUpsertResult>("category.upsert");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadAddResult>
|
||||
VeloxDispatcher::on_download_add(const proto::DownloadSpec&) {
|
||||
return not_implemented<proto::DownloadAddResult>("download.add");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadAddBatchResult>
|
||||
VeloxDispatcher::on_download_addBatch(const proto::DownloadAddBatchParams&) {
|
||||
return not_implemented<proto::DownloadAddBatchResult>("download.addBatch");
|
||||
}
|
||||
proto::HandlerResult<proto::BulkTaskResult>
|
||||
VeloxDispatcher::on_download_cancel(const proto::DownloadCancelParams&) {
|
||||
return not_implemented<proto::BulkTaskResult>("download.cancel");
|
||||
}
|
||||
proto::HandlerResult<proto::TaskDetail>
|
||||
VeloxDispatcher::on_download_get(const proto::DownloadGetParams& params) {
|
||||
// No store is wired yet, so no task exists and every id is genuinely not-found. This
|
||||
// is the real -32010 answer (contracts/ error fixture download.get.not-found), not a
|
||||
// placeholder; it becomes a store lookup when store/ is wired in.
|
||||
return std::unexpected(proto::HandlerError{proto::ErrorCode::TaskNotFound, "no such task",
|
||||
nlohmann::json{{"taskId", params.taskId}}});
|
||||
}
|
||||
proto::HandlerResult<proto::BulkTaskResult>
|
||||
VeloxDispatcher::on_download_pause(const proto::DownloadPauseParams&) {
|
||||
return not_implemented<proto::BulkTaskResult>("download.pause");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadProbeResult>
|
||||
VeloxDispatcher::on_download_probe(const proto::DownloadProbeParams&) {
|
||||
return not_implemented<proto::DownloadProbeResult>("download.probe");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadProvideAuthResult>
|
||||
VeloxDispatcher::on_download_provideAuth(const proto::DownloadProvideAuthParams&) {
|
||||
return not_implemented<proto::DownloadProvideAuthResult>("download.provideAuth");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadRefreshUrlResult>
|
||||
VeloxDispatcher::on_download_refreshUrl(const proto::DownloadRefreshUrlParams&) {
|
||||
return not_implemented<proto::DownloadRefreshUrlResult>("download.refreshUrl");
|
||||
}
|
||||
proto::HandlerResult<proto::DownloadRemoveResult>
|
||||
VeloxDispatcher::on_download_remove(const proto::DownloadRemoveParams&) {
|
||||
return not_implemented<proto::DownloadRemoveResult>("download.remove");
|
||||
}
|
||||
proto::HandlerResult<proto::BulkTaskResult>
|
||||
VeloxDispatcher::on_download_resume(const proto::DownloadResumeParams&) {
|
||||
return not_implemented<proto::BulkTaskResult>("download.resume");
|
||||
}
|
||||
proto::HandlerResult<proto::BulkTaskResult>
|
||||
VeloxDispatcher::on_download_start(const proto::DownloadStartParams&) {
|
||||
return not_implemented<proto::BulkTaskResult>("download.start");
|
||||
}
|
||||
proto::HandlerResult<proto::TaskSummary>
|
||||
VeloxDispatcher::on_download_update(const proto::DownloadUpdateParams&) {
|
||||
return not_implemented<proto::TaskSummary>("download.update");
|
||||
}
|
||||
proto::HandlerResult<proto::GrabberHarvestResult>
|
||||
VeloxDispatcher::on_grabber_harvest(const proto::GrabberHarvestParams&) {
|
||||
return not_implemented<proto::GrabberHarvestResult>("grabber.harvest");
|
||||
}
|
||||
proto::HandlerResult<proto::GrabberStartResult>
|
||||
VeloxDispatcher::on_grabber_start(const proto::GrabberStartParams&) {
|
||||
return not_implemented<proto::GrabberStartResult>("grabber.start");
|
||||
}
|
||||
proto::HandlerResult<proto::GrabberStatusResult>
|
||||
VeloxDispatcher::on_grabber_status(const proto::GrabberStatusParams&) {
|
||||
return not_implemented<proto::GrabberStatusResult>("grabber.status");
|
||||
}
|
||||
proto::HandlerResult<proto::Limiter> VeloxDispatcher::on_limiter_get(const proto::LimiterGetParams&) {
|
||||
return not_implemented<proto::Limiter>("limiter.get");
|
||||
}
|
||||
proto::HandlerResult<proto::Limiter> VeloxDispatcher::on_limiter_set(const proto::Limiter&) {
|
||||
return not_implemented<proto::Limiter>("limiter.set");
|
||||
}
|
||||
proto::HandlerResult<proto::MediaAddVariantResult>
|
||||
VeloxDispatcher::on_media_addVariant(const proto::MediaAddVariantParams&) {
|
||||
return not_implemented<proto::MediaAddVariantResult>("media.addVariant");
|
||||
}
|
||||
proto::HandlerResult<proto::MediaListVariantsResult>
|
||||
VeloxDispatcher::on_media_listVariants(const proto::MediaListVariantsParams&) {
|
||||
return not_implemented<proto::MediaListVariantsResult>("media.listVariants");
|
||||
}
|
||||
proto::HandlerResult<proto::QueueListResult>
|
||||
VeloxDispatcher::on_queue_list(const proto::QueueListParams&) {
|
||||
return not_implemented<proto::QueueListResult>("queue.list");
|
||||
}
|
||||
proto::HandlerResult<proto::QueueReorderResult>
|
||||
VeloxDispatcher::on_queue_reorder(const proto::QueueReorderParams&) {
|
||||
return not_implemented<proto::QueueReorderResult>("queue.reorder");
|
||||
}
|
||||
proto::HandlerResult<proto::QueueStartResult>
|
||||
VeloxDispatcher::on_queue_start(const proto::QueueStartParams&) {
|
||||
return not_implemented<proto::QueueStartResult>("queue.start");
|
||||
}
|
||||
proto::HandlerResult<proto::QueueStopResult>
|
||||
VeloxDispatcher::on_queue_stop(const proto::QueueStopParams&) {
|
||||
return not_implemented<proto::QueueStopResult>("queue.stop");
|
||||
}
|
||||
proto::HandlerResult<proto::QueueUpsertResult>
|
||||
VeloxDispatcher::on_queue_upsert(const proto::QueueUpsertParams&) {
|
||||
return not_implemented<proto::QueueUpsertResult>("queue.upsert");
|
||||
}
|
||||
proto::HandlerResult<proto::RulesListResult>
|
||||
VeloxDispatcher::on_rules_list(const proto::RulesListParams&) {
|
||||
return not_implemented<proto::RulesListResult>("rules.list");
|
||||
}
|
||||
proto::HandlerResult<proto::RulesUpsertResult>
|
||||
VeloxDispatcher::on_rules_upsert(const proto::RulesUpsertParams&) {
|
||||
return not_implemented<proto::RulesUpsertResult>("rules.upsert");
|
||||
}
|
||||
proto::HandlerResult<proto::ScheduleGetResult>
|
||||
VeloxDispatcher::on_schedule_get(const proto::ScheduleGetParams&) {
|
||||
return not_implemented<proto::ScheduleGetResult>("schedule.get");
|
||||
}
|
||||
proto::HandlerResult<proto::ScheduleSetResult>
|
||||
VeloxDispatcher::on_schedule_set(const proto::ScheduleSetParams&) {
|
||||
return not_implemented<proto::ScheduleSetResult>("schedule.set");
|
||||
}
|
||||
proto::HandlerResult<proto::SettingsGetResult>
|
||||
VeloxDispatcher::on_settings_get(const proto::SettingsGetParams&) {
|
||||
return not_implemented<proto::SettingsGetResult>("settings.get");
|
||||
}
|
||||
proto::HandlerResult<proto::SettingsSetResult>
|
||||
VeloxDispatcher::on_settings_set(const proto::SettingsSetParams&) {
|
||||
return not_implemented<proto::SettingsSetResult>("settings.set");
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
|
||||
// VeloxDispatcher implements the generated velox::proto::Dispatcher — one virtual per RPC
|
||||
// method. The generated dispatch() does the envelope, the transport check and the param
|
||||
// parse; a method here only ever sees a validated, typed params struct and returns a
|
||||
// typed result.
|
||||
//
|
||||
// Scope of this drop (AGENT-DAEMON.md build order): the transport is real, the store is
|
||||
// not. session.hello / session.pair / session.subscribe are handled in the server layer
|
||||
// (they are connection- and transport-stateful) and never reach this class in the running
|
||||
// daemon. download.list answers with an empty table so a client can connect and render.
|
||||
// Every other method returns "not implemented in this build" — which the generated
|
||||
// dispatch() surfaces as -32603 — until the store and scheduler land.
|
||||
//
|
||||
// The -32603 collapse for genuine in-handler errors (-32010 / -32011 / -32013) is a known
|
||||
// codegen gap, filed as P1 in daemon/docs/proto-requests-m1.md. Not worked around here.
|
||||
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
class VeloxDispatcher final : public velox::proto::Dispatcher {
|
||||
public:
|
||||
velox::proto::HandlerResult<velox::proto::CaptureRules>
|
||||
on_capture_getRules(const velox::proto::CaptureGetRulesParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::CaptureOfferResult>
|
||||
on_capture_offer(const velox::proto::CaptureOfferParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::CategoryListResult>
|
||||
on_category_list(const velox::proto::CategoryListParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::CategoryRemoveResult>
|
||||
on_category_remove(const velox::proto::CategoryRemoveParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::CategoryUpsertResult>
|
||||
on_category_upsert(const velox::proto::CategoryUpsertParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadAddResult>
|
||||
on_download_add(const velox::proto::DownloadSpec&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadAddBatchResult>
|
||||
on_download_addBatch(const velox::proto::DownloadAddBatchParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::BulkTaskResult>
|
||||
on_download_cancel(const velox::proto::DownloadCancelParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::TaskDetail>
|
||||
on_download_get(const velox::proto::DownloadGetParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadListResult>
|
||||
on_download_list(const velox::proto::DownloadListParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::BulkTaskResult>
|
||||
on_download_pause(const velox::proto::DownloadPauseParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadProbeResult>
|
||||
on_download_probe(const velox::proto::DownloadProbeParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadProvideAuthResult>
|
||||
on_download_provideAuth(const velox::proto::DownloadProvideAuthParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadRefreshUrlResult>
|
||||
on_download_refreshUrl(const velox::proto::DownloadRefreshUrlParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::DownloadRemoveResult>
|
||||
on_download_remove(const velox::proto::DownloadRemoveParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::BulkTaskResult>
|
||||
on_download_resume(const velox::proto::DownloadResumeParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::BulkTaskResult>
|
||||
on_download_start(const velox::proto::DownloadStartParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::TaskSummary>
|
||||
on_download_update(const velox::proto::DownloadUpdateParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::GrabberHarvestResult>
|
||||
on_grabber_harvest(const velox::proto::GrabberHarvestParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::GrabberStartResult>
|
||||
on_grabber_start(const velox::proto::GrabberStartParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::GrabberStatusResult>
|
||||
on_grabber_status(const velox::proto::GrabberStatusParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::Limiter>
|
||||
on_limiter_get(const velox::proto::LimiterGetParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::Limiter> on_limiter_set(const velox::proto::Limiter&) override;
|
||||
velox::proto::HandlerResult<velox::proto::MediaAddVariantResult>
|
||||
on_media_addVariant(const velox::proto::MediaAddVariantParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::MediaListVariantsResult>
|
||||
on_media_listVariants(const velox::proto::MediaListVariantsParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::QueueListResult>
|
||||
on_queue_list(const velox::proto::QueueListParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::QueueReorderResult>
|
||||
on_queue_reorder(const velox::proto::QueueReorderParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::QueueStartResult>
|
||||
on_queue_start(const velox::proto::QueueStartParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::QueueStopResult>
|
||||
on_queue_stop(const velox::proto::QueueStopParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::QueueUpsertResult>
|
||||
on_queue_upsert(const velox::proto::QueueUpsertParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::RulesListResult>
|
||||
on_rules_list(const velox::proto::RulesListParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::RulesUpsertResult>
|
||||
on_rules_upsert(const velox::proto::RulesUpsertParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::ScheduleGetResult>
|
||||
on_schedule_get(const velox::proto::ScheduleGetParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::ScheduleSetResult>
|
||||
on_schedule_set(const velox::proto::ScheduleSetParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::SessionHelloResult>
|
||||
on_session_hello(const velox::proto::SessionHelloParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::SessionPairResult>
|
||||
on_session_pair(const velox::proto::SessionPairParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::SessionSubscribeResult>
|
||||
on_session_subscribe(const velox::proto::SessionSubscribeParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::SettingsGetResult>
|
||||
on_settings_get(const velox::proto::SettingsGetParams&) override;
|
||||
velox::proto::HandlerResult<velox::proto::SettingsSetResult>
|
||||
on_settings_set(const velox::proto::SettingsSetParams&) override;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,109 @@
|
||||
#include "rpc/event_loop.hpp"
|
||||
|
||||
#include <poll.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
EventLoop::EventLoop() {
|
||||
wake_fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
|
||||
if (wake_fd_ < 0) throw std::runtime_error("eventfd() failed");
|
||||
fds_.emplace(wake_fd_, Entry{kRead, [this](int, unsigned) { drain_wakeup(); }});
|
||||
}
|
||||
|
||||
EventLoop::~EventLoop() {
|
||||
if (wake_fd_ >= 0) ::close(wake_fd_);
|
||||
}
|
||||
|
||||
void EventLoop::add_fd(int fd, unsigned interest, Callback cb) {
|
||||
fds_[fd] = Entry{interest, std::move(cb)};
|
||||
}
|
||||
|
||||
void EventLoop::mod_fd(int fd, unsigned interest) {
|
||||
if (auto it = fds_.find(fd); it != fds_.end()) it->second.interest = interest;
|
||||
}
|
||||
|
||||
void EventLoop::del_fd(int fd) {
|
||||
if (fd == wake_fd_) return; // internal, never removed
|
||||
fds_.erase(fd);
|
||||
}
|
||||
|
||||
void EventLoop::wake() noexcept {
|
||||
const std::uint64_t one = 1;
|
||||
// Best-effort: an EAGAIN here means a wakeup is already pending, which is fine.
|
||||
[[maybe_unused]] ssize_t n = ::write(wake_fd_, &one, sizeof(one));
|
||||
}
|
||||
|
||||
void EventLoop::stop() noexcept {
|
||||
stop_requested_ = true;
|
||||
wake();
|
||||
}
|
||||
|
||||
void EventLoop::drain_wakeup() noexcept {
|
||||
std::uint64_t sink = 0;
|
||||
while (::read(wake_fd_, &sink, sizeof(sink)) > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
void EventLoop::run() {
|
||||
if (running_) throw std::logic_error("EventLoop::run() is not re-entrant");
|
||||
running_ = true;
|
||||
stop_requested_ = false;
|
||||
|
||||
std::vector<pollfd> pfds;
|
||||
std::vector<int> fired;
|
||||
|
||||
while (!stop_requested_) {
|
||||
pfds.clear();
|
||||
pfds.reserve(fds_.size());
|
||||
for (const auto& [fd, e] : fds_) {
|
||||
short ev = 0;
|
||||
if (e.interest & kRead) ev |= POLLIN;
|
||||
if (e.interest & kWrite) ev |= POLLOUT;
|
||||
if (ev == 0 && fd != wake_fd_) continue;
|
||||
pollfd p{};
|
||||
p.fd = fd;
|
||||
p.events = ev;
|
||||
pfds.push_back(p);
|
||||
}
|
||||
|
||||
const int rc = ::poll(pfds.data(), pfds.size(), -1);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error("poll() failed");
|
||||
}
|
||||
if (rc == 0) continue;
|
||||
|
||||
// Snapshot the fds that fired before invoking any callback: a callback may erase
|
||||
// entries from fds_, which would invalidate iteration over pfds' referents.
|
||||
fired.clear();
|
||||
for (const auto& p : pfds) {
|
||||
if (p.revents != 0) fired.push_back(p.fd);
|
||||
}
|
||||
|
||||
for (const int fd : fired) {
|
||||
const auto it = fds_.find(fd);
|
||||
if (it == fds_.end()) continue; // removed by an earlier callback this pass
|
||||
|
||||
// Recompute revents for this fd from the snapshot.
|
||||
unsigned events = 0;
|
||||
for (const auto& p : pfds) {
|
||||
if (p.fd != fd) continue;
|
||||
if (p.revents & (POLLIN | POLLHUP | POLLERR)) events |= kRead;
|
||||
if (p.revents & POLLOUT) events |= kWrite;
|
||||
break;
|
||||
}
|
||||
if (events != 0) it->second.cb(fd, events);
|
||||
}
|
||||
}
|
||||
|
||||
running_ = false;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
// A single-threaded poll(2) reactor. Every RPC listener and connection registers its fd
|
||||
// here; the loop never blocks on disk or DNS (AGENT-DAEMON.md build step 1 — "Never block
|
||||
// the RPC loop"). Long work is handed to CORE's pools later; this class only multiplexes
|
||||
// readiness.
|
||||
//
|
||||
// Thread model: run() executes on one thread. add_fd/mod_fd/del_fd are called from
|
||||
// callbacks on that same thread. stop() and wake() are async-signal-safe and safe to call
|
||||
// from any thread or a signal handler — they only write() a byte to an internal eventfd.
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
enum Interest : unsigned {
|
||||
kNone = 0,
|
||||
kRead = 1u << 0,
|
||||
kWrite = 1u << 1,
|
||||
};
|
||||
|
||||
class EventLoop {
|
||||
public:
|
||||
// Called when the fd is readable and/or writable. `events` is the subset of the fd's
|
||||
// registered Interest that fired. A callback may add/modify/remove any fd, including
|
||||
// its own, and may call stop().
|
||||
using Callback = std::function<void(int fd, unsigned events)>;
|
||||
|
||||
EventLoop();
|
||||
~EventLoop();
|
||||
|
||||
EventLoop(const EventLoop&) = delete;
|
||||
EventLoop& operator=(const EventLoop&) = delete;
|
||||
|
||||
// Register `fd` (must be non-blocking) for `interest`. Replaces any prior registration.
|
||||
void add_fd(int fd, unsigned interest, Callback cb);
|
||||
// Change the interest mask for an already-registered fd.
|
||||
void mod_fd(int fd, unsigned interest);
|
||||
// Stop watching `fd`. Does not close it — ownership stays with the caller.
|
||||
void del_fd(int fd);
|
||||
|
||||
// Run until stop() is called. Re-entrant calls are not supported.
|
||||
void run();
|
||||
|
||||
// Ask run() to return after the current poll wakeup. Async-signal-safe.
|
||||
void stop() noexcept;
|
||||
|
||||
// Force one poll() wakeup without stopping — used when interest changed from outside a
|
||||
// callback. Async-signal-safe.
|
||||
void wake() noexcept;
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
unsigned interest;
|
||||
Callback cb;
|
||||
};
|
||||
|
||||
void drain_wakeup() noexcept;
|
||||
|
||||
int wake_fd_; // eventfd, always registered
|
||||
bool running_ = false;
|
||||
std::atomic<bool> stop_requested_ = false; // set from stop(), read by run()
|
||||
std::unordered_map<int, Entry> fds_;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
// NDJSON framing: one JSON value per line, '\n'-terminated. This is the wire framing on
|
||||
// the Unix socket ($XDG_RUNTIME_DIR/velox/velox.sock) per AGENT-DAEMON.md build step 1.
|
||||
// A frame carries no length prefix — the newline is the delimiter — so a reader must
|
||||
// buffer a partial tail until the next '\n' arrives.
|
||||
//
|
||||
// Header-only: it is pure string slicing with no I/O and no dependency beyond <string>.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
// Largest single frame accepted before the connection is considered abusive. A well-formed
|
||||
// request (even download.addBatch with a big clipboard blob) is far below this; anything
|
||||
// past it is either a bug or an attack, and the server drops the connection.
|
||||
inline constexpr std::size_t kMaxFrameBytes = 8 * 1024 * 1024;
|
||||
|
||||
// Accumulates bytes off a stream socket and hands back complete lines. Bytes after the
|
||||
// last '\n' stay buffered for next time. A trailing '\r' (CRLF) is trimmed so a client
|
||||
// that writes CRLF still parses.
|
||||
class FrameReader {
|
||||
public:
|
||||
// Feed a chunk just read from the socket. Returns the frames completed by this chunk,
|
||||
// in order, each with its line terminator removed. Empty lines are skipped (a stray
|
||||
// blank line between frames is not an error).
|
||||
std::vector<std::string> feed(std::string_view chunk) {
|
||||
std::vector<std::string> out;
|
||||
buf_.append(chunk);
|
||||
std::size_t start = 0;
|
||||
for (;;) {
|
||||
const std::size_t nl = buf_.find('\n', start);
|
||||
if (nl == std::string::npos) break;
|
||||
std::string_view line{buf_.data() + start, nl - start};
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
if (!line.empty()) out.emplace_back(line);
|
||||
start = nl + 1;
|
||||
}
|
||||
buf_.erase(0, start);
|
||||
return out;
|
||||
}
|
||||
|
||||
// True once the unframed tail has grown past the cap without a newline — the caller
|
||||
// must close the connection rather than buffer without bound.
|
||||
bool overflowed() const noexcept { return buf_.size() > kMaxFrameBytes; }
|
||||
|
||||
std::size_t buffered() const noexcept { return buf_.size(); }
|
||||
|
||||
private:
|
||||
std::string buf_;
|
||||
};
|
||||
|
||||
// Frame a payload for writing: exactly the JSON text plus one '\n'. Kept as a function so
|
||||
// the "+ newline" rule lives in one place.
|
||||
inline std::string frame(std::string_view payload) {
|
||||
std::string out;
|
||||
out.reserve(payload.size() + 1);
|
||||
out.append(payload);
|
||||
out.push_back('\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "rpc/pairing.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <random>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
bool EnvAutoApprover::approve(const PairingRequest& req) {
|
||||
(void)req;
|
||||
const char* v = std::getenv("VELOX_PAIR_AUTO");
|
||||
return v != nullptr && std::string_view(v) == "1";
|
||||
}
|
||||
|
||||
PairingRateLimiter::Decision PairingRateLimiter::check(std::string_view origin,
|
||||
Clock::time_point now) {
|
||||
auto it = by_origin_.find(origin);
|
||||
if (it == by_origin_.end()) return {true, 0};
|
||||
|
||||
Entry& e = it->second;
|
||||
if (now < e.locked_until) {
|
||||
const auto left =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(e.locked_until - now).count();
|
||||
return {false, static_cast<int>(left) + 1};
|
||||
}
|
||||
|
||||
while (!e.failures.empty() && now - e.failures.front() > kWindow) e.failures.pop_front();
|
||||
if (static_cast<int>(e.failures.size()) >= kMaxPerWindow) {
|
||||
e.locked_until = now + kLockout;
|
||||
return {false, static_cast<int>(kLockout.count())};
|
||||
}
|
||||
return {true, 0};
|
||||
}
|
||||
|
||||
void PairingRateLimiter::record_failure(std::string_view origin, Clock::time_point now) {
|
||||
Entry& e = by_origin_.try_emplace(std::string(origin)).first->second;
|
||||
while (!e.failures.empty() && now - e.failures.front() > kWindow) e.failures.pop_front();
|
||||
e.failures.push_back(now);
|
||||
if (static_cast<int>(e.failures.size()) >= kMaxPerWindow) e.locked_until = now + kLockout;
|
||||
}
|
||||
|
||||
void PairingRateLimiter::record_success(std::string_view origin) {
|
||||
by_origin_.erase(std::string(origin));
|
||||
}
|
||||
|
||||
std::string make_pairing_code() {
|
||||
std::random_device rd;
|
||||
std::uniform_int_distribution<int> d(0, 9999);
|
||||
char buf[5];
|
||||
std::snprintf(buf, sizeof(buf), "%04d", d(rd));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
// The human side of session.pair: showing the user a code and getting an Allow / Deny.
|
||||
//
|
||||
// docs/05 §4 wants a GUI dialog when the GUI is connected, else a desktop notification
|
||||
// with actions. Neither exists yet (that is integration, build step 7), so this is an
|
||||
// interface with a development stub. The token mechanism around it — generation, hashing,
|
||||
// storage, revocation, rate limiting — is real.
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
struct PairingRequest {
|
||||
std::string origin; // moz-extension://<uuid>, from the verified Origin header
|
||||
std::string client_name; // SessionPairParams.clientName, shown in the prompt
|
||||
std::string code; // four digits, shown to the user and echoable in Options
|
||||
};
|
||||
|
||||
class PairingApprover {
|
||||
public:
|
||||
virtual ~PairingApprover() = default;
|
||||
// Returns true iff the user approved. Must not block the RPC loop indefinitely; the
|
||||
// real notification-backed approver will run async and is not this shape.
|
||||
virtual bool approve(const PairingRequest& req) = 0;
|
||||
};
|
||||
|
||||
// Development / test stub: approves iff $VELOX_PAIR_AUTO == "1", otherwise denies. Never
|
||||
// shipped as the default in a release build.
|
||||
class EnvAutoApprover final : public PairingApprover {
|
||||
public:
|
||||
bool approve(const PairingRequest& req) override;
|
||||
};
|
||||
|
||||
// Per-origin failed-attempt limiter: 5 failures in a rolling 60 s, then a 60 s lockout
|
||||
// (docs/05 §4, fixture session.pair.rate-limited). In-memory and keyed by origin, so a
|
||||
// reconnect does not reset it. A success clears the origin's history.
|
||||
class PairingRateLimiter {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
struct Decision {
|
||||
bool allowed;
|
||||
int retry_after_sec; // set when !allowed
|
||||
};
|
||||
|
||||
Decision check(std::string_view origin, Clock::time_point now = Clock::now());
|
||||
void record_failure(std::string_view origin, Clock::time_point now = Clock::now());
|
||||
void record_success(std::string_view origin);
|
||||
|
||||
private:
|
||||
static constexpr int kMaxPerWindow = 5;
|
||||
static constexpr auto kWindow = std::chrono::seconds(60);
|
||||
static constexpr auto kLockout = std::chrono::seconds(60);
|
||||
|
||||
struct Entry {
|
||||
std::deque<Clock::time_point> failures;
|
||||
Clock::time_point locked_until{};
|
||||
};
|
||||
std::map<std::string, Entry, std::less<>> by_origin_;
|
||||
};
|
||||
|
||||
// A four-digit code for the prompt. Uniform over 0000-9999.
|
||||
std::string make_pairing_code();
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "rpc/runtime_dir.hpp"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::error_code errc(int e) { return std::error_code(e, std::generic_category()); }
|
||||
|
||||
// Ensure `dir` exists as a directory we own with mode 0700. Creates it if absent.
|
||||
std::error_code ensure_private_dir(const std::string& dir) {
|
||||
if (::mkdir(dir.c_str(), 0700) != 0 && errno != EEXIST) return errc(errno);
|
||||
|
||||
struct stat st{};
|
||||
if (::lstat(dir.c_str(), &st) != 0) return errc(errno);
|
||||
if (!S_ISDIR(st.st_mode)) return errc(ENOTDIR);
|
||||
if (st.st_uid != ::geteuid()) return errc(EPERM);
|
||||
|
||||
// Tighten if a prior run (or umask) left it looser. Group/other bits must be clear:
|
||||
// the socket is 0600 but a traversable parent still lets another user stat it.
|
||||
if ((st.st_mode & 077) != 0 && ::chmod(dir.c_str(), 0700) != 0) return errc(errno);
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::error_code resolve_runtime_dir(RuntimeDir& out) {
|
||||
std::string base;
|
||||
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') {
|
||||
base = xdg;
|
||||
} else {
|
||||
base = "/run/user/" + std::to_string(::geteuid());
|
||||
struct stat st{};
|
||||
if (::stat(base.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
|
||||
// No XDG_RUNTIME_DIR and no /run/user/<uid>: we refuse rather than pick an
|
||||
// insecure fallback. The caller surfaces this as "cannot start".
|
||||
return errc(ENOENT);
|
||||
}
|
||||
}
|
||||
if (!base.empty() && base.back() == '/') base.pop_back();
|
||||
|
||||
const std::string dir = base + "/velox";
|
||||
if (auto ec = ensure_private_dir(dir)) return ec;
|
||||
|
||||
out.path = dir;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::error_code resolve_data_dir(std::string& out) {
|
||||
std::string base;
|
||||
if (const char* xdg = ::getenv("XDG_DATA_HOME"); xdg != nullptr && xdg[0] != '\0') {
|
||||
base = xdg;
|
||||
} else if (const char* home = ::getenv("HOME"); home != nullptr && home[0] != '\0') {
|
||||
base = std::string(home) + "/.local/share";
|
||||
} else {
|
||||
return errc(ENOENT);
|
||||
}
|
||||
if (!base.empty() && base.back() == '/') base.pop_back();
|
||||
|
||||
// Create the XDG base components leniently, then the velox dir with a strict check.
|
||||
::mkdir(base.c_str(), 0700);
|
||||
const std::string dir = base + "/velox";
|
||||
if (auto ec = ensure_private_dir(dir)) return ec;
|
||||
|
||||
out = dir;
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
// Resolves $XDG_RUNTIME_DIR/velox/ — the home of velox.sock, ws.port and the
|
||||
// single-instance lock (docs/01 §5, AGENT-DAEMON.md build step 1). Creating it 0700 and
|
||||
// refusing a pre-existing dir we do not own is a security boundary, not a convenience.
|
||||
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
struct RuntimeDir {
|
||||
std::string path; // absolute, no trailing slash, e.g. /run/user/1000/velox
|
||||
|
||||
std::string socket_path() const { return path + "/velox.sock"; }
|
||||
std::string ws_port_path() const { return path + "/ws.port"; }
|
||||
};
|
||||
|
||||
// Resolve and ensure the directory exists, mode 0700, owned by the current user.
|
||||
//
|
||||
// - $XDG_RUNTIME_DIR set -> "<it>/velox"
|
||||
// - unset -> "/run/user/<uid>/velox" if that base exists, else an error
|
||||
// (we do not fall back to /tmp: a world-traversable runtime dir defeats the 0600 socket)
|
||||
//
|
||||
// On success `out` is filled and an ok error_code is returned. On failure `out` is
|
||||
// untouched and the error_code explains why (base missing, exists but not a dir, wrong
|
||||
// owner, wrong perms, mkdir failed).
|
||||
std::error_code resolve_runtime_dir(RuntimeDir& out);
|
||||
|
||||
// The persistent data directory: $XDG_DATA_HOME/velox or ~/.local/share/velox
|
||||
// (docs/01 §5). Created 0700 if absent. Holds velox.db. On success `out` is the absolute
|
||||
// path with no trailing slash.
|
||||
std::error_code resolve_data_dir(std::string& out);
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,309 @@
|
||||
#include "rpc/uds_server.hpp"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "version.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace proto = velox::proto;
|
||||
using nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
std::error_code errc(int e) { return std::error_code(e, std::generic_category()); }
|
||||
|
||||
// Largest reply we will buffer for a client that is not reading. Past this the client is
|
||||
// wedged and the connection is dropped rather than growing the daemon's RSS without bound.
|
||||
constexpr std::size_t kMaxOutBytes = 16 * 1024 * 1024;
|
||||
|
||||
std::string uuid4() {
|
||||
std::random_device rd;
|
||||
std::uniform_int_distribution<std::uint32_t> d;
|
||||
std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd);
|
||||
b = (b & 0xFFFF0FFFu) | 0x00004000u; // version 4
|
||||
c = (c & 0x3FFFFFFFu) | 0x80000000u; // variant 1
|
||||
char buf[37];
|
||||
std::snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF),
|
||||
(c >> 16), (c & 0xFFFF), e);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
int major_of(const std::string& semver) {
|
||||
// "1.3.0" -> 1. A missing or non-numeric leading component is treated as major -1 so
|
||||
// it can never accidentally match the daemon's.
|
||||
try {
|
||||
return std::stoi(semver.substr(0, semver.find('.')));
|
||||
} catch (...) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
json rpc_error(const json& id, proto::ErrorCode code, std::string_view msg, json data = nullptr) {
|
||||
return proto::make_error(id, code, msg, std::move(data));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UdsServer::UdsServer(EventLoop& loop, proto::Dispatcher& dispatcher, std::string socket_path)
|
||||
: loop_(loop), dispatcher_(dispatcher), path_(std::move(socket_path)) {}
|
||||
|
||||
UdsServer::~UdsServer() {
|
||||
for (auto& [fd, c] : conns_) {
|
||||
loop_.del_fd(fd);
|
||||
::close(fd);
|
||||
}
|
||||
if (listen_fd_ >= 0) {
|
||||
loop_.del_fd(listen_fd_);
|
||||
::close(listen_fd_);
|
||||
}
|
||||
if (bound_) ::unlink(path_.c_str());
|
||||
}
|
||||
|
||||
std::error_code UdsServer::start() {
|
||||
if (path_.size() + 1 > sizeof(sockaddr_un::sun_path)) return errc(ENAMETOOLONG);
|
||||
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return errc(errno);
|
||||
|
||||
// A socket file from a previous run blocks bind() with EADDRINUSE. Single-instance is
|
||||
// enforced separately (main.cpp lock socket), so an existing file here is stale.
|
||||
::unlink(path_.c_str());
|
||||
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
std::memcpy(addr.sun_path, path_.c_str(), path_.size());
|
||||
|
||||
// umask can only tighten; set the mode explicitly after bind so it is exactly 0600.
|
||||
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
const int e = errno;
|
||||
::close(fd);
|
||||
return errc(e);
|
||||
}
|
||||
bound_ = true;
|
||||
if (::chmod(path_.c_str(), 0600) != 0) {
|
||||
const int e = errno;
|
||||
::close(fd);
|
||||
::unlink(path_.c_str());
|
||||
bound_ = false;
|
||||
return errc(e);
|
||||
}
|
||||
if (::listen(fd, SOMAXCONN) != 0) {
|
||||
const int e = errno;
|
||||
::close(fd);
|
||||
::unlink(path_.c_str());
|
||||
bound_ = false;
|
||||
return errc(e);
|
||||
}
|
||||
|
||||
listen_fd_ = fd;
|
||||
loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); });
|
||||
return {};
|
||||
}
|
||||
|
||||
void UdsServer::on_listener_readable() {
|
||||
for (;;) {
|
||||
const int cfd = ::accept4(listen_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);
|
||||
if (cfd < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
|
||||
if (errno == EINTR || errno == ECONNABORTED) continue;
|
||||
break; // EMFILE/ENFILE: stop accepting this pass; loop retries on next readable
|
||||
}
|
||||
|
||||
ucred cred{};
|
||||
socklen_t len = sizeof(cred);
|
||||
if (::getsockopt(cfd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 ||
|
||||
cred.uid != ::geteuid()) {
|
||||
// Not the same user. The socket mode should already prevent this; refuse hard
|
||||
// regardless — this is the authorization on the Unix transport (docs/01 §2).
|
||||
::close(cfd);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto conn = std::make_unique<Conn>();
|
||||
conn->fd = cfd;
|
||||
conns_.emplace(cfd, std::move(conn));
|
||||
loop_.add_fd(cfd, kRead, [this](int fd, unsigned ev) { on_conn_event(fd, ev); });
|
||||
}
|
||||
}
|
||||
|
||||
void UdsServer::on_conn_event(int fd, unsigned events) {
|
||||
const auto it = conns_.find(fd);
|
||||
if (it == conns_.end()) return;
|
||||
Conn& c = *it->second;
|
||||
|
||||
if (events & kWrite) {
|
||||
flush(c);
|
||||
if (conns_.find(fd) == conns_.end()) return; // flush closed it
|
||||
}
|
||||
|
||||
if (events & kRead) {
|
||||
char buf[64 * 1024];
|
||||
for (;;) {
|
||||
const ssize_t n = ::read(fd, buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
auto lines = c.reader.feed(std::string_view(buf, static_cast<std::size_t>(n)));
|
||||
const bool overflowed = c.reader.overflowed();
|
||||
for (auto& line : lines) {
|
||||
handle_line(c, line);
|
||||
// handle_line may have replied with a fatal error and closed the
|
||||
// connection (e.g. a protocol-major mismatch). Once that happens `c`
|
||||
// is dangling — stop touching it.
|
||||
if (conns_.find(fd) == conns_.end()) return;
|
||||
}
|
||||
if (overflowed) {
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (n == 0) { // peer closed
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
|
||||
if (errno == EINTR) continue;
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UdsServer::handle_line(Conn& c, const std::string& line) {
|
||||
json req = json::parse(line, nullptr, /*allow_exceptions=*/false);
|
||||
if (req.is_discarded()) {
|
||||
queue_reply(c, rpc_error(nullptr, proto::ErrorCode::ParseError, "invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
const json id = req.is_object() && req.contains("id") ? req.at("id") : json(nullptr);
|
||||
const std::string method =
|
||||
req.is_object() && req.contains("method") && req.at("method").is_string()
|
||||
? req.at("method").get<std::string>()
|
||||
: std::string{};
|
||||
|
||||
if (!method.empty()) {
|
||||
json reply;
|
||||
if (handle_session_method(c, method, req, reply)) {
|
||||
if (!reply.is_null()) queue_reply(c, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else: the generated router. It returns a null json for a notification
|
||||
// that needs no reply.
|
||||
json reply = proto::dispatch(dispatcher_, proto::Transport::Uds, req);
|
||||
if (!reply.is_null()) queue_reply(c, reply);
|
||||
}
|
||||
|
||||
bool UdsServer::handle_session_method(Conn& c, const std::string& method, const json& request,
|
||||
json& reply) {
|
||||
const json id = request.contains("id") ? request.at("id") : json(nullptr);
|
||||
const json params = request.contains("params") ? request.at("params") : json::object();
|
||||
|
||||
if (method == "session.hello") {
|
||||
auto p = proto::parse<proto::SessionHelloParams>(params, "params");
|
||||
if (!p) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message,
|
||||
json{{"path", p.error().path}});
|
||||
return true;
|
||||
}
|
||||
const int want = major_of(std::string(proto::kProtocolVersion));
|
||||
const int got = major_of(p->protocolVersion);
|
||||
if (got != want) {
|
||||
reply = rpc_error(
|
||||
id, proto::ErrorCode::VersionMismatch,
|
||||
"protocol major version mismatch: daemon speaks " + std::to_string(want) +
|
||||
".x, client speaks " + std::to_string(got < 0 ? 0 : got) + ".x",
|
||||
json{{"expected", std::string(proto::kProtocolVersion)},
|
||||
{"actual", p->protocolVersion}});
|
||||
c.close_after_flush = true; // no method is served on a mismatched major
|
||||
return true;
|
||||
}
|
||||
|
||||
c.hello_ok = true;
|
||||
if (c.session_id.empty()) c.session_id = uuid4();
|
||||
|
||||
proto::SessionHelloResult r;
|
||||
r.daemonVersion = std::string(velox::daemon::kDaemonVersion);
|
||||
r.protocolVersion = std::string(proto::kProtocolVersion);
|
||||
r.sessionId = c.session_id;
|
||||
r.transport = proto::SessionHelloResultTransport::Uds;
|
||||
reply = proto::make_result(id, r);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method == "session.subscribe") {
|
||||
auto p = proto::parse<proto::SessionSubscribeParams>(params, "params");
|
||||
if (!p) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message,
|
||||
json{{"path", p.error().path}});
|
||||
return true;
|
||||
}
|
||||
// Event fan-out is not wired yet; accept the subscription and echo it back so a
|
||||
// client can already register its interest without erroring.
|
||||
proto::SessionSubscribeResult r;
|
||||
r.ok = true;
|
||||
for (const auto& ev : p->events) r.events.emplace_back(proto::to_string(ev));
|
||||
reply = proto::make_result(id, r);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // session.pair falls through to dispatch() -> -32003 on the Unix socket
|
||||
}
|
||||
|
||||
void UdsServer::queue_reply(Conn& c, const json& reply) {
|
||||
c.outbuf += frame(reply.dump());
|
||||
if (c.outbuf.size() - c.out_off > kMaxOutBytes) {
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
flush(c);
|
||||
}
|
||||
|
||||
void UdsServer::flush(Conn& c) {
|
||||
while (c.out_off < c.outbuf.size()) {
|
||||
const ssize_t n =
|
||||
::write(c.fd, c.outbuf.data() + c.out_off, c.outbuf.size() - c.out_off);
|
||||
if (n > 0) {
|
||||
c.out_off += static_cast<std::size_t>(n);
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
loop_.mod_fd(c.fd, kRead | kWrite);
|
||||
return;
|
||||
}
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
|
||||
c.outbuf.clear();
|
||||
c.out_off = 0;
|
||||
if (c.close_after_flush) {
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
loop_.mod_fd(c.fd, kRead);
|
||||
}
|
||||
|
||||
void UdsServer::close_conn(int fd) {
|
||||
if (const auto it = conns_.find(fd); it != conns_.end()) {
|
||||
loop_.del_fd(fd);
|
||||
::close(fd);
|
||||
conns_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
// The Unix-domain-socket RPC listener: $XDG_RUNTIME_DIR/velox/velox.sock, mode 0600,
|
||||
// SO_PEERCRED same-UID check (docs/01 §2, AGENT-DAEMON.md build step 1). NDJSON framing.
|
||||
// Non-blocking throughout; every fd runs through the shared EventLoop so one slow client
|
||||
// never stalls another.
|
||||
//
|
||||
// session.hello and session.subscribe are handled here because they are connection- and
|
||||
// transport-stateful (protocol-major check, sessionId, per-connection subscription set).
|
||||
// Every other method is routed through the generated velox::proto::dispatch(), which does
|
||||
// the envelope, the -32003 privileged-transport refusal and the typed param parse.
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
|
||||
#include "rpc/ndjson.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
class EventLoop;
|
||||
|
||||
class UdsServer {
|
||||
public:
|
||||
UdsServer(EventLoop& loop, velox::proto::Dispatcher& dispatcher, std::string socket_path);
|
||||
~UdsServer();
|
||||
|
||||
UdsServer(const UdsServer&) = delete;
|
||||
UdsServer& operator=(const UdsServer&) = delete;
|
||||
|
||||
// Create the socket, bind, chmod 0600, listen, and register with the loop. A stale
|
||||
// socket file left by a crashed daemon is removed first. Returns a non-ok error_code
|
||||
// (and changes nothing) on any failure.
|
||||
std::error_code start();
|
||||
|
||||
const std::string& socket_path() const noexcept { return path_; }
|
||||
std::size_t connection_count() const noexcept { return conns_.size(); }
|
||||
|
||||
private:
|
||||
struct Conn {
|
||||
int fd;
|
||||
FrameReader reader;
|
||||
std::string outbuf;
|
||||
std::size_t out_off = 0; // bytes of outbuf already written
|
||||
bool close_after_flush = false;
|
||||
bool hello_ok = false;
|
||||
std::string session_id;
|
||||
};
|
||||
|
||||
void on_listener_readable();
|
||||
void on_conn_event(int fd, unsigned events);
|
||||
void handle_line(Conn& c, const std::string& line);
|
||||
|
||||
// Returns true and fills `reply` if `method` is one this layer answers directly
|
||||
// (session.hello / session.subscribe). Returns false to let dispatch() handle it.
|
||||
bool handle_session_method(Conn& c, const std::string& method, const nlohmann::json& request,
|
||||
nlohmann::json& reply);
|
||||
|
||||
void queue_reply(Conn& c, const nlohmann::json& reply);
|
||||
void flush(Conn& c);
|
||||
void close_conn(int fd);
|
||||
|
||||
EventLoop& loop_;
|
||||
velox::proto::Dispatcher& dispatcher_;
|
||||
std::string path_;
|
||||
int listen_fd_ = -1;
|
||||
bool bound_ = false; // path_ is ours to unlink on destruction
|
||||
std::unordered_map<int, std::unique_ptr<Conn>> conns_;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,152 @@
|
||||
#include "rpc/ws_frame.hpp"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
bool is_control(WsOpcode op) {
|
||||
return op == WsOpcode::Close || op == WsOpcode::Ping || op == WsOpcode::Pong;
|
||||
}
|
||||
bool is_known_data(WsOpcode op) {
|
||||
return op == WsOpcode::Text || op == WsOpcode::Binary;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WsFrameReader::Status WsFrameReader::feed(std::string_view bytes,
|
||||
std::vector<WsMessage>& messages) {
|
||||
buf_.append(bytes);
|
||||
|
||||
for (;;) {
|
||||
if (buf_.size() < 2) return Status::Ok;
|
||||
|
||||
const auto b0 = static_cast<std::uint8_t>(buf_[0]);
|
||||
const auto b1 = static_cast<std::uint8_t>(buf_[1]);
|
||||
|
||||
const bool fin = (b0 & 0x80) != 0;
|
||||
const std::uint8_t rsv = b0 & 0x70;
|
||||
const auto opcode = static_cast<WsOpcode>(b0 & 0x0F);
|
||||
const bool masked = (b1 & 0x80) != 0;
|
||||
std::uint64_t len = b1 & 0x7F;
|
||||
|
||||
if (rsv != 0) {
|
||||
error_ = "RSV bits set with no negotiated extension";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
if (!masked) {
|
||||
error_ = "client frame is not masked"; // RFC 6455 §5.1
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
|
||||
std::size_t header = 2;
|
||||
if (len == 126) {
|
||||
if (buf_.size() < 4) return Status::Ok;
|
||||
len = (static_cast<std::uint64_t>(static_cast<std::uint8_t>(buf_[2])) << 8) |
|
||||
static_cast<std::uint8_t>(buf_[3]);
|
||||
header = 4;
|
||||
} else if (len == 127) {
|
||||
if (buf_.size() < 10) return Status::Ok;
|
||||
len = 0;
|
||||
for (int i = 0; i < 8; ++i)
|
||||
len = (len << 8) | static_cast<std::uint8_t>(buf_[2 + i]);
|
||||
header = 10;
|
||||
}
|
||||
|
||||
if (is_control(opcode)) {
|
||||
if (!fin) {
|
||||
error_ = "fragmented control frame";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
if (len > 125) {
|
||||
error_ = "control frame payload over 125 bytes";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
}
|
||||
if (len > kMaxMessageBytes || frag_.size() + len > kMaxMessageBytes) {
|
||||
error_ = "message exceeds the size cap";
|
||||
return Status::MessageTooBig;
|
||||
}
|
||||
|
||||
const std::size_t need = header + 4 + static_cast<std::size_t>(len);
|
||||
if (buf_.size() < need) return Status::Ok;
|
||||
|
||||
const char* mask = buf_.data() + header;
|
||||
const char* body = mask + 4;
|
||||
|
||||
std::string payload;
|
||||
payload.resize(static_cast<std::size_t>(len));
|
||||
for (std::uint64_t i = 0; i < len; ++i)
|
||||
payload[i] = static_cast<char>(body[i] ^ mask[i & 3]);
|
||||
|
||||
buf_.erase(0, need);
|
||||
|
||||
// --- dispatch by opcode ---------------------------------------------------
|
||||
if (is_control(opcode)) {
|
||||
messages.push_back(WsMessage{opcode, std::move(payload)});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opcode == WsOpcode::Continuation) {
|
||||
if (!in_fragment_) {
|
||||
error_ = "continuation frame with nothing to continue";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
frag_.insert(frag_.end(), payload.begin(), payload.end());
|
||||
if (fin) {
|
||||
messages.push_back(
|
||||
WsMessage{frag_opcode_, std::string(frag_.begin(), frag_.end())});
|
||||
frag_.clear();
|
||||
in_fragment_ = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_known_data(opcode)) {
|
||||
error_ = "unknown opcode";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
if (in_fragment_) {
|
||||
error_ = "new data frame started mid-fragment";
|
||||
return Status::ProtocolError;
|
||||
}
|
||||
if (fin) {
|
||||
messages.push_back(WsMessage{opcode, std::move(payload)});
|
||||
} else {
|
||||
frag_opcode_ = opcode;
|
||||
in_fragment_ = true;
|
||||
frag_.assign(payload.begin(), payload.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string ws_encode(WsOpcode opcode, std::string_view payload) {
|
||||
std::string out;
|
||||
out.push_back(static_cast<char>(0x80 | static_cast<std::uint8_t>(opcode))); // FIN + opcode
|
||||
|
||||
const std::size_t n = payload.size();
|
||||
if (n < 126) {
|
||||
out.push_back(static_cast<char>(n));
|
||||
} else if (n <= 0xFFFF) {
|
||||
out.push_back(static_cast<char>(126));
|
||||
out.push_back(static_cast<char>((n >> 8) & 0xFF));
|
||||
out.push_back(static_cast<char>(n & 0xFF));
|
||||
} else {
|
||||
out.push_back(static_cast<char>(127));
|
||||
for (int i = 7; i >= 0; --i)
|
||||
out.push_back(static_cast<char>((static_cast<std::uint64_t>(n) >> (i * 8)) & 0xFF));
|
||||
}
|
||||
out.append(payload); // server frames are never masked
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ws_close_payload(std::uint16_t code, std::string_view reason) {
|
||||
std::string p;
|
||||
p.push_back(static_cast<char>((code >> 8) & 0xFF));
|
||||
p.push_back(static_cast<char>(code & 0xFF));
|
||||
p.append(reason);
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
// RFC 6455 frame codec — the security-sensitive parser on the loopback WebSocket
|
||||
// transport. Incremental: feed() takes whatever bytes arrived and yields whole messages.
|
||||
// A client frame MUST be masked (RFC 6455 §5.1); an unmasked client frame is a protocol
|
||||
// error and the caller must close 1002.
|
||||
//
|
||||
// Kept deliberately small: text and binary data frames (reassembled across continuation
|
||||
// frames), plus ping / pong / close control frames. No extensions, no RSV bits.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
enum class WsOpcode : std::uint8_t {
|
||||
Continuation = 0x0,
|
||||
Text = 0x1,
|
||||
Binary = 0x2,
|
||||
Close = 0x8,
|
||||
Ping = 0x9,
|
||||
Pong = 0xA,
|
||||
};
|
||||
|
||||
struct WsMessage {
|
||||
WsOpcode opcode; // Text, Binary, Close, Ping or Pong (never Continuation)
|
||||
std::string payload; // reassembled; unmasked
|
||||
};
|
||||
|
||||
class WsFrameReader {
|
||||
public:
|
||||
enum class Status { Ok, ProtocolError, MessageTooBig };
|
||||
|
||||
// Append `bytes` and pull out every message they complete. On a non-Ok status the
|
||||
// caller sends a Close and drops the connection; `messages` still holds anything
|
||||
// decoded before the fault.
|
||||
Status feed(std::string_view bytes, std::vector<WsMessage>& messages);
|
||||
|
||||
std::string_view error() const noexcept { return error_; }
|
||||
|
||||
private:
|
||||
// Cap on a single reassembled message. A JSON-RPC call over this transport is small;
|
||||
// past this the peer is misbehaving.
|
||||
static constexpr std::size_t kMaxMessageBytes = 8 * 1024 * 1024;
|
||||
|
||||
std::string buf_; // undecoded bytes
|
||||
std::vector<char> frag_; // partial data message across continuations
|
||||
WsOpcode frag_opcode_ = WsOpcode::Text;
|
||||
bool in_fragment_ = false;
|
||||
std::string error_;
|
||||
};
|
||||
|
||||
// Build a server->client frame (never masked). `payload` may be empty for Close/Ping/Pong.
|
||||
std::string ws_encode(WsOpcode opcode, std::string_view payload);
|
||||
|
||||
// A Close frame body: 2-byte big-endian status code, optional UTF-8 reason.
|
||||
std::string ws_close_payload(std::uint16_t code, std::string_view reason = {});
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "rpc/ws_handshake.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "util/crypto.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
std::string lower(std::string_view s) {
|
||||
std::string out(s);
|
||||
std::transform(out.begin(), out.end(), out.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view trim(std::string_view s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r'))
|
||||
s.remove_suffix(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string simple_response(std::string_view status_line, std::string_view body) {
|
||||
std::string r;
|
||||
r.append("HTTP/1.1 ").append(status_line).append("\r\n");
|
||||
r.append("Content-Length: ").append(std::to_string(body.size())).append("\r\n");
|
||||
r.append("Connection: close\r\n\r\n");
|
||||
r.append(body);
|
||||
return r;
|
||||
}
|
||||
|
||||
bool looks_like_extension_origin(std::string_view origin) {
|
||||
// moz-extension://<uuid-or-token>. We do not pin a specific extension id here — the
|
||||
// pairing token is the identity; this only rejects a page origin (http/https/file).
|
||||
return origin.rfind("moz-extension://", 0) == 0 && origin.size() > 16;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string ws_accept_key(std::string_view sec_websocket_key) {
|
||||
std::string concat(sec_websocket_key);
|
||||
concat.append(kGuid);
|
||||
const auto digest = velox::daemon::crypto::sha1(concat);
|
||||
return velox::daemon::crypto::base64_encode(digest.data(), digest.size());
|
||||
}
|
||||
|
||||
HandshakeResult ws_try_handshake(std::string_view buffer) {
|
||||
HandshakeResult res;
|
||||
|
||||
const auto end = buffer.find("\r\n\r\n");
|
||||
if (end == std::string_view::npos) return res; // headers still arriving
|
||||
res.complete = true;
|
||||
res.consumed = end + 4;
|
||||
|
||||
const std::string_view head = buffer.substr(0, end);
|
||||
const auto first_nl = head.find("\r\n");
|
||||
const std::string_view request_line = head.substr(0, first_nl);
|
||||
|
||||
std::map<std::string, std::string> headers;
|
||||
std::size_t pos = (first_nl == std::string_view::npos) ? head.size() : first_nl + 2;
|
||||
while (pos < head.size()) {
|
||||
const auto nl = head.find("\r\n", pos);
|
||||
const std::string_view line =
|
||||
head.substr(pos, nl == std::string_view::npos ? head.size() - pos : nl - pos);
|
||||
const auto colon = line.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
headers[lower(trim(line.substr(0, colon)))] =
|
||||
std::string(trim(line.substr(colon + 1)));
|
||||
}
|
||||
if (nl == std::string_view::npos) break;
|
||||
pos = nl + 2;
|
||||
}
|
||||
|
||||
auto get = [&](const char* k) -> std::string_view {
|
||||
const auto it = headers.find(k);
|
||||
return it == headers.end() ? std::string_view{} : std::string_view{it->second};
|
||||
};
|
||||
|
||||
const bool is_get = request_line.rfind("GET ", 0) == 0;
|
||||
const bool upgrade_ws = lower(get("upgrade")).find("websocket") != std::string::npos;
|
||||
const bool conn_upgrade = lower(get("connection")).find("upgrade") != std::string::npos;
|
||||
const std::string_view key = get("sec-websocket-key");
|
||||
const std::string_view version = get("sec-websocket-version");
|
||||
const std::string_view origin = get("origin");
|
||||
|
||||
if (!is_get || !upgrade_ws || !conn_upgrade || key.empty()) {
|
||||
res.response = simple_response("400 Bad Request", "not a WebSocket upgrade");
|
||||
return res;
|
||||
}
|
||||
if (version != "13") {
|
||||
std::string r = "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n";
|
||||
r.append("Connection: close\r\n\r\n");
|
||||
res.response = std::move(r);
|
||||
return res;
|
||||
}
|
||||
if (origin.empty() || !looks_like_extension_origin(origin)) {
|
||||
// docs/05 §4: verify Origin is a moz-extension origin. A page cannot pair.
|
||||
res.response = simple_response("403 Forbidden", "origin not permitted");
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string r = "HTTP/1.1 101 Switching Protocols\r\n";
|
||||
r.append("Upgrade: websocket\r\n");
|
||||
r.append("Connection: Upgrade\r\n");
|
||||
r.append("Sec-WebSocket-Accept: ").append(ws_accept_key(key)).append("\r\n\r\n");
|
||||
|
||||
res.ok = true;
|
||||
res.response = std::move(r);
|
||||
res.origin = std::string(origin);
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
// The RFC 6455 opening handshake, plus the two checks the extension spec makes
|
||||
// non-negotiable (docs/05 §4): the request must carry an Origin, and it must look like a
|
||||
// Firefox extension origin (moz-extension://<uuid>). The token check happens later, in
|
||||
// session.hello — the handshake only gets the socket to WebSocket framing.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
struct HandshakeResult {
|
||||
bool complete = false; // a full request was parsed
|
||||
bool ok = false; // ... and it is a valid, allowed upgrade
|
||||
std::string response; // bytes to write back: 101 on ok, 400/403 otherwise
|
||||
std::string origin; // the verified Origin, when ok
|
||||
std::size_t consumed = 0; // bytes of input that formed the request
|
||||
};
|
||||
|
||||
// Parse an accumulating HTTP request buffer. Returns complete=false (and consumed=0) while
|
||||
// the header block is still arriving. Once "\r\n\r\n" is seen, validates and fills in the
|
||||
// 101 (or an error) response. A body, if any, is not expected on an upgrade and is
|
||||
// ignored.
|
||||
HandshakeResult ws_try_handshake(std::string_view buffer);
|
||||
|
||||
// Exposed for the unit test: RFC 6455 §1.3 accept value for a client key.
|
||||
std::string ws_accept_key(std::string_view sec_websocket_key);
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,440 @@
|
||||
#include "rpc/ws_server.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/ws_handshake.hpp"
|
||||
#include "store/pairings.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
#include "version.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace proto = velox::proto;
|
||||
using nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
std::error_code errc(int e) { return std::error_code(e, std::generic_category()); }
|
||||
|
||||
constexpr std::size_t kMaxOutBytes = 16 * 1024 * 1024;
|
||||
constexpr std::size_t kMaxHandshakeBytes = 16 * 1024;
|
||||
|
||||
std::string now_iso() {
|
||||
std::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
::gmtime_r(&t, &tm);
|
||||
char buf[32];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string uuid4() {
|
||||
std::random_device rd;
|
||||
std::uniform_int_distribution<std::uint32_t> d;
|
||||
std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd);
|
||||
b = (b & 0xFFFF0FFFu) | 0x00004000u;
|
||||
c = (c & 0x3FFFFFFFu) | 0x80000000u;
|
||||
char s[37];
|
||||
std::snprintf(s, sizeof(s), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF),
|
||||
(c >> 16), (c & 0xFFFF), e);
|
||||
return std::string(s);
|
||||
}
|
||||
|
||||
int major_of(const std::string& semver) {
|
||||
try {
|
||||
return std::stoi(semver.substr(0, semver.find('.')));
|
||||
} catch (...) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
json rpc_error(const json& id, proto::ErrorCode code, std::string_view msg, json data = nullptr) {
|
||||
return proto::make_error(id, code, msg, std::move(data));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WsServer::WsServer(EventLoop& loop, proto::Dispatcher& dispatcher, store::Db& db,
|
||||
PairingApprover& approver, RuntimeDir runtime)
|
||||
: loop_(loop),
|
||||
dispatcher_(dispatcher),
|
||||
db_(db),
|
||||
approver_(approver),
|
||||
runtime_(std::move(runtime)) {}
|
||||
|
||||
WsServer::~WsServer() {
|
||||
for (auto& [fd, c] : conns_) {
|
||||
loop_.del_fd(fd);
|
||||
::close(fd);
|
||||
}
|
||||
if (listen_fd_ >= 0) {
|
||||
loop_.del_fd(listen_fd_);
|
||||
::close(listen_fd_);
|
||||
}
|
||||
if (wrote_port_file_) ::unlink(runtime_.ws_port_path().c_str());
|
||||
}
|
||||
|
||||
std::error_code WsServer::start() {
|
||||
int fd = -1;
|
||||
for (int p = kPortLo; p <= kPortHi; ++p) {
|
||||
fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return errc(errno);
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); // 127.0.0.1 only — never INADDR_ANY
|
||||
addr.sin_port = ::htons(static_cast<std::uint16_t>(p));
|
||||
|
||||
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0) {
|
||||
port_ = p;
|
||||
break;
|
||||
}
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
if (errno != EADDRINUSE) return errc(errno);
|
||||
}
|
||||
if (fd < 0) return errc(EADDRINUSE); // 52000-52016 all taken
|
||||
|
||||
if (::listen(fd, SOMAXCONN) != 0) {
|
||||
const int e = errno;
|
||||
::close(fd);
|
||||
return errc(e);
|
||||
}
|
||||
|
||||
// Publish the port for the extension, which cannot read $XDG_RUNTIME_DIR itself but
|
||||
// can be told where to look by a connected GUI. 0600, same as the socket.
|
||||
const std::string pf = runtime_.ws_port_path();
|
||||
const int pfd = ::open(pf.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
|
||||
if (pfd < 0) {
|
||||
const int e = errno;
|
||||
::close(fd);
|
||||
return errc(e);
|
||||
}
|
||||
const std::string line = std::to_string(port_) + "\n";
|
||||
[[maybe_unused]] ssize_t w = ::write(pfd, line.data(), line.size());
|
||||
::close(pfd);
|
||||
wrote_port_file_ = true;
|
||||
|
||||
listen_fd_ = fd;
|
||||
loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); });
|
||||
return {};
|
||||
}
|
||||
|
||||
void WsServer::on_listener_readable() {
|
||||
for (;;) {
|
||||
const int cfd = ::accept4(listen_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);
|
||||
if (cfd < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
|
||||
if (errno == EINTR || errno == ECONNABORTED) continue;
|
||||
break;
|
||||
}
|
||||
auto conn = std::make_unique<Conn>();
|
||||
conn->fd = cfd;
|
||||
conns_.emplace(cfd, std::move(conn));
|
||||
loop_.add_fd(cfd, kRead, [this](int fd, unsigned ev) { on_conn_event(fd, ev); });
|
||||
}
|
||||
}
|
||||
|
||||
void WsServer::on_conn_event(int fd, unsigned events) {
|
||||
const auto it = conns_.find(fd);
|
||||
if (it == conns_.end()) return;
|
||||
Conn& c = *it->second;
|
||||
|
||||
if (events & kWrite) {
|
||||
flush(c);
|
||||
if (conns_.find(fd) == conns_.end()) return;
|
||||
}
|
||||
if (!(events & kRead)) return;
|
||||
|
||||
char buf[64 * 1024];
|
||||
for (;;) {
|
||||
const ssize_t n = ::read(fd, buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
const std::string_view chunk(buf, static_cast<std::size_t>(n));
|
||||
if (c.phase == Phase::Handshake) {
|
||||
c.in_raw.append(chunk);
|
||||
if (c.in_raw.size() > kMaxHandshakeBytes) {
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
progress_handshake(c);
|
||||
} else {
|
||||
on_ws_bytes(c, chunk);
|
||||
}
|
||||
if (conns_.find(fd) == conns_.end()) return;
|
||||
continue;
|
||||
}
|
||||
if (n == 0) {
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
|
||||
if (errno == EINTR) continue;
|
||||
close_conn(fd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void WsServer::progress_handshake(Conn& c) {
|
||||
const HandshakeResult hs = ws_try_handshake(c.in_raw);
|
||||
if (!hs.complete) return;
|
||||
|
||||
c.outbuf.append(hs.response);
|
||||
if (!hs.ok) {
|
||||
c.close_after_flush = true;
|
||||
flush(c);
|
||||
return;
|
||||
}
|
||||
|
||||
c.origin = hs.origin;
|
||||
c.phase = Phase::Open;
|
||||
std::string leftover = c.in_raw.substr(hs.consumed);
|
||||
c.in_raw.clear();
|
||||
flush(c);
|
||||
if (conns_.count(c.fd) && !leftover.empty()) on_ws_bytes(c, leftover);
|
||||
}
|
||||
|
||||
void WsServer::on_ws_bytes(Conn& c, std::string_view bytes) {
|
||||
std::vector<WsMessage> msgs;
|
||||
const auto st = c.frames.feed(bytes, msgs);
|
||||
|
||||
for (auto& m : msgs) {
|
||||
switch (m.opcode) {
|
||||
case WsOpcode::Text:
|
||||
handle_rpc(c, m.payload);
|
||||
break;
|
||||
case WsOpcode::Binary:
|
||||
begin_close(c, 1003, "binary frames are not accepted"); // 1003: unacceptable data
|
||||
break;
|
||||
case WsOpcode::Ping:
|
||||
send_frame(c, WsOpcode::Pong, m.payload);
|
||||
break;
|
||||
case WsOpcode::Pong:
|
||||
break;
|
||||
case WsOpcode::Close:
|
||||
send_frame(c, WsOpcode::Close, m.payload);
|
||||
c.close_after_flush = true;
|
||||
flush(c);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (conns_.find(c.fd) == conns_.end()) return;
|
||||
}
|
||||
|
||||
if (st == WsFrameReader::Status::ProtocolError) {
|
||||
begin_close(c, 1002, c.frames.error()); // 1002: protocol error
|
||||
} else if (st == WsFrameReader::Status::MessageTooBig) {
|
||||
begin_close(c, 1009, "message too big"); // 1009: message too big
|
||||
}
|
||||
}
|
||||
|
||||
void WsServer::handle_rpc(Conn& c, const std::string& text) {
|
||||
json req = json::parse(text, nullptr, false);
|
||||
if (req.is_discarded()) {
|
||||
send_text(c, rpc_error(nullptr, proto::ErrorCode::ParseError, "invalid JSON"));
|
||||
return;
|
||||
}
|
||||
const json id = req.is_object() && req.contains("id") ? req.at("id") : json(nullptr);
|
||||
const std::string method =
|
||||
req.is_object() && req.contains("method") && req.at("method").is_string()
|
||||
? req.at("method").get<std::string>()
|
||||
: std::string{};
|
||||
|
||||
if (!method.empty()) {
|
||||
json reply;
|
||||
if (handle_session_ws(c, method, req, reply)) {
|
||||
if (!reply.is_null()) send_text(c, reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-session method requires an authenticated connection.
|
||||
if (!c.authed) {
|
||||
send_text(c, rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first"));
|
||||
return;
|
||||
}
|
||||
|
||||
json reply = proto::dispatch(dispatcher_, proto::Transport::Ws, req);
|
||||
if (!reply.is_null()) send_text(c, reply);
|
||||
}
|
||||
|
||||
bool WsServer::handle_session_ws(Conn& c, const std::string& method, const json& request,
|
||||
json& reply) {
|
||||
const json id = request.contains("id") ? request.at("id") : json(nullptr);
|
||||
const json params = request.contains("params") ? request.at("params") : json::object();
|
||||
|
||||
if (method == "session.pair") {
|
||||
const auto dec = rate_limiter_.check(c.origin);
|
||||
if (!dec.allowed) {
|
||||
reply = rpc_error(id, proto::ErrorCode::RateLimited,
|
||||
"too many pairing attempts; try again later",
|
||||
json{{"retryAfterSec", dec.retry_after_sec}});
|
||||
return true;
|
||||
}
|
||||
auto p = proto::parse<proto::SessionPairParams>(params, "params");
|
||||
if (!p) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message,
|
||||
json{{"path", p.error().path}});
|
||||
return true;
|
||||
}
|
||||
|
||||
PairingRequest pr{c.origin, p->clientName, make_pairing_code()};
|
||||
if (!approver_.approve(pr)) {
|
||||
rate_limiter_.record_failure(c.origin);
|
||||
reply = rpc_error(id, proto::ErrorCode::NotPaired, "pairing was not approved");
|
||||
return true;
|
||||
}
|
||||
|
||||
store::Pairings pairings(db_);
|
||||
auto created = pairings.create(c.origin, p->clientName, now_iso());
|
||||
if (!created) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InternalError,
|
||||
"could not store the pairing: " + created.error().message);
|
||||
return true;
|
||||
}
|
||||
rate_limiter_.record_success(c.origin);
|
||||
|
||||
proto::SessionPairResult r;
|
||||
r.token = created->token;
|
||||
reply = proto::make_result(id, r);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method == "session.hello") {
|
||||
auto p = proto::parse<proto::SessionHelloParams>(params, "params");
|
||||
if (!p) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message,
|
||||
json{{"path", p.error().path}});
|
||||
return true;
|
||||
}
|
||||
const int want = major_of(std::string(proto::kProtocolVersion));
|
||||
const int got = major_of(p->protocolVersion);
|
||||
if (got != want) {
|
||||
reply = rpc_error(id, proto::ErrorCode::VersionMismatch,
|
||||
"protocol major version mismatch",
|
||||
json{{"expected", std::string(proto::kProtocolVersion)},
|
||||
{"actual", p->protocolVersion}});
|
||||
c.close_after_flush = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
store::Pairings pairings(db_);
|
||||
auto found = p->token ? pairings.find_active_by_token(*p->token)
|
||||
: store::DbResult<std::optional<store::Pairing>>(std::nullopt);
|
||||
if (!found) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InternalError, found.error().message);
|
||||
return true;
|
||||
}
|
||||
if (!found->has_value()) {
|
||||
// Absent, malformed or wrong — all reported the same, and all count toward the
|
||||
// pairing rate limit (fixture session.hello.not-paired).
|
||||
rate_limiter_.record_failure(c.origin);
|
||||
reply = rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first");
|
||||
return true;
|
||||
}
|
||||
|
||||
c.authed = true;
|
||||
c.pairing_id = (*found)->pairing_id;
|
||||
if (c.session_id.empty()) c.session_id = uuid4();
|
||||
(void)pairings.touch(c.pairing_id, now_iso());
|
||||
|
||||
proto::SessionHelloResult r;
|
||||
r.daemonVersion = std::string(velox::daemon::kDaemonVersion);
|
||||
r.protocolVersion = std::string(proto::kProtocolVersion);
|
||||
r.sessionId = c.session_id;
|
||||
r.transport = proto::SessionHelloResultTransport::Ws;
|
||||
reply = proto::make_result(id, r);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method == "session.subscribe") {
|
||||
if (!c.authed) {
|
||||
reply = rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first");
|
||||
return true;
|
||||
}
|
||||
auto p = proto::parse<proto::SessionSubscribeParams>(params, "params");
|
||||
if (!p) {
|
||||
reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message,
|
||||
json{{"path", p.error().path}});
|
||||
return true;
|
||||
}
|
||||
proto::SessionSubscribeResult r;
|
||||
r.ok = true;
|
||||
for (const auto& ev : p->events) r.events.emplace_back(proto::to_string(ev));
|
||||
reply = proto::make_result(id, r);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void WsServer::send_text(Conn& c, const json& value) {
|
||||
send_frame(c, WsOpcode::Text, value.dump());
|
||||
}
|
||||
|
||||
void WsServer::send_frame(Conn& c, WsOpcode op, std::string_view payload) {
|
||||
c.outbuf += ws_encode(op, payload);
|
||||
if (c.outbuf.size() - c.out_off > kMaxOutBytes) {
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
flush(c);
|
||||
}
|
||||
|
||||
void WsServer::begin_close(Conn& c, std::uint16_t code, std::string_view reason) {
|
||||
if (c.phase == Phase::Closing) return;
|
||||
c.phase = Phase::Closing;
|
||||
send_frame(c, WsOpcode::Close, ws_close_payload(code, reason));
|
||||
if (conns_.count(c.fd)) {
|
||||
c.close_after_flush = true;
|
||||
flush(c);
|
||||
}
|
||||
}
|
||||
|
||||
void WsServer::flush(Conn& c) {
|
||||
while (c.out_off < c.outbuf.size()) {
|
||||
const ssize_t n = ::write(c.fd, c.outbuf.data() + c.out_off, c.outbuf.size() - c.out_off);
|
||||
if (n > 0) {
|
||||
c.out_off += static_cast<std::size_t>(n);
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
loop_.mod_fd(c.fd, kRead | kWrite);
|
||||
return;
|
||||
}
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
c.outbuf.clear();
|
||||
c.out_off = 0;
|
||||
if (c.close_after_flush) {
|
||||
close_conn(c.fd);
|
||||
return;
|
||||
}
|
||||
loop_.mod_fd(c.fd, kRead);
|
||||
}
|
||||
|
||||
void WsServer::close_conn(int fd) {
|
||||
if (const auto it = conns_.find(fd); it != conns_.end()) {
|
||||
loop_.del_fd(fd);
|
||||
::close(fd);
|
||||
conns_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
// The loopback WebSocket transport (docs/05 §4). Binds 127.0.0.1 only, on the first free
|
||||
// port in 52000-52016, and writes the chosen port to <runtime>/ws.port. Any local process
|
||||
// can connect, so a token is mandatory: session.pair mints one (behind a user prompt,
|
||||
// rate-limited), session.hello must present it, and every other method is refused -32002
|
||||
// until it does. Privileged methods are refused -32003 by the generated dispatch().
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
|
||||
#include "rpc/pairing.hpp"
|
||||
#include "rpc/runtime_dir.hpp"
|
||||
#include "rpc/ws_frame.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
class Db;
|
||||
}
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
class EventLoop;
|
||||
|
||||
class WsServer {
|
||||
public:
|
||||
WsServer(EventLoop& loop, velox::proto::Dispatcher& dispatcher, velox::daemon::store::Db& db,
|
||||
PairingApprover& approver, RuntimeDir runtime);
|
||||
~WsServer();
|
||||
|
||||
WsServer(const WsServer&) = delete;
|
||||
WsServer& operator=(const WsServer&) = delete;
|
||||
|
||||
// Pick a port, bind loopback, write ws.port, listen, register with the loop.
|
||||
std::error_code start();
|
||||
|
||||
int port() const noexcept { return port_; }
|
||||
std::size_t connection_count() const noexcept { return conns_.size(); }
|
||||
|
||||
static constexpr int kPortLo = 52000;
|
||||
static constexpr int kPortHi = 52016;
|
||||
|
||||
private:
|
||||
enum class Phase { Handshake, Open, Closing };
|
||||
|
||||
struct Conn {
|
||||
int fd;
|
||||
Phase phase = Phase::Handshake;
|
||||
std::string in_raw; // bytes before the upgrade completes
|
||||
WsFrameReader frames;
|
||||
std::string outbuf;
|
||||
std::size_t out_off = 0;
|
||||
bool close_after_flush = false;
|
||||
std::string origin;
|
||||
bool authed = false;
|
||||
std::string pairing_id;
|
||||
std::string session_id;
|
||||
};
|
||||
|
||||
void on_listener_readable();
|
||||
void on_conn_event(int fd, unsigned events);
|
||||
void progress_handshake(Conn& c);
|
||||
void on_ws_bytes(Conn& c, std::string_view bytes);
|
||||
void handle_rpc(Conn& c, const std::string& text);
|
||||
bool handle_session_ws(Conn& c, const std::string& method, const nlohmann::json& request,
|
||||
nlohmann::json& reply);
|
||||
|
||||
void send_text(Conn& c, const nlohmann::json& value);
|
||||
void send_frame(Conn& c, WsOpcode op, std::string_view payload);
|
||||
void begin_close(Conn& c, std::uint16_t code, std::string_view reason);
|
||||
void flush(Conn& c);
|
||||
void close_conn(int fd);
|
||||
|
||||
EventLoop& loop_;
|
||||
velox::proto::Dispatcher& dispatcher_;
|
||||
velox::daemon::store::Db& db_;
|
||||
PairingApprover& approver_;
|
||||
RuntimeDir runtime_;
|
||||
PairingRateLimiter rate_limiter_;
|
||||
|
||||
int listen_fd_ = -1;
|
||||
int port_ = 0;
|
||||
bool wrote_port_file_ = false;
|
||||
std::unordered_map<int, std::unique_ptr<Conn>> conns_;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "store/migrations.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
// Generated at build time from store/migrations/*.sql by embed_migrations.cmake.
|
||||
#include "migrations_embedded.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
std::span<const Migration> embedded_migrations() {
|
||||
return {kEmbeddedMigrations.data(), kEmbeddedMigrations.size()};
|
||||
}
|
||||
|
||||
DbResult<MigrationOutcome> migrate_to_head(Db& db) {
|
||||
const auto all = embedded_migrations();
|
||||
|
||||
MigrationOutcome out;
|
||||
out.from_version = db.user_version();
|
||||
out.to_version = out.from_version;
|
||||
if (out.from_version < 0) {
|
||||
return std::unexpected(DbError{0, "could not read PRAGMA user_version"});
|
||||
}
|
||||
|
||||
for (const auto& m : all) {
|
||||
if (m.version <= out.from_version) continue;
|
||||
|
||||
// Each migration is one transaction: a failure half-way leaves user_version and
|
||||
// the schema exactly where they were.
|
||||
auto r = db.transaction([&]() -> DbResult<void> {
|
||||
if (auto e = db.exec(m.sql); !e) return e;
|
||||
return db.set_user_version(m.version);
|
||||
});
|
||||
if (!r) {
|
||||
return std::unexpected(DbError{r.error().code, "migration " + std::string(m.name) +
|
||||
" failed: " + r.error().message});
|
||||
}
|
||||
out.to_version = m.version;
|
||||
++out.applied;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// The schema migrator. Numbered SQL files in store/migrations/ are embedded at build time
|
||||
// (see embed_migrations.cmake). On startup the daemon calls migrate_to_head(db): every
|
||||
// migration whose version exceeds PRAGMA user_version is applied in order, each in its own
|
||||
// transaction, and user_version is advanced to match.
|
||||
//
|
||||
// Forward-only: a released migration is immutable. The forward-only test in
|
||||
// daemon/tests replays from every prior released user_version to head.
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
struct Migration {
|
||||
std::int64_t version; // 1, 2, 3, ... ; matches the NNNN_ prefix
|
||||
std::string_view name; // e.g. "0001_initial"
|
||||
std::string_view sql; // the file body
|
||||
};
|
||||
|
||||
// The embedded set, sorted by version ascending. Defined in the generated header.
|
||||
std::span<const Migration> embedded_migrations();
|
||||
|
||||
struct MigrationOutcome {
|
||||
std::int64_t from_version = 0;
|
||||
std::int64_t to_version = 0;
|
||||
int applied = 0;
|
||||
};
|
||||
|
||||
// Apply every migration newer than db.user_version(). A no-op (applied == 0) when the DB
|
||||
// is already at or beyond the highest embedded version.
|
||||
DbResult<MigrationOutcome> migrate_to_head(Db& db);
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,176 @@
|
||||
-- Migration 0001 — initial schema.
|
||||
--
|
||||
-- Applied when PRAGMA user_version < 1. The migrator wraps this file in one transaction
|
||||
-- and sets user_version = 1 on success. Forward-only: never edit a released migration,
|
||||
-- add 0002_*.sql instead (AGENT-DAEMON.md build step 3).
|
||||
--
|
||||
-- Conventions:
|
||||
-- * ids are lowercase UUID text, except the built-in rows below.
|
||||
-- * timestamps are RFC 3339 UTC strings ("2026-09-10T14:55:02Z") — same on the wire,
|
||||
-- so projection to TaskSummary is a copy.
|
||||
-- * JSON-valued columns hold a TEXT document; SQLite's json1 validates on read where
|
||||
-- it matters. Marked "-- json" below.
|
||||
-- * credentials NEVER live here (CLAUDE.md §4) — the Secret Service holds those.
|
||||
|
||||
-- --- settings : the whole config bag, one row per SettingKey --------------------------
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL -- json: the value as it appears in the Settings schema
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- --- categories : folder + extension routing ----------------------------------------
|
||||
CREATE TABLE categories (
|
||||
category_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
save_dir TEXT NOT NULL,
|
||||
extensions TEXT NOT NULL DEFAULT '[]', -- json array of lowercase extensions, no dot
|
||||
builtin INTEGER NOT NULL DEFAULT 0 -- 1 = cannot be deleted (category.remove -> -32602)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
INSERT INTO categories (category_id, name, save_dir, extensions, builtin) VALUES
|
||||
('general', 'General', '~/Downloads', '[]', 1),
|
||||
('programs', 'Programs', '~/Downloads/Programs', '["exe","msi","deb","rpm","dmg","appimage","iso","zip","tar","gz","xz","7z"]', 1),
|
||||
('video', 'Video', '~/Downloads/Video', '["mp4","mkv","webm","avi","mov","flv","m4v","ts"]', 1),
|
||||
('audio', 'Audio', '~/Downloads/Audio', '["mp3","flac","aac","ogg","opus","wav","m4a"]', 1),
|
||||
('documents','Documents', '~/Downloads/Documents', '["pdf","doc","docx","xls","xlsx","ppt","pptx","odt","epub"]', 1),
|
||||
('images', 'Images', '~/Downloads/Images', '["jpg","jpeg","png","gif","webp","svg","bmp","tiff"]', 1);
|
||||
|
||||
-- --- queues : ordered runs with their own concurrency cap ---------------------------
|
||||
CREATE TABLE queues (
|
||||
queue_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'stopped' -- 'running' | 'stopped'
|
||||
CHECK (state IN ('running','stopped')),
|
||||
max_concurrent INTEGER NOT NULL DEFAULT 2 CHECK (max_concurrent BETWEEN 1 AND 32),
|
||||
on_complete TEXT NOT NULL DEFAULT 'nothing' -- 'nothing'|'exit'|'shutdown'|'hangup'
|
||||
CHECK (on_complete IN ('nothing','exit','shutdown','hangup')),
|
||||
schedule TEXT -- json Schedule, or NULL for manual
|
||||
) WITHOUT ROWID;
|
||||
|
||||
INSERT INTO queues (queue_id, name, state, max_concurrent) VALUES
|
||||
('main', 'Main Queue', 'stopped', 4);
|
||||
|
||||
-- --- tasks : the download list -----------------------------------------------------
|
||||
-- Column set is chosen so a row projects onto proto TaskSummary with no computation
|
||||
-- beyond reading segments/history for the detail view.
|
||||
CREATE TABLE tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL, -- as supplied
|
||||
effective_url TEXT, -- after redirects; NULL until first probe
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
save_dir TEXT NOT NULL, -- absolute, canonicalized, inside an allowed root
|
||||
category_id TEXT REFERENCES categories(category_id) ON DELETE SET NULL,
|
||||
queue_id TEXT REFERENCES queues(queue_id) ON DELETE SET NULL,
|
||||
queue_position INTEGER, -- NULL unless queued; run order within the queue
|
||||
|
||||
state TEXT NOT NULL DEFAULT 'new'
|
||||
CHECK (state IN ('new','probing','queued','connecting','downloading','paused',
|
||||
'retry_wait','assembling','verifying','complete','failed','cancelled')),
|
||||
-- ADR 0013: why a paused task is paused. NULL unless state='paused'. 'auto' means CORE
|
||||
-- entered it (auth_required/server_file_changed/disk_full); the code is in error_code.
|
||||
pause_reason TEXT CHECK (pause_reason IN
|
||||
('user','schedule','queue_stopped','admission_reconcile','auto')),
|
||||
|
||||
size_bytes INTEGER, -- NULL when the server reported no length
|
||||
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
resumable INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Requested vs effective, per ADR 0010 / ADR 0012. Requested values come from the
|
||||
-- DownloadSpec; effective values are written by the engine as it runs.
|
||||
req_segments INTEGER, -- DownloadSpec.segments (NULL = use setting)
|
||||
eff_segments INTEGER NOT NULL DEFAULT 0,-- TaskSummary.segments (in use right now)
|
||||
req_buffer_bytes INTEGER,
|
||||
eff_buffer_bytes INTEGER, -- TaskDetail.effectiveBufferBytes
|
||||
|
||||
start_mode TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (start_mode IN ('auto','now','queue','manual')),
|
||||
description TEXT,
|
||||
|
||||
-- Validators, kept for If-Range resume revalidation (docs/04 §5).
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
content_type TEXT,
|
||||
|
||||
checksum_algo TEXT CHECK (checksum_algo IN ('md5','sha1','sha256','sha512')),
|
||||
checksum_value TEXT,
|
||||
|
||||
-- proto TaskError, flattened. Set on failed / retry_wait, and on an auto-pause.
|
||||
error_code TEXT, -- TaskErrorCode string
|
||||
error_message TEXT,
|
||||
error_http_status INTEGER,
|
||||
error_retryable INTEGER,
|
||||
error_attempt INTEGER,
|
||||
error_next_retry_at TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
last_try_at TEXT,
|
||||
completed_at TEXT
|
||||
) STRICT;
|
||||
|
||||
-- download.list filters/sorts in the daemon (brief: never materialize 100k rows for 40).
|
||||
-- These cover the common filter columns and both default sorts.
|
||||
CREATE INDEX idx_tasks_state ON tasks(state);
|
||||
CREATE INDEX idx_tasks_category ON tasks(category_id);
|
||||
CREATE INDEX idx_tasks_queue_order ON tasks(queue_id, queue_position);
|
||||
CREATE INDEX idx_tasks_created ON tasks(created_at);
|
||||
CREATE INDEX idx_tasks_completed ON tasks(completed_at);
|
||||
|
||||
-- --- segments : per-connection byte ranges for one task ---------------------------
|
||||
-- Inclusive ranges [start_byte, end_byte], matching HTTP Range and ADR 0010. A whole-file
|
||||
-- zero-length download is one row with end_byte = start_byte - 1 = -1 (ADR 0010 B3a), so
|
||||
-- end_byte is not constrained to >= 0.
|
||||
CREATE TABLE segments (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
|
||||
idx INTEGER NOT NULL,
|
||||
start_byte INTEGER NOT NULL,
|
||||
end_byte INTEGER NOT NULL,
|
||||
completed_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'connecting'
|
||||
CHECK (state IN ('connecting','downloading','stalled','complete','failed')),
|
||||
PRIMARY KEY (task_id, idx)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
-- --- rules : the routing / capture rules engine table ---------------------------
|
||||
CREATE TABLE rules (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
priority INTEGER NOT NULL, -- lower runs first; rules.list returns priority order
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
match TEXT NOT NULL, -- json: the match clause (host/ext/size/mime/...)
|
||||
action TEXT NOT NULL -- json: capture decision + category + queue + start mode
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_rules_priority ON rules(priority);
|
||||
|
||||
-- --- history : completed and removed tasks, for the History view --------------------
|
||||
-- A task leaving the list (complete, or removed by the user) drops a snapshot here so the
|
||||
-- main tasks table stays the size of the active list.
|
||||
CREATE TABLE history (
|
||||
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
save_dir TEXT NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
final_state TEXT NOT NULL, -- 'complete' | 'cancelled' | 'failed'
|
||||
category_id TEXT,
|
||||
finished_at TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL -- json: the full TaskSummary at the time it left
|
||||
);
|
||||
|
||||
CREATE INDEX idx_history_finished ON history(finished_at);
|
||||
CREATE INDEX idx_history_task ON history(task_id);
|
||||
|
||||
-- --- pairings : WebSocket transport tokens, HASHED (docs/05 §4, CLAUDE.md §4) --------
|
||||
-- The plaintext token is returned to the extension exactly once, from session.pair, and
|
||||
-- never stored. token_sha256 is the lookup key on every subsequent connect.
|
||||
CREATE TABLE pairings (
|
||||
pairing_id TEXT PRIMARY KEY,
|
||||
token_sha256 TEXT NOT NULL UNIQUE, -- hex SHA-256 of the 256-bit token
|
||||
origin TEXT NOT NULL, -- moz-extension://<uuid>, verified on the WS upgrade
|
||||
label TEXT NOT NULL DEFAULT '', -- human-readable, shown in Options -> Unpair
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
revoked_at TEXT -- non-NULL once unpaired; kept for the audit trail
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_pairings_origin ON pairings(origin);
|
||||
@@ -0,0 +1,104 @@
|
||||
#include "store/pairings.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <random>
|
||||
|
||||
#include "util/crypto.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string uuid4() {
|
||||
std::random_device rd;
|
||||
std::uniform_int_distribution<std::uint32_t> d;
|
||||
std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd);
|
||||
b = (b & 0xFFFF0FFFu) | 0x00004000u;
|
||||
c = (c & 0x3FFFFFFFu) | 0x80000000u;
|
||||
char buf[37];
|
||||
std::snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF),
|
||||
(c >> 16), (c & 0xFFFF), e);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
Pairing read_row(Stmt& s) {
|
||||
Pairing p;
|
||||
p.pairing_id = s.column_text(0);
|
||||
p.origin = s.column_text(1);
|
||||
p.label = s.column_text(2);
|
||||
p.created_at = s.column_text(3);
|
||||
if (!s.column_is_null(4)) p.last_seen_at = s.column_text(4);
|
||||
if (!s.column_is_null(5)) p.revoked_at = s.column_text(5);
|
||||
return p;
|
||||
}
|
||||
|
||||
constexpr std::string_view kCols =
|
||||
"pairing_id, origin, label, created_at, last_seen_at, revoked_at";
|
||||
|
||||
} // namespace
|
||||
|
||||
DbResult<Pairings::Created> Pairings::create(std::string_view origin, std::string_view label,
|
||||
std::string_view now_iso) {
|
||||
Created out{uuid4(), velox::daemon::crypto::random_token(32)};
|
||||
const std::string hash = velox::daemon::crypto::sha256_hex(out.token);
|
||||
|
||||
auto st = db_.prepare(
|
||||
"INSERT INTO pairings(pairing_id, token_sha256, origin, label, created_at) "
|
||||
"VALUES(?1, ?2, ?3, ?4, ?5)");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, out.pairing_id); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(2, std::string_view(hash)); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(3, origin); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(4, label); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(5, now_iso); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->step(); !r) return std::unexpected(r.error());
|
||||
return out;
|
||||
}
|
||||
|
||||
DbResult<std::optional<Pairing>> Pairings::find_active_by_token(std::string_view token) {
|
||||
const std::string hash = velox::daemon::crypto::sha256_hex(token);
|
||||
auto st = db_.prepare(std::string("SELECT ").append(kCols).append(
|
||||
" FROM pairings WHERE token_sha256 = ?1 AND revoked_at IS NULL"));
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, std::string_view(hash)); !r) return std::unexpected(r.error());
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
if (!*row) return std::optional<Pairing>{};
|
||||
return std::optional<Pairing>{read_row(*st)};
|
||||
}
|
||||
|
||||
DbResult<void> Pairings::touch(std::string_view pairing_id, std::string_view now_iso) {
|
||||
auto st = db_.prepare("UPDATE pairings SET last_seen_at = ?2 WHERE pairing_id = ?1");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, pairing_id); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(2, now_iso); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->step(); !r) return std::unexpected(r.error());
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<bool> Pairings::revoke(std::string_view pairing_id, std::string_view now_iso) {
|
||||
auto st = db_.prepare(
|
||||
"UPDATE pairings SET revoked_at = ?2 WHERE pairing_id = ?1 AND revoked_at IS NULL");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, pairing_id); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(2, now_iso); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->step(); !r) return std::unexpected(r.error());
|
||||
return sqlite3_changes(db_.raw()) > 0;
|
||||
}
|
||||
|
||||
DbResult<std::vector<Pairing>> Pairings::list_active() {
|
||||
auto st = db_.prepare(std::string("SELECT ").append(kCols).append(
|
||||
" FROM pairings WHERE revoked_at IS NULL ORDER BY created_at"));
|
||||
if (!st) return std::unexpected(st.error());
|
||||
std::vector<Pairing> out;
|
||||
for (;;) {
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
if (!*row) break;
|
||||
out.push_back(read_row(*st));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
// Access to the `pairings` table: the WebSocket transport's revocable per-install tokens
|
||||
// (docs/05 §4). The plaintext token is returned by create() exactly once and never
|
||||
// stored — only its SHA-256 (CLAUDE.md §4).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
struct Pairing {
|
||||
std::string pairing_id;
|
||||
std::string origin;
|
||||
std::string label;
|
||||
std::string created_at;
|
||||
std::optional<std::string> last_seen_at;
|
||||
std::optional<std::string> revoked_at;
|
||||
};
|
||||
|
||||
class Pairings {
|
||||
public:
|
||||
explicit Pairings(Db& db) : db_(db) {}
|
||||
|
||||
struct Created {
|
||||
std::string pairing_id;
|
||||
std::string token; // plaintext — send once, to the client, then forget
|
||||
};
|
||||
|
||||
// Mint a token for `origin`, store its hash + `label`, timestamp `now_iso`.
|
||||
DbResult<Created> create(std::string_view origin, std::string_view label,
|
||||
std::string_view now_iso);
|
||||
|
||||
// The active (non-revoked) pairing whose token hashes to this value, if any.
|
||||
DbResult<std::optional<Pairing>> find_active_by_token(std::string_view plaintext_token);
|
||||
|
||||
// Bump last_seen_at. Called on every authenticated connect.
|
||||
DbResult<void> touch(std::string_view pairing_id, std::string_view now_iso);
|
||||
|
||||
// Mark revoked. Returns false if there was no such active pairing.
|
||||
DbResult<bool> revoke(std::string_view pairing_id, std::string_view now_iso);
|
||||
|
||||
DbResult<std::vector<Pairing>> list_active();
|
||||
|
||||
private:
|
||||
Db& db_;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
// --- Db ------------------------------------------------------------------------------
|
||||
|
||||
Db::~Db() {
|
||||
if (db_ != nullptr) sqlite3_close(db_);
|
||||
}
|
||||
|
||||
Db::Db(Db&& o) noexcept : db_(std::exchange(o.db_, nullptr)) {}
|
||||
|
||||
Db& Db::operator=(Db&& o) noexcept {
|
||||
if (this != &o) {
|
||||
if (db_ != nullptr) sqlite3_close(db_);
|
||||
db_ = std::exchange(o.db_, nullptr);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
DbError Db::last_error() const {
|
||||
return DbError{sqlite3_extended_errcode(db_), sqlite3_errmsg(db_)};
|
||||
}
|
||||
|
||||
DbResult<Db> Db::open(const std::string& path) {
|
||||
sqlite3* handle = nullptr;
|
||||
const int rc = sqlite3_open_v2(
|
||||
path.c_str(), &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX,
|
||||
nullptr);
|
||||
if (rc != SQLITE_OK) {
|
||||
DbError e{rc, handle != nullptr ? sqlite3_errmsg(handle) : "sqlite3_open_v2 failed"};
|
||||
if (handle != nullptr) sqlite3_close(handle);
|
||||
return std::unexpected(std::move(e));
|
||||
}
|
||||
|
||||
Db db(handle);
|
||||
// Not a secret store (credentials go to the Secret Service), but task URLs and pairing
|
||||
// hashes still are not world-readable. SQLite honours the umask; pin 0600 explicitly.
|
||||
if (path != ":memory:" && !path.empty() && path.front() != ':') {
|
||||
::chmod(path.c_str(), 0600);
|
||||
::chmod((path + "-wal").c_str(), 0600);
|
||||
::chmod((path + "-shm").c_str(), 0600);
|
||||
}
|
||||
// WAL for crash-safe concurrent readers (docs/01 §1). busy_timeout so a writer waits
|
||||
// rather than returning SQLITE_BUSY under the RPC loop. foreign_keys is per-connection.
|
||||
for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000"}) {
|
||||
if (auto r = db.exec(pragma); !r) return std::unexpected(r.error());
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
DbResult<void> Db::exec(std::string_view sql) {
|
||||
char* err = nullptr;
|
||||
const int rc = sqlite3_exec(db_, std::string(sql).c_str(), nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
DbError e{rc, err != nullptr ? err : sqlite3_errmsg(db_)};
|
||||
sqlite3_free(err);
|
||||
return std::unexpected(std::move(e));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<Stmt> Db::prepare(std::string_view sql) {
|
||||
sqlite3_stmt* s = nullptr;
|
||||
const int rc =
|
||||
sqlite3_prepare_v2(db_, sql.data(), static_cast<int>(sql.size()), &s, nullptr);
|
||||
if (rc != SQLITE_OK) return std::unexpected(last_error());
|
||||
return Stmt(db_, s);
|
||||
}
|
||||
|
||||
std::int64_t Db::user_version() {
|
||||
auto st = prepare("PRAGMA user_version");
|
||||
if (!st) return -1;
|
||||
auto row = st->step();
|
||||
if (!row || !*row) return -1;
|
||||
return st->column_int(0);
|
||||
}
|
||||
|
||||
DbResult<void> Db::set_user_version(std::int64_t v) {
|
||||
// PRAGMA does not accept a bound parameter; the value is our own integer.
|
||||
return exec("PRAGMA user_version=" + std::to_string(v));
|
||||
}
|
||||
|
||||
// --- Stmt ----------------------------------------------------------------------------
|
||||
|
||||
Stmt::~Stmt() {
|
||||
if (stmt_ != nullptr) sqlite3_finalize(stmt_);
|
||||
}
|
||||
|
||||
Stmt::Stmt(Stmt&& o) noexcept
|
||||
: db_(std::exchange(o.db_, nullptr)), stmt_(std::exchange(o.stmt_, nullptr)) {}
|
||||
|
||||
Stmt& Stmt::operator=(Stmt&& o) noexcept {
|
||||
if (this != &o) {
|
||||
if (stmt_ != nullptr) sqlite3_finalize(stmt_);
|
||||
db_ = std::exchange(o.db_, nullptr);
|
||||
stmt_ = std::exchange(o.stmt_, nullptr);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
DbError Stmt::last_error() const {
|
||||
return DbError{sqlite3_extended_errcode(db_), sqlite3_errmsg(db_)};
|
||||
}
|
||||
|
||||
DbResult<void> Stmt::bind(int i, std::int64_t v) {
|
||||
if (sqlite3_bind_int64(stmt_, i, v) != SQLITE_OK) return std::unexpected(last_error());
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<void> Stmt::bind(int i, std::string_view v) {
|
||||
if (sqlite3_bind_text(stmt_, i, v.data(), static_cast<int>(v.size()), SQLITE_TRANSIENT) !=
|
||||
SQLITE_OK)
|
||||
return std::unexpected(last_error());
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<void> Stmt::bind_null(int i) {
|
||||
if (sqlite3_bind_null(stmt_, i) != SQLITE_OK) return std::unexpected(last_error());
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<bool> Stmt::step() {
|
||||
const int rc = sqlite3_step(stmt_);
|
||||
if (rc == SQLITE_ROW) return true;
|
||||
if (rc == SQLITE_DONE) return false;
|
||||
return std::unexpected(last_error());
|
||||
}
|
||||
|
||||
DbResult<void> Stmt::reset() {
|
||||
if (sqlite3_reset(stmt_) != SQLITE_OK) return std::unexpected(last_error());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::int64_t Stmt::column_int(int i) const { return sqlite3_column_int64(stmt_, i); }
|
||||
|
||||
std::string Stmt::column_text(int i) const {
|
||||
const auto* p = sqlite3_column_text(stmt_, i);
|
||||
if (p == nullptr) return {};
|
||||
return std::string(reinterpret_cast<const char*>(p),
|
||||
static_cast<std::size_t>(sqlite3_column_bytes(stmt_, i)));
|
||||
}
|
||||
|
||||
bool Stmt::column_is_null(int i) const {
|
||||
return sqlite3_column_type(stmt_, i) == SQLITE_NULL;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
// A thin RAII wrapper over the SQLite C API — just enough for the store: open in WAL mode,
|
||||
// run statements, prepare/bind/step. Errors are returned, never thrown (the RPC loop must
|
||||
// not unwind through an exception). No ORM, no query builder.
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
struct DbError {
|
||||
int code = 0; // SQLite result code
|
||||
std::string message;
|
||||
|
||||
std::string to_string() const {
|
||||
return message + " (sqlite " + std::to_string(code) + ")";
|
||||
}
|
||||
};
|
||||
|
||||
template <class T>
|
||||
using DbResult = std::expected<T, DbError>;
|
||||
|
||||
class Stmt;
|
||||
|
||||
class Db {
|
||||
public:
|
||||
Db() = default;
|
||||
~Db();
|
||||
Db(Db&&) noexcept;
|
||||
Db& operator=(Db&&) noexcept;
|
||||
Db(const Db&) = delete;
|
||||
Db& operator=(const Db&) = delete;
|
||||
|
||||
// Open (creating if absent) at `path`, set WAL, busy timeout, and foreign_keys=ON.
|
||||
// ":memory:" is accepted for tests.
|
||||
static DbResult<Db> open(const std::string& path);
|
||||
|
||||
// Run one or more statements with no result rows (DDL, PRAGMA, INSERT without
|
||||
// returning). Uses sqlite3_exec, so it accepts a multi-statement script.
|
||||
DbResult<void> exec(std::string_view sql);
|
||||
|
||||
DbResult<Stmt> prepare(std::string_view sql);
|
||||
|
||||
// Convenience: run `fn` between BEGIN and COMMIT; ROLLBACK and propagate on error.
|
||||
template <class Fn>
|
||||
DbResult<void> transaction(Fn&& fn) {
|
||||
if (auto r = exec("BEGIN"); !r) return r;
|
||||
auto r = std::forward<Fn>(fn)();
|
||||
if (!r) {
|
||||
exec("ROLLBACK"); // best effort; original error wins
|
||||
return r;
|
||||
}
|
||||
return exec("COMMIT");
|
||||
}
|
||||
|
||||
std::int64_t user_version();
|
||||
DbResult<void> set_user_version(std::int64_t v);
|
||||
|
||||
sqlite3* raw() const noexcept { return db_; }
|
||||
explicit operator bool() const noexcept { return db_ != nullptr; }
|
||||
|
||||
private:
|
||||
explicit Db(sqlite3* db) : db_(db) {}
|
||||
DbError last_error() const;
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
};
|
||||
|
||||
// A prepared statement. bind_* are 1-indexed. step() returns true while rows remain.
|
||||
class Stmt {
|
||||
public:
|
||||
Stmt() = default;
|
||||
~Stmt();
|
||||
Stmt(Stmt&&) noexcept;
|
||||
Stmt& operator=(Stmt&&) noexcept;
|
||||
Stmt(const Stmt&) = delete;
|
||||
Stmt& operator=(const Stmt&) = delete;
|
||||
|
||||
DbResult<void> bind(int i, std::int64_t v);
|
||||
DbResult<void> bind(int i, std::string_view v);
|
||||
DbResult<void> bind_null(int i);
|
||||
|
||||
// true: a row is available; false: done. Any error code is surfaced via error().
|
||||
DbResult<bool> step();
|
||||
DbResult<void> reset();
|
||||
|
||||
std::int64_t column_int(int i) const;
|
||||
std::string column_text(int i) const;
|
||||
bool column_is_null(int i) const;
|
||||
|
||||
sqlite3_stmt* raw() const noexcept { return stmt_; }
|
||||
|
||||
private:
|
||||
friend class Db;
|
||||
explicit Stmt(sqlite3* db, sqlite3_stmt* s) : db_(db), stmt_(s) {}
|
||||
DbError last_error() const;
|
||||
|
||||
sqlite3* db_ = nullptr; // borrowed, for error messages
|
||||
sqlite3_stmt* stmt_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "util/crypto.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace velox::daemon::crypto {
|
||||
|
||||
std::array<std::uint8_t, 20> sha1(std::string_view data) {
|
||||
std::array<std::uint8_t, 20> out{};
|
||||
::SHA1(reinterpret_cast<const unsigned char*>(data.data()), data.size(), out.data());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string sha256_hex(std::string_view data) {
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
::SHA256(reinterpret_cast<const unsigned char*>(data.data()), data.size(), digest);
|
||||
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(SHA256_DIGEST_LENGTH * 2);
|
||||
for (unsigned char b : digest) {
|
||||
out.push_back(kHex[b >> 4]);
|
||||
out.push_back(kHex[b & 0x0F]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string base64_encode(const std::uint8_t* data, std::size_t len) {
|
||||
// 4 chars per 3 bytes, rounded up, plus a NUL that EVP writes.
|
||||
std::string out(4 * ((len + 2) / 3), '\0');
|
||||
const int n = ::EVP_EncodeBlock(reinterpret_cast<unsigned char*>(out.data()), data,
|
||||
static_cast<int>(len));
|
||||
if (n < 0) throw std::runtime_error("EVP_EncodeBlock failed");
|
||||
out.resize(static_cast<std::size_t>(n));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string random_token(std::size_t n) {
|
||||
std::string raw(n, '\0');
|
||||
if (::RAND_bytes(reinterpret_cast<unsigned char*>(raw.data()), static_cast<int>(n)) != 1) {
|
||||
throw std::runtime_error("RAND_bytes failed");
|
||||
}
|
||||
std::string b64 =
|
||||
base64_encode(reinterpret_cast<const std::uint8_t*>(raw.data()), raw.size());
|
||||
// base64 -> base64url, and drop '=' padding.
|
||||
for (char& c : b64) {
|
||||
if (c == '+') c = '-';
|
||||
else if (c == '/') c = '_';
|
||||
}
|
||||
while (!b64.empty() && b64.back() == '=') b64.pop_back();
|
||||
return b64;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::crypto
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
// Small cryptographic helpers over libcrypto: the WebSocket accept-key hash, the pairing
|
||||
// token hash, base64, and a CSPRNG token. Nothing bespoke — thin wrappers so callers do
|
||||
// not touch the OpenSSL API directly.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace velox::daemon::crypto {
|
||||
|
||||
// SHA-1 of `data`, raw 20 bytes. Used only for the RFC 6455 Sec-WebSocket-Accept value.
|
||||
std::array<std::uint8_t, 20> sha1(std::string_view data);
|
||||
|
||||
// SHA-256 of `data` as lowercase hex (64 chars). The pairings table stores this, never
|
||||
// the token itself (CLAUDE.md §4).
|
||||
std::string sha256_hex(std::string_view data);
|
||||
|
||||
// Standard base64 (with '+' '/' '='), used for Sec-WebSocket-Accept.
|
||||
std::string base64_encode(const std::uint8_t* data, std::size_t len);
|
||||
|
||||
// `n` bytes from the system CSPRNG, encoded base64url without padding. The pairing token
|
||||
// is 32 bytes -> 43 chars, matching SessionPairResult.token's "256 bits, base64url".
|
||||
std::string random_token(std::size_t n = 32);
|
||||
|
||||
} // namespace velox::daemon::crypto
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace velox::daemon {
|
||||
|
||||
// The daemon's own build version, reported in session.hello as daemonVersion. Distinct
|
||||
// from the protocol version (velox::proto::kProtocolVersion), which is what the major
|
||||
// compatibility check keys on.
|
||||
inline constexpr std::string_view kDaemonVersion = "0.1.0";
|
||||
|
||||
} // namespace velox::daemon
|
||||
@@ -0,0 +1,18 @@
|
||||
# daemon unit + integration tests. Registered with ctest; run via `ctest --preset dev`.
|
||||
# No external test framework — each file is a small self-checking binary.
|
||||
|
||||
function(veloxd_test name)
|
||||
cmake_parse_arguments(T "" "" "LIBS" ${ARGN})
|
||||
add_executable(veloxd_${name}_test ${name}_test.cpp)
|
||||
target_link_libraries(veloxd_${name}_test PRIVATE ${T_LIBS})
|
||||
target_compile_options(veloxd_${name}_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
add_test(NAME veloxd.${name} COMMAND veloxd_${name}_test)
|
||||
set_tests_properties(veloxd.${name} PROPERTIES TIMEOUT 30)
|
||||
endfunction()
|
||||
|
||||
veloxd_test(ndjson LIBS veloxd_rpc)
|
||||
veloxd_test(uds_roundtrip LIBS veloxd_rpc)
|
||||
veloxd_test(store_migrations LIBS veloxd_store)
|
||||
veloxd_test(pairings LIBS veloxd_store veloxd_rpc)
|
||||
veloxd_test(ws_frame LIBS veloxd_rpc)
|
||||
veloxd_test(ws_server LIBS veloxd_rpc)
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
// Minimal test harness: CHECK accumulates failures, TEST_MAIN reports and sets the exit
|
||||
// code. Deliberately tiny — the daemon does not pull in a test framework for this.
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace veloxd_test {
|
||||
|
||||
inline std::vector<std::string>& failures() {
|
||||
static std::vector<std::string> f;
|
||||
return f;
|
||||
}
|
||||
inline int& checks() {
|
||||
static int n = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace veloxd_test
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
++::veloxd_test::checks(); \
|
||||
if (!(cond)) { \
|
||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
||||
std::to_string(__LINE__) + ": " + #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_EQ(a, b) \
|
||||
do { \
|
||||
++::veloxd_test::checks(); \
|
||||
auto _va = (a); \
|
||||
auto _vb = (b); \
|
||||
if (!(_va == _vb)) { \
|
||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
||||
std::to_string(__LINE__) + ": " + #a + \
|
||||
" == " + #b); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define TEST_MAIN() \
|
||||
int main() { \
|
||||
run(); \
|
||||
for (const auto& f : ::veloxd_test::failures()) std::printf("FAIL %s\n", f.c_str()); \
|
||||
std::printf("%d/%d checks passed\n", \
|
||||
::veloxd_test::checks() - \
|
||||
static_cast<int>(::veloxd_test::failures().size()), \
|
||||
::veloxd_test::checks()); \
|
||||
return ::veloxd_test::failures().empty() ? 0 : 1; \
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "rpc/ndjson.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
|
||||
using velox::daemon::rpc::FrameReader;
|
||||
using velox::daemon::rpc::frame;
|
||||
|
||||
void run() {
|
||||
// One chunk, one frame.
|
||||
{
|
||||
FrameReader r;
|
||||
auto f = r.feed("{\"a\":1}\n");
|
||||
CHECK_EQ(f.size(), 1u);
|
||||
CHECK_EQ(f.at(0), std::string("{\"a\":1}"));
|
||||
CHECK_EQ(r.buffered(), 0u);
|
||||
}
|
||||
|
||||
// A frame split across two feeds is delivered only when the newline arrives.
|
||||
{
|
||||
FrameReader r;
|
||||
CHECK_EQ(r.feed("{\"a\":").size(), 0u);
|
||||
auto f = r.feed("1}\n");
|
||||
CHECK_EQ(f.size(), 1u);
|
||||
CHECK_EQ(f.at(0), std::string("{\"a\":1}"));
|
||||
}
|
||||
|
||||
// Several frames in one chunk, plus a partial tail held back.
|
||||
{
|
||||
FrameReader r;
|
||||
auto f = r.feed("1\n2\n3\n4");
|
||||
CHECK_EQ(f.size(), 3u);
|
||||
CHECK_EQ(f.at(0), std::string("1"));
|
||||
CHECK_EQ(f.at(2), std::string("3"));
|
||||
CHECK_EQ(r.buffered(), 1u);
|
||||
auto g = r.feed("\n");
|
||||
CHECK_EQ(g.size(), 1u);
|
||||
CHECK_EQ(g.at(0), std::string("4"));
|
||||
}
|
||||
|
||||
// CRLF terminator is trimmed; blank lines are skipped.
|
||||
{
|
||||
FrameReader r;
|
||||
auto f = r.feed("x\r\n\r\n\ny\r\n");
|
||||
CHECK_EQ(f.size(), 2u);
|
||||
CHECK_EQ(f.at(0), std::string("x"));
|
||||
CHECK_EQ(f.at(1), std::string("y"));
|
||||
}
|
||||
|
||||
// Overflow latches once the unframed tail passes the cap.
|
||||
{
|
||||
FrameReader r;
|
||||
CHECK(!r.overflowed());
|
||||
std::string big(9u * 1024 * 1024, 'a'); // no newline
|
||||
r.feed(big);
|
||||
CHECK(r.overflowed());
|
||||
}
|
||||
|
||||
// frame() appends exactly one newline.
|
||||
CHECK_EQ(frame("hello"), std::string("hello\n"));
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,87 @@
|
||||
// The pairings table + the pairing rate limiter.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "rpc/pairing.hpp"
|
||||
#include "store/migrations.hpp"
|
||||
#include "store/pairings.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
using namespace velox::daemon;
|
||||
|
||||
void run() {
|
||||
// --- store: create -> find-by-token -> revoke ---------------------------------
|
||||
{
|
||||
auto db = store::Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
if (!db) return;
|
||||
CHECK(store::migrate_to_head(*db).has_value());
|
||||
|
||||
store::Pairings p(*db);
|
||||
auto created = p.create("moz-extension://abc", "Velox for Firefox", "2026-09-10T00:00:00Z");
|
||||
CHECK(created.has_value());
|
||||
if (!created) return;
|
||||
CHECK(created->token.size() >= 40); // 32 bytes base64url, unpadded
|
||||
CHECK(!created->pairing_id.empty());
|
||||
|
||||
// The plaintext token is not in the DB — only its hash.
|
||||
auto st = db->prepare("SELECT count(*) FROM pairings WHERE token_sha256 = ?1");
|
||||
CHECK(st.has_value());
|
||||
CHECK(st->bind(1, std::string_view(created->token)).has_value());
|
||||
auto row = st->step();
|
||||
CHECK(row.has_value() && *row);
|
||||
CHECK_EQ(st->column_int(0), 0); // token itself never stored
|
||||
|
||||
auto found = p.find_active_by_token(created->token);
|
||||
CHECK(found.has_value());
|
||||
CHECK(found->has_value());
|
||||
if (found && *found) CHECK_EQ((*found)->origin, std::string("moz-extension://abc"));
|
||||
|
||||
auto missing = p.find_active_by_token("not-the-token");
|
||||
CHECK(missing.has_value() && !missing->has_value());
|
||||
|
||||
auto revoked = p.revoke(created->pairing_id, "2026-09-10T01:00:00Z");
|
||||
CHECK(revoked.has_value() && *revoked == true);
|
||||
|
||||
auto after = p.find_active_by_token(created->token);
|
||||
CHECK(after.has_value() && !after->has_value()); // revoked -> not active
|
||||
|
||||
auto revoke_again = p.revoke(created->pairing_id, "2026-09-10T02:00:00Z");
|
||||
CHECK(revoke_again.has_value() && *revoke_again == false);
|
||||
}
|
||||
|
||||
// --- rate limiter: 5 failures, then a lockout with retryAfter -----------------
|
||||
{
|
||||
rpc::PairingRateLimiter rl;
|
||||
using Clock = rpc::PairingRateLimiter::Clock;
|
||||
const auto t0 = Clock::now();
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
CHECK(rl.check("origin-a", t0).allowed);
|
||||
rl.record_failure("origin-a", t0);
|
||||
}
|
||||
const auto d = rl.check("origin-a", t0);
|
||||
CHECK(!d.allowed);
|
||||
CHECK(d.retry_after_sec > 0 && d.retry_after_sec <= 61);
|
||||
|
||||
// A different origin is unaffected — the lockout is per-origin.
|
||||
CHECK(rl.check("origin-b", t0).allowed);
|
||||
|
||||
// Still locked 30 s later; clear after the lockout elapses.
|
||||
CHECK(!rl.check("origin-a", t0 + std::chrono::seconds(30)).allowed);
|
||||
CHECK(rl.check("origin-a", t0 + std::chrono::seconds(121)).allowed);
|
||||
|
||||
// A success wipes the origin's history.
|
||||
rl.record_failure("origin-c", t0);
|
||||
rl.record_failure("origin-c", t0);
|
||||
rl.record_success("origin-c");
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
CHECK(rl.check("origin-c", t0).allowed);
|
||||
rl.record_failure("origin-c", t0);
|
||||
}
|
||||
CHECK(rl.check("origin-c", t0).allowed); // only 4 since the reset
|
||||
}
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,118 @@
|
||||
// The migrator: fresh DB -> head, idempotent re-run, and forward-only from every released
|
||||
// user_version (M1 DoD: "a forward-only test from every released schema version").
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "store/migrations.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
using namespace velox::daemon::store;
|
||||
|
||||
namespace {
|
||||
|
||||
std::int64_t head_version() {
|
||||
std::int64_t v = 0;
|
||||
for (const auto& m : embedded_migrations()) v = std::max(v, m.version);
|
||||
return v;
|
||||
}
|
||||
|
||||
bool table_exists(Db& db, const char* name) {
|
||||
auto st = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1");
|
||||
if (!st) return false;
|
||||
if (!st->bind(1, std::string_view(name))) return false;
|
||||
auto row = st->step();
|
||||
return row && *row;
|
||||
}
|
||||
|
||||
std::int64_t count(Db& db, const char* sql) {
|
||||
auto st = db.prepare(sql);
|
||||
if (!st) return -1;
|
||||
auto row = st->step();
|
||||
if (!row || !*row) return -1;
|
||||
return st->column_int(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
const std::int64_t head = head_version();
|
||||
CHECK(head >= 1);
|
||||
|
||||
// --- fresh in-memory DB migrates cleanly to head --------------------------------
|
||||
{
|
||||
auto db = Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
if (!db) return;
|
||||
CHECK_EQ(db->user_version(), 0);
|
||||
|
||||
auto out = migrate_to_head(*db);
|
||||
CHECK(out.has_value());
|
||||
if (out) {
|
||||
CHECK_EQ(out->from_version, 0);
|
||||
CHECK_EQ(out->to_version, head);
|
||||
CHECK_EQ(static_cast<std::int64_t>(out->applied), head);
|
||||
}
|
||||
CHECK_EQ(db->user_version(), head);
|
||||
|
||||
for (const char* t : {"settings", "categories", "queues", "tasks", "segments",
|
||||
"rules", "history", "pairings"}) {
|
||||
CHECK(table_exists(*db, t));
|
||||
}
|
||||
// Seed rows the initial migration inserts.
|
||||
CHECK_EQ(count(*db, "SELECT count(*) FROM categories WHERE builtin=1"), 6);
|
||||
CHECK_EQ(count(*db, "SELECT count(*) FROM queues"), 1);
|
||||
|
||||
// FK + cascade wired: a segment for a missing task is rejected; deleting a task
|
||||
// takes its segments with it.
|
||||
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at) "
|
||||
"VALUES('t1','http://x','/tmp','2026-09-10T00:00:00Z')")
|
||||
.has_value());
|
||||
CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
|
||||
"VALUES('t1',0,0,99)")
|
||||
.has_value());
|
||||
CHECK(!db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
|
||||
"VALUES('nope',0,0,99)")
|
||||
.has_value());
|
||||
CHECK(db->exec("DELETE FROM tasks WHERE task_id='t1'").has_value());
|
||||
CHECK_EQ(count(*db, "SELECT count(*) FROM segments"), 0);
|
||||
|
||||
// A whole-file zero-length download: one segment, end_byte = -1 (ADR 0010 B3a).
|
||||
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,size_bytes) "
|
||||
"VALUES('z','http://x','/tmp','2026-09-10T00:00:00Z',0)")
|
||||
.has_value());
|
||||
CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
|
||||
"VALUES('z',0,0,-1)")
|
||||
.has_value());
|
||||
}
|
||||
|
||||
// --- re-running the migrator on an at-head DB is a no-op ------------------------
|
||||
{
|
||||
auto db = Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
(void)migrate_to_head(*db);
|
||||
auto again = migrate_to_head(*db);
|
||||
CHECK(again.has_value());
|
||||
if (again) {
|
||||
CHECK_EQ(again->applied, 0);
|
||||
CHECK_EQ(again->to_version, head);
|
||||
}
|
||||
}
|
||||
|
||||
// --- forward-only: from every released version [0 .. head-1], reach head --------
|
||||
for (std::int64_t start = 0; start < head; ++start) {
|
||||
auto db = Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
if (!db) continue;
|
||||
CHECK(db->set_user_version(start).has_value());
|
||||
auto out = migrate_to_head(*db);
|
||||
CHECK(out.has_value());
|
||||
if (out) {
|
||||
CHECK_EQ(out->from_version, start);
|
||||
CHECK_EQ(out->to_version, head);
|
||||
}
|
||||
CHECK_EQ(db->user_version(), head);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,197 @@
|
||||
// Integration: a real UdsServer on a temp socket, a real client socket, NDJSON round trips.
|
||||
// Proves the transport, the generated dispatch() wiring, and the session-layer handling.
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "rpc/dispatcher.hpp"
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/ndjson.hpp"
|
||||
#include "rpc/uds_server.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
using nlohmann::json;
|
||||
namespace rpc = velox::daemon::rpc;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string make_temp_socket_path() {
|
||||
char tmpl[] = "/tmp/veloxd-test-XXXXXX";
|
||||
const char* dir = ::mkdtemp(tmpl);
|
||||
return std::string(dir ? dir : "/tmp") + "/velox.sock";
|
||||
}
|
||||
|
||||
int connect_client(const std::string& path) {
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
std::memcpy(addr.sun_path, path.c_str(), path.size());
|
||||
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
// Send one framed request, read one framed reply (blocking client; the server is async).
|
||||
json call(int fd, const json& request) {
|
||||
const std::string out = rpc::frame(request.dump());
|
||||
if (::write(fd, out.data(), out.size()) != static_cast<ssize_t>(out.size())) return {};
|
||||
|
||||
std::string buf;
|
||||
char chunk[4096];
|
||||
for (;;) {
|
||||
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
||||
if (n <= 0) return {};
|
||||
buf.append(chunk, static_cast<std::size_t>(n));
|
||||
if (const auto nl = buf.find('\n'); nl != std::string::npos)
|
||||
return json::parse(buf.substr(0, nl), nullptr, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
const std::string sock = make_temp_socket_path();
|
||||
|
||||
rpc::EventLoop loop;
|
||||
rpc::VeloxDispatcher dispatcher;
|
||||
rpc::UdsServer server(loop, dispatcher, sock);
|
||||
const auto ec = server.start();
|
||||
CHECK(!ec);
|
||||
if (ec) return;
|
||||
|
||||
std::thread loop_thread([&loop] { loop.run(); });
|
||||
|
||||
// --- session.hello, matching major -> a real SessionHelloResult -------------------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
CHECK(c >= 0);
|
||||
const json reply = call(c, {{"jsonrpc", "2.0"},
|
||||
{"id", 1},
|
||||
{"method", "session.hello"},
|
||||
{"params",
|
||||
{{"clientType", "test"},
|
||||
{"clientName", "roundtrip"},
|
||||
{"protocolVersion", std::string(velox::proto::kProtocolVersion)}}}});
|
||||
CHECK(reply.contains("result"));
|
||||
CHECK_EQ(reply["id"].get<int>(), 1);
|
||||
CHECK_EQ(reply["result"]["protocolVersion"].get<std::string>(),
|
||||
std::string(velox::proto::kProtocolVersion));
|
||||
CHECK_EQ(reply["result"]["transport"].get<std::string>(), std::string("uds"));
|
||||
CHECK(!reply["result"]["sessionId"].get<std::string>().empty());
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- session.hello, wrong major -> -32001, connection closed after the reply ------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
const json reply = call(c, {{"jsonrpc", "2.0"},
|
||||
{"id", 2},
|
||||
{"method", "session.hello"},
|
||||
{"params",
|
||||
{{"clientType", "gui"},
|
||||
{"clientName", "from the future"},
|
||||
{"protocolVersion", "2.0.0"}}}});
|
||||
CHECK(reply.contains("error"));
|
||||
CHECK_EQ(reply["error"]["code"].get<int>(), -32001);
|
||||
CHECK_EQ(reply["error"]["data"]["actual"].get<std::string>(), std::string("2.0.0"));
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- download.list -> an empty table (dispatcher answers this one for real) -------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
const json reply =
|
||||
call(c, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "download.list"}, {"params", json::object()}});
|
||||
CHECK(reply.contains("result"));
|
||||
CHECK_EQ(reply["result"]["total"].get<int>(), 0);
|
||||
CHECK(reply["result"]["items"].is_array());
|
||||
CHECK_EQ(reply["result"]["items"].size(), 0u);
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- an unknown method -> -32601 ------------------------------------------------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
const json reply =
|
||||
call(c, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "no.such.method"}, {"params", json::object()}});
|
||||
CHECK(reply.contains("error"));
|
||||
CHECK_EQ(reply["error"]["code"].get<int>(), -32601);
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- malformed JSON -> -32700, id null ----------------------------------------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
const std::string bad = "{ this is not json )\n";
|
||||
CHECK(::write(c, bad.data(), bad.size()) == static_cast<ssize_t>(bad.size()));
|
||||
std::string buf;
|
||||
char chunk[1024];
|
||||
const ssize_t n = ::read(c, chunk, sizeof(chunk));
|
||||
CHECK(n > 0);
|
||||
if (n > 0) {
|
||||
buf.assign(chunk, static_cast<std::size_t>(n));
|
||||
const json reply = json::parse(buf.substr(0, buf.find('\n')), nullptr, false);
|
||||
CHECK_EQ(reply["error"]["code"].get<int>(), -32700);
|
||||
CHECK(reply["id"].is_null());
|
||||
}
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- download.get on an unknown id -> -32010, with data.taskId ------------------
|
||||
// (contracts/ error fixture download.get.not-found; reachable now that 1.4.0 gave
|
||||
// handlers the HandlerError channel — ADR 0014.)
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
const std::string missing = "00000000-0000-4000-8000-000000000000";
|
||||
const json reply = call(c, {{"jsonrpc", "2.0"},
|
||||
{"id", 6},
|
||||
{"method", "download.get"},
|
||||
{"params", {{"taskId", missing}}}});
|
||||
CHECK(reply.contains("error"));
|
||||
CHECK_EQ(reply["error"]["code"].get<int>(), -32010);
|
||||
CHECK_EQ(reply["error"]["data"]["taskId"].get<std::string>(), missing);
|
||||
::close(c);
|
||||
}
|
||||
|
||||
// --- two requests in one write, pipelined on one connection --------------------
|
||||
{
|
||||
const int c = connect_client(sock);
|
||||
std::string out = rpc::frame(json({{"jsonrpc", "2.0"}, {"id", 7}, {"method", "download.list"}, {"params", json::object()}}).dump());
|
||||
out += rpc::frame(json({{"jsonrpc", "2.0"}, {"id", 8}, {"method", "download.list"}, {"params", json::object()}}).dump());
|
||||
CHECK(::write(c, out.data(), out.size()) == static_cast<ssize_t>(out.size()));
|
||||
std::string buf;
|
||||
char chunk[4096];
|
||||
int seen = 0;
|
||||
while (seen < 2) {
|
||||
const ssize_t n = ::read(c, chunk, sizeof(chunk));
|
||||
if (n <= 0) break;
|
||||
buf.append(chunk, static_cast<std::size_t>(n));
|
||||
std::size_t nl;
|
||||
while ((nl = buf.find('\n')) != std::string::npos) {
|
||||
const json reply = json::parse(buf.substr(0, nl), nullptr, false);
|
||||
CHECK(reply.contains("result"));
|
||||
++seen;
|
||||
buf.erase(0, nl + 1);
|
||||
}
|
||||
}
|
||||
CHECK_EQ(seen, 2);
|
||||
::close(c);
|
||||
}
|
||||
|
||||
loop.stop();
|
||||
loop_thread.join();
|
||||
::unlink(sock.c_str());
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,150 @@
|
||||
#include "rpc/ws_frame.hpp"
|
||||
#include "rpc/ws_handshake.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "check.hpp"
|
||||
|
||||
using namespace velox::daemon::rpc;
|
||||
|
||||
namespace {
|
||||
|
||||
// Build a *client* frame: FIN/opcode, mask bit set, a fixed 4-byte mask, masked payload.
|
||||
std::string client_frame(WsOpcode op, std::string_view payload, bool fin = true) {
|
||||
std::string f;
|
||||
f.push_back(static_cast<char>((fin ? 0x80 : 0x00) | static_cast<std::uint8_t>(op)));
|
||||
const std::size_t n = payload.size();
|
||||
if (n < 126) {
|
||||
f.push_back(static_cast<char>(0x80 | n));
|
||||
} else if (n <= 0xFFFF) {
|
||||
f.push_back(static_cast<char>(0x80 | 126));
|
||||
f.push_back(static_cast<char>((n >> 8) & 0xFF));
|
||||
f.push_back(static_cast<char>(n & 0xFF));
|
||||
} else {
|
||||
f.push_back(static_cast<char>(0x80 | 127));
|
||||
for (int i = 7; i >= 0; --i)
|
||||
f.push_back(static_cast<char>((static_cast<std::uint64_t>(n) >> (i * 8)) & 0xFF));
|
||||
}
|
||||
const char key[4] = {0x12, 0x34, 0x56, 0x78};
|
||||
f.append(key, 4);
|
||||
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ key[i & 3]));
|
||||
return f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
// --- one text frame ------------------------------------------------------------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
CHECK(r.feed(client_frame(WsOpcode::Text, "{\"a\":1}"), m) == WsFrameReader::Status::Ok);
|
||||
CHECK_EQ(m.size(), 1u);
|
||||
CHECK(m[0].opcode == WsOpcode::Text);
|
||||
CHECK_EQ(m[0].payload, std::string("{\"a\":1}"));
|
||||
}
|
||||
|
||||
// --- fragmented: text (fin=0) + continuation (fin=1) --------------------------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
r.feed(client_frame(WsOpcode::Text, "hel", /*fin=*/false), m);
|
||||
CHECK_EQ(m.size(), 0u);
|
||||
r.feed(client_frame(WsOpcode::Continuation, "lo", /*fin=*/true), m);
|
||||
CHECK_EQ(m.size(), 1u);
|
||||
CHECK_EQ(m[0].payload, std::string("hello"));
|
||||
}
|
||||
|
||||
// --- byte-at-a-time delivery still reassembles -------------------------------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
const std::string frame = client_frame(WsOpcode::Text, "streamed");
|
||||
for (char ch : frame) r.feed(std::string_view(&ch, 1), m);
|
||||
CHECK_EQ(m.size(), 1u);
|
||||
CHECK_EQ(m[0].payload, std::string("streamed"));
|
||||
}
|
||||
|
||||
// --- a 200-byte payload exercises the 16-bit length path --------------------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
const std::string big(200, 'x');
|
||||
r.feed(client_frame(WsOpcode::Text, big), m);
|
||||
CHECK_EQ(m.size(), 1u);
|
||||
CHECK_EQ(m[0].payload.size(), 200u);
|
||||
}
|
||||
|
||||
// --- ping is surfaced so the server can pong -------------------------------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
r.feed(client_frame(WsOpcode::Ping, "hi"), m);
|
||||
CHECK_EQ(m.size(), 1u);
|
||||
CHECK(m[0].opcode == WsOpcode::Ping);
|
||||
}
|
||||
|
||||
// --- an unmasked client frame is a protocol error (RFC 6455 §5.1) ----------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
std::string bad;
|
||||
bad.push_back(static_cast<char>(0x81)); // FIN + text
|
||||
bad.push_back(static_cast<char>(0x03)); // len 3, mask bit clear
|
||||
bad.append("abc");
|
||||
CHECK(r.feed(bad, m) == WsFrameReader::Status::ProtocolError);
|
||||
}
|
||||
|
||||
// --- a declared length past the cap is rejected before allocating ----------
|
||||
{
|
||||
WsFrameReader r;
|
||||
std::vector<WsMessage> m;
|
||||
std::string hdr;
|
||||
hdr.push_back(static_cast<char>(0x82)); // FIN + binary
|
||||
hdr.push_back(static_cast<char>(0x80 | 127));
|
||||
for (int i = 7; i >= 0; --i)
|
||||
hdr.push_back(static_cast<char>((0x0000000001000000ull >> (i * 8)) & 0xFF)); // 16 MiB
|
||||
CHECK(r.feed(hdr, m) == WsFrameReader::Status::MessageTooBig);
|
||||
}
|
||||
|
||||
// --- ws_encode: server frames are unmasked, correct length byte ------------
|
||||
{
|
||||
const std::string f = ws_encode(WsOpcode::Text, "abc");
|
||||
CHECK_EQ(static_cast<std::uint8_t>(f[0]), 0x81u);
|
||||
CHECK_EQ(static_cast<std::uint8_t>(f[1]), 0x03u); // len 3, no mask bit
|
||||
CHECK_EQ(f.substr(2), std::string("abc"));
|
||||
}
|
||||
|
||||
// --- RFC 6455 §1.3 sample accept value ------------------------------------
|
||||
CHECK_EQ(ws_accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
std::string("s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
|
||||
|
||||
// --- handshake: a page origin is refused, an extension origin upgrades -----
|
||||
{
|
||||
const std::string req_page =
|
||||
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\nOrigin: https://evil.example\r\n\r\n";
|
||||
const auto r = ws_try_handshake(req_page);
|
||||
CHECK(r.complete);
|
||||
CHECK(!r.ok);
|
||||
CHECK(r.response.find("403") != std::string::npos);
|
||||
}
|
||||
{
|
||||
const std::string req_ext =
|
||||
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"Origin: moz-extension://11111111-2222-3333-4444-555555555555\r\n\r\n";
|
||||
const auto r = ws_try_handshake(req_ext);
|
||||
CHECK(r.complete);
|
||||
CHECK(r.ok);
|
||||
CHECK(r.response.find("101") != std::string::npos);
|
||||
CHECK(r.response.find("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") !=
|
||||
std::string::npos);
|
||||
CHECK_EQ(r.origin, std::string("moz-extension://11111111-2222-3333-4444-555555555555"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -0,0 +1,216 @@
|
||||
// Integration: a real WsServer on a loopback port, a hand-rolled WebSocket client.
|
||||
// Covers the handshake, the pairing flow, the token gate (-32002), and the
|
||||
// privileged-over-WS refusal (-32003).
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "rpc/dispatcher.hpp"
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/pairing.hpp"
|
||||
#include "rpc/runtime_dir.hpp"
|
||||
#include "rpc/ws_frame.hpp"
|
||||
#include "rpc/ws_server.hpp"
|
||||
#include "store/migrations.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
using nlohmann::json;
|
||||
namespace rpc = velox::daemon::rpc;
|
||||
namespace store = velox::daemon::store;
|
||||
|
||||
namespace {
|
||||
|
||||
int dial(int port) {
|
||||
const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
sockaddr_in a{};
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
|
||||
a.sin_port = ::htons(static_cast<std::uint16_t>(port));
|
||||
if (::connect(fd, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
void write_all(int fd, std::string_view s) {
|
||||
std::size_t off = 0;
|
||||
while (off < s.size()) {
|
||||
const ssize_t n = ::write(fd, s.data() + off, s.size() - off);
|
||||
if (n <= 0) return;
|
||||
off += static_cast<std::size_t>(n);
|
||||
}
|
||||
}
|
||||
|
||||
std::string read_some(int fd) {
|
||||
char buf[8192];
|
||||
const ssize_t n = ::read(fd, buf, sizeof(buf));
|
||||
return n > 0 ? std::string(buf, static_cast<std::size_t>(n)) : std::string{};
|
||||
}
|
||||
|
||||
// A masked client text frame.
|
||||
std::string client_text(std::string_view payload) {
|
||||
std::string f;
|
||||
f.push_back(static_cast<char>(0x81)); // FIN + text
|
||||
const std::size_t n = payload.size();
|
||||
if (n < 126) {
|
||||
f.push_back(static_cast<char>(0x80 | n));
|
||||
} else {
|
||||
f.push_back(static_cast<char>(0x80 | 126));
|
||||
f.push_back(static_cast<char>((n >> 8) & 0xFF));
|
||||
f.push_back(static_cast<char>(n & 0xFF));
|
||||
}
|
||||
const char k[4] = {0x0A, 0x0B, 0x0C, 0x0D};
|
||||
f.append(k, 4);
|
||||
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ k[i & 3]));
|
||||
return f;
|
||||
}
|
||||
|
||||
// Decode one unmasked server frame from `buf`, consuming it. Returns payload; sets `op`.
|
||||
std::string server_frame(std::string& buf, rpc::WsOpcode& op) {
|
||||
if (buf.size() < 2) return {};
|
||||
op = static_cast<rpc::WsOpcode>(buf[0] & 0x0F);
|
||||
std::size_t len = static_cast<std::uint8_t>(buf[1]) & 0x7F;
|
||||
std::size_t header = 2;
|
||||
if (len == 126) {
|
||||
len = (static_cast<std::size_t>(static_cast<std::uint8_t>(buf[2])) << 8) |
|
||||
static_cast<std::uint8_t>(buf[3]);
|
||||
header = 4;
|
||||
}
|
||||
if (buf.size() < header + len) return {};
|
||||
std::string payload = buf.substr(header, len);
|
||||
buf.erase(0, header + len);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Send a request frame, wait for one text reply, return its parsed JSON.
|
||||
json rpc_call(int fd, const json& req) {
|
||||
write_all(fd, client_text(req.dump()));
|
||||
std::string buf;
|
||||
for (;;) {
|
||||
buf += read_some(fd);
|
||||
rpc::WsOpcode op{};
|
||||
std::string save = buf;
|
||||
const std::string payload = server_frame(buf, op);
|
||||
if (payload.empty() && buf == save) continue; // need more bytes
|
||||
if (op == rpc::WsOpcode::Text) return json::parse(payload, nullptr, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
::unsetenv("VELOX_PAIR_AUTO");
|
||||
|
||||
auto db = store::Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
if (!db) return;
|
||||
CHECK(store::migrate_to_head(*db).has_value());
|
||||
|
||||
char tmpl[] = "/tmp/velox-ws-test-XXXXXX";
|
||||
const char* dir = ::mkdtemp(tmpl);
|
||||
CHECK(dir != nullptr);
|
||||
rpc::RuntimeDir rt{dir ? dir : "/tmp"};
|
||||
|
||||
rpc::EventLoop loop;
|
||||
rpc::VeloxDispatcher dispatcher;
|
||||
rpc::EnvAutoApprover approver;
|
||||
rpc::WsServer server(loop, dispatcher, *db, approver, rt);
|
||||
const auto ec = server.start();
|
||||
CHECK(!ec);
|
||||
if (ec) return;
|
||||
CHECK(server.port() >= rpc::WsServer::kPortLo);
|
||||
CHECK(server.port() <= rpc::WsServer::kPortHi);
|
||||
|
||||
std::thread th([&loop] { loop.run(); });
|
||||
|
||||
const std::string origin = "moz-extension://11111111-2222-3333-4444-555555555555";
|
||||
|
||||
// --- handshake ---------------------------------------------------------------
|
||||
const int fd = dial(server.port());
|
||||
CHECK(fd >= 0);
|
||||
write_all(fd,
|
||||
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Origin: " + origin + "\r\n\r\n");
|
||||
std::string hs;
|
||||
while (hs.find("\r\n\r\n") == std::string::npos) hs += read_some(fd);
|
||||
CHECK(hs.find("101 Switching Protocols") != std::string::npos);
|
||||
|
||||
// --- session.hello with no token -> -32002 --------------------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "session.hello"},
|
||||
{"params", {{"clientType", "extension"},
|
||||
{"clientName", "Velox for Firefox"},
|
||||
{"protocolVersion",
|
||||
std::string(velox::proto::kProtocolVersion)}}}});
|
||||
CHECK(r.contains("error"));
|
||||
CHECK_EQ(r["error"]["code"].get<int>(), -32002);
|
||||
}
|
||||
|
||||
// --- session.pair with approval off -> not approved ----------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "session.pair"},
|
||||
{"params", {{"clientName", "Velox for Firefox"},
|
||||
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
|
||||
CHECK(r.contains("error"));
|
||||
}
|
||||
|
||||
// --- approval on -> a token, then hello with it succeeds ----------------
|
||||
::setenv("VELOX_PAIR_AUTO", "1", 1);
|
||||
std::string token;
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "session.pair"},
|
||||
{"params", {{"clientName", "Velox for Firefox"},
|
||||
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result")) {
|
||||
token = r["result"]["token"].get<std::string>();
|
||||
CHECK(token.size() >= 40);
|
||||
}
|
||||
}
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "session.hello"},
|
||||
{"params", {{"clientType", "extension"},
|
||||
{"clientName", "Velox for Firefox"},
|
||||
{"protocolVersion",
|
||||
std::string(velox::proto::kProtocolVersion)},
|
||||
{"token", token}}}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result"))
|
||||
CHECK_EQ(r["result"]["transport"].get<std::string>(), std::string("ws"));
|
||||
}
|
||||
|
||||
// --- privileged method over WS -> -32003 -------------------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 5}, {"method", "settings.get"},
|
||||
{"params", {{"keys", nullptr}}}});
|
||||
CHECK(r.contains("error"));
|
||||
CHECK_EQ(r["error"]["code"].get<int>(), -32003);
|
||||
}
|
||||
|
||||
// --- a non-privileged method while authed -> a real result -----------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "download.list"},
|
||||
{"params", json::object()}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result")) CHECK_EQ(r["result"]["total"].get<int>(), 0);
|
||||
}
|
||||
|
||||
::close(fd);
|
||||
loop.stop();
|
||||
th.join();
|
||||
::unlink(rt.ws_port_path().c_str());
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
Reference in New Issue
Block a user