This commit is contained in:
parent
1d6b04c4f7
commit
2a4e2c1c85
8 changed files with 1381 additions and 41 deletions
|
|
@ -483,6 +483,26 @@ constexpr std::size_t kMaxSubmissionsPerPeer = 6;
|
|||
constexpr std::size_t kMaxSubmissionsPerWindow = 240;
|
||||
constexpr auto kRateWindow = std::chrono::minutes(10);
|
||||
|
||||
// A SECOND, tighter budget, for crypto submissions only.
|
||||
//
|
||||
// Choosing the crypto rail spends a receiving address out of a finite pool
|
||||
// that only an offline wallet ceremony can refill, and the address is spent
|
||||
// per SUBMISSION rather than per payment — an order nobody ever pays has
|
||||
// still consumed one. Under the general budget alone, a stranger needs no
|
||||
// account, no card and no money to walk the pool to zero (six per peer is
|
||||
// plenty when a default pool is a hundred addresses), and then no buyer can
|
||||
// choose crypto until the owner is at a desk with paper.
|
||||
//
|
||||
// So crypto gets its own smaller allowance on the same window and the same
|
||||
// peer key. A real buyer picks crypto once, maybe twice after a mistyped
|
||||
// field; nobody legitimately opens six crypto orders in ten minutes. The
|
||||
// global leg is the backstop against a spread-out flood, sized so a broad
|
||||
// attack costs many addresses rather than the whole pool.
|
||||
constexpr std::size_t kMaxCryptoPerPeer = 2;
|
||||
constexpr std::size_t kMaxCryptoPerWindow = 20;
|
||||
std::deque<RatePoint> gRecentCrypto;
|
||||
std::unordered_map<std::string, std::deque<RatePoint>> gRecentCryptoPerPeer;
|
||||
|
||||
bool RateLimitAllows(std::string_view peer) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gRateMutex);
|
||||
|
|
@ -511,6 +531,71 @@ bool RateLimitAllows(std::string_view peer) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The crypto leg of the same limiter, charged only when the buyer picked the
|
||||
// rail that spends an address. Deliberately a separate budget rather than a
|
||||
// smaller kMaxSubmissionsPerPeer: tightening the general limit would punish
|
||||
// the ordinary buyer who fixes a form error, and it is not form errors that
|
||||
// exhaust the pool.
|
||||
bool CryptoRateLimitAllows(std::string_view peer) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gRateMutex);
|
||||
|
||||
auto expire = [&](std::deque<RatePoint>& seen) {
|
||||
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
||||
};
|
||||
|
||||
expire(gRecentCrypto);
|
||||
if (gRecentCrypto.size() >= kMaxCryptoPerWindow) return false;
|
||||
|
||||
if (!peer.empty()) {
|
||||
// Same leak-avoidance as the general limiter: expire every peer and
|
||||
// drop the emptied entries rather than keeping a row per address that
|
||||
// ever submitted.
|
||||
std::erase_if(gRecentCryptoPerPeer, [&](auto& entry) {
|
||||
expire(entry.second);
|
||||
return entry.second.empty();
|
||||
});
|
||||
std::deque<RatePoint>& seen = gRecentCryptoPerPeer[std::string(peer)];
|
||||
if (seen.size() >= kMaxCryptoPerPeer) return false;
|
||||
seen.push_back(now);
|
||||
}
|
||||
|
||||
gRecentCrypto.push_back(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The inverse of NowIso8601, for the one caller that needs an order's real age:
|
||||
// exactly "YYYY-MM-DDTHH:MM:SSZ", which is the only shape this codebase writes.
|
||||
// nullopt for anything else — a ledger line from another tool, or a truncated
|
||||
// write — so the caller can fall back rather than trust a half-parsed date.
|
||||
// (std::chrono::parse would be the obvious tool and is not in this libc++.)
|
||||
std::optional<std::chrono::sys_seconds> ParseIso8601Utc(std::string_view s) {
|
||||
if (s.size() != 20 || s[4] != '-' || s[7] != '-' || s[10] != 'T'
|
||||
|| s[13] != ':' || s[16] != ':' || s[19] != 'Z') {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto num = [&](std::size_t at, std::size_t len) -> std::optional<int> {
|
||||
int v = 0;
|
||||
const auto [end, ec] =
|
||||
std::from_chars(s.data() + at, s.data() + at + len, v);
|
||||
if (ec != std::errc{} || end != s.data() + at + len) return std::nullopt;
|
||||
return v;
|
||||
};
|
||||
const auto y = num(0, 4), mo = num(5, 2), d = num(8, 2);
|
||||
const auto h = num(11, 2), mi = num(14, 2), sec = num(17, 2);
|
||||
if (!y || !mo || !d || !h || !mi || !sec) return std::nullopt;
|
||||
if (*mo < 1 || *mo > 12 || *d < 1 || *d > 31) return std::nullopt;
|
||||
if (*h > 23 || *mi > 59 || *sec > 60) return std::nullopt;
|
||||
const std::chrono::year_month_day ymd{ std::chrono::year{ *y },
|
||||
std::chrono::month{
|
||||
static_cast<unsigned>(*mo) },
|
||||
std::chrono::day{
|
||||
static_cast<unsigned>(*d) } };
|
||||
if (!ymd.ok()) return std::nullopt;
|
||||
return std::chrono::sys_days{ ymd } + std::chrono::hours{ *h }
|
||||
+ std::chrono::minutes{ *mi } + std::chrono::seconds{ *sec };
|
||||
}
|
||||
|
||||
// RFC 3339 UTC. Recorded so the order log can be read chronologically
|
||||
// without depending on file order.
|
||||
std::string NowIso8601() {
|
||||
|
|
@ -646,6 +731,16 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
||||
parsed.value, "429");
|
||||
}
|
||||
// Crypto pays a second, tighter toll: this submission is about to spend a
|
||||
// receiving address that only an offline wallet ceremony can replace. The
|
||||
// charge happens here rather than at CreateLink so the general budget is
|
||||
// already spent too — a peer probing the pool burns their ordinary
|
||||
// checkout allowance at the same time.
|
||||
if (wantsCrypto && !CryptoRateLimitAllows(peer)) {
|
||||
return reject({{ "pay", "Too many crypto orders from here just now — please "
|
||||
"try again shortly, or pick bank or card." }},
|
||||
parsed.value, "429");
|
||||
}
|
||||
|
||||
std::int64_t unitMinor = 0;
|
||||
Money::Totals totals;
|
||||
|
|
@ -977,7 +1072,21 @@ void ReconcilerLoop(const std::stop_token& stop) {
|
|||
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now });
|
||||
if (!inserted) {
|
||||
using namespace std::chrono;
|
||||
const auto age = now - it->second.first;
|
||||
// Age from the ORDER, not from when this process first saw it.
|
||||
// Steady-clock first-seen restarts the seven days on every
|
||||
// deploy, so a year-old awaiting order gets polled for another
|
||||
// week after each one — wasted calls against both providers,
|
||||
// growing with every abandoned order the ledger has ever held.
|
||||
// The record's timestamp is the real age; a timestamp that will
|
||||
// not parse falls back to the old behaviour rather than
|
||||
// dropping an order that might be live.
|
||||
const std::optional<std::chrono::sys_seconds> placed =
|
||||
ParseIso8601Utc(order.createdAt);
|
||||
const auto age =
|
||||
placed ? std::chrono::duration_cast<
|
||||
std::chrono::steady_clock::duration>(
|
||||
std::chrono::system_clock::now() - *placed)
|
||||
: now - it->second.first;
|
||||
if (age > hours(24 * 7)) continue;
|
||||
const auto due = age > hours(2)
|
||||
? seconds(minutes(10))
|
||||
|
|
@ -1180,7 +1289,14 @@ int Serve(std::uint16_t port) {
|
|||
});
|
||||
|
||||
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
|
||||
std::println("catcrafts-server: listening on 127.0.0.1:{} "
|
||||
// std::cerr like every other diagnostic, and not for consistency alone:
|
||||
// under journald stdout is a pipe, so it is FULLY buffered — this line
|
||||
// once sat invisible for hours (or died unflushed with the process) while
|
||||
// deploy tooling polled the journal for it as a liveness signal. stderr
|
||||
// is unbuffered; the one line that announces what the server IS must not
|
||||
// arrive after the fact.
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: listening on 127.0.0.1:{} "
|
||||
"({} projects, {} posts, payments: bank={} crypto={})",
|
||||
port, gContent.projects.size(), gContent.posts.size(),
|
||||
gRails.bank ? gRails.bank->Name() : "off",
|
||||
|
|
|
|||
Loading…
Reference in a new issue