daemon: rpc/ws_server — loopback WebSocket transport + pairing (build step 1, second half)

The extension's fallback transport (docs/05 §4). veloxd now also listens
on 127.0.0.1, first free port in 52000-52016, and writes it to
<runtime>/ws.port (0600).

- rpc/ws_frame — RFC 6455 frame codec. Incremental; reassembles
  continuation frames; enforces "client frames MUST be masked" (§5.1);
  caps a reassembled message at 8 MiB. This is the attacker-adjacent
  parser, so it has its own test table.
- rpc/ws_handshake — HTTP upgrade parse, Sec-WebSocket-Accept
  (SHA-1 + base64 via libcrypto), and the two non-negotiable checks:
  an Origin header must be present and must be moz-extension:// (a page
  cannot pair). Version must be 13.
- rpc/ws_server — per-connection Handshake -> Open state machine on the
  shared EventLoop. Token gate: session.pair mints a token behind the
  approver + rate limiter; session.hello must present a valid one;
  every other method is -32002 until authed. Privileged methods are
  refused -32003 by the generated dispatch(). Ping -> Pong; Close
  echoed. session.hello major-version mismatch -> -32001.
- rpc/pairing — PairingApprover interface + EnvAutoApprover dev stub
  (approves iff VELOX_PAIR_AUTO=1); PairingRateLimiter (5 failures / 60 s
  per origin, then 60 s lockout -> -32014, survives reconnect); a
  four-digit code generator.
- store/pairings — the pairings table: create() returns the plaintext
  token once and stores only its SHA-256; find_active_by_token,
  touch, revoke, list_active.
- util/crypto — sha1 / sha256_hex / base64 / random_token over libcrypto.
- store/sqlite — pin the DB file (and -wal/-shm) to 0600.
- runtime_dir — resolve_data_dir() for $XDG_DATA_HOME/velox (velox.db).
- main.cpp — opens + migrates velox.db, starts both transports; a WS
  bind failure is logged, not fatal (capture must fail open, the Unix
  socket still serves the GUI/CLI).

Real gap, flagged not hidden: the pairing prompt is EnvAutoApprover for
now — a GUI dialog / desktop notification is build step 7. Pairing
needs VELOX_PAIR_AUTO=1 until then.

Tests (ASan+UBSan and TSan clean): veloxd.ws_frame (codec + handshake
vectors incl. the RFC 6455 §1.3 accept sample), veloxd.pairings (token
create/find/revoke, hash-not-token, rate-limit window + lockout +
per-origin isolation + success reset), veloxd.ws_server (full flow: 101
handshake, -32002 gate, deny-then-approve pairing, hello-with-token,
-32003 privileged refusal, real download.list). 27 daemon/cli tests
green; full tree green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-10 15:44:28 +04:00
co-authored by Claude Sonnet 5
parent e585113daf
commit dab071c41a
21 changed files with 1823 additions and 32 deletions
+54
View File
@@ -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
+71
View File
@@ -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
+20
View File
@@ -53,4 +53,24 @@ std::error_code resolve_runtime_dir(RuntimeDir& out) {
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
+5
View File
@@ -27,4 +27,9 @@ struct RuntimeDir {
// 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
+152
View File
@@ -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
+61
View File
@@ -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
+121
View File
@@ -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
+30
View File
@@ -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
+440
View File
@@ -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
+92
View File
@@ -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