Compare commits

...

41 Commits

Author SHA1 Message Date
Richard Holland
59aca20962 Merge branch 'dev' into pwabootloader 2026-10-12 08:09:09 +10:00
Richard Holland
582080f662 Merge branch 'dev' into pwabootloader 2026-10-11 23:10:05 +10:00
tequ
d86f8876d9 Merge branch 'dev' into pwabootloader 2026-10-11 21:56:11 +09:00
tequ
4c47b50852 fix test error 2026-10-11 21:53:54 +09:00
Richard Holland
78a5b01ada clang fix 2026-10-11 17:43:06 +11:00
Richard Holland
87345be657 Merge branch 'dev' into pwabootloader 2026-10-11 16:23:57 +10:00
Richard Holland
4b30492219 bug fix 2026-10-11 17:03:49 +11:00
Richard Holland
3b60258c38 Merge branch 'dev' into pwabootloader 2026-10-11 15:29:16 +10:00
Richard Holland
c1cc177989 change pwa<acchex> to pwa<accid>.domain 2026-10-11 16:27:53 +11:00
Richard Holland
d00b16a137 re-add DNS lookup, smaller this time. anti-abuse semantics around 'full domain' support. 2026-10-11 16:18:38 +11:00
Richard Holland
34c215e132 undo adding the block/allow publishers, tbd later date. add x-headers and slim down code 2026-10-11 15:11:59 +11:00
Richard Holland
59a123c214 domain support, subdomain enforcement 1/2 2026-10-11 13:59:38 +11:00
Richard Holland
184a4617f1 allow and blocklists from hookstate, ensure subdomains are used 2026-10-11 11:03:54 +11:00
Richard Holland
98636ac6b0 Merge branch 'dev' into pwabootloader 2026-10-08 23:13:59 +10:00
Richard Holland
4a6eca2a3a Merge branch 'dev' into pwabootloader 2026-10-08 17:26:34 +10:00
Richard Holland
b01668d31b Merge branch 'dev' into pwabootloader 2026-10-07 19:21:12 +10:00
tequ
41018a79a3 fix io_service to io_context 2026-10-06 18:22:53 +09:00
tequ
14b1fdf06d Merge branch 'dev' into pwabootloader 2026-10-06 18:14:06 +09:00
Richard Holland
26388ec284 Merge branch 'dev' into pwabootloader 2026-10-05 18:20:19 +10:00
Richard Holland
069e06462f Merge branch 'dev' into pwabootloader 2026-10-04 10:59:23 +10:00
Richard Holland
d1324b19b6 Merge branch 'dev' into pwabootloader 2026-10-02 15:32:39 +10:00
Richard Holland
b1150782e6 pwa fixes 2026-10-02 15:09:08 +10:00
Richard Holland
2e34ba2ece Merge branch 'dev' into pwabootloader 2026-09-29 03:41:00 +10:00
Richard Holland
a553b352c4 Merge branch 'dev' into pwabootloader 2026-09-26 14:38:54 +10:00
Richard Holland
9eae5c4865 forwarded for proxy-only, rpc command for pwa stats, own port 2026-09-26 14:28:53 +10:00
Richard Holland
8e6bd71629 Merge branch 'dev' into pwabootloader 2026-09-26 13:25:49 +10:00
Richard Holland
44264cc209 re-scope pwa amendment to just pwabootloader 2026-09-26 13:19:18 +10:00
Richard Holland
c7865f0588 Merge branch 'dev' into pwabootloader 2026-09-26 13:15:07 +10:00
Richard Holland
600b05ec6b Merge branch 'dev' into pwabootloader 2026-09-26 11:14:59 +10:00
Richard Holland
4c3e2ed76e Merge branch 'dev' into pwabootloader 2026-09-25 20:21:37 +10:00
Richard Holland
d0113d6c94 Merge branch 'dev' into pwabootloader 2026-09-23 14:35:12 +10:00
Richard Holland
bbd46c49ea fix: update URIToken UTF-8 tests to cover both sides of featurePWALoa… (#816)
* fix: update URIToken UTF-8 tests to cover both sides of featurePWALoader amendment gate

The testURIUTF8 testcase now runs all UTF-8 validation cases with and without
the featurePWALoader amendment, ensuring consensus isn't broken and the old
behaviour is maintained before the amendment is enabled.

Changes:
- Wrap test in for loop toggling featurePWALoader on/off
- NUL byte: use std::string(1, '\0') instead of C-string literal to avoid
  strlen truncation; NUL is valid well-formed UTF-8 accepted by both validators
- Noncharacters U+FFFE/U+FFFF: amendment-gated expectations (temMALFORMED
  pre-amendment, tesSUCCESS post-amendment)
- Remove all 18 TODO: REVIEW comments
- Organise test sections with clear headers for always-succeed, always-fail,
  and amendment-gated cases

* style: clang-format-18 for URIToken_test.cpp

---------

Co-authored-by: RichardAHBot <bot@xahau.org>
2026-09-21 15:16:44 +10:00
RichardAHBot
9a557dd147 PWA: Add account delete, uritoken gate, pwa config, RPC handlers, and fix AppLoader JSS conflict (#815)
- Add [pwa] config section to xahaud-example.cfg
- Add PWA enum to hook/hookapi.h and hook/Enum.h
- Use LEDGER_ENTRY_DUPLICATE for AppLoader to avoid JSS conflicts
- Add JSS(AppLoader) back to jss.h (needed by PWA code)
- Add PWA account delete test case and uritoken utf8 gate test
- Add PWA hook support in applyHook.cpp
- Add PWA RPC handlers in ServerHandler.cpp and LedgerEntry.cpp

Co-authored-by: RichardAHBot <bot@xahau.org>
2026-09-21 00:58:10 +10:00
Richard Holland
4f38ae3ea3 bug 2026-09-13 23:34:32 +10:00
RichardAHBot
7392d726c5 fix: regenerate hook/sfcodes.h to include sfAppLoader and sfAppLoaderID
The new sfields (sfAppLoaderID=UINT256/92, sfAppLoader=VL/96) were
added to sfields.macro but the generated hook header was not updated,
causing CI verify-generated-headers to fail.
2026-09-13 22:38:09 +10:00
RichardAHBot
d48b4ef2c7 style: clang-format-18 for PWA test file 2026-09-13 22:38:09 +10:00
RichardAHBot
77f0a6dc27 test(PWALoader): fix misplaced <html/> self-closing tag test
The self-closing <html/> followed by </html> test was in the rejected
section despite expecting R::ok. Moved it to the accepted section where
it belongs among tests that validate valid HTML structures.
2026-09-13 22:38:09 +10:00
RichardAHBot
f4523aa9b3 PWA: Add missing jss::AppLoader, Cache-Control header, and validation tests
Bug fix:
  - Added JSS(AppLoader) to jss.h. The directAccountKeylets[] array
    references jss::AppLoader, but this JSS entry was never declared.
    Without it the test build would fail with an undefined identifier.

Security:
  - Added Cache-Control: no-cache, no-store, must-revalidate to the PWA
    content response headers. Since the served HTML document changes with
    each ledger version, caching is undesirable and could cause clients
    to display stale content.

Testing:
  - Added 24 new validation test cases for appLoader::validate():
    - Uppercase and mixed-case tag names (<HTML>, <Html>)
    - Attributes on <html>
    - Nested elements (<body><p>hi</p></body>)
    - BOM followed by doctype
    - Trailing whitespace after </html>
    - BOM-only and BOM-whitespace-only documents (Result::empty)
    - Missing > on </html> end tag (Result::unclosed)
    - Doctype without html element (Result::noHtmlElement)
    - <head> before <html> (Result::noDoctype)
2026-09-13 22:38:09 +10:00
Richard Holland
8350ec92a3 Merge branch 'dev' into pwabootloader 2026-09-11 12:43:31 +10:00
Richard Holland
ad62a359f3 Merge branch 'dev' into pwabootloader 2026-09-09 14:29:07 +10:00
Richard Holland
2637f6ae26 init 2026-08-26 21:56:18 +10:00
36 changed files with 3367 additions and 7 deletions

View File

@@ -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>

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View 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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)

View File

@@ -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},
}))

View File

@@ -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)

View File

@@ -74,6 +74,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, ({
{sfTickSize, soeOPTIONAL},
{sfNFTokenMinter, soeOPTIONAL},
{sfHookStateScale, soeOPTIONAL},
{sfAppLoader, soeOPTIONAL},
}))
/** This transaction type cancels an existing escrow. */

View File

@@ -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

View File

@@ -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

View File

@@ -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();
}

View 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

View File

@@ -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
{

View File

@@ -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);

View 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

View File

@@ -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();
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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
//

View File

@@ -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);

View File

@@ -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"

View File

@@ -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

View File

@@ -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);

View File

@@ -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},

View File

@@ -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,

View File

@@ -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},

View File

@@ -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();

View File

@@ -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&);

View File

@@ -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},

View 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

View 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