aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-14 12:33:54 -0700
committerAdam Soltys <[email protected]>2026-06-17 07:47:47 -0700
commit1dc51829c39422495e289ba11f8668352abdba41 (patch)
tree40feacbbda8e91dd6246dddc9fdf83ab7755f77d /lib
parent7332e55641c21e024e50f83176b302df9c488e9a (diff)
downloadcoinos-server-1dc51829c39422495e289ba11f8668352abdba41.tar.gz
coinos-server-1dc51829c39422495e289ba11f8668352abdba41.zip
withdraw-lock: refuse external sends when /locks/<type>.locked is present
Port of master 9142b4db. Out-of-band emergency stop: nobal.sh (or a human) creates a sentinel file on the host, bind-mounted at /locks in the container; debit() checks for the file on every external send and refuses with "External withdrawals temporarily disabled". /locks/ALL.locked is a global override. Lives outside the db so a db-write vector can't disable it. Internal sends are exempt. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat (limited to 'lib')
-rw-r--r--lib/payments.ts25
1 files changed, 25 insertions, 0 deletions
diff --git a/lib/payments.ts b/lib/payments.ts
index e346e5f0..7445300b 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1,4 +1,5 @@
import config from "$config";
+import { existsSync } from "fs";
import api from "$lib/api";
import changeid from "$lib/changeid";
import { db, g, gf, s, sa } from "$lib/db";
@@ -65,6 +66,20 @@ const { URL } = process.env;
const dust = 547;
+// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a
+// human) can `touch /home/adam/locks/<type>.locked` on the host, which is
+// bind-mounted into this container at /locks. Existence of /locks/<type>.locked
+// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside
+// db so it can't be disabled by a db-write vector (May 18 /gateway lesson).
+//
+// Internal payments are never blocked by these — receives and internal
+// transfers between coinos users keep working with the site fully online.
+const isWithdrawLocked = (type: string): string | null => {
+ if (existsSync("/locks/ALL.locked")) return "ALL";
+ if (existsSync(`/locks/${type}.locked`)) return type;
+ return null;
+};
+
const resolveBip353 = async (name: string, domain: string) => {
const qname = `${name}.user._bitcoin-payment.${domain}`;
const res = await fetch(
@@ -179,6 +194,16 @@ export const debit = async ({
const frozen = (await g("hardfreeze")) || ((await g("freeze")) && type !== PaymentType.internal);
const skipServerLimit = type === PaymentType.fund || type === PaymentType.internal;
+ // Out-of-band withdrawal lock (file-based, can't be defeated by db writes).
+ // Internal sends are exempt — they don't touch external wallets.
+ if (type !== PaymentType.internal) {
+ const lockedKind = isWithdrawLocked(type);
+ if (lockedKind) {
+ warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind);
+ fail("External withdrawals temporarily disabled");
+ }
+ }
+
if (frozen || (userLimit != null && amount > userLimit && !whitelisted) || (!skipServerLimit && serverLimit != null && amount > serverLimit)) {
warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, serverLimit);
fail("Problem sending payment");