Compare commits

...
Author SHA1 Message Date
Richard Holland 198e69d10d simplify pwalists 2026-10-20 17:23:15 +11:00
Richard Holland b1ce7de738 remove unneeded feature gate 2026-10-20 17:05:33 +11:00
Richard Holland 2c165c8be1 add rpc, and a list / txt file to pwa list 2026-10-20 16:58:06 +11:00
Richard Holland d2f1331748 pwa blocklist init 2026-10-20 14:36:11 +11:00
11 changed files with 864 additions and 41 deletions

No files matched your search

+71 -5
View File
@@ -315,6 +315,9 @@
# - The admin RPC "pwa_info" reports requests by outcome and bytes
# served; "reset": true zeroes them, "flush_dns": true drops the
# custom domain cache (CLI: xahaud pwa_info [reset] [flush_dns]).
# - Which accounts are served is set node-wide, not per port, by
# [pwa_allowlist], [pwa_blocklist], [pwa_list_file] and
# [pwa_list_providers]: see "PWA lists" in section 3.
#
# Example (also add port_pwa to the [server] list):
#
@@ -375,11 +378,6 @@
# the node but can still send requests anywhere, including the
# visitor's own machine, so keep admin ports behind credentials.
#
# [pwa_allowlist] and [pwa_blocklist], one r-address per line, apply to
# every pwa port: if the allowlist is non-empty only those accounts are
# served; blocklisted accounts are never served (403), whatever the
# allowlist says. An account on both is a startup error.
#
# ssl_key = <filename>
# ssl_cert = <filename>
# ssl_chain = <filename>
@@ -1025,6 +1023,74 @@
# And the ledger is built by applying the transactions to the parent
# ledger.
#
#
#
# PWA lists
#
# Which accounts' AppLoader documents the pwa ports serve (see "pwa ports"
# in section 1). Node-wide: every pwa port applies the same rules, in
# this order:
#
# 1. [pwa_blocklist]: never served (403).
# 2. [pwa_allowlist]: served.
# 3. [pwa_list_providers]: the first provider with a hook state entry
# for the account decides.
# 4. Listed nowhere: served, unless [pwa_allowlist] is non-empty.
#
# Steps 1, 2 and 4 are settled before a request is queued; step 3 reads
# the validated ledger.
#
#
# [pwa_allowlist]
# [pwa_blocklist]
#
# One r-address per line. Entries here are fixed for the node's lifetime;
# an account on both lists is a startup error. The same two sections may
# also live in [pwa_list_file], below, where they can be changed at
# runtime; the two sources are merged.
#
# Example:
# [pwa_blocklist]
# rBadActor...
#
#
# [pwa_list_file]
#
# Path of a file holding [pwa_allowlist] and [pwa_blocklist] in the same
# format ('#' comments allowed), relative to the folder of xahaud.cfg
# unless absolute. Set this to change the lists without a restart: the
# admin RPC pwa_list rewrites the file (atomically, keeping its mode;
# comments are dropped and entries sorted) before the change takes
# effect, and starts from the file as it is on disk, so edits made by
# hand are kept. "pwa_list reload" applies hand edits without a change.
# The file need not exist yet, but its directory must, and xahaud must
# be able to write there. Entries from xahaud.cfg cannot be removed by
# the RPC. Without this setting pwa_list only shows the lists.
#
# pwa_list show the lists
# pwa_list block rBad... add to the blocklist
# pwa_list unblock rOk... allow rOk... move: removals apply first
# pwa_list reload pick up hand edits
#
# Over JSON: {"allow" | "block" | "unallow" | "unblock": r-address or
# array, "reload": bool}. A request applies whole or not at all.
#
# Example:
# /var/lib/xahaud/pwa-lists.txt
#
#
# [pwa_list_providers]
#
# One r-address per line, most trusted first. Each is an account whose
# hook publishes a list on-ledger as hook state under the namespace
# 5057415F414C4C4F575F424C4F434B5F4C495354000000000000000000000000
# (ASCII "PWA_ALLOW_BLOCK_LIST"),
# with key = the listed account's 20-byte AccountID, zero padded on the
# left (as state_set pads it): all-zero data blocks, any other data
# allows, no entry means this provider has no opinion and the next is
# tried. Consulted only for accounts on neither local list. Providers
# are configured here only, not in [pwa_list_file]; pwa_list shows them.
#
#-------------------------------------------------------------------------------
#
# 4. HTTPS Client
+8
View File
@@ -115,6 +115,14 @@ std::size_t constexpr maxAppLoaderLength = 4096;
*/
std::uint64_t constexpr appLoaderFeeDropsPerByte = 1;
/** Hook state namespace of PWA allow/block list providers: key = the
listed AccountID, zero padded on the left as state_set does; all-zero
data blocks, any other data allows. A convention between provider hooks
and the nodes reading them, not consensus. ASCII "PWA_ALLOW_BLOCK_LIST".
*/
uint256 const pwaListNamespace{
"5057415F414C4C4F575F424C4F434B5F4C495354000000000000000000000000"};
/** The maximum length of a URI inside a Credential */
std::size_t constexpr maxCredentialURILength = 256;
-2
View File
@@ -60,8 +60,6 @@ struct Port
std::vector<boost::asio::ip::network_v6> secure_gateway_nets_v6;
// "pwa" ports only; see the "pwa ports" section of xahaud-example.cfg.
std::set<std::string> pwa_domains; // zones, lower case, no trailing dot
std::set<AccountID> pwa_allowlist; // if non-empty, only these are served
std::set<AccountID> pwa_blocklist; // never served; wins over allowlist
bool pwa_custom_domains = false; // serve hosts named by TXT records
std::optional<beast::IP::Endpoint> pwa_resolver; // for those; else system
std::string user;
+270
View File
@@ -32,9 +32,11 @@
#include <test/jtx/envconfig.h>
#include <xrpld/core/Config.h>
#include <xrpld/rpc/ServerHandler.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/beast/test/yield_to.h>
#include <xrpl/beast/utility/temp_dir.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SystemParameters.h>
#include <xrpl/protocol/jss.h>
@@ -49,6 +51,8 @@
#include <array>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <thread>
@@ -375,6 +379,12 @@ class PWAServe_test : public beast::unit_test::suite,
return env.rpc("json", "pwa_info", params)[jss::result];
}
Json::Value
pwaList(jtx::Env& env, std::string const& params = "{}")
{
return env.rpc("json", "pwa_list", params)[jss::result];
}
//--------------------------------------------------------------------------
void
@@ -453,6 +463,43 @@ class PWAServe_test : public beast::unit_test::suite,
c["pwa_allowlist"].append(a.human());
c["pwa_blocklist"].append(a.human());
}));
// The lists file need not exist yet, but its directory must; what it
// holds must parse, and count with the cfg's lists.
beast::temp_dir td;
auto withFile = [&](std::string const& text,
std::function<void(Config&)> const& more = {}) {
auto const f = td.file("lists.txt");
boost::system::error_code ec;
writeFileContents(ec, f, text);
return accepts([&](Config& c) {
c[SECTION_PWA_LIST_FILE].legacy(f);
if (more)
more(c);
});
};
BEAST_EXPECT(withFile("# nothing listed\n"));
BEAST_EXPECT(withFile(
" [pwa_allowlist]\r\n" + a.human() + "\r\n[pwa_blocklist]\n"));
for (auto const& bad :
{a.human() + "\n",
"[pwa_list_providers]\n" + a.human() + "\n",
std::string("[pwa_blocklist]\nnot-an-address\n"),
"[pwa_blocklist]\n" + toBase58(AccountID{}) + "\n",
"[pwa_allowlist]\n" + a.human() + "\n[pwa_blocklist]\n" +
a.human() + "\n"})
BEAST_EXPECTS(!withFile(bad), bad);
BEAST_EXPECT(!withFile(
"[pwa_allowlist]\n" + a.human() + "\n",
[&](Config& c) { c["pwa_blocklist"].append(a.human()); }));
BEAST_EXPECT(accepts([&](Config& c) {
c[SECTION_PWA_LIST_FILE].legacy(td.file("absent.txt"));
}));
BEAST_EXPECT(!accepts([&](Config& c) {
c[SECTION_PWA_LIST_FILE].legacy(td.file("absent/lists.txt"));
}));
BEAST_EXPECT(!accepts(
[&](Config& c) { c[SECTION_PWA_LIST_FILE].legacy(td.path()); }));
}
void
@@ -741,6 +788,227 @@ class PWAServe_test : public beast::unit_test::suite,
{200, 200, 403, 403});
}
void
testListRPC(boost::asio::yield_context& yield)
{
testcase("pwa_list edits the lists at runtime, through the lists file");
using namespace jtx;
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
beast::temp_dir td;
auto const file = td.file("pwa-lists.txt");
auto const cfg = [&] {
return pwaConfig([&](Config& c) {
c[SECTION_PWA_LIST_FILE].legacy(file);
c["pwa_blocklist"].append(carol.human());
});
};
using Names = std::set<std::string>;
auto const names = [](Json::Value const& v) {
Names out;
for (auto const& e : v)
out.insert(e.asString());
return out;
};
auto const onDisk = [&] {
boost::system::error_code ec;
return getFileContents(ec, file);
};
auto const one = [](char const* key, Account const& a) {
return std::string("{\"") + key + "\": \"" + a.human() + "\"}";
};
{
Env env{*this, cfg()};
env.fund(XRP(1000), alice, bob, carol);
for (auto const& a : {alice, bob, carol})
publish(env, a, doc());
auto const status = [&](Account const& a) {
return getDoc(env, a, yield).result_int();
};
// Nothing in the file yet, which need not exist; carol is fixed.
auto r = pwaList(env);
BEAST_EXPECT(r[jss::status] == "success" && r["changed"] == false);
BEAST_EXPECT(
r["allowlist"].size() == 0 && r["blocklist"].size() == 0);
BEAST_EXPECT(names(r["cfg_blocklist"]) == Names{carol.human()});
BEAST_EXPECT(!r.isMember("cfg_allowlist") && r["file"] == file);
BEAST_EXPECT(!boost::filesystem::exists(file));
// An edit is written to the file and applies at once.
r = pwaList(env, one("block", bob));
BEAST_EXPECT(r["changed"] == true);
BEAST_EXPECT(names(r["blocklist"]) == Names{bob.human()});
BEAST_EXPECT(
onDisk().find("[pwa_blocklist]\n" + bob.human() + "\n") !=
std::string::npos);
BEAST_EXPECT(
status(alice) == 200 && status(bob) == 403 &&
status(carol) == 403);
BEAST_EXPECT(pwaList(env, one("block", bob))["changed"] == false);
// Removals come first, so a move is one step: never on neither.
r = env.rpc(
"pwa_list",
"unblock",
bob.human(),
"allow",
bob.human())[jss::result];
BEAST_EXPECT(r["changed"] == true && r["blocklist"].size() == 0);
BEAST_EXPECT(names(r["allowlist"]) == Names{bob.human()});
BEAST_EXPECT(status(alice) == 403 && status(bob) == 200);
// Refused whole: cfg entries, both lists, ambiguity, bad input.
auto const text = onDisk();
for (auto const& bad :
{one("unblock", carol),
one("allow", carol),
one("block", bob),
R"({"allow": [")" + alice.human() + R"("], "unallow": ")" +
alice.human() + R"("})",
R"({"block": [")" + alice.human() + R"(", "nope"]})",
std::string(R"({"allow": "rrrrrrrrrrrrrrrrrrrrrhoLvTp"})"),
std::string(R"({"allow": {}})"),
std::string(R"({"block": [1]})"),
std::string(R"({"reload": 1})")})
BEAST_EXPECTS(
pwaList(env, bad)[jss::error] == "invalidParams", bad);
BEAST_EXPECT(onDisk() == text);
BEAST_EXPECT(status(alice) == 403 && status(bob) == 200);
BEAST_EXPECT(
env.rpc("pwa_list", "allow")[jss::error] == "invalidParams");
BEAST_EXPECT(
env.rpc("pwa_list", "add", alice.human())[jss::error] ==
"invalidParams");
BEAST_EXPECT(
env.rpc("pwa_list", "reload", "reload")[jss::error] ==
"invalidParams");
// Hand edits wait for reload (or the next edit, which keeps them).
{
boost::system::error_code ec;
writeFileContents(
ec,
file,
text + "[pwa_allowlist]\n" + alice.human() + "\n");
}
BEAST_EXPECT(status(alice) == 403);
r = env.rpc("pwa_list", "reload")[jss::result];
BEAST_EXPECT(r["changed"] == true);
BEAST_EXPECT(
names(r["allowlist"]) == Names({alice.human(), bob.human()}));
BEAST_EXPECT(status(alice) == 200);
// A file broken by hand is reported and changes nothing.
auto const good = onDisk();
{
boost::system::error_code ec;
writeFileContents(ec, file, good + "[nope]\n");
}
BEAST_EXPECT(
pwaList(env, R"({"reload": true})")[jss::error] == "internal");
BEAST_EXPECT(
pwaList(env, one("unallow", bob))[jss::error] == "internal");
BEAST_EXPECT(status(bob) == 200);
{
boost::system::error_code ec;
writeFileContents(ec, file, good);
}
}
// A restart picks the file up again.
{
Env env{*this, cfg()};
auto const r = pwaList(env);
BEAST_EXPECT(
names(r["allowlist"]) == Names({alice.human(), bob.human()}) &&
r["blocklist"].size() == 0);
}
// Without a lists file there is nothing to edit; and admin only.
{
Env env{*this, pwaConfig()};
BEAST_EXPECT(!pwaList(env).isMember("file"));
BEAST_EXPECT(
pwaList(env, one("block", alice))[jss::error] ==
"notSupported");
}
{
Env env{*this, no_admin(cfg())};
auto const jrc = makeJSONRPCClient(env.app().config());
BEAST_EXPECT(jrc->invoke("pwa_list", Json::objectValue)[jss::result]
.isNull());
}
}
void
testHookLists()
{
testcase("hook-controlled list providers");
using namespace jtx;
Account const alice{"alice"}, bob{"bob"}, carol{"carol"}, dave{"dave"},
p1{"p1"}, p2{"p2"};
using V = std::optional<bool>;
constexpr V ask = std::nullopt;
auto check = [&](PWAPolicy policy,
std::array<V, 4> expect,
bool withView = true) {
Env env{*this};
env.app().openLedger().modify([&](OpenView& view, beast::Journal) {
// What each provider's hook would state_set.
auto put = [&](Account const& p, Account const& a, Blob v) {
uint256 key;
std::copy(a.id().begin(), a.id().end(), key.end() - 20);
auto sle = std::make_shared<SLE>(
keylet::hookState(p.id(), key, pwaListNamespace));
sle->setFieldH256(sfHookStateKey, key);
sle->setFieldVL(sfHookStateData, v);
view.rawInsert(sle);
};
put(p1, alice, {0, 0});
put(p1, bob, {0, 1});
put(p2, alice, {1});
put(p2, carol, {0});
int i = 0;
for (auto const& a : {alice, bob, carol, dave})
BEAST_EXPECTS(
policy.verdict(withView ? &view : nullptr, a.id()) ==
expect[i++],
a.name());
return false;
});
};
auto const providers = [&](PWALists cfg, PWALists file = {}) {
PWAPolicy p;
p.cfg = std::move(cfg);
p.file = std::move(file);
p.providers = {p1.id(), p2.id()};
return p;
};
// First provider with an entry wins; no entry passes through.
check(providers({}), {false, true, false, true});
// Local lists first, cfg and file alike; listed nowhere falls to
// the allowlist rule.
check(
providers({{alice.id()}, {bob.id()}}), {true, false, false, false});
check(
providers({{}, {bob.id()}}, {{alice.id()}, {}}),
{true, false, false, false});
// Without a view (the I/O thread's early check) providers are not
// consulted: what the lists settle is settled, the rest is open.
check(providers({}), {ask, ask, ask, ask}, false);
check(
providers({{alice.id()}, {bob.id()}}),
{true, false, ask, ask},
false);
// With no providers the lists settle everything, view or not.
PWAPolicy local;
local.file = {{alice.id()}, {bob.id()}};
check(local, {true, false, false, false}, false);
check(local, {true, false, false, false});
check(PWAPolicy{}, {true, true, true, true}, false);
}
void
testProxy(boost::asio::yield_context& yield)
{
@@ -1003,10 +1271,12 @@ public:
run() override
{
testConfigValidation();
testHookLists();
yield_to([&](boost::asio::yield_context& yield) {
testServe(yield);
testRouting(yield);
testLists(yield);
testListRPC(yield);
testProxy(yield);
testCustomDomains(yield);
testPWAInfo(yield);
+2
View File
@@ -79,6 +79,8 @@ struct ConfigSection
#define SECTION_PORT_GRPC "port_grpc"
#define SECTION_PWA_ALLOWLIST "pwa_allowlist"
#define SECTION_PWA_BLOCKLIST "pwa_blocklist"
#define SECTION_PWA_LIST_PROVIDERS "pwa_list_providers"
#define SECTION_PWA_LIST_FILE "pwa_list_file"
#define SECTION_PREFETCH_WORKERS "prefetch_workers"
#define SECTION_REDUCE_RELAY "reduce_relay"
#define SECTION_RELATIONAL_DB "relational_db"
+25
View File
@@ -1412,6 +1412,30 @@ private:
return jvRequest;
}
// pwa_list [reload] [allow|block|unallow|unblock <account>]...
Json::Value
parsePWAList(Json::Value const& jvParams)
{
Json::Value jvRequest(Json::objectValue);
for (Json::UInt i = 0; i < jvParams.size(); ++i)
{
auto const op = jvParams[i].asString();
if (op == "reload" && !jvRequest.isMember(op))
{
jvRequest[op] = true;
continue;
}
if ((op != "allow" && op != "block" && op != "unallow" &&
op != "unblock") ||
++i == jvParams.size())
return rpcError(rpcINVALID_PARAMS);
if (!jvRequest.isMember(op))
jvRequest[op] = Json::arrayValue;
jvRequest[op].append(jvParams[i].asString());
}
return jvRequest;
}
// pwa_validate <account> <hash> [<domain>]
Json::Value
parsePWAValidate(Json::Value const& jvParams)
@@ -1515,6 +1539,7 @@ public:
{"ping", &RPCParser::parseAsIs, 0, 0},
{"print", &RPCParser::parseAsIs, 0, 1},
{"pwa_info", &RPCParser::parsePWAInfo, 0, 2},
{"pwa_list", &RPCParser::parsePWAList, 0, -1},
{"pwa_validate", &RPCParser::parsePWAValidate, 2, 3},
// { "profile", &RPCParser::parseProfile, 1, 9
// },
+79 -2
View File
@@ -33,6 +33,7 @@
#include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/ssl/ssl_stream.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/utility/string_view.hpp>
#include <array>
@@ -40,7 +41,10 @@
#include <chrono>
#include <condition_variable>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <vector>
namespace ripple {
@@ -51,6 +55,51 @@ operator<(Port const& lhs, Port const& rhs)
return lhs.name < rhs.name;
}
class ReadView;
/** A [pwa_allowlist] and a [pwa_blocklist], from one source. */
struct PWALists
{
std::set<AccountID> allow; // if non-empty, only these are served
std::set<AccountID> block; // never served; wins over allowlist
bool
operator==(PWALists const&) const = default;
};
/** Which accounts' loaders the pwa ports serve. Node-global: every pwa
port applies the same policy. It has three sources, and `verdict`
consults them in this order:
1. the blocklists (cfg and file): refused;
2. the allowlists (cfg and file): served;
3. `providers`, in order: the first with a hook state entry for the
account under pwaListNamespace decides (all-zero data refuses,
anything else serves). Needs a ledger;
4. listed nowhere: served, unless an allowlist is non-empty.
A snapshot is immutable once built; ServerHandler replaces it whole. */
struct PWAPolicy
{
PWALists cfg; // from xahaud.cfg: fixed
PWALists file; // from the lists file: pwa_list edits
std::vector<AccountID> providers; // [pwa_list_providers], first wins
bool
operator==(PWAPolicy const&) const = default;
/** Whether `account` may be served. With a view the answer is always
settled. Without one the providers are not consulted, so nullopt
means "listed nowhere; ask again with a ledger". */
std::optional<bool>
verdict(ReadView const* view, AccountID const& account) const;
/** Accounts on both an allowlist and a blocklist, cfg and file
counted together. Non-empty is a configuration error. */
std::set<AccountID>
conflicts() const;
};
class ServerHandler
{
public:
@@ -116,6 +165,11 @@ public:
// Configuration for the Overlay
boost::asio::ip::tcp::endpoint overlay;
// The PWA policy as read at startup; pwaListsFile (empty: none) is
// where the pwa_list RPC persists its edits to policy.file.
PWAPolicy pwaPolicy;
boost::filesystem::path pwaListsFile;
void
makeContexts();
};
@@ -165,6 +219,14 @@ private:
static constexpr std::size_t maxPWADnsInFlight = 4;
static constexpr std::size_t maxPWADnsEntries = 10'000;
// The live PWA policy, replaced whole on each edit so a request sees
// one consistent snapshot. pwaEditMutex_ serialises edits (and so the
// lists file write); pwaPolicyMutex_ guards only the pointer.
std::mutex pwaEditMutex_;
mutable std::mutex pwaPolicyMutex_;
std::shared_ptr<PWAPolicy const> pwaPolicy_ =
std::make_shared<PWAPolicy const>();
void
resolvePWADomain(
std::string const& host,
@@ -228,6 +290,20 @@ public:
return pwaStats_;
}
std::shared_ptr<PWAPolicy const>
pwaPolicy() const
{
std::lock_guard lock(pwaPolicyMutex_);
return pwaPolicy_;
}
/** The pwa_list RPC: optionally reread the lists file, take `remove`
off and then put `add` on the file's lists, write the file if that
changed it, then publish the new policy. All or nothing. Returns the
RPC result. */
Json::Value
pwaListsEdit(PWALists const& add, PWALists const& remove, bool reload);
/** Drop every cached custom domain answer; returns how many. */
std::size_t
pwaDnsFlush()
@@ -323,8 +399,9 @@ private:
void
onPWARequest(Session& session);
/** Apply the allowlist and blocklist, then post the ledger read to the
job queue as jtCLIENT_PWA. Callable from any thread. */
/** Refuse what the PWA policy settles without a ledger, then post the
ledger read to the job queue as jtCLIENT_PWA. Callable from any
thread. */
void
queuePWALookup(
std::shared_ptr<Session> const& session,
+1
View File
@@ -145,6 +145,7 @@ Handler const handlerArray[]{
{"ping", byRef(&doPing), Role::USER, NO_CONDITION},
{"print", byRef(&doPrint), Role::ADMIN, NO_CONDITION},
{"pwa_info", byRef(&doPWAInfo), Role::ADMIN, NO_CONDITION},
{"pwa_list", byRef(&doPWAList), Role::ADMIN, NO_CONDITION},
{"pwa_validate", byRef(&doPWAValidate), Role::USER, NO_CONDITION},
// { "profile", byRef (&doProfile), Role::USER,
// NEEDS_CURRENT_LEDGER },
+323 -32
View File
@@ -31,6 +31,7 @@
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpld/rpc/detail/Tuning.h>
#include <xrpld/rpc/json_body.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
@@ -60,13 +61,19 @@
#include <arpa/inet.h>
#include <arpa/nameser.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <resolv.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstring>
#include <iterator>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
@@ -152,6 +159,12 @@ void
ServerHandler::setup(Setup const& setup, beast::Journal journal)
{
setup_ = setup;
{
std::lock_guard editLock(pwaEditMutex_);
auto policy = std::make_shared<PWAPolicy const>(setup.pwaPolicy);
std::lock_guard lock(pwaPolicyMutex_);
pwaPolicy_ = std::move(policy);
}
endpoints_ = m_server->ports(setup.ports);
// fix auto ports
@@ -866,15 +879,251 @@ ServerHandler::resolvePWADomain(
pwaDnsCache_[host] = dns;
}
std::optional<bool>
PWAPolicy::verdict(ReadView const* view, AccountID const& account) const
{
auto const on = [&](std::set<AccountID> PWALists::*list) {
return (cfg.*list).count(account) > 0 ||
(file.*list).count(account) > 0;
};
if (on(&PWALists::block))
return false;
if (on(&PWALists::allow))
return true;
if (!providers.empty())
{
if (!view)
return std::nullopt;
uint256 key;
std::copy(account.begin(), account.end(), key.end() - account.size());
for (auto const& provider : providers)
if (auto const sle = view->read(
keylet::hookState(provider, key, pwaListNamespace)))
{
auto const v = sle->getFieldVL(sfHookStateData);
return std::any_of(
v.begin(), v.end(), [](auto b) { return b != 0; });
}
}
return cfg.allow.empty() && file.allow.empty();
}
std::set<AccountID>
PWAPolicy::conflicts() const
{
std::set<AccountID> out;
for (auto const* allow : {&cfg.allow, &file.allow})
for (auto const& a : *allow)
if (cfg.block.count(a) > 0 || file.block.count(a) > 0)
out.insert(a);
return out;
}
// The lists file: [pwa_allowlist] and [pwa_blocklist] sections of one
// r-address per line, '#' comments. Returns why it is unusable, if it is;
// no file at all is two empty lists.
static std::optional<std::string>
readPWAListsFile(boost::filesystem::path const& path, PWALists& out)
{
out = {};
boost::system::error_code ec;
if (!boost::filesystem::exists(path, ec))
{
if (ec)
return path.string() + ": " + ec.message();
return std::nullopt;
}
if (!boost::filesystem::is_regular_file(path, ec))
return path.string() + ": not a regular file";
auto const data = getFileContents(ec, path);
if (ec)
return path.string() + ": " + ec.message();
std::set<AccountID>* list = nullptr;
std::istringstream in(data);
std::string line;
for (int n = 1; std::getline(in, line); ++n)
{
boost::algorithm::trim(line);
auto const at = path.string() + ":" + std::to_string(n) + ": ";
if (line.empty() || line[0] == '#')
continue;
if (line == "[" SECTION_PWA_ALLOWLIST "]")
list = &out.allow;
else if (line == "[" SECTION_PWA_BLOCKLIST "]")
list = &out.block;
else if (!list || line[0] == '[')
return at +
"expected [" SECTION_PWA_ALLOWLIST
"] or [" SECTION_PWA_BLOCKLIST "]";
else if (auto const a = parseBase58<AccountID>(line); a && !a->isZero())
list->insert(*a);
else
return at + "invalid r-address '" + line + "'";
}
return std::nullopt;
}
static std::string
pwaListsText(PWALists const& lists)
{
std::string text =
"# PWA lists for every pwa port, applied together with any\n"
"# [" SECTION_PWA_ALLOWLIST "] and [" SECTION_PWA_BLOCKLIST
"] in the cfg file. One r-address per line.\n"
"# Written by the pwa_list admin RPC, which does not keep comments.\n"
"# After editing by hand, run: pwa_list reload\n";
auto const section = [&](char const* name, std::set<AccountID> const& v) {
text += std::string("\n[") + name + "]\n";
for (auto const& a : v)
text += toBase58(a) + "\n";
};
section(SECTION_PWA_ALLOWLIST, lists.allow);
section(SECTION_PWA_BLOCKLIST, lists.block);
return text;
}
// Replace the file at `path` (through a symlink: its target) with `text`:
// write a sibling temporary with the old file's mode, fsync it and rename it
// over. Returns why that failed, if it did; the old file is then untouched.
static std::optional<std::string>
replaceFile(boost::filesystem::path path, std::string const& text)
{
boost::system::error_code ec;
if (boost::filesystem::is_symlink(path, ec))
path = boost::filesystem::canonical(path, ec);
if (ec)
return path.string() + ": " + ec.message();
auto const tmp = path.string() + ".tmp";
int const fd =
::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
if (fd < 0)
return tmp + ": " + std::strerror(errno);
std::optional<std::string> err;
auto const check = [&](bool ok, char const* what) {
if (!ok && !err)
err = tmp + ": " + what + ": " + std::strerror(errno);
};
struct stat old;
if (::stat(path.c_str(), &old) == 0)
check(::fchmod(fd, old.st_mode & 07777) == 0, "fchmod");
for (std::size_t done = 0; !err && done < text.size();)
{
auto const n = ::write(fd, text.data() + done, text.size() - done);
if (n < 0 && errno == EINTR)
continue;
check(n > 0, "write");
if (n > 0)
done += static_cast<std::size_t>(n);
}
if (!err)
check(::fsync(fd) == 0, "fsync");
check(::close(fd) == 0, "close");
if (!err)
check(::rename(tmp.c_str(), path.c_str()) == 0, "rename");
if (err)
::unlink(tmp.c_str());
return err;
}
Json::Value
ServerHandler::pwaListsEdit(
PWALists const& add,
PWALists const& remove,
bool reload)
{
using List = std::set<AccountID> PWALists::*;
static constexpr List lists[] = {&PWALists::allow, &PWALists::block};
auto const name = [](List l) {
return std::string(l == &PWALists::allow ? "allow" : "block");
};
auto const& path = setup_.pwaListsFile;
bool const edit = !(add == PWALists{} && remove == PWALists{});
if ((edit || reload) && path.empty())
return RPC::make_error(
rpcNOT_SUPPORTED,
"No lists file to edit: set [" SECTION_PWA_LIST_FILE "]");
std::lock_guard editLock(pwaEditMutex_);
auto const current = pwaPolicy();
auto const& cfg = current->cfg;
for (auto const l : lists)
for (auto const& a : remove.*l)
{
if ((add.*l).count(a) > 0)
return RPC::make_param_error(
toBase58(a) + " is in both " + name(l) + " and un" +
name(l));
if ((cfg.*l).count(a) > 0)
return RPC::make_param_error(
toBase58(a) + " is on [pwa_" + name(l) +
"list] in the cfg file, which this cannot change");
}
// Every edit starts from the file as it is now, so hand edits made since
// the last write are kept rather than overwritten.
PWALists onDisk = current->file;
if (edit || reload)
if (auto const why = readPWAListsFile(path, onDisk))
return RPC::make_error(rpcINTERNAL, *why);
PWAPolicy next = *current;
next.file = onDisk;
for (auto const l : lists)
{
for (auto const& a : remove.*l)
(next.file.*l).erase(a);
for (auto const& a : add.*l)
if ((cfg.*l).count(a) == 0)
(next.file.*l).insert(a);
}
if (auto const both = next.conflicts(); !both.empty())
return RPC::make_param_error(
toBase58(*both.begin()) + " would be on both lists");
// Persist first: if the write fails nothing has changed.
if (next.file != onDisk)
if (auto const why = replaceFile(path, pwaListsText(next.file)))
return RPC::make_error(
rpcINTERNAL, "Cannot write the lists file: " + *why);
bool const changed = next != *current;
auto published = current;
if (changed)
{
published = std::make_shared<PWAPolicy const>(std::move(next));
std::lock_guard lock(pwaPolicyMutex_);
pwaPolicy_ = published;
}
auto const& shown = *published;
Json::Value ret(Json::objectValue);
auto const put = [&](char const* key, auto const& v) {
Json::Value& out = ret[key] = Json::arrayValue;
for (auto const& a : v)
out.append(toBase58(a));
};
put("allowlist", shown.file.allow);
put("blocklist", shown.file.block);
if (!shown.cfg.allow.empty())
put("cfg_allowlist", shown.cfg.allow);
if (!shown.cfg.block.empty())
put("cfg_blocklist", shown.cfg.block);
if (!shown.providers.empty())
put("providers", shown.providers);
if (!path.empty())
ret["file"] = path.string();
ret["changed"] = changed;
return ret;
}
void
ServerHandler::queuePWALookup(
std::shared_ptr<Session> const& session,
PWALookup const& lookup)
{
auto const& port = session->port();
if (port.pwa_blocklist.count(lookup.account) > 0 ||
(!port.pwa_allowlist.empty() &&
port.pwa_allowlist.count(lookup.account) == 0))
// Refuse early what the policy settles without a ledger; providers are
// consulted in the job, against the validated ledger.
if (auto const v = pwaPolicy()->verdict(nullptr, lookup.account); v && !*v)
{
++pwaStats_.outcomes[std::size_t(Outcome::blocked)];
return pwaError(*session, 403, lookup.head);
@@ -930,6 +1179,11 @@ ServerHandler::processPWARequest(
if (!ledger)
{
}
else if (!*pwaPolicy()->verdict(ledger.get(), account))
{
outcome = Outcome::blocked;
status = 403;
}
else if (!lookup.redirectTail.empty())
{
if (index)
@@ -2112,32 +2366,8 @@ parse_Ports(Config const& config, std::ostream& log)
}
// PWA ports host third-party content and must only ever be reached
// through a reverse proxy. [pwa_allowlist] and [pwa_blocklist], one
// r-address per line, apply to all of them.
auto accounts = [&](char const* name) {
std::set<AccountID> out;
for (auto const& line : config.section(name).values())
{
auto const account = parseBase58<AccountID>(line);
if (!account || account->isZero())
{
log << "Invalid r-address '" << line << "' in [" << name << "]";
Throw<std::exception>();
}
out.insert(*account);
}
return out;
};
auto const allowlist = accounts(SECTION_PWA_ALLOWLIST);
auto const blocklist = accounts(SECTION_PWA_BLOCKLIST);
for (auto const& a : allowlist)
if (blocklist.count(a) > 0)
{
log << "Account " << toBase58(a)
<< " is in both [" SECTION_PWA_ALLOWLIST
"] and [" SECTION_PWA_BLOCKLIST "]";
Throw<std::exception>();
}
// through a reverse proxy. Which accounts they serve is node-global,
// not per port: see setup_PWAPolicy.
for (auto& p : result)
{
auto fail = [&](char const* why) {
@@ -2175,8 +2405,6 @@ parse_Ports(Config const& config, std::ostream& log)
!p.user.empty() || !p.password.empty() || !p.ssl_key.empty() ||
!p.ssl_cert.empty() || !p.ssl_chain.empty())
fail("pwa port cannot have admin, credentials or TLS");
p.pwa_allowlist = allowlist;
p.pwa_blocklist = blocklist;
}
if (config.standalone())
@@ -2255,11 +2483,74 @@ setup_Overlay(ServerHandler::Setup& setup)
setup.overlay = {iter->ip, iter->port};
}
// The three sources of the PWA policy, one r-address per line each:
// [pwa_allowlist] and [pwa_blocklist] in the cfg file (fixed) and in the
// lists file (edited by the pwa_list RPC), and [pwa_list_providers] in the
// cfg file. The lists file is opt-in: the one named by [pwa_list_file],
// relative to the cfg file's directory. It need not exist yet (the RPC
// creates it), but its directory must.
static void
setup_PWAPolicy(
Config const& config,
ServerHandler::Setup& setup,
std::ostream& log)
{
auto const fail = [&](std::string const& why) {
log << why;
Throw<std::exception>();
};
auto const read = [&](char const* name, auto&& put) {
for (auto const& line : config.section(name).values())
{
auto const a = parseBase58<AccountID>(line);
if (!a || a->isZero())
fail("Invalid r-address '" + line + "' in [" + name + "]");
put(*a);
}
};
auto& policy = setup.pwaPolicy;
read(SECTION_PWA_ALLOWLIST, [&](auto const& a) {
policy.cfg.allow.insert(a);
});
read(SECTION_PWA_BLOCKLIST, [&](auto const& a) {
policy.cfg.block.insert(a);
});
read(SECTION_PWA_LIST_PROVIDERS, [&](auto const& a) {
policy.providers.push_back(a);
});
auto& path = setup.pwaListsFile;
if (config.exists(SECTION_PWA_LIST_FILE))
{
path = config.section(SECTION_PWA_LIST_FILE).legacy();
if (path.empty())
fail("Invalid path in [" SECTION_PWA_LIST_FILE "]");
if (path.is_relative() && !config.CONFIG_DIR.empty())
path = config.CONFIG_DIR / path;
// Catch a mistyped path now rather than at the first edit.
boost::system::error_code ec;
auto const dir = boost::filesystem::absolute(path).parent_path();
if (!boost::filesystem::is_directory(dir, ec))
fail(
"[" SECTION_PWA_LIST_FILE "]: no such directory: " +
dir.string());
if (auto const why = readPWAListsFile(path, policy.file))
fail(*why);
}
if (auto const both = policy.conflicts(); !both.empty())
fail(
"Account " + toBase58(*both.begin()) +
" is in both [" SECTION_PWA_ALLOWLIST
"] and [" SECTION_PWA_BLOCKLIST "]");
}
ServerHandler::Setup
setup_ServerHandler(Config const& config, std::ostream&& log)
{
ServerHandler::Setup setup;
setup.ports = parse_Ports(config, log);
setup_PWAPolicy(config, setup, log);
if (config.UDP_HIGHWAY)
{
+2
View File
@@ -117,6 +117,8 @@ doPing(RPC::JsonContext&);
Json::Value
doPWAInfo(RPC::JsonContext&);
Json::Value
doPWAList(RPC::JsonContext&);
Json::Value
doPWAValidate(RPC::JsonContext&);
Json::Value
doPrint(RPC::JsonContext&);
+83
View File
@@ -0,0 +1,83 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/ServerHandler.h>
#include <xrpl/protocol/ErrorCodes.h>
namespace ripple {
/** pwa_list: the PWA allowlist and blocklist. Admin only.
"allow" and "block" put r-addresses on that list, "unallow" and
"unblock" take them off it; each is an r-address or an array of them.
Removals apply first, so {"unblock": a, "allow": a} moves a in one step,
and the request applies whole or not at all. Changes are written to the
lists file ([pwa_list_file]) before they take effect, starting from the
file as it is now so hand edits are kept; {"reload": true} just rereads
it. Entries from the cfg file are fixed.
Returns the lists file's "allowlist" and "blocklist", the cfg file's
"cfg_allowlist" and "cfg_blocklist" when non-empty, the "file" path and
whether anything "changed". Every pwa port applies the union.
*/
Json::Value
doPWAList(RPC::JsonContext& context)
{
auto const& params = context.params;
PWALists add, remove;
std::pair<char const*, std::set<AccountID>*> const fields[] = {
{"allow", &add.allow},
{"block", &add.block},
{"unallow", &remove.allow},
{"unblock", &remove.block}};
for (auto const& [key, out] : fields)
{
if (!params.isMember(key))
continue;
Json::Value accounts = params[key];
if (accounts.isString())
{
accounts = Json::arrayValue;
accounts.append(params[key]);
}
if (!accounts.isArray())
return RPC::expected_field_error(key, "r-address or array");
for (auto const& v : accounts)
{
auto const a = v.isString() ? parseBase58<AccountID>(v.asString())
: std::nullopt;
if (!a || a->isZero())
return RPC::invalid_field_error(key);
out->insert(*a);
}
}
bool reload = false;
if (params.isMember("reload"))
{
if (!params["reload"].isBool())
return RPC::expected_field_error("reload", "bool");
reload = params["reload"].asBool();
}
return context.app.getServerHandler().pwaListsEdit(add, remove, reload);
}
} // namespace ripple