mirror of
https://github.com/Xahau/xahaud.git
synced 2026-10-15 08:18:48 +00:00
Compare commits
41 Commits
pqdsa
...
pwabootloa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59aca20962 | ||
|
|
582080f662 | ||
|
|
d86f8876d9 | ||
|
|
4c47b50852 | ||
|
|
78a5b01ada | ||
|
|
87345be657 | ||
|
|
4b30492219 | ||
|
|
3b60258c38 | ||
|
|
c1cc177989 | ||
|
|
d00b16a137 | ||
|
|
34c215e132 | ||
|
|
59a123c214 | ||
|
|
184a4617f1 | ||
|
|
98636ac6b0 | ||
|
|
4a6eca2a3a | ||
|
|
b01668d31b | ||
|
|
41018a79a3 | ||
|
|
14b1fdf06d | ||
|
|
26388ec284 | ||
|
|
069e06462f | ||
|
|
d1324b19b6 | ||
|
|
b1150782e6 | ||
|
|
2e34ba2ece | ||
|
|
a553b352c4 | ||
|
|
9eae5c4865 | ||
|
|
8e6bd71629 | ||
|
|
44264cc209 | ||
|
|
c7865f0588 | ||
|
|
600b05ec6b | ||
|
|
4c3e2ed76e | ||
|
|
d0113d6c94 | ||
|
|
bbd46c49ea | ||
|
|
9a557dd147 | ||
|
|
4f38ae3ea3 | ||
|
|
7392d726c5 | ||
|
|
d48b4ef2c7 | ||
|
|
77f0a6dc27 | ||
|
|
f4523aa9b3 | ||
|
|
8350ec92a3 | ||
|
|
ad62a359f3 | ||
|
|
2637f6ae26 |
@@ -166,10 +166,13 @@
|
||||
# ws Websockets
|
||||
# wss Secure Websockets
|
||||
# peer Peer Protocol
|
||||
# pwa On-ledger AppLoader documents (featurePWALoader), for a
|
||||
# reverse proxy only. Read "pwa ports" below first.
|
||||
#
|
||||
# Restrictions:
|
||||
#
|
||||
# Only one port may be configured to support the peer protocol.
|
||||
# The pwa protocol cannot be combined with any other protocol.
|
||||
# A port cannot have websocket and non websocket protocols at the
|
||||
# same time. It is possible have both Websockets and Secure Websockets
|
||||
# together in one port.
|
||||
@@ -264,6 +267,119 @@
|
||||
# "secure_gateway" connections, then they will be treated as
|
||||
# "admin" addresses.
|
||||
#
|
||||
# pwa ports
|
||||
#
|
||||
# A port whose protocol is "pwa" serves accounts' on-ledger AppLoader
|
||||
# documents (featurePWALoader) as HTML. This makes the node a web host
|
||||
# for third-party content: anyone can publish a loader to an account
|
||||
# for about a drop per byte, and nothing in protocol takes one down.
|
||||
# Hosting, and a hosting provider's obligations, are yours.
|
||||
#
|
||||
# One host per account, on a zone used for nothing else:
|
||||
#
|
||||
# https://pwa<account-index>.<domain>/<r-address>
|
||||
# https://<domain>/<r-address> 301 to the above
|
||||
# https://<custom host>/ see custom_domains
|
||||
#
|
||||
# The first label of the Host header is "pwa" followed by the decimal
|
||||
# AccountIndex of the account (an account created before AccountIndex
|
||||
# existed has no host); the rest must be one of the port's "domain"
|
||||
# values; the path must be that account's exact r-address. One
|
||||
# trailing slash and a query string are ignored; anything else is
|
||||
# 404. The bare zone never serves a document (every page would share
|
||||
# one origin): it redirects, keeping :port and query.
|
||||
# Never set cookies at or above the zone and never serve your own
|
||||
# site from it. Wildcard DNS and a wildcard certificate cover it.
|
||||
#
|
||||
# Rules enforced at startup and on every request:
|
||||
#
|
||||
# - "pwa" is the port's only protocol; no admin, user/password or
|
||||
# ssl_* keys (terminate TLS at the proxy).
|
||||
# - "domain" is required: the zone(s), comma-separated. The proxy must
|
||||
# forward the Host header unchanged.
|
||||
# - "secure_gateway" is required and may not cover every address.
|
||||
# Every request must come from it carrying X-Forwarded-For, whose
|
||||
# RIGHTMOST entry (the one the proxy wrote) is the client. The
|
||||
# "Forwarded" header is ignored.
|
||||
# - Clients are metered per IPv4 address, per IPv6 /64; a miss costs
|
||||
# what a hit does; over-use is answered 503 until it decays.
|
||||
# - GET and HEAD only; every response closes the connection; only
|
||||
# validated state is served, with an ETag (304 on If-None-Match).
|
||||
# X-NetworkID, X-LedgerSeq, X-LedgerHash and X-PWASeq name the
|
||||
# validated ledger and AppLoaderSequence the document came from, so
|
||||
# a client (a browser extension, say) can vet it against a second
|
||||
# node with the pwa_validate RPC (account, hash of the bytes, host).
|
||||
# - Lookups run on at most two job queue threads, below client RPC;
|
||||
# beyond a bounded backlog they are refused with 503. Put a cache in
|
||||
# front (the ETag makes revalidation cheap).
|
||||
# - 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]).
|
||||
#
|
||||
# Example (also add port_pwa to the [server] list):
|
||||
#
|
||||
# [port_pwa]
|
||||
# port = 8088
|
||||
# ip = 127.0.0.1
|
||||
# protocol = pwa
|
||||
# secure_gateway = 127.0.0.1
|
||||
# domain = pwapp.example
|
||||
#
|
||||
# with nginx in front, TLS terminated there, Host passed through:
|
||||
#
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name pwapp.example ~^pwa[0-9]+\.pwapp\.example$;
|
||||
# ssl_certificate /etc/ssl/wildcard.pwapp.example.pem;
|
||||
# ssl_certificate_key /etc/ssl/wildcard.pwapp.example.key;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8088;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# custom_domains = 0 | 1
|
||||
#
|
||||
# Default 0. With 1, a request whose Host is outside every zone is
|
||||
# served as the account named by DNS, so a domain owner can point
|
||||
# their own name at your proxy:
|
||||
#
|
||||
# app.example.org. CNAME pwa-proxy.your.example.
|
||||
# _xahau-pwa.app.example.org. TXT "rAbC..."
|
||||
#
|
||||
# The record lives on the _xahau-pwa label beneath the host because
|
||||
# the host is usually a CNAME to your proxy. Exactly one address must
|
||||
# be present there (other TXT strings are ignored; two addresses count
|
||||
# as none). The named account must consent: its Domain field must
|
||||
# equal the host or a parent of it (ignoring case), else 503. Lists
|
||||
# apply as at the account's own host; only the root path is a
|
||||
# document. No usable record is 404 (cached 15 minutes), a resolver
|
||||
# failure 503 (cached 60 s), a record is cached for its TTL (clamped
|
||||
# to 60..3600 s). Lookups run on their own job queue threads, at most
|
||||
# four at a time and one per host; a request that misses the cache is
|
||||
# answered 429 with Retry-After and a refreshing page rather than
|
||||
# waiting, so a flood of Host headers cannot tie up the node. The
|
||||
# admin RPC pwa_info with "flush_dns": true drops the cache. Your
|
||||
# proxy must accept, and hold certificates for, the hosts you will
|
||||
# serve (e.g. Caddy on-demand TLS); xahaud only sees the Host header.
|
||||
#
|
||||
# resolver = <IPv4 address>[:port]
|
||||
#
|
||||
# The recursive resolver for custom_domains; default is the system
|
||||
# resolver. xahaud does not validate DNSSEC, so prefer a validating
|
||||
# resolver on the same host.
|
||||
#
|
||||
# Documents are served with "Content-Security-Policy: sandbox
|
||||
# allow-scripts ...": an opaque origin that cannot read anything from
|
||||
# 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>
|
||||
|
||||
@@ -97,6 +97,7 @@ target_link_libraries (ripple_syslibs
|
||||
crypt32
|
||||
>
|
||||
$<$<NOT:$<BOOL:${MSVC}>>:dl>
|
||||
$<$<NOT:$<BOOL:${MSVC}>>:resolv>
|
||||
$<$<NOT:$<OR:$<BOOL:${MSVC}>,$<BOOL:${APPLE}>>>:rt>)
|
||||
|
||||
if (NOT MSVC)
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
|
||||
#include "error.h"
|
||||
#include "extern.h"
|
||||
#include "sfcodes.h"
|
||||
#include "macro.h"
|
||||
#include "sfcodes.h"
|
||||
#include "tts.h"
|
||||
|
||||
#include "ls_flags.h"
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
#define sfRewardTime ((2U << 16U) + 98U)
|
||||
#define sfRewardLgrFirst ((2U << 16U) + 99U)
|
||||
#define sfRewardLgrLast ((2U << 16U) + 100U)
|
||||
#define sfAppLoaderSequence ((2U << 16U) + 101U)
|
||||
#define sfIndexNext ((3U << 16U) + 1U)
|
||||
#define sfIndexPrevious ((3U << 16U) + 2U)
|
||||
#define sfBookNode ((3U << 16U) + 3U)
|
||||
@@ -222,6 +223,7 @@
|
||||
#define sfProvider ((7U << 16U) + 30U)
|
||||
#define sfMPTokenMetadata ((7U << 16U) + 31U)
|
||||
#define sfCredentialType ((7U << 16U) + 32U)
|
||||
#define sfAppLoader ((7U << 16U) + 96U)
|
||||
#define sfHookName ((7U << 16U) + 97U)
|
||||
#define sfRemarkValue ((7U << 16U) + 98U)
|
||||
#define sfRemarkName ((7U << 16U) + 99U)
|
||||
|
||||
66
include/xrpl/protocol/AppLoader.h
Normal file
66
include/xrpl/protocol/AppLoader.h
Normal file
@@ -0,0 +1,66 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 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.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#ifndef RIPPLE_PROTOCOL_APPLOADER_H_INCLUDED
|
||||
#define RIPPLE_PROTOCOL_APPLOADER_H_INCLUDED
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
/** Validation of an AppLoader document (sfAppLoader), the bootstrap page of
|
||||
a Progressive Web App served from an account.
|
||||
|
||||
Consensus cannot afford a real HTML parser, so this is a shallow sanity
|
||||
gate and nothing more. A document must be non-empty and at most
|
||||
maxAppLoaderLength bytes; well-formed UTF-8; free of C0 control
|
||||
characters other than TAB, LF, FF and CR, and of DEL; begin, after an
|
||||
optional BOM and whitespace, with an HTML doctype or an <html start tag;
|
||||
and contain an <html start tag followed later by a </html> end tag with
|
||||
nothing but whitespace after the last one. Nothing parses comments,
|
||||
scripts or attributes, and content is not policed.
|
||||
|
||||
Consensus critical: a change to what it accepts needs an amendment.
|
||||
*/
|
||||
namespace appLoader {
|
||||
|
||||
enum class Result : std::uint8_t {
|
||||
ok = 0,
|
||||
empty,
|
||||
tooLarge,
|
||||
badUTF8,
|
||||
badControlChar,
|
||||
noDoctype, // does not open with a doctype or <html tag
|
||||
noHtmlElement, // no <html start tag
|
||||
unclosed, // no </html> end tag after it
|
||||
trailingGarbage, // non-whitespace after the last </html>
|
||||
};
|
||||
|
||||
/** Total: every input yields a result. `data` may be null iff size is 0. */
|
||||
Result
|
||||
validate(std::uint8_t const* data, std::size_t size);
|
||||
|
||||
char const*
|
||||
to_string(Result r);
|
||||
|
||||
} // namespace appLoader
|
||||
} // namespace ripple
|
||||
|
||||
#endif
|
||||
@@ -328,6 +328,14 @@ xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq);
|
||||
Keylet
|
||||
did(AccountID const& account) noexcept;
|
||||
|
||||
/** An AppLoader belonging to an account.
|
||||
|
||||
There is at most one per account, so the key is derived from the owner's
|
||||
AccountID alone.
|
||||
*/
|
||||
Keylet
|
||||
appLoader(AccountID const& account) noexcept;
|
||||
|
||||
Keylet
|
||||
oracle(AccountID const& account, std::uint32_t const& documentID) noexcept;
|
||||
|
||||
@@ -416,7 +424,7 @@ struct keyletDesc
|
||||
|
||||
// This list should include all of the keylet functions that take a single
|
||||
// AccountID parameter.
|
||||
std::array<keyletDesc<AccountID const&>, 6> const directAccountKeylets{
|
||||
std::array<keyletDesc<AccountID const&>, 7> const directAccountKeylets{
|
||||
{{&keylet::account, jss::AccountRoot, false},
|
||||
{&keylet::ownerDir, jss::DirectoryNode, true},
|
||||
{&keylet::signers, jss::SignerList, true},
|
||||
@@ -424,7 +432,8 @@ std::array<keyletDesc<AccountID const&>, 6> const directAccountKeylets{
|
||||
// test it anyway, since the invariant checks for it.
|
||||
{&keylet::nftpage_min, jss::NFTokenPage, true},
|
||||
{&keylet::nftpage_max, jss::NFTokenPage, true},
|
||||
{&keylet::did, jss::DID, true}}};
|
||||
{&keylet::did, jss::DID, true},
|
||||
{&keylet::appLoader, jss::AppLoader, true}}};
|
||||
|
||||
MPTID
|
||||
makeMptID(std::uint32_t sequence, AccountID const& account);
|
||||
|
||||
@@ -96,6 +96,25 @@ std::size_t constexpr maxDIDAttestationLength = 256;
|
||||
/** The maximum length of a domain */
|
||||
std::size_t constexpr maxDomainLength = 256;
|
||||
|
||||
/** The maximum length of an AppLoader document.
|
||||
|
||||
The AppLoader holds the bootstrap document of a Progressive Web App
|
||||
served from an account. It is deliberately capped: the blob lives in a
|
||||
ledger object that carries no owner reserve at all, so the only things
|
||||
standing between it and unbounded ledger growth are this limit and the
|
||||
per-byte transaction fee below.
|
||||
*/
|
||||
std::size_t constexpr maxAppLoaderLength = 4096;
|
||||
|
||||
/** Drops of transaction fee charged per byte of AppLoader document.
|
||||
|
||||
Storage is paid for through the fee rather than through an owner
|
||||
reserve, so that a loader can be published by an account sitting at its
|
||||
reserve floor. The fee is charged on every AccountSet that carries the
|
||||
field, including overwrites.
|
||||
*/
|
||||
std::uint64_t constexpr appLoaderFeeDropsPerByte = 1;
|
||||
|
||||
/** The maximum length of a URI inside a Credential */
|
||||
std::size_t constexpr maxCredentialURILength = 256;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
// If you add an amendment here, then do not forget to increment `numFeatures`
|
||||
// in include/xrpl/protocol/Feature.h.
|
||||
|
||||
XRPL_FEATURE(PWALoader, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (20261005, Supported::yes, VoteBehavior::DefaultYes)
|
||||
XRPL_FEATURE(EscrowDestinationCancel, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (20260929, Supported::yes, VoteBehavior::DefaultNo)
|
||||
|
||||
@@ -142,6 +142,22 @@ LEDGER_ENTRY(ltIMPORT_VLSEQ, 0x0049, ImportVLSequence, import_vlseq, ({
|
||||
{sfPreviousTxnLgrSeq, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
/** The bootstrap document of a Progressive Web App (featurePWALoader).
|
||||
|
||||
At most one per account, keyed on the owner alone (keylet::appLoader).
|
||||
It is in no owner directory and takes no reserve: its storage is paid
|
||||
for by a per-byte fee on the AccountSet that writes it, and
|
||||
DeleteAccount erases it explicitly. AppLoaderSequence equals the owner's
|
||||
AccountRoot AppLoaderSequence as of the AccountSet that wrote it.
|
||||
*/
|
||||
LEDGER_ENTRY(ltAPP_LOADER, 0x004c, AppLoader, app_loader, ({
|
||||
{sfOwner, soeREQUIRED},
|
||||
{sfAppLoader, soeREQUIRED},
|
||||
{sfAppLoaderSequence, soeREQUIRED},
|
||||
{sfPreviousTxnID, soeREQUIRED},
|
||||
{sfPreviousTxnLgrSeq, soeREQUIRED},
|
||||
}))
|
||||
|
||||
/** The ledger object which tracks the current negative UNL state.
|
||||
|
||||
\note This is a singleton: only one such object exists in the ledger.
|
||||
@@ -262,6 +278,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
|
||||
{sfHookStateScale, soeOPTIONAL},
|
||||
{sfCron, soeOPTIONAL},
|
||||
{sfAMMID, soeOPTIONAL},
|
||||
{sfAppLoaderSequence, soeOPTIONAL},
|
||||
{sfManifestID, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ TYPED_SFIELD(sfImportSequence, UINT32, 97)
|
||||
TYPED_SFIELD(sfRewardTime, UINT32, 98)
|
||||
TYPED_SFIELD(sfRewardLgrFirst, UINT32, 99)
|
||||
TYPED_SFIELD(sfRewardLgrLast, UINT32, 100)
|
||||
TYPED_SFIELD(sfAppLoaderSequence, UINT32, 101)
|
||||
|
||||
// 64-bit integers (common)
|
||||
TYPED_SFIELD(sfIndexNext, UINT64, 1)
|
||||
@@ -294,6 +295,7 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
|
||||
TYPED_SFIELD(sfProvider, VL, 30)
|
||||
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
|
||||
TYPED_SFIELD(sfCredentialType, VL, 32)
|
||||
TYPED_SFIELD(sfAppLoader, VL, 96)
|
||||
TYPED_SFIELD(sfHookName, VL, 97)
|
||||
TYPED_SFIELD(sfRemarkValue, VL, 98)
|
||||
TYPED_SFIELD(sfRemarkName, VL, 99)
|
||||
|
||||
@@ -74,6 +74,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, ({
|
||||
{sfTickSize, soeOPTIONAL},
|
||||
{sfNFTokenMinter, soeOPTIONAL},
|
||||
{sfHookStateScale, soeOPTIONAL},
|
||||
{sfAppLoader, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
/** This transaction type cancels an existing escrow. */
|
||||
|
||||
@@ -610,6 +610,7 @@ JSS(reserve_base_native); // out: NetworkOPs
|
||||
JSS(reserve_inc); // out: NetworkOPs
|
||||
JSS(reserve_inc_xrp); // out: NetworkOPs
|
||||
JSS(reserve_inc_native); // out: NetworkOPs
|
||||
JSS(reset); // in/out: PWAInfo
|
||||
JSS(response); // websocket
|
||||
JSS(result); // RPC
|
||||
JSS(ripple_lines); // out: NetworkOPs
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
#include <xrpl/basics/BasicConfig.h>
|
||||
#include <xrpl/beast/net/IPEndpoint.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <boost/asio/ip/network_v4.hpp>
|
||||
#include <boost/asio/ip/network_v6.hpp>
|
||||
@@ -57,6 +58,12 @@ struct Port
|
||||
std::vector<boost::asio::ip::network_v6> admin_nets_v6;
|
||||
std::vector<boost::asio::ip::network_v4> secure_gateway_nets_v4;
|
||||
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;
|
||||
std::string password;
|
||||
std::string admin_user;
|
||||
@@ -126,6 +133,9 @@ struct ParsedPort
|
||||
std::vector<boost::asio::ip::network_v6> admin_nets_v6;
|
||||
std::vector<boost::asio::ip::network_v4> secure_gateway_nets_v4;
|
||||
std::vector<boost::asio::ip::network_v6> secure_gateway_nets_v6;
|
||||
std::set<std::string> pwa_domains;
|
||||
bool pwa_custom_domains = false;
|
||||
std::optional<beast::IP::Endpoint> pwa_resolver;
|
||||
};
|
||||
|
||||
void
|
||||
|
||||
@@ -298,7 +298,7 @@ Door<Handler>::Door(
|
||||
port_.protocol.count("peer") > 0)
|
||||
, plain_(
|
||||
port_.protocol.count("http") > 0 || port_.protocol.count("ws") > 0 ||
|
||||
port_.protocol.count("ws2"))
|
||||
port_.protocol.count("ws2") || port_.protocol.count("pwa"))
|
||||
{
|
||||
reOpen();
|
||||
}
|
||||
|
||||
180
src/libxrpl/protocol/AppLoader.cpp
Normal file
180
src/libxrpl/protocol/AppLoader.cpp
Normal file
@@ -0,0 +1,180 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 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 <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/UTF8.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace ripple {
|
||||
namespace appLoader {
|
||||
|
||||
namespace {
|
||||
|
||||
// HTML "ASCII whitespace": TAB, LF, FF, CR, SPACE.
|
||||
inline bool
|
||||
isSpace(std::uint8_t c) noexcept
|
||||
{
|
||||
return c == 0x09 || c == 0x0A || c == 0x0C || c == 0x0D || c == 0x20;
|
||||
}
|
||||
|
||||
inline std::uint8_t
|
||||
lower(std::uint8_t c) noexcept
|
||||
{
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<std::uint8_t>(c + 32) : c;
|
||||
}
|
||||
|
||||
// Case-insensitive match of lower-case `lit` at `pos`; never reads past
|
||||
// the end.
|
||||
bool
|
||||
matchAt(
|
||||
std::uint8_t const* data,
|
||||
std::size_t size,
|
||||
std::size_t pos,
|
||||
std::string_view lit) noexcept
|
||||
{
|
||||
if (pos > size || size - pos < lit.size())
|
||||
return false;
|
||||
for (std::size_t i = 0; i < lit.size(); ++i)
|
||||
if (lower(data[pos + i]) != static_cast<std::uint8_t>(lit[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t
|
||||
findFrom(
|
||||
std::uint8_t const* data,
|
||||
std::size_t size,
|
||||
std::size_t from,
|
||||
std::string_view lit) noexcept
|
||||
{
|
||||
for (std::size_t i = from; i + lit.size() <= size; ++i)
|
||||
if (matchAt(data, size, i, lit))
|
||||
return i;
|
||||
return size;
|
||||
}
|
||||
|
||||
// So that "<htmlish>" is not mistaken for "<html>".
|
||||
inline bool
|
||||
isTagNameEnd(std::uint8_t c) noexcept
|
||||
{
|
||||
return isSpace(c) || c == '>' || c == '/';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Result
|
||||
validate(std::uint8_t const* data, std::size_t size)
|
||||
{
|
||||
if (size == 0 || data == nullptr)
|
||||
return Result::empty;
|
||||
if (size > maxAppLoaderLength)
|
||||
return Result::tooLarge;
|
||||
if (!isValidUTF8(Slice(data, size)))
|
||||
return Result::badUTF8;
|
||||
for (std::size_t i = 0; i < size; ++i)
|
||||
if (!isSpace(data[i]) && (data[i] < 0x20 || data[i] == 0x7F))
|
||||
return Result::badControlChar;
|
||||
|
||||
std::size_t begin = 0;
|
||||
if (size >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
|
||||
begin = 3;
|
||||
while (begin < size && isSpace(data[begin]))
|
||||
++begin;
|
||||
if (begin == size)
|
||||
return Result::empty;
|
||||
|
||||
// "<!doctype" followed by whitespace is the only doctype form any
|
||||
// browser treats as standards-mode HTML.
|
||||
bool const doctype = matchAt(data, size, begin, "<!doctype") &&
|
||||
begin + 9 < size && isSpace(data[begin + 9]);
|
||||
bool const html = matchAt(data, size, begin, "<html") && begin + 5 < size &&
|
||||
isTagNameEnd(data[begin + 5]);
|
||||
if (!doctype && !html)
|
||||
return Result::noDoctype;
|
||||
|
||||
std::size_t open = begin;
|
||||
for (;;)
|
||||
{
|
||||
open = findFrom(data, size, open, "<html");
|
||||
if (open == size)
|
||||
return Result::noHtmlElement;
|
||||
if (open + 5 < size && isTagNameEnd(data[open + 5]))
|
||||
break;
|
||||
++open;
|
||||
}
|
||||
|
||||
// The LAST well-formed </html>, so that one quoted inside a script
|
||||
// does not shadow the real one. Linear: each probe resumes one past
|
||||
// the previous match.
|
||||
// closeEnd == 0 means none found: a well-formed </html> always ends
|
||||
// past position 0.
|
||||
std::size_t closeEnd = 0;
|
||||
for (std::size_t probe = open;;)
|
||||
{
|
||||
std::size_t const found = findFrom(data, size, probe, "</html");
|
||||
if (found == size)
|
||||
break;
|
||||
std::size_t j = found + 6;
|
||||
while (j < size && isSpace(data[j]))
|
||||
++j;
|
||||
if (j < size && data[j] == '>')
|
||||
closeEnd = j + 1;
|
||||
probe = found + 1;
|
||||
}
|
||||
if (closeEnd == 0)
|
||||
return Result::unclosed;
|
||||
|
||||
for (std::size_t i = closeEnd; i < size; ++i)
|
||||
if (!isSpace(data[i]))
|
||||
return Result::trailingGarbage;
|
||||
return Result::ok;
|
||||
}
|
||||
|
||||
char const*
|
||||
to_string(Result r)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case Result::ok:
|
||||
return "ok";
|
||||
case Result::empty:
|
||||
return "empty document";
|
||||
case Result::tooLarge:
|
||||
return "document exceeds maxAppLoaderLength";
|
||||
case Result::badUTF8:
|
||||
return "malformed UTF-8";
|
||||
case Result::badControlChar:
|
||||
return "forbidden control character";
|
||||
case Result::noDoctype:
|
||||
return "does not open with a doctype or <html> tag";
|
||||
case Result::noHtmlElement:
|
||||
return "missing <html> start tag";
|
||||
case Result::unclosed:
|
||||
return "missing or misplaced </html> end tag";
|
||||
case Result::trailingGarbage:
|
||||
return "content after </html>";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
} // namespace appLoader
|
||||
} // namespace ripple
|
||||
@@ -81,6 +81,7 @@ enum class LedgerNameSpace : std::uint16_t {
|
||||
IMPORT_VLSEQ = 'I',
|
||||
UNL_REPORT = 'R',
|
||||
CRON = 'L',
|
||||
APP_LOADER = 'W',
|
||||
AMM = 'A',
|
||||
BRIDGE = LEDGER_NAMESPACE2(0x01, 'H'),
|
||||
XCHAIN_CLAIM_ID = 'Q',
|
||||
@@ -614,6 +615,12 @@ did(AccountID const& account) noexcept
|
||||
return {ltDID, indexHash(LedgerNameSpace::DID, account)};
|
||||
}
|
||||
|
||||
Keylet
|
||||
appLoader(AccountID const& account) noexcept
|
||||
{
|
||||
return {ltAPP_LOADER, indexHash(LedgerNameSpace::APP_LOADER, account)};
|
||||
}
|
||||
|
||||
Keylet
|
||||
oracle(AccountID const& account, std::uint32_t const& documentID) noexcept
|
||||
{
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
#include <xrpl/beast/core/LexicalCast.h>
|
||||
#include <xrpl/beast/rfc2616.h>
|
||||
#include <xrpl/server/Port.h>
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <boost/algorithm/string/classification.hpp>
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
#include <boost/algorithm/string/split.hpp>
|
||||
#include <boost/algorithm/string/trim.hpp>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
@@ -66,6 +69,14 @@ operator<<(std::ostream& os, Port const& p)
|
||||
}
|
||||
}
|
||||
|
||||
if (!p.pwa_domains.empty())
|
||||
{
|
||||
os << "domains:";
|
||||
for (auto const& d : p.pwa_domains)
|
||||
os << " " << d;
|
||||
os << ", ";
|
||||
}
|
||||
|
||||
if (p.secure_gateway_nets_v4.size() || p.secure_gateway_nets_v6.size())
|
||||
{
|
||||
os << "secure_gateway nets:";
|
||||
@@ -311,6 +322,64 @@ parse_Port(ParsedPort& port, Section const& section, std::ostream& log)
|
||||
port.secure_gateway_nets_v4,
|
||||
port.secure_gateway_nets_v6);
|
||||
|
||||
// pwa ports: the zone(s) documents are served under, normalised like a
|
||||
// Host header: lower case, no trailing dot, LDH labels of 1..63, with
|
||||
// room left for the longest "pwa<AccountIndex>" label in front.
|
||||
if (auto const optResult = section.get("domain"))
|
||||
{
|
||||
std::vector<std::string> domains;
|
||||
boost::algorithm::split(
|
||||
domains, *optResult, boost::algorithm::is_any_of(","));
|
||||
for (auto& domain : domains)
|
||||
{
|
||||
boost::algorithm::trim(domain);
|
||||
boost::algorithm::to_lower(domain);
|
||||
if (!domain.empty() && domain.back() == '.')
|
||||
domain.pop_back();
|
||||
if (domain.empty())
|
||||
continue;
|
||||
bool valid = domain.size() + 24 <= 253;
|
||||
std::vector<std::string> labels;
|
||||
boost::algorithm::split(
|
||||
labels, domain, boost::algorithm::is_any_of("."));
|
||||
for (auto const& l : labels)
|
||||
valid = valid && !l.empty() && l.size() <= 63 &&
|
||||
l.front() != '-' && l.back() != '-' &&
|
||||
l.find_first_not_of(
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789-") ==
|
||||
std::string::npos;
|
||||
if (!valid)
|
||||
{
|
||||
log << "Invalid value '" << domain << "' for key 'domain' in ["
|
||||
<< section.name()
|
||||
<< "]: expected a DNS zone such as pwapp.example";
|
||||
Throw<std::exception>();
|
||||
}
|
||||
port.pwa_domains.insert(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom domains on pwa ports, and the IPv4 recursive resolver to use for
|
||||
// their TXT records (default: the system resolver).
|
||||
if (auto const v = section.get("custom_domains"))
|
||||
if (!beast::lexicalCastChecked(port.pwa_custom_domains, *v))
|
||||
{
|
||||
log << "Invalid value '" << *v << "' for key 'custom_domains' in ["
|
||||
<< section.name() << "]: expected 0 or 1";
|
||||
Throw<std::exception>();
|
||||
}
|
||||
if (auto const v = section.get("resolver"))
|
||||
{
|
||||
auto const ep = beast::IP::Endpoint::from_string_checked(*v);
|
||||
if (!ep || !ep->is_v4() || ep->address().is_unspecified())
|
||||
{
|
||||
log << "Invalid value '" << *v << "' for key 'resolver' in ["
|
||||
<< section.name() << "]: expected an IPv4 address[:port]";
|
||||
Throw<std::exception>();
|
||||
}
|
||||
port.pwa_resolver = ep->port() ? *ep : ep->at_port(53);
|
||||
}
|
||||
|
||||
set(port.user, "user", section);
|
||||
set(port.password, "password", section);
|
||||
set(port.admin_user, "admin_user", section);
|
||||
|
||||
400
src/test/app/PWALoader_test.cpp
Normal file
400
src/test/app/PWALoader_test.cpp
Normal file
@@ -0,0 +1,400 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 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 <test/jtx.h>
|
||||
#include <xrpl/basics/StringUtilities.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/digest.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ripple {
|
||||
namespace test {
|
||||
|
||||
class PWALoader_test : public beast::unit_test::suite
|
||||
{
|
||||
static appLoader::Result
|
||||
check(std::string const& s)
|
||||
{
|
||||
return appLoader::validate(
|
||||
reinterpret_cast<std::uint8_t const*>(s.data()), s.size());
|
||||
}
|
||||
|
||||
static std::string
|
||||
docOfSize(std::size_t len)
|
||||
{
|
||||
std::string const head = "<!DOCTYPE html><html><body>";
|
||||
std::string const tail = "</body></html>";
|
||||
if (len < head.size() + tail.size())
|
||||
return head + tail;
|
||||
return head + std::string(len - head.size() - tail.size(), 'x') + tail;
|
||||
}
|
||||
|
||||
static std::string
|
||||
doc()
|
||||
{
|
||||
return "<!DOCTYPE html><html><body>hi</body></html>";
|
||||
}
|
||||
|
||||
// AccountSet carrying sfAppLoader.
|
||||
static Json::Value
|
||||
set(jtx::Account const& a, std::string const& d)
|
||||
{
|
||||
auto jt = jtx::noop(a);
|
||||
jt[sfAppLoader.fieldName] = strHex(d);
|
||||
return jt;
|
||||
}
|
||||
|
||||
// validate() is consensus critical: these pin its edges, so a refactor
|
||||
// that moves any of them fails here rather than forking the network.
|
||||
void
|
||||
testValidator()
|
||||
{
|
||||
testcase("validator");
|
||||
using R = appLoader::Result;
|
||||
using namespace std::string_literals;
|
||||
|
||||
for (auto const& s :
|
||||
{"<!DOCTYPE html><html></html>"s,
|
||||
"<!doctype html><html></html>"s,
|
||||
"<html></html>"s,
|
||||
"<HTML></HTML>"s,
|
||||
"<html lang=\"en\" class=\"root\"></html>"s,
|
||||
" \n\t<html></html>\n\n "s,
|
||||
"<html></html >"s,
|
||||
"<html></html\n>"s,
|
||||
"\xEF\xBB\xBF<html></html>"s,
|
||||
"\xEF\xBB\xBF<!DOCTYPE html><html></html>"s,
|
||||
"<html><body>\xE6\xBC\xA2\xE5\xAD\x97</body></html>"s,
|
||||
"<html><script>var s=\"</html>\";</script></html>"s,
|
||||
"<html>\t\n\r\f</html>"s,
|
||||
"<html></html> \n"s,
|
||||
"<!DOCTYPE html>\n<!-- c --><html></html>"s,
|
||||
"<html>\xC2\x85</html>"s, // C1 controls are not rejected
|
||||
docOfSize(maxAppLoaderLength)})
|
||||
BEAST_EXPECTS(check(s) == R::ok, s);
|
||||
|
||||
std::vector<std::pair<std::string, R>> const bad{
|
||||
{""s, R::empty},
|
||||
{" \n"s, R::empty},
|
||||
{"\xEF\xBB\xBF"s, R::empty},
|
||||
{docOfSize(maxAppLoaderLength + 1), R::tooLarge},
|
||||
{"<html>\xC0\xAF</html>"s, R::badUTF8},
|
||||
{"<html>\xED\xA0\x80</html>"s, R::badUTF8},
|
||||
{"<html>\xEF\xBF\xBE</html>"s, R::badUTF8},
|
||||
{"<html>\xE6\xBC</html>"s, R::badUTF8},
|
||||
{"<html></html>\xE6"s, R::badUTF8},
|
||||
{"<html>\0</html>"s, R::badControlChar},
|
||||
{"<html>\x1B</html>"s, R::badControlChar},
|
||||
{"<html>\x7F</html>"s, R::badControlChar},
|
||||
{"hello"s, R::noDoctype},
|
||||
{"<div>frag</div>"s, R::noDoctype},
|
||||
{"<htmlish></html>"s, R::noDoctype},
|
||||
{"<!DOCTYPEhtml><html></html>"s, R::noDoctype},
|
||||
{"<!DOCTYPE"s, R::noDoctype},
|
||||
{"<!DOCTYPE html>"s, R::noHtmlElement},
|
||||
{"<!DOCTYPE html><htmlish></html>"s, R::noHtmlElement},
|
||||
{"<html>"s, R::unclosed},
|
||||
{"<html></html"s, R::unclosed},
|
||||
{"<html></htmlx>"s, R::unclosed},
|
||||
{"<html></html>trailing"s, R::trailingGarbage},
|
||||
{"<html></html><p>"s, R::trailingGarbage},
|
||||
{"<html></html>\xC2\xA0"s, R::trailingGarbage}, // NBSP
|
||||
};
|
||||
for (auto const& [s, r] : bad)
|
||||
BEAST_EXPECTS(
|
||||
check(s) == r, s + ": " + appLoader::to_string(check(s)));
|
||||
}
|
||||
|
||||
void
|
||||
testEnabled()
|
||||
{
|
||||
testcase("amendment gate");
|
||||
using namespace jtx;
|
||||
Env env{*this, supported_amendments() - featurePWALoader};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
env(set(alice, doc()), fee(XRP(1)), ter(temDISABLED));
|
||||
env(set(alice, ""), fee(XRP(1)), ter(temDISABLED));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfAppLoaderSequence));
|
||||
}
|
||||
|
||||
void
|
||||
testSetAndRemove()
|
||||
{
|
||||
testcase("set, overwrite, remove, sequence");
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
auto const kl = keylet::appLoader(alice.id());
|
||||
auto seq = [&] {
|
||||
return (*env.le(alice))[~sfAppLoaderSequence].value_or(0);
|
||||
};
|
||||
|
||||
BEAST_EXPECT(!env.le(kl));
|
||||
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfAppLoaderSequence));
|
||||
|
||||
// Create: the object, with the owner's new sequence; nothing inline
|
||||
// on the AccountRoot, no reserve, not in the owner directory.
|
||||
env(set(alice, doc()), fee(XRP(1)));
|
||||
env.close();
|
||||
auto sle = env.le(kl);
|
||||
if (BEAST_EXPECT(sle))
|
||||
{
|
||||
BEAST_EXPECT((*sle)[sfAppLoader] == makeSlice(doc()));
|
||||
BEAST_EXPECT((*sle)[sfOwner] == alice.id());
|
||||
BEAST_EXPECT((*sle)[sfAppLoaderSequence] == 1);
|
||||
}
|
||||
BEAST_EXPECT(seq() == 1);
|
||||
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfAppLoader));
|
||||
BEAST_EXPECT((*env.le(alice))[sfOwnerCount] == 0);
|
||||
BEAST_EXPECT(
|
||||
env.rpc(
|
||||
"json",
|
||||
"account_objects",
|
||||
R"({"account":")" + alice.human() +
|
||||
R"("})")[jss::result][jss::account_objects]
|
||||
.size() == 0);
|
||||
auto const txid = (*sle)[sfPreviousTxnID];
|
||||
|
||||
// Overwrite: new content, next sequence, rethreaded.
|
||||
env(set(alice, "<html><body>v2</body></html>"), fee(XRP(1)));
|
||||
env.close();
|
||||
sle = env.le(kl);
|
||||
if (BEAST_EXPECT(sle))
|
||||
{
|
||||
BEAST_EXPECT(
|
||||
(*sle)[sfAppLoader] ==
|
||||
makeSlice(std::string("<html><body>v2</body></html>")));
|
||||
BEAST_EXPECT((*sle)[sfAppLoaderSequence] == 2);
|
||||
BEAST_EXPECT((*sle)[sfPreviousTxnID] != txid);
|
||||
}
|
||||
BEAST_EXPECT(seq() == 2);
|
||||
|
||||
// An AccountSet without the field leaves everything alone.
|
||||
env(noop(alice));
|
||||
env.close();
|
||||
BEAST_EXPECT((*env.le(kl))[sfAppLoaderSequence] == 2 && seq() == 2);
|
||||
|
||||
// Remove with an empty blob: the object goes, the sequence still
|
||||
// advances (so a copy of sequence 2 is known to be stale), and
|
||||
// removing again is a no-op that advances it again.
|
||||
env(set(alice, ""), fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(kl) && seq() == 3);
|
||||
env(set(alice, ""), fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(kl) && seq() == 4);
|
||||
|
||||
// Republish: the sequence carries on.
|
||||
env(set(alice, doc()), fee(XRP(1)));
|
||||
env.close();
|
||||
BEAST_EXPECT((*env.le(kl))[sfAppLoaderSequence] == 5 && seq() == 5);
|
||||
|
||||
// ledger_entry reaches it by account or by index.
|
||||
for (auto const& req :
|
||||
{R"({"app_loader":")" + alice.human() + R"("})",
|
||||
R"({"index":")" + to_string(kl.key) + R"("})"})
|
||||
{
|
||||
auto const r = env.rpc("json", "ledger_entry", req)[jss::result];
|
||||
BEAST_EXPECTS(
|
||||
r[jss::node][sfLedgerEntryType.fieldName] == jss::AppLoader &&
|
||||
r[jss::node][sfAppLoaderSequence.fieldName] == 5,
|
||||
req);
|
||||
}
|
||||
BEAST_EXPECT(
|
||||
env.rpc(
|
||||
"json",
|
||||
"ledger_entry",
|
||||
R"({"app_loader":"nope"})")[jss::result][jss::error] ==
|
||||
"malformedAddress");
|
||||
}
|
||||
|
||||
void
|
||||
testFee()
|
||||
{
|
||||
testcase("fee: base plus one drop per byte");
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
auto const base = env.current()->fees().base;
|
||||
auto const kl = keylet::appLoader(alice.id());
|
||||
|
||||
auto const expected = base + XRPAmount{1024};
|
||||
env(set(alice, docOfSize(1024)),
|
||||
fee(expected - XRPAmount{1}),
|
||||
ter(telINSUF_FEE_P));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(kl));
|
||||
auto const before = env.balance(alice);
|
||||
env(set(alice, docOfSize(1024)), fee(expected));
|
||||
env.close();
|
||||
BEAST_EXPECT(
|
||||
env.le(kl) && before - env.balance(alice) == drops(expected));
|
||||
|
||||
// Overwrites pay again; removal is base fee only.
|
||||
env(set(alice, docOfSize(100)),
|
||||
fee(base + XRPAmount{99}),
|
||||
ter(telINSUF_FEE_P));
|
||||
env(set(alice, docOfSize(100)), fee(base + XRPAmount{100}));
|
||||
env(set(alice, ""), fee(base));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(kl));
|
||||
|
||||
// Publishing takes no reserve: an account at its reserve floor can.
|
||||
Account const poor{"poor"};
|
||||
auto const reserve = env.current()->fees().accountReserve(0);
|
||||
env.fund(drops(reserve + base + XRPAmount{100}), noripple(poor));
|
||||
env.close();
|
||||
env(set(poor, docOfSize(100)), fee(base + XRPAmount{100}));
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(keylet::appLoader(poor.id())));
|
||||
BEAST_EXPECT(env.balance(poor) == drops(reserve));
|
||||
env.require(owners(poor, 0));
|
||||
}
|
||||
|
||||
void
|
||||
testMalformed()
|
||||
{
|
||||
testcase("malformed documents are temMALFORMED");
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
for (auto const& d :
|
||||
{std::string("hello"),
|
||||
std::string("<html>"),
|
||||
std::string("<html></html>x"),
|
||||
std::string("<html>\0</html>", 14),
|
||||
std::string("<html>\xC0\xAF</html>"),
|
||||
docOfSize(maxAppLoaderLength + 1)})
|
||||
{
|
||||
env(set(alice, d), fee(XRP(1)), ter(temMALFORMED));
|
||||
env.close();
|
||||
}
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfAppLoaderSequence));
|
||||
}
|
||||
|
||||
void
|
||||
testAccountDelete()
|
||||
{
|
||||
testcase("account delete erases the loader");
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
Account const alice{"alice"}, bob{"bob"};
|
||||
env.fund(XRP(1000), alice, bob);
|
||||
env.close();
|
||||
env(set(alice, doc()), fee(XRP(1)));
|
||||
env.close();
|
||||
for (int i = 0; i < 256; ++i)
|
||||
env.close();
|
||||
// Not in the owner directory, so it cannot block deletion; erased
|
||||
// explicitly, or AccountRootsDeletedClean would fire on the orphan.
|
||||
env(acctdelete(alice, bob),
|
||||
fee(drops(env.current()->fees().increment)));
|
||||
env.close();
|
||||
BEAST_EXPECT(!env.le(alice));
|
||||
BEAST_EXPECT(!env.le(keylet::appLoader(alice.id())));
|
||||
}
|
||||
|
||||
void
|
||||
testValidateRPC()
|
||||
{
|
||||
testcase("pwa_validate");
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(1000), alice);
|
||||
env.close();
|
||||
auto validate = [&](std::string const& d, std::string const& domain) {
|
||||
Json::Value p(Json::objectValue);
|
||||
p[jss::account] = alice.human();
|
||||
p[jss::hash] = to_string(sha512Half(makeSlice(d)));
|
||||
if (!domain.empty())
|
||||
p[jss::domain] = domain;
|
||||
return env.rpc("json", "pwa_validate", to_string(p))[jss::result];
|
||||
};
|
||||
|
||||
BEAST_EXPECT(validate(doc(), "")["reason"] == "no loader");
|
||||
env(set(alice, doc()), fee(XRP(1)));
|
||||
env.close();
|
||||
auto r = validate(doc(), "");
|
||||
BEAST_EXPECT(r["valid"] == true && r["app_loader_sequence"] == 1);
|
||||
BEAST_EXPECT(r[jss::ledger_index] == env.closed()->seq());
|
||||
BEAST_EXPECT(
|
||||
validate("<html></html>", "")["reason"] == "hash mismatch");
|
||||
|
||||
// domain: the account's Domain, or a host beneath it, ignoring case.
|
||||
BEAST_EXPECT(
|
||||
validate(doc(), "pwa.test")["reason"] ==
|
||||
"domain not endorsed by account");
|
||||
auto jt = noop(alice);
|
||||
jt[sfDomain.fieldName] = strHex(std::string("Pwa.Test"));
|
||||
env(jt);
|
||||
env.close();
|
||||
for (auto const d :
|
||||
{"pwa.test", "PWA.TEST.", "pwaabcd.pwa.test", "a.b.pwa.test"})
|
||||
BEAST_EXPECTS(validate(doc(), d)["valid"] == true, d);
|
||||
for (auto const d : {"other.test", "xpwa.test", "pwa.test.evil"})
|
||||
BEAST_EXPECTS(validate(doc(), d)["valid"] == false, d);
|
||||
|
||||
// Malformed input.
|
||||
BEAST_EXPECT(
|
||||
env.rpc(
|
||||
"json",
|
||||
"pwa_validate",
|
||||
R"({"account":"x","hash":"00"})")[jss::result][jss::error] ==
|
||||
"actMalformed");
|
||||
BEAST_EXPECT(
|
||||
env.rpc(
|
||||
"pwa_validate", alice.human(), "zz")[jss::result][jss::error] ==
|
||||
"invalidParams");
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testValidator();
|
||||
testEnabled();
|
||||
testSetAndRemove();
|
||||
testFee();
|
||||
testMalformed();
|
||||
testAccountDelete();
|
||||
testValidateRPC();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(PWALoader, app, ripple);
|
||||
|
||||
} // namespace test
|
||||
} // namespace ripple
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <xrpld/app/tx/detail/Transactor.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/InnerObjectFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
@@ -1234,6 +1235,90 @@ class Invariants_test : public beast::unit_test::suite
|
||||
{tecINVARIANT_FAILED, tecINVARIANT_FAILED});
|
||||
}
|
||||
|
||||
void
|
||||
testAppLoader()
|
||||
{
|
||||
using namespace test::jtx;
|
||||
static std::string const good = "<html></html>";
|
||||
|
||||
auto insert = [](ApplyContext& ac,
|
||||
AccountID const& owner,
|
||||
uint256 const& key,
|
||||
std::string const& blob,
|
||||
std::uint32_t seq) {
|
||||
auto sle = std::make_shared<SLE>(Keylet{ltAPP_LOADER, key});
|
||||
sle->setAccountID(sfOwner, owner);
|
||||
sle->setFieldVL(sfAppLoader, Slice(blob.data(), blob.size()));
|
||||
sle->setFieldU32(sfAppLoaderSequence, seq);
|
||||
ac.view().insert(sle);
|
||||
auto root = ac.view().peek(keylet::account(owner));
|
||||
root->setFieldU32(sfAppLoaderSequence, seq);
|
||||
ac.view().update(root);
|
||||
return true;
|
||||
};
|
||||
Preclose publish = [](Account const& A1, Account const&, Env& env) {
|
||||
auto jt = noop(A1);
|
||||
jt[sfAppLoader.fieldName] = strHex(good);
|
||||
env(jt, fee(XRP(1)));
|
||||
return bool(env.le(keylet::appLoader(A1.id())));
|
||||
};
|
||||
auto bad = [&](char const* log,
|
||||
Precheck const& pre,
|
||||
std::uint16_t txType = ttACCOUNT_SET,
|
||||
Preclose const& preclose = {}) {
|
||||
doInvariantCheck(
|
||||
{{log}},
|
||||
pre,
|
||||
XRPAmount{},
|
||||
STTx{TxType(txType), [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
preclose);
|
||||
};
|
||||
|
||||
testcase << "AppLoader at the wrong key";
|
||||
bad("AppLoader is not keyed at its owner's keylet",
|
||||
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
|
||||
return insert(ac, A1, keylet::appLoader(A2).key, good, 1);
|
||||
});
|
||||
testcase << "AppLoader with invalid blob";
|
||||
bad("AppLoader blob does not validate",
|
||||
[&](Account const& A1, Account const&, ApplyContext& ac) {
|
||||
return insert(ac, A1, keylet::appLoader(A1).key, "nope", 1);
|
||||
});
|
||||
testcase << "AppLoader blob over the size limit";
|
||||
bad("AppLoader blob does not validate",
|
||||
[&](Account const& A1, Account const&, ApplyContext& ac) {
|
||||
return insert(
|
||||
ac,
|
||||
A1,
|
||||
keylet::appLoader(A1).key,
|
||||
"<html>" + std::string(maxAppLoaderLength, 'x') + "</html>",
|
||||
1);
|
||||
});
|
||||
testcase << "AppLoader sequence differs from its owner's";
|
||||
bad(
|
||||
"AppLoader sequence does not match its owner's",
|
||||
[&](Account const& A1, Account const&, ApplyContext& ac) {
|
||||
auto sle = ac.view().peek(keylet::appLoader(A1));
|
||||
sle->setFieldU32(sfAppLoaderSequence, 7);
|
||||
ac.view().update(sle);
|
||||
return true;
|
||||
},
|
||||
ttACCOUNT_SET,
|
||||
publish);
|
||||
testcase << "AppLoader changed by the wrong transaction type";
|
||||
bad(
|
||||
"AppLoader changed by a transaction other than AccountSet or "
|
||||
"AccountDelete",
|
||||
[&](Account const& A1, Account const&, ApplyContext& ac) {
|
||||
auto sle = ac.view().peek(keylet::appLoader(A1));
|
||||
ac.view().erase(sle);
|
||||
return true;
|
||||
},
|
||||
ttPAYMENT,
|
||||
publish);
|
||||
}
|
||||
|
||||
void
|
||||
testLockedBalance()
|
||||
{
|
||||
@@ -1289,6 +1374,7 @@ public:
|
||||
testNFTokenPageInvariants();
|
||||
testPermissionedDomainInvariants();
|
||||
testLockedBalance();
|
||||
testAppLoader();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
1020
src/test/rpc/PWAServe_test.cpp
Normal file
1020
src/test/rpc/PWAServe_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -437,6 +437,13 @@ DeleteAccount::doApply()
|
||||
if (src->isFieldPresent(sfHookNamespaces) || src->isFieldPresent(sfHooks))
|
||||
return tecHAS_OBLIGATIONS;
|
||||
|
||||
// The AppLoader is in no owner directory, so the cleanup walk below never
|
||||
// visits it; erase it here or AccountRootsDeletedClean fires on the
|
||||
// orphan. Not gated on featurePWALoader: without it no such object can
|
||||
// exist, and gating could only ever leave one behind.
|
||||
if (auto sleLoader = view().peek(keylet::appLoader(account_)))
|
||||
view().erase(sleLoader);
|
||||
|
||||
// Delete all of the entries in the account directory.
|
||||
Keylet const ownerDirKeylet{keylet::ownerDir(account_)};
|
||||
auto const ter = cleanupOnAccountDelete(
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include <xrpld/ledger/ReadView.h>
|
||||
#include <xrpld/ledger/View.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/FeeUnits.h>
|
||||
#include <xrpl/protocol/STArray.h>
|
||||
@@ -613,6 +614,7 @@ LedgerEntryTypesMatch::visitEntry(
|
||||
case ltMPTOKEN:
|
||||
case ltCREDENTIAL:
|
||||
case ltPERMISSIONED_DOMAIN:
|
||||
case ltAPP_LOADER:
|
||||
case ltMANIFEST:
|
||||
break;
|
||||
default:
|
||||
@@ -2020,4 +2022,61 @@ ValidLockedBalance::finalize(
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
void
|
||||
ValidAppLoader::visitEntry(
|
||||
bool isDelete,
|
||||
std::shared_ptr<SLE const> const& before,
|
||||
std::shared_ptr<SLE const> const& after)
|
||||
{
|
||||
auto const& sle = after ? after : before;
|
||||
if (!sle || sle->getType() != ltAPP_LOADER)
|
||||
return;
|
||||
touched_ = true;
|
||||
if (!isDelete)
|
||||
live_.push_back(after);
|
||||
}
|
||||
|
||||
bool
|
||||
ValidAppLoader::finalize(
|
||||
STTx const& tx,
|
||||
TER const,
|
||||
XRPAmount const,
|
||||
ReadView const& view,
|
||||
beast::Journal const& j)
|
||||
{
|
||||
if (!touched_)
|
||||
return true;
|
||||
|
||||
auto fail = [&](char const* why) {
|
||||
JLOG(j.fatal()) << "Invariant failed: AppLoader " << why;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!view.rules().enabled(featurePWALoader))
|
||||
return fail("changed before featurePWALoader was enabled");
|
||||
if (tx.getTxnType() != ttACCOUNT_SET && tx.getTxnType() != ttACCOUNT_DELETE)
|
||||
return fail(
|
||||
"changed by a transaction other than AccountSet or "
|
||||
"AccountDelete");
|
||||
|
||||
for (auto const& sle : live_)
|
||||
{
|
||||
auto const owner = sle->getAccountID(sfOwner);
|
||||
if (sle->key() != keylet::appLoader(owner).key)
|
||||
return fail("is not keyed at its owner's keylet");
|
||||
auto const blob = sle->getFieldVL(sfAppLoader);
|
||||
if (appLoader::validate(blob.data(), blob.size()) !=
|
||||
appLoader::Result::ok)
|
||||
return fail("blob does not validate");
|
||||
auto const root = view.read(keylet::account(owner));
|
||||
if (!root ||
|
||||
(*root)[~sfAppLoaderSequence] != (*sle)[sfAppLoaderSequence])
|
||||
return fail("sequence does not match its owner's");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ripple
|
||||
|
||||
@@ -708,8 +708,37 @@ public:
|
||||
beast::Journal const&);
|
||||
};
|
||||
|
||||
// additional invariant checks can be declared above and then added to this
|
||||
// tuple
|
||||
/**
|
||||
* @brief Invariant: AppLoader objects stay consistent with their owners.
|
||||
*
|
||||
* An ltAPP_LOADER is in no owner directory, so nothing else would notice it
|
||||
* drifting. For every transaction that creates, changes or deletes one:
|
||||
* featurePWALoader is enabled and the transaction is an AccountSet or
|
||||
* AccountDelete; a live loader is keyed at keylet::appLoader(sfOwner), its
|
||||
* blob passes appLoader::validate, and its AppLoaderSequence equals its
|
||||
* owner's.
|
||||
*/
|
||||
class ValidAppLoader
|
||||
{
|
||||
bool touched_ = false;
|
||||
std::vector<std::shared_ptr<SLE const>> live_;
|
||||
|
||||
public:
|
||||
void
|
||||
visitEntry(
|
||||
bool,
|
||||
std::shared_ptr<SLE const> const&,
|
||||
std::shared_ptr<SLE const> const&);
|
||||
|
||||
bool
|
||||
finalize(
|
||||
STTx const&,
|
||||
TER const,
|
||||
XRPAmount const,
|
||||
ReadView const&,
|
||||
beast::Journal const&);
|
||||
};
|
||||
|
||||
using InvariantChecks = std::tuple<
|
||||
TransactionFeeCheck,
|
||||
AccountRootsNotDeleted,
|
||||
@@ -729,7 +758,8 @@ using InvariantChecks = std::tuple<
|
||||
ValidMPTIssuance,
|
||||
ValidPermissionedDomain,
|
||||
ValidAMM,
|
||||
ValidLockedBalance>;
|
||||
ValidLockedBalance,
|
||||
ValidAppLoader>;
|
||||
|
||||
/**
|
||||
* @brief get a tuple of all invariant checks
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <xrpld/core/Config.h>
|
||||
#include <xrpld/ledger/View.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/protocol/AppLoader.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
@@ -55,6 +56,25 @@ SetAccount::makeTxConsequences(PreflightContext const& ctx)
|
||||
return TxConsequences{ctx.tx, getTxConsequencesCategory(ctx.tx)};
|
||||
}
|
||||
|
||||
XRPAmount
|
||||
SetAccount::calculateBaseFee(ReadView const& view, STTx const& tx)
|
||||
{
|
||||
XRPAmount extraFee{0};
|
||||
|
||||
if (view.rules().enabled(featurePWALoader))
|
||||
{
|
||||
if (auto const loader = tx[~sfAppLoader])
|
||||
{
|
||||
// One drop per byte, on every AccountSet carrying the field;
|
||||
// removal carries an empty blob and so costs nothing extra.
|
||||
extraFee += XRPAmount{static_cast<std::int64_t>(
|
||||
loader->size() * appLoaderFeeDropsPerByte)};
|
||||
}
|
||||
}
|
||||
|
||||
return Transactor::calculateBaseFee(view, tx) + extraFee;
|
||||
}
|
||||
|
||||
NotTEC
|
||||
SetAccount::preflight(PreflightContext const& ctx)
|
||||
{
|
||||
@@ -184,6 +204,28 @@ SetAccount::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
// AppLoader
|
||||
if (auto const loader = tx[~sfAppLoader])
|
||||
{
|
||||
if (!ctx.rules.enabled(featurePWALoader))
|
||||
return temDISABLED;
|
||||
|
||||
// An empty blob is the removal sentinel, matching Domain and
|
||||
// MessageKey. It is not validated as a document.
|
||||
if (!loader->empty())
|
||||
{
|
||||
auto const result =
|
||||
appLoader::validate(loader->data(), loader->size());
|
||||
|
||||
if (result != appLoader::Result::ok)
|
||||
{
|
||||
JLOG(j.trace()) << "Malformed transaction: AppLoader: "
|
||||
<< appLoader::to_string(result);
|
||||
return temMALFORMED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HookStateScale
|
||||
if (tx.isFieldPresent(sfHookStateScale))
|
||||
{
|
||||
@@ -565,6 +607,42 @@ SetAccount::doApply()
|
||||
}
|
||||
}
|
||||
|
||||
// AppLoader: its own ltAPP_LOADER object (no directory entry, no
|
||||
// reserve; the per-byte fee from calculateBaseFee pays for it). Every
|
||||
// AccountSet carrying the field, removal included, advances the owner's
|
||||
// AppLoaderSequence, and a written object records the sequence it was
|
||||
// written at, so a reader can tell whether a copy is current.
|
||||
if (view().rules().enabled(featurePWALoader) &&
|
||||
tx.isFieldPresent(sfAppLoader))
|
||||
{
|
||||
Blob const loader = tx.getFieldVL(sfAppLoader);
|
||||
Keylet const klLoader = keylet::appLoader(account_);
|
||||
auto sleLoader = view().peek(klLoader);
|
||||
std::uint32_t const seq = (*sle)[~sfAppLoaderSequence].value_or(0) + 1;
|
||||
(*sle)[sfAppLoaderSequence] = seq;
|
||||
|
||||
if (loader.empty())
|
||||
{
|
||||
if (sleLoader)
|
||||
view().erase(sleLoader);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool const create = !sleLoader;
|
||||
if (create)
|
||||
{
|
||||
sleLoader = std::make_shared<SLE>(klLoader);
|
||||
(*sleLoader)[sfOwner] = account_;
|
||||
}
|
||||
sleLoader->setFieldVL(sfAppLoader, loader);
|
||||
(*sleLoader)[sfAppLoaderSequence] = seq;
|
||||
if (create)
|
||||
view().insert(sleLoader);
|
||||
else
|
||||
view().update(sleLoader);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TransferRate
|
||||
//
|
||||
|
||||
@@ -41,6 +41,14 @@ public:
|
||||
static TxConsequences
|
||||
makeTxConsequences(PreflightContext const& ctx);
|
||||
|
||||
/** Adds appLoaderFeeDropsPerByte per byte of AppLoader document.
|
||||
|
||||
The AppLoader ledger object carries no owner reserve, so the cost of
|
||||
the storage is recovered here instead.
|
||||
*/
|
||||
static XRPAmount
|
||||
calculateBaseFee(ReadView const& view, STTx const& tx);
|
||||
|
||||
static NotTEC
|
||||
preflight(PreflightContext const& ctx);
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ struct ConfigSection
|
||||
#define SECTION_PEERS_IN_MAX "peers_in_max"
|
||||
#define SECTION_PEERS_OUT_MAX "peers_out_max"
|
||||
#define SECTION_PORT_GRPC "port_grpc"
|
||||
#define SECTION_PWA_ALLOWLIST "pwa_allowlist"
|
||||
#define SECTION_PWA_BLOCKLIST "pwa_blocklist"
|
||||
#define SECTION_PREFETCH_WORKERS "prefetch_workers"
|
||||
#define SECTION_REDUCE_RELAY "reduce_relay"
|
||||
#define SECTION_RELATIONAL_DB "relational_db"
|
||||
|
||||
@@ -47,6 +47,8 @@ enum JobType {
|
||||
jtCLIENT_FEE_CHANGE, // Subscription for fee change by a client
|
||||
jtCLIENT_CONSENSUS, // Subscription for consensus state change by a client
|
||||
jtCLIENT_ACCT_HIST, // Subscription for account history by a client
|
||||
jtCLIENT_PWA, // AppLoader document lookup for a pwa port
|
||||
jtCLIENT_PWADNS, // Custom domain TXT lookup for a pwa port (blocks)
|
||||
jtCLIENT_RPC, // Client RPC request
|
||||
jtCLIENT_WEBSOCKET, // Client websocket request
|
||||
jtRPC, // A websocket command from the client
|
||||
|
||||
@@ -90,6 +90,10 @@ private:
|
||||
add(jtCLIENT_CONSENSUS, "clientConsensus", maxLimit, 2000ms, 5000ms);
|
||||
add(jtCLIENT_ACCT_HIST, "clientAccountHistory", maxLimit, 2000ms, 5000ms);
|
||||
add(jtCLIENT_RPC, "clientRPC", maxLimit, 2000ms, 5000ms);
|
||||
// Web traffic from pwa ports: at most two workers, and no latency
|
||||
// targets, so that it can never move the node's load factor.
|
||||
add(jtCLIENT_PWA, "clientPWA", 2, 0ms, 0ms);
|
||||
add(jtCLIENT_PWADNS, "clientPWADNS", 2, 0ms, 0ms);
|
||||
add(jtCLIENT_WEBSOCKET, "clientWebsocket", maxLimit, 2000ms, 5000ms);
|
||||
add(jtRPC, "RPC", maxLimit, 0ms, 0ms);
|
||||
add(jtUPDATE_PF, "updatePaths", 1, 0ms, 0ms);
|
||||
|
||||
@@ -1380,6 +1380,34 @@ private:
|
||||
return jvRequest;
|
||||
}
|
||||
|
||||
// pwa_info [reset] [flush_dns]
|
||||
Json::Value
|
||||
parsePWAInfo(Json::Value const& jvParams)
|
||||
{
|
||||
Json::Value jvRequest(Json::objectValue);
|
||||
for (Json::UInt i = 0; i < jvParams.size(); ++i)
|
||||
{
|
||||
auto const param = jvParams[i].asString();
|
||||
if ((param != "reset" && param != "flush_dns") ||
|
||||
jvRequest.isMember(param))
|
||||
return rpcError(rpcINVALID_PARAMS);
|
||||
jvRequest[param] = true;
|
||||
}
|
||||
return jvRequest;
|
||||
}
|
||||
|
||||
// pwa_validate <account> <hash> [<domain>]
|
||||
Json::Value
|
||||
parsePWAValidate(Json::Value const& jvParams)
|
||||
{
|
||||
Json::Value jvRequest(Json::objectValue);
|
||||
jvRequest[jss::account] = jvParams[0u].asString();
|
||||
jvRequest[jss::hash] = jvParams[1u].asString();
|
||||
if (jvParams.size() > 2)
|
||||
jvRequest[jss::domain] = jvParams[2u].asString();
|
||||
return jvRequest;
|
||||
}
|
||||
|
||||
// server_info [counters]
|
||||
Json::Value
|
||||
parseServerInfo(Json::Value const& jvParams)
|
||||
@@ -1469,6 +1497,8 @@ public:
|
||||
{"peers", &RPCParser::parseAsIs, 0, 0},
|
||||
{"ping", &RPCParser::parseAsIs, 0, 0},
|
||||
{"print", &RPCParser::parseAsIs, 0, 1},
|
||||
{"pwa_info", &RPCParser::parsePWAInfo, 0, 2},
|
||||
{"pwa_validate", &RPCParser::parsePWAValidate, 2, 3},
|
||||
// { "profile", &RPCParser::parseProfile, 1, 9
|
||||
// },
|
||||
{"random", &RPCParser::parseAsIs, 0, 0},
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <xrpld/rpc/detail/UDPInfoSub.h>
|
||||
#include <xrpld/rpc/detail/WSInfoSub.h>
|
||||
#include <xrpl/json/Output.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/server/Server.h>
|
||||
#include <xrpl/server/Session.h>
|
||||
#include <xrpl/server/WSSession.h>
|
||||
@@ -34,6 +35,9 @@
|
||||
#include <boost/beast/ssl/ssl_stream.hpp>
|
||||
#include <boost/utility/string_view.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
@@ -50,6 +54,42 @@ operator<(Port const& lhs, Port const& rhs)
|
||||
class ServerHandler
|
||||
{
|
||||
public:
|
||||
/** How a pwa port answered, for the pwa_info RPC. */
|
||||
enum class PWAOutcome : std::uint8_t {
|
||||
served, // 200
|
||||
notModified, // 304
|
||||
redirected, // 301
|
||||
notFound, // 404: no such loader
|
||||
badTarget, // 404: host and path name no document
|
||||
unknownDomain, // 404: no usable _xahau-pwa record
|
||||
badHost, // 400
|
||||
noForwardedFor, // 400
|
||||
directConnection, // 403: not from a secure_gateway address
|
||||
blocked, // 403: allowlist / blocklist
|
||||
domainMismatch, // 503: account's Domain does not name the host
|
||||
dnsPending, // 429: lookup started or in flight, retry
|
||||
dnsFailed, // 503: resolver failed
|
||||
upgradeRefused, // 403
|
||||
badMethod, // 405
|
||||
throttled, // 503: resource manager
|
||||
busy, // 503: too many in flight
|
||||
unavailable, // 503: no validated ledger or lookup failed
|
||||
count_
|
||||
};
|
||||
|
||||
struct PWAStats
|
||||
{
|
||||
std::array<std::atomic<std::uint64_t>, std::size_t(PWAOutcome::count_)>
|
||||
outcomes{};
|
||||
std::atomic<std::uint64_t> bytesServed{0};
|
||||
|
||||
static char const*
|
||||
name(PWAOutcome o);
|
||||
|
||||
Json::Value
|
||||
getJson(bool reset);
|
||||
};
|
||||
|
||||
struct Setup
|
||||
{
|
||||
explicit Setup() = default;
|
||||
@@ -99,6 +139,47 @@ private:
|
||||
std::condition_variable condition_;
|
||||
bool stopped_{false};
|
||||
std::map<std::reference_wrapper<Port const>, int> count_;
|
||||
PWAStats pwaStats_;
|
||||
|
||||
// PWA lookups queued or running on the job queue. Requests past
|
||||
// maxPWAJobs are refused with 503 rather than queued.
|
||||
std::atomic<std::size_t> pwaJobs_{0};
|
||||
static constexpr std::size_t maxPWAJobs = 64;
|
||||
|
||||
// Custom hosts: _xahau-pwa.<host> TXT -> account, looked up with
|
||||
// libresolv on jtCLIENT_PWADNS (it blocks) and cached: a record for its
|
||||
// TTL clamped to 60..3600 s, no record 15 min, a failure 60 s. At most
|
||||
// maxPWADnsInFlight lookups run at once, one per host, and a request
|
||||
// that misses the cache is answered 429 at once, so no flood of Host
|
||||
// headers can start more than a handful of queries or hold a thread.
|
||||
struct PWADns
|
||||
{
|
||||
enum class Status : std::uint8_t { found, none, failed };
|
||||
Status status = Status::failed;
|
||||
AccountID account;
|
||||
std::chrono::steady_clock::time_point expires;
|
||||
};
|
||||
std::mutex pwaDnsMutex_;
|
||||
std::map<std::string, PWADns> pwaDnsCache_;
|
||||
std::set<std::string> pwaDnsInFlight_;
|
||||
static constexpr std::size_t maxPWADnsInFlight = 4;
|
||||
static constexpr std::size_t maxPWADnsEntries = 10'000;
|
||||
|
||||
void
|
||||
resolvePWADomain(
|
||||
std::string const& host,
|
||||
std::optional<beast::IP::Endpoint> const& resolver);
|
||||
|
||||
// One document lookup, once the request has named an account.
|
||||
struct PWALookup
|
||||
{
|
||||
AccountID account;
|
||||
bool head = false;
|
||||
std::string ifNoneMatch;
|
||||
std::string domain; // custom host it came in on, if any
|
||||
std::uint64_t index = 0; // AccountIndex the host label named
|
||||
std::string redirectTail; // non-empty: bare-zone 301 to pwa<index>
|
||||
};
|
||||
|
||||
// A private type used to restrict access to the ServerHandler constructor.
|
||||
struct ServerHandlerCreator
|
||||
@@ -141,6 +222,22 @@ public:
|
||||
return setup_;
|
||||
}
|
||||
|
||||
PWAStats&
|
||||
pwaStats()
|
||||
{
|
||||
return pwaStats_;
|
||||
}
|
||||
|
||||
/** Drop every cached custom domain answer; returns how many. */
|
||||
std::size_t
|
||||
pwaDnsFlush()
|
||||
{
|
||||
std::lock_guard lock(pwaDnsMutex_);
|
||||
auto const n = pwaDnsCache_.size();
|
||||
pwaDnsCache_.clear();
|
||||
return n;
|
||||
}
|
||||
|
||||
Endpoints const&
|
||||
endpoints() const
|
||||
{
|
||||
@@ -217,6 +314,28 @@ private:
|
||||
std::shared_ptr<Session> const&,
|
||||
std::shared_ptr<JobQueue::Coro> coro);
|
||||
|
||||
/** Entry point for ports whose protocol is "pwa". Runs on the I/O
|
||||
thread: proxy and X-Forwarded-For checks, metering, routing of the
|
||||
Host header and target (document host, custom domain or bare-zone
|
||||
redirect), then
|
||||
queuePWALookup. */
|
||||
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. */
|
||||
void
|
||||
queuePWALookup(
|
||||
std::shared_ptr<Session> const& session,
|
||||
PWALookup const& lookup);
|
||||
|
||||
/** Serve the document, or the bare-zone redirect, from the validated
|
||||
ledger. Runs on the job queue. Always closes the connection. */
|
||||
void
|
||||
processPWARequest(
|
||||
std::shared_ptr<Session> const& session,
|
||||
PWALookup const& lookup);
|
||||
|
||||
void
|
||||
processRequest(
|
||||
Port const& port,
|
||||
|
||||
@@ -144,6 +144,8 @@ Handler const handlerArray[]{
|
||||
{"path_find", byRef(&doPathFind), Role::USER, NEEDS_CURRENT_LEDGER},
|
||||
{"ping", byRef(&doPing), Role::USER, NO_CONDITION},
|
||||
{"print", byRef(&doPrint), Role::ADMIN, NO_CONDITION},
|
||||
{"pwa_info", byRef(&doPWAInfo), Role::ADMIN, NO_CONDITION},
|
||||
{"pwa_validate", byRef(&doPWAValidate), Role::USER, NO_CONDITION},
|
||||
// { "profile", byRef (&doProfile), Role::USER,
|
||||
// NEEDS_CURRENT_LEDGER },
|
||||
{"random", byRef(&doRandom), Role::USER, NO_CONDITION},
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
|
||||
#include <xrpld/rpc/ServerHandler.h>
|
||||
|
||||
#include <xrpld/app/ledger/LedgerMaster.h>
|
||||
#include <xrpld/app/main/Application.h>
|
||||
#include <xrpld/app/misc/NetworkOPs.h>
|
||||
#include <xrpld/core/ConfigSections.h>
|
||||
#include <xrpld/core/JobQueue.h>
|
||||
#include <xrpld/ledger/ReadView.h>
|
||||
#include <xrpld/overlay/Overlay.h>
|
||||
#include <xrpld/rpc/RPCHandler.h>
|
||||
#include <xrpld/rpc/Role.h>
|
||||
@@ -30,15 +32,20 @@
|
||||
#include <xrpld/rpc/detail/Tuning.h>
|
||||
#include <xrpld/rpc/json_body.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/StringUtilities.h>
|
||||
#include <xrpl/basics/base64.h>
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/basics/make_SSLContext.h>
|
||||
#include <xrpl/beast/core/LexicalCast.h>
|
||||
#include <xrpl/beast/net/IPAddressConversion.h>
|
||||
#include <xrpl/beast/rfc2616.h>
|
||||
#include <xrpl/json/json_reader.h>
|
||||
#include <xrpl/json/to_string.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/ErrorCodes.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/RPCErr.h>
|
||||
#include <xrpl/protocol/SystemParameters.h>
|
||||
#include <xrpl/resource/Fees.h>
|
||||
#include <xrpl/resource/ResourceManager.h>
|
||||
#include <xrpl/server/Server.h>
|
||||
@@ -46,11 +53,24 @@
|
||||
#include <xrpl/server/detail/JSONRPCUtil.h>
|
||||
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <boost/beast/core/string.hpp>
|
||||
#include <boost/beast/http/fields.hpp>
|
||||
#include <boost/beast/http/string_body.hpp>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <arpa/nameser.h>
|
||||
#include <netinet/in.h>
|
||||
#include <resolv.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <iterator>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
@@ -203,6 +223,14 @@ ServerHandler::onHandoff(
|
||||
p.count("ws") > 0 || p.count("ws2") > 0 || p.count("wss") > 0 ||
|
||||
p.count("wss2") > 0};
|
||||
|
||||
// PWA ports: no websockets, no peers, no status page. Every request,
|
||||
// upgrade requests included, goes to onRequest and from there to
|
||||
// onPWARequest, so that it meets the proxy rules, is metered and counted,
|
||||
// and is answered without the full version string statusRequestResponse
|
||||
// would put in the Server header.
|
||||
if (p.count("pwa") > 0)
|
||||
return {};
|
||||
|
||||
if (websocket::is_upgrade(request))
|
||||
{
|
||||
if (!is_ws)
|
||||
@@ -293,9 +321,713 @@ buffers_to_string(ConstBufferSequence const& bs)
|
||||
return s;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
//
|
||||
// PWA ports (protocol = pwa): serve accounts' on-ledger AppLoader documents
|
||||
// as HTML, one host per account, behind a reverse proxy only. See the "pwa
|
||||
// ports" section of xahaud-example.cfg.
|
||||
//
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
using Outcome = ServerHandler::PWAOutcome;
|
||||
|
||||
// The client a secure_gateway proxy vouches for: the RIGHTMOST entry of the
|
||||
// (last) X-Forwarded-For field, the only one the proxy itself wrote. Not
|
||||
// forwardedFor(), which takes the leftmost entry and trusts a client-supplied
|
||||
// Forwarded header. IPv4-mapped IPv6 is returned as IPv4.
|
||||
std::optional<boost::asio::ip::address>
|
||||
pwaClientAddress(http_request_type const& request)
|
||||
{
|
||||
std::string_view value;
|
||||
bool found = false;
|
||||
for (auto const& field : request)
|
||||
if (boost::beast::iequals(field.name_string(), "X-Forwarded-For"))
|
||||
{
|
||||
value = {field.value().data(), field.value().size()};
|
||||
found = true;
|
||||
}
|
||||
if (!found)
|
||||
return std::nullopt;
|
||||
if (auto const comma = value.rfind(','); comma != std::string_view::npos)
|
||||
value.remove_prefix(comma + 1);
|
||||
auto const isSpace = [](char c) { return c == ' ' || c == '\t'; };
|
||||
while (!value.empty() && isSpace(value.front()))
|
||||
value.remove_prefix(1);
|
||||
while (!value.empty() && isSpace(value.back()))
|
||||
value.remove_suffix(1);
|
||||
if (value.empty())
|
||||
return std::nullopt;
|
||||
|
||||
boost::system::error_code ec;
|
||||
auto addr = boost::asio::ip::make_address(std::string(value), ec);
|
||||
if (ec)
|
||||
return std::nullopt;
|
||||
if (addr.is_v6() && addr.to_v6().is_v4_mapped())
|
||||
addr = boost::asio::ip::make_address_v4(
|
||||
boost::asio::ip::v4_mapped, addr.to_v6());
|
||||
if (addr.is_unspecified())
|
||||
return std::nullopt;
|
||||
return addr;
|
||||
}
|
||||
|
||||
// Bill IPv4 per address and IPv6 per /64: a subscriber usually holds a /64.
|
||||
boost::asio::ip::address
|
||||
pwaBillingAddress(boost::asio::ip::address const& client)
|
||||
{
|
||||
if (!client.is_v6())
|
||||
return client;
|
||||
auto bytes = client.to_v6().to_bytes();
|
||||
std::fill(bytes.begin() + 8, bytes.end(), 0);
|
||||
return boost::asio::ip::address_v6(bytes);
|
||||
}
|
||||
|
||||
// What a request's Host header and target name. Every account has a host of
|
||||
// its own so that loaders never share an origin with each other or with the
|
||||
// operator's site:
|
||||
//
|
||||
// pwa<account-index>.<zone>/<r-address> the label is the AccountIndex
|
||||
// (decimal) of the account the
|
||||
// path names
|
||||
// <zone>/<r-address> 301 to the above, keeping :port
|
||||
// and query, scheme-relative
|
||||
// <custom host>/ with custom_domains: served as
|
||||
// the account its _xahau-pwa TXT
|
||||
// names, if that account's Domain
|
||||
// names the host (or a parent)
|
||||
//
|
||||
// One trailing slash and a query string are ignored. Anything else is 404.
|
||||
struct PWARoute
|
||||
{
|
||||
enum class Kind { account, domain, redirect, badHost, badTarget };
|
||||
Kind kind = Kind::badTarget;
|
||||
AccountID account;
|
||||
std::uint64_t index = 0; // account: what the label said
|
||||
std::string redirectTail; // redirect: Location after "//pwa<index>"
|
||||
std::string domain; // domain: the normalised Host, no port
|
||||
};
|
||||
|
||||
PWARoute
|
||||
parsePWATarget(Port const& port, http_request_type const& request)
|
||||
{
|
||||
using Kind = PWARoute::Kind;
|
||||
PWARoute route;
|
||||
auto badHost = [&] {
|
||||
route.kind = Kind::badHost;
|
||||
return route;
|
||||
};
|
||||
|
||||
// Exactly one Host, no IP literal; an optional :port (digits, since it
|
||||
// is echoed into a redirect) and trailing dot are dropped.
|
||||
auto const range = request.equal_range(boost::beast::http::field::host);
|
||||
if (range.first == request.end() || std::next(range.first) != range.second)
|
||||
return badHost();
|
||||
std::string host(range.first->value().data(), range.first->value().size());
|
||||
if (host.empty() || host.front() == '[')
|
||||
return badHost();
|
||||
std::string hostPort;
|
||||
if (auto const colon = host.find(':'); colon != std::string::npos)
|
||||
{
|
||||
hostPort = host.substr(colon);
|
||||
host.erase(colon);
|
||||
if (hostPort.size() < 2 || hostPort.size() > 6 ||
|
||||
hostPort.find_first_not_of("0123456789", 1) != std::string::npos)
|
||||
return badHost();
|
||||
}
|
||||
boost::algorithm::to_lower(host);
|
||||
if (!host.empty() && host.back() == '.')
|
||||
host.pop_back();
|
||||
if (host.empty() ||
|
||||
host.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789-.") !=
|
||||
std::string::npos)
|
||||
return badHost();
|
||||
|
||||
// Path less query (kept for a redirect; beast has refused any target
|
||||
// with control characters) and one trailing slash.
|
||||
auto const target = request.target();
|
||||
if (target.empty() || target.front() != '/')
|
||||
return route;
|
||||
std::string path{target.substr(1)};
|
||||
std::string query;
|
||||
if (auto const cut = path.find_first_of("?#"); cut != std::string::npos)
|
||||
{
|
||||
if (path[cut] == '?')
|
||||
query = path.substr(cut, path.find('#', cut) - cut);
|
||||
path.erase(cut);
|
||||
}
|
||||
if (!path.empty() && path.back() == '/')
|
||||
path.pop_back();
|
||||
if (path.find('/') != std::string::npos)
|
||||
return route;
|
||||
|
||||
// Anything under one of our zones is a wrong URL, never a custom domain.
|
||||
bool const ours = port.pwa_domains.count(host) > 0 ||
|
||||
std::any_of(port.pwa_domains.begin(),
|
||||
port.pwa_domains.end(),
|
||||
[&](auto const& z) {
|
||||
return boost::algorithm::ends_with(host, "." + z);
|
||||
});
|
||||
if (!ours)
|
||||
{
|
||||
if (port.pwa_custom_domains && path.empty())
|
||||
{
|
||||
route.kind = Kind::domain;
|
||||
route.domain = std::move(host);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
auto const account = parseBase58<AccountID>(path);
|
||||
if (!account || account->isZero())
|
||||
return route;
|
||||
route.account = *account;
|
||||
|
||||
if (port.pwa_domains.count(host) > 0)
|
||||
{
|
||||
// Scheme-relative: the browser keeps the scheme it used. The index
|
||||
// comes from the ledger later.
|
||||
route.kind = Kind::redirect;
|
||||
route.redirectTail = "." + host + hostPort + "/" + path + query;
|
||||
return route;
|
||||
}
|
||||
|
||||
// pwa<decimal AccountIndex>.<zone>; whether it is the path's account's
|
||||
// index is checked against the ledger later.
|
||||
auto const dot = host.find('.');
|
||||
std::string label;
|
||||
if (dot != std::string::npos && dot > 3 && host.compare(0, 3, "pwa") == 0)
|
||||
label = host.substr(3, dot - 3);
|
||||
if (!label.empty() && port.pwa_domains.count(host.substr(dot + 1)) > 0 &&
|
||||
label.size() <= 20 && (label == "0" || label.front() != '0') &&
|
||||
label.find_first_not_of("0123456789") == std::string::npos &&
|
||||
beast::lexicalCastChecked(route.index, label))
|
||||
route.kind = Kind::account;
|
||||
return route;
|
||||
}
|
||||
|
||||
// Does an account's Domain field name `host`, or a parent of it?
|
||||
bool
|
||||
pwaEndorses(std::shared_ptr<SLE const> const& root, std::string const& host)
|
||||
{
|
||||
if (!root || !root->isFieldPresent(sfDomain))
|
||||
return false;
|
||||
auto const d = root->getFieldVL(sfDomain);
|
||||
auto const endorsed =
|
||||
boost::algorithm::to_lower_copy(std::string(d.begin(), d.end()));
|
||||
return !endorsed.empty() &&
|
||||
(host == endorsed || boost::algorithm::ends_with(host, "." + endorsed));
|
||||
}
|
||||
|
||||
std::string
|
||||
pwaReason(int status)
|
||||
{
|
||||
return std::string(boost::beast::http::obsolete_reason(
|
||||
static_cast<boost::beast::http::status>(status)));
|
||||
}
|
||||
|
||||
// A complete HTTP/1.1 response, then close: one request per connection.
|
||||
// HEAD gets the GET's headers, Content-Length included, and no body.
|
||||
void
|
||||
pwaReply(
|
||||
Session& session,
|
||||
int status,
|
||||
bool head,
|
||||
std::vector<std::string> const& headers = {},
|
||||
std::string const& body = {})
|
||||
{
|
||||
std::string out =
|
||||
"HTTP/1.1 " + std::to_string(status) + " " + pwaReason(status) + "\r\n";
|
||||
out += "Server: " + systemName() +
|
||||
"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\n"
|
||||
"Referrer-Policy: no-referrer\r\n";
|
||||
for (auto const& h : headers)
|
||||
out += h + "\r\n";
|
||||
if (status != 304) // a 304 has no body and no Content-Length
|
||||
{
|
||||
if (status != 200 &&
|
||||
std::none_of(headers.begin(), headers.end(), [](auto const& h) {
|
||||
return boost::algorithm::istarts_with(h, "Content-Type:");
|
||||
}))
|
||||
out += "Content-Type: text/plain; charset=utf-8\r\n";
|
||||
if (status != 200 && status != 301)
|
||||
out += "Cache-Control: no-store\r\n";
|
||||
out += "Content-Length: " + std::to_string(body.size()) + "\r\n";
|
||||
}
|
||||
out += "\r\n";
|
||||
if (status != 304 && !head)
|
||||
out += body;
|
||||
session.write(out);
|
||||
session.close(true);
|
||||
}
|
||||
|
||||
void
|
||||
pwaError(
|
||||
Session& session,
|
||||
int status,
|
||||
bool head,
|
||||
std::vector<std::string> const& headers = {})
|
||||
{
|
||||
pwaReply(
|
||||
session, status, head, headers, std::string(pwaReason(status)) + "\n");
|
||||
}
|
||||
|
||||
// Releases a ServerHandler PWA job slot when the last copy of the job
|
||||
// closure dies, whether or not it ever ran.
|
||||
// Non-copyable: a copy would release the slot twice.
|
||||
struct PWAJobSlot
|
||||
{
|
||||
std::atomic<std::size_t>& count;
|
||||
|
||||
explicit PWAJobSlot(std::atomic<std::size_t>& c) : count(c)
|
||||
{
|
||||
}
|
||||
PWAJobSlot(PWAJobSlot const&) = delete;
|
||||
PWAJobSlot&
|
||||
operator=(PWAJobSlot const&) = delete;
|
||||
|
||||
~PWAJobSlot()
|
||||
{
|
||||
count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
char const*
|
||||
ServerHandler::PWAStats::name(PWAOutcome o)
|
||||
{
|
||||
static constexpr char const* names[] = {
|
||||
"served",
|
||||
"not_modified",
|
||||
"redirected",
|
||||
"not_found",
|
||||
"bad_target",
|
||||
"unknown_domain",
|
||||
"bad_host",
|
||||
"no_forwarded_for",
|
||||
"direct_connection",
|
||||
"blocked",
|
||||
"domain_mismatch",
|
||||
"dns_pending",
|
||||
"dns_failed",
|
||||
"upgrade_refused",
|
||||
"bad_method",
|
||||
"throttled",
|
||||
"busy",
|
||||
"unavailable"};
|
||||
static_assert(std::size(names) == std::size_t(PWAOutcome::count_));
|
||||
return names[std::size_t(o)];
|
||||
}
|
||||
|
||||
Json::Value
|
||||
ServerHandler::PWAStats::getJson(bool reset)
|
||||
{
|
||||
Json::Value ret(Json::objectValue);
|
||||
Json::Value& o = ret["outcomes"] = Json::objectValue;
|
||||
std::uint64_t total = 0;
|
||||
for (std::size_t i = 0; i < outcomes.size(); ++i)
|
||||
{
|
||||
auto const n = reset ? outcomes[i].exchange(0) : outcomes[i].load();
|
||||
total += n;
|
||||
o[name(PWAOutcome(i))] = std::to_string(n);
|
||||
}
|
||||
ret["requests"] = std::to_string(total);
|
||||
ret["bytes_served"] =
|
||||
std::to_string(reset ? bytesServed.exchange(0) : bytesServed.load());
|
||||
return ret;
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::onPWARequest(Session& session)
|
||||
{
|
||||
using boost::beast::http::verb;
|
||||
auto const& port = session.port();
|
||||
auto const& request = session.request();
|
||||
auto const remote = session.remoteAddress().at_port(0);
|
||||
bool const head = request.method() == verb::head;
|
||||
auto count = [&](Outcome o) { ++pwaStats_.outcomes[std::size_t(o)]; };
|
||||
|
||||
// Only the configured proxy, and it must say who it forwards for.
|
||||
if (!ipAllowed(
|
||||
remote.address(),
|
||||
port.secure_gateway_nets_v4,
|
||||
port.secure_gateway_nets_v6))
|
||||
{
|
||||
count(Outcome::directConnection);
|
||||
return pwaError(session, 403, head);
|
||||
}
|
||||
auto const forwarded = pwaClientAddress(request);
|
||||
if (!forwarded)
|
||||
{
|
||||
count(Outcome::noForwardedFor);
|
||||
return pwaError(session, 400, head);
|
||||
}
|
||||
|
||||
// Meter the client, never the proxy.
|
||||
auto usage = m_resourceManager.newInboundEndpoint(
|
||||
beast::IPAddressConversion::from_asio(pwaBillingAddress(*forwarded)));
|
||||
if (usage.disconnect(m_journal))
|
||||
{
|
||||
count(Outcome::throttled);
|
||||
return pwaError(session, 503, head);
|
||||
}
|
||||
usage.charge(Resource::feeReferenceRPC);
|
||||
|
||||
if (request.method() != verb::get && !head)
|
||||
{
|
||||
usage.charge(Resource::feeMalformedRPC);
|
||||
count(Outcome::badMethod);
|
||||
return pwaError(session, 405, head, {"Allow: GET, HEAD"});
|
||||
}
|
||||
if (boost::beast::websocket::is_upgrade(request))
|
||||
{
|
||||
usage.charge(Resource::feeMalformedRPC);
|
||||
count(Outcome::upgradeRefused);
|
||||
return pwaError(session, 403, head);
|
||||
}
|
||||
|
||||
auto const route = parsePWATarget(port, request);
|
||||
switch (route.kind)
|
||||
{
|
||||
case PWARoute::Kind::badHost:
|
||||
usage.charge(Resource::feeMalformedRPC);
|
||||
count(Outcome::badHost);
|
||||
return pwaError(session, 400, head);
|
||||
case PWARoute::Kind::badTarget:
|
||||
usage.charge(Resource::feeMalformedRPC);
|
||||
count(Outcome::badTarget);
|
||||
return pwaError(session, 404, head);
|
||||
case PWARoute::Kind::redirect:
|
||||
case PWARoute::Kind::account:
|
||||
case PWARoute::Kind::domain:
|
||||
break;
|
||||
}
|
||||
|
||||
// A miss costs what a hit does, so sweeping is no cheaper than reading.
|
||||
usage.charge(Resource::feeMediumBurdenRPC);
|
||||
|
||||
PWALookup lookup;
|
||||
lookup.account = route.account;
|
||||
lookup.head = head;
|
||||
lookup.domain = route.domain;
|
||||
lookup.index = route.index;
|
||||
lookup.redirectTail = route.redirectTail;
|
||||
if (auto const it = request.find(boost::beast::http::field::if_none_match);
|
||||
it != request.end())
|
||||
lookup.ifNoneMatch =
|
||||
std::string(it->value().data(), it->value().size());
|
||||
|
||||
if (route.kind == PWARoute::Kind::domain)
|
||||
{
|
||||
// The cache answers now. A miss starts one lookup (if fewer than
|
||||
// maxPWADnsInFlight are running and none for this host) and is
|
||||
// answered 429 with a refresh, so no request ever waits on DNS.
|
||||
std::optional<PWADns> dns;
|
||||
bool start = false;
|
||||
{
|
||||
std::lock_guard lock(pwaDnsMutex_);
|
||||
auto const it = pwaDnsCache_.find(route.domain);
|
||||
if (it != pwaDnsCache_.end() &&
|
||||
it->second.expires > std::chrono::steady_clock::now())
|
||||
dns = it->second;
|
||||
else if (
|
||||
pwaDnsInFlight_.size() < maxPWADnsInFlight &&
|
||||
pwaDnsInFlight_.insert(route.domain).second)
|
||||
start = true;
|
||||
}
|
||||
if (!dns)
|
||||
{
|
||||
usage.charge(Resource::feeMediumBurdenRPC);
|
||||
if (start &&
|
||||
!m_jobQueue.addJob(
|
||||
jtCLIENT_PWADNS,
|
||||
"PWA-DNS",
|
||||
[this, host = route.domain, resolver = port.pwa_resolver] {
|
||||
resolvePWADomain(host, resolver);
|
||||
}))
|
||||
{
|
||||
std::lock_guard lock(pwaDnsMutex_);
|
||||
pwaDnsInFlight_.erase(route.domain);
|
||||
}
|
||||
count(Outcome::dnsPending);
|
||||
return pwaReply(
|
||||
session,
|
||||
429,
|
||||
head,
|
||||
{"Retry-After: 2", "Content-Type: text/html; charset=utf-8"},
|
||||
"<!DOCTYPE html><html><head><meta http-equiv=\"refresh\" "
|
||||
"content=\"2\"></head><body>Looking up this host; retrying."
|
||||
"</body></html>\n");
|
||||
}
|
||||
switch (dns->status)
|
||||
{
|
||||
case PWADns::Status::none:
|
||||
count(Outcome::unknownDomain);
|
||||
return pwaError(session, 404, head);
|
||||
case PWADns::Status::failed:
|
||||
count(Outcome::dnsFailed);
|
||||
return pwaError(session, 503, head);
|
||||
case PWADns::Status::found:
|
||||
lookup.account = dns->account;
|
||||
}
|
||||
}
|
||||
queuePWALookup(session.detach(), lookup);
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::resolvePWADomain(
|
||||
std::string const& host,
|
||||
std::optional<beast::IP::Endpoint> const& resolver)
|
||||
{
|
||||
using Status = PWADns::Status;
|
||||
PWADns dns;
|
||||
std::uint32_t ttl = 60;
|
||||
struct __res_state res
|
||||
{
|
||||
};
|
||||
if (res_ninit(&res) == 0)
|
||||
{
|
||||
res.retrans = 1; // seconds per attempt
|
||||
res.retry = 2;
|
||||
if (resolver)
|
||||
{
|
||||
res.nscount = 1;
|
||||
res.nsaddr_list[0].sin_family = AF_INET;
|
||||
res.nsaddr_list[0].sin_port = htons(resolver->port());
|
||||
res.nsaddr_list[0].sin_addr.s_addr =
|
||||
htonl(resolver->to_v4().to_uint());
|
||||
}
|
||||
unsigned char buf[4096];
|
||||
int const n = res_nquery(
|
||||
&res,
|
||||
("_xahau-pwa." + host).c_str(),
|
||||
ns_c_in,
|
||||
ns_t_txt,
|
||||
buf,
|
||||
sizeof(buf));
|
||||
ns_msg msg;
|
||||
if (n < 0)
|
||||
{
|
||||
// NXDOMAIN and NOERROR-without-data are both "no record".
|
||||
if (res.res_h_errno == HOST_NOT_FOUND || res.res_h_errno == NO_DATA)
|
||||
dns.status = Status::none;
|
||||
}
|
||||
else if (ns_initparse(buf, n, &msg) == 0)
|
||||
{
|
||||
// Exactly one distinct address across the TXT records; other
|
||||
// strings are ignored, two or more addresses count as none.
|
||||
dns.status = Status::none;
|
||||
bool conflict = false;
|
||||
ns_rr rr;
|
||||
for (int i = 0; i < ns_msg_count(msg, ns_s_an); ++i)
|
||||
{
|
||||
if (ns_parserr(&msg, ns_s_an, i, &rr) != 0 ||
|
||||
ns_rr_type(rr) != ns_t_txt)
|
||||
continue;
|
||||
auto const* p = ns_rr_rdata(rr);
|
||||
auto const* const end = p + ns_rr_rdlen(rr);
|
||||
for (; p < end && p + 1 + *p <= end; p += 1 + *p)
|
||||
{
|
||||
auto const a = parseBase58<AccountID>(
|
||||
boost::algorithm::trim_copy(std::string(
|
||||
reinterpret_cast<char const*>(p + 1), *p)));
|
||||
if (!a || a->isZero())
|
||||
continue;
|
||||
conflict = conflict ||
|
||||
(dns.status == Status::found && dns.account != *a);
|
||||
dns.status = Status::found;
|
||||
dns.account = *a;
|
||||
ttl = std::clamp<std::uint32_t>(ns_rr_ttl(rr), 60, 3600);
|
||||
}
|
||||
}
|
||||
if (conflict)
|
||||
dns.status = Status::none;
|
||||
}
|
||||
res_nclose(&res);
|
||||
}
|
||||
dns.expires = std::chrono::steady_clock::now() +
|
||||
(dns.status == Status::found ? std::chrono::seconds(ttl)
|
||||
: dns.status == Status::none
|
||||
? std::chrono::seconds(std::chrono::minutes(15))
|
||||
: std::chrono::seconds(60));
|
||||
|
||||
std::lock_guard lock(pwaDnsMutex_);
|
||||
pwaDnsInFlight_.erase(host);
|
||||
if (pwaDnsCache_.size() >= maxPWADnsEntries)
|
||||
{
|
||||
auto const now = std::chrono::steady_clock::now();
|
||||
std::erase_if(pwaDnsCache_, [&](auto const& kv) {
|
||||
return kv.second.expires <= now;
|
||||
});
|
||||
if (pwaDnsCache_.size() >= maxPWADnsEntries)
|
||||
pwaDnsCache_.erase(pwaDnsCache_.begin());
|
||||
}
|
||||
pwaDnsCache_[host] = dns;
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
++pwaStats_.outcomes[std::size_t(Outcome::blocked)];
|
||||
return pwaError(*session, 403, lookup.head);
|
||||
}
|
||||
|
||||
// jtCLIENT_PWA limits the workers; this bounds the queue behind them
|
||||
// and so the detached connections it holds.
|
||||
if (pwaJobs_.fetch_add(1, std::memory_order_relaxed) >= maxPWAJobs)
|
||||
{
|
||||
pwaJobs_.fetch_sub(1, std::memory_order_relaxed);
|
||||
++pwaStats_.outcomes[std::size_t(Outcome::busy)];
|
||||
return pwaError(*session, 503, lookup.head);
|
||||
}
|
||||
auto const slot = std::make_shared<PWAJobSlot>(pwaJobs_);
|
||||
if (!m_jobQueue.addJob(
|
||||
jtCLIENT_PWA, "PWA-Client", [this, session, slot, lookup]() {
|
||||
processPWARequest(session, lookup);
|
||||
}))
|
||||
{
|
||||
++pwaStats_.outcomes[std::size_t(Outcome::unavailable)];
|
||||
pwaError(*session, 503, lookup.head);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::processPWARequest(
|
||||
std::shared_ptr<Session> const& session,
|
||||
PWALookup const& lookup)
|
||||
{
|
||||
auto const& account = lookup.account;
|
||||
|
||||
// Decide the whole response before writing any of it: the ledger read
|
||||
// can throw (SHAMapMissingNode), and nothing above Job::doJob catches.
|
||||
Outcome outcome = Outcome::unavailable;
|
||||
int status = 503;
|
||||
std::vector<std::string> headers;
|
||||
std::string body;
|
||||
try
|
||||
{
|
||||
// Validated state only: a closed ledger may still lose consensus.
|
||||
auto const ledger = app_.getLedgerMaster().getValidatedLedger();
|
||||
std::shared_ptr<SLE const> sle, root;
|
||||
if (ledger)
|
||||
{
|
||||
sle = ledger->read(keylet::appLoader(account));
|
||||
root = ledger->read(keylet::account(account));
|
||||
}
|
||||
// The host label must be the account's AccountIndex; an account
|
||||
// without one (created before it existed) has no host.
|
||||
std::optional<std::uint64_t> index;
|
||||
if (root)
|
||||
index = (*root)[~sfAccountIndex];
|
||||
if (!ledger)
|
||||
{
|
||||
}
|
||||
else if (!lookup.redirectTail.empty())
|
||||
{
|
||||
if (index)
|
||||
{
|
||||
outcome = Outcome::redirected;
|
||||
status = 301;
|
||||
headers = {
|
||||
"Location: //pwa" + std::to_string(*index) +
|
||||
lookup.redirectTail,
|
||||
"Cache-Control: public, max-age=86400"};
|
||||
body = "Moved Permanently\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = Outcome::notFound;
|
||||
status = 404;
|
||||
}
|
||||
}
|
||||
else if (!sle || (lookup.domain.empty() && index != lookup.index))
|
||||
{
|
||||
outcome = Outcome::notFound;
|
||||
status = 404;
|
||||
}
|
||||
else if (!lookup.domain.empty() && !pwaEndorses(root, lookup.domain))
|
||||
{
|
||||
// A custom host serves an account only with its consent.
|
||||
outcome = Outcome::domainMismatch;
|
||||
status = 503;
|
||||
}
|
||||
else if (sle)
|
||||
{
|
||||
// Every change is threaded, so the last transaction that touched
|
||||
// the object is a strong validator for its content.
|
||||
std::string const etag =
|
||||
"\"" + to_string(sle->getFieldH256(sfPreviousTxnID)) + "\"";
|
||||
headers = {
|
||||
"Content-Security-Policy: sandbox allow-scripts allow-forms "
|
||||
"allow-popups",
|
||||
"X-Frame-Options: DENY",
|
||||
"Cross-Origin-Resource-Policy: same-origin",
|
||||
"Cross-Origin-Opener-Policy: same-origin",
|
||||
"Cache-Control: no-cache", // store, but revalidate
|
||||
"ETag: " + etag,
|
||||
// Enough for a client to vet the document against a second
|
||||
// node (pwa_validate, or ledger_entry app_loader at this
|
||||
// ledger).
|
||||
"X-NetworkID: " + std::to_string(app_.config().NETWORK_ID),
|
||||
"X-LedgerSeq: " + std::to_string(ledger->seq()),
|
||||
"X-LedgerHash: " + to_string(ledger->info().hash),
|
||||
"X-PWASeq: " +
|
||||
std::to_string(sle->getFieldU32(sfAppLoaderSequence))};
|
||||
|
||||
// If-None-Match: the tag as we sent it, or *.
|
||||
if (lookup.ifNoneMatch == etag || lookup.ifNoneMatch == "*")
|
||||
{
|
||||
outcome = Outcome::notModified;
|
||||
status = 304;
|
||||
}
|
||||
else
|
||||
{
|
||||
Blob const blob = sle->getFieldVL(sfAppLoader);
|
||||
body.assign(blob.begin(), blob.end());
|
||||
headers.push_back("Content-Type: text/html; charset=utf-8");
|
||||
outcome = Outcome::served;
|
||||
status = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(app_.journal("PWA").warn())
|
||||
<< "lookup for " << toBase58(account) << " failed: " << e.what();
|
||||
outcome = Outcome::unavailable;
|
||||
status = 503;
|
||||
headers.clear();
|
||||
body.clear();
|
||||
}
|
||||
|
||||
++pwaStats_.outcomes[std::size_t(outcome)];
|
||||
if (status == 200 || status == 304 || status == 301)
|
||||
{
|
||||
if (status == 200 && !lookup.head)
|
||||
pwaStats_.bytesServed += body.size();
|
||||
return pwaReply(*session, status, lookup.head, headers, body);
|
||||
}
|
||||
pwaError(*session, status, lookup.head);
|
||||
}
|
||||
|
||||
void
|
||||
ServerHandler::onRequest(Session& session)
|
||||
{
|
||||
// PWA ports speak nothing else.
|
||||
if (session.port().protocol.count("pwa") > 0)
|
||||
{
|
||||
onPWARequest(session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure RPC is enabled on the port
|
||||
if (session.port().protocol.count("http") == 0 &&
|
||||
session.port().protocol.count("https") == 0)
|
||||
@@ -1333,6 +2065,9 @@ to_Port(ParsedPort const& parsed, std::ostream& log)
|
||||
p.admin_nets_v6 = parsed.admin_nets_v6;
|
||||
p.secure_gateway_nets_v4 = parsed.secure_gateway_nets_v4;
|
||||
p.secure_gateway_nets_v6 = parsed.secure_gateway_nets_v6;
|
||||
p.pwa_domains = parsed.pwa_domains;
|
||||
p.pwa_custom_domains = parsed.pwa_custom_domains;
|
||||
p.pwa_resolver = parsed.pwa_resolver;
|
||||
|
||||
return p;
|
||||
}
|
||||
@@ -1371,6 +2106,74 @@ parse_Ports(Config const& config, std::ostream& log)
|
||||
result.push_back(to_Port(parsed, 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>();
|
||||
}
|
||||
for (auto& p : result)
|
||||
{
|
||||
auto fail = [&](char const* why) {
|
||||
log << "Invalid [" << p.name << "]: " << why;
|
||||
Throw<std::exception>();
|
||||
};
|
||||
if (p.protocol.count("pwa") == 0)
|
||||
{
|
||||
if (!p.pwa_domains.empty() || p.pwa_custom_domains ||
|
||||
p.pwa_resolver)
|
||||
fail(
|
||||
"domain, custom_domains and resolver apply to pwa "
|
||||
"ports only");
|
||||
continue;
|
||||
}
|
||||
if (p.protocol.size() != 1)
|
||||
fail("pwa cannot be combined with any other protocol");
|
||||
if (p.pwa_domains.empty())
|
||||
fail("pwa port requires domain");
|
||||
// A /0 gateway would let every client write its own X-Forwarded-For.
|
||||
auto const everyAddress = [](auto const& nets) {
|
||||
return std::any_of(nets.begin(), nets.end(), [](auto const& n) {
|
||||
return n.prefix_length() == 0;
|
||||
});
|
||||
};
|
||||
if ((p.secure_gateway_nets_v4.empty() &&
|
||||
p.secure_gateway_nets_v6.empty()) ||
|
||||
everyAddress(p.secure_gateway_nets_v4) ||
|
||||
everyAddress(p.secure_gateway_nets_v6))
|
||||
fail(
|
||||
"pwa port requires secure_gateway naming the proxy's own "
|
||||
"addresses");
|
||||
if (!p.admin_nets_v4.empty() || !p.admin_nets_v6.empty() ||
|
||||
!p.admin_user.empty() || !p.admin_password.empty() ||
|
||||
!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())
|
||||
{
|
||||
auto it = result.begin();
|
||||
|
||||
@@ -115,6 +115,10 @@ doPeers(RPC::JsonContext&);
|
||||
Json::Value
|
||||
doPing(RPC::JsonContext&);
|
||||
Json::Value
|
||||
doPWAInfo(RPC::JsonContext&);
|
||||
Json::Value
|
||||
doPWAValidate(RPC::JsonContext&);
|
||||
Json::Value
|
||||
doPrint(RPC::JsonContext&);
|
||||
Json::Value
|
||||
doRandom(RPC::JsonContext&);
|
||||
|
||||
@@ -633,6 +633,25 @@ parseXChainOwnedCreateAccountClaimID(
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
static std::optional<uint256>
|
||||
parseAppLoader(Json::Value const& params, Json::Value& jvResult)
|
||||
{
|
||||
if (!params.isString())
|
||||
{
|
||||
jvResult[jss::error] = "malformedAddress";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto const account = parseBase58<AccountID>(params.asString());
|
||||
if (!account || account->isZero())
|
||||
{
|
||||
jvResult[jss::error] = "malformedAddress";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return keylet::appLoader(*account).key;
|
||||
}
|
||||
|
||||
static std::optional<uint256>
|
||||
parseDID(Json::Value const& params, Json::Value& jvResult)
|
||||
{
|
||||
@@ -1090,6 +1109,7 @@ doLedgerEntry(RPC::JsonContext& context)
|
||||
{jss::account_root, parseAccountRoot, ltACCOUNT_ROOT},
|
||||
// TODO: add amendments
|
||||
{jss::amm, parseAMM, ltAMM},
|
||||
{jss::app_loader, parseAppLoader, ltAPP_LOADER},
|
||||
{jss::bridge, parseBridge, ltBRIDGE},
|
||||
{jss::check, parseCheck, ltCHECK},
|
||||
{jss::credential, parseCredential, ltCREDENTIAL},
|
||||
|
||||
74
src/xrpld/rpc/handlers/PWAInfo.cpp
Normal file
74
src/xrpld/rpc/handlers/PWAInfo.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 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>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
/** pwa_info: usage of this node's "pwa" ports. Admin only.
|
||||
|
||||
{"reset": true} zeroes the request counters after reading them;
|
||||
{"flush_dns": true} drops every cached custom domain answer. Returns the
|
||||
ports (name, ip, port, domains, custom_domains), "requests" and
|
||||
"outcomes" (64-bit counters as strings) and "bytes_served".
|
||||
*/
|
||||
Json::Value
|
||||
doPWAInfo(RPC::JsonContext& context)
|
||||
{
|
||||
auto const& params = context.params;
|
||||
auto flag = [&](char const* key, bool& out) {
|
||||
if (!params.isMember(key))
|
||||
return true;
|
||||
if (!params[key].isBool())
|
||||
return false;
|
||||
out = params[key].asBool();
|
||||
return true;
|
||||
};
|
||||
bool reset = false, flush = false;
|
||||
if (!flag(jss::reset, reset))
|
||||
return RPC::expected_field_error(jss::reset, "bool");
|
||||
if (!flag("flush_dns", flush))
|
||||
return RPC::expected_field_error("flush_dns", "bool");
|
||||
|
||||
auto& handler = context.app.getServerHandler();
|
||||
Json::Value ret = handler.pwaStats().getJson(reset);
|
||||
if (flush)
|
||||
ret["dns_flushed"] = static_cast<Json::UInt>(handler.pwaDnsFlush());
|
||||
Json::Value& ports = ret[jss::ports] = Json::arrayValue;
|
||||
for (auto const& port : handler.setup().ports)
|
||||
{
|
||||
if (port.protocol.count("pwa") == 0)
|
||||
continue;
|
||||
Json::Value& p = ports.append(Json::objectValue);
|
||||
p[jss::name] = port.name;
|
||||
p[jss::ip] = port.ip.to_string();
|
||||
p[jss::port] = port.port;
|
||||
Json::Value& domains = p["domains"] = Json::arrayValue;
|
||||
for (auto const& d : port.pwa_domains)
|
||||
domains.append(d);
|
||||
p["custom_domains"] = port.pwa_custom_domains;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace ripple
|
||||
111
src/xrpld/rpc/handlers/PWAValidate.cpp
Normal file
111
src/xrpld/rpc/handlers/PWAValidate.cpp
Normal file
@@ -0,0 +1,111 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2025 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/ledger/LedgerMaster.h>
|
||||
#include <xrpld/rpc/Context.h>
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/ErrorCodes.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/RPCErr.h>
|
||||
#include <xrpl/protocol/digest.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
/** pwa_validate: is a served AppLoader document the account's current one?
|
||||
|
||||
{"account": <r-address>, "hash": <hex SHA-512Half of the bytes served>,
|
||||
"domain": <host it was served from, optional>}
|
||||
|
||||
Judged against the validated ledger. "valid" is true when the account's
|
||||
current loader hashes to `hash` and, if `domain` is given, the account's
|
||||
Domain field equals it or is a parent of it (ignoring case) -- i.e. the
|
||||
owner endorses that host. On false, "reason" says why. A client that
|
||||
was served from an older ledger (X-LedgerSeq) than "ledger_index" and
|
||||
gets "hash mismatch" should simply reload.
|
||||
*/
|
||||
Json::Value
|
||||
doPWAValidate(RPC::JsonContext& context)
|
||||
{
|
||||
auto const& params = context.params;
|
||||
for (auto const& field : {jss::account, jss::hash})
|
||||
if (!params.isMember(field))
|
||||
return RPC::missing_field_error(field);
|
||||
else if (!params[field].isString())
|
||||
return RPC::invalid_field_error(field);
|
||||
auto const account =
|
||||
parseBase58<AccountID>(params[jss::account].asString());
|
||||
if (!account || account->isZero())
|
||||
return rpcError(rpcACT_MALFORMED);
|
||||
uint256 hash;
|
||||
if (!hash.parseHex(params[jss::hash].asString()))
|
||||
return RPC::invalid_field_error(jss::hash);
|
||||
std::string domain;
|
||||
if (params.isMember(jss::domain))
|
||||
{
|
||||
if (!params[jss::domain].isString())
|
||||
return RPC::invalid_field_error(jss::domain);
|
||||
domain =
|
||||
boost::algorithm::to_lower_copy(params[jss::domain].asString());
|
||||
if (!domain.empty() && domain.back() == '.')
|
||||
domain.pop_back();
|
||||
}
|
||||
|
||||
auto const ledger = context.ledgerMaster.getValidatedLedger();
|
||||
if (!ledger)
|
||||
return rpcError(rpcNOT_SYNCED);
|
||||
|
||||
Json::Value ret(Json::objectValue);
|
||||
ret[jss::ledger_index] = ledger->seq();
|
||||
auto invalid = [&](char const* reason) {
|
||||
ret["valid"] = false;
|
||||
ret["reason"] = reason;
|
||||
return ret;
|
||||
};
|
||||
|
||||
auto const sle = ledger->read(keylet::appLoader(*account));
|
||||
if (!sle)
|
||||
return invalid("no loader");
|
||||
ret["app_loader_sequence"] = (*sle)[sfAppLoaderSequence];
|
||||
if (sha512Half(makeSlice(sle->getFieldVL(sfAppLoader))) != hash)
|
||||
return invalid("hash mismatch");
|
||||
if (!domain.empty())
|
||||
{
|
||||
auto const root = ledger->read(keylet::account(*account));
|
||||
std::string endorsed;
|
||||
if (root && root->isFieldPresent(sfDomain))
|
||||
{
|
||||
auto const d = root->getFieldVL(sfDomain);
|
||||
endorsed = boost::algorithm::to_lower_copy(
|
||||
std::string(d.begin(), d.end()));
|
||||
}
|
||||
if (endorsed.empty() ||
|
||||
(domain != endorsed &&
|
||||
!boost::algorithm::ends_with(domain, "." + endorsed)))
|
||||
return invalid("domain not endorsed by account");
|
||||
}
|
||||
ret["valid"] = true;
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace ripple
|
||||
Reference in New Issue
Block a user