1 Commits
Author SHA1 Message Date
raucao 7a57d3b4c5 Cache successful Blossom responses
Cache returned files from Blossom on the Nginx hosts, until either
deleted by the user or hitting the global cache size limit (1 GB)
2026-08-16 11:53:51 -06:00
8 changed files with 4 additions and 304 deletions
+1 -1
Submodule nodes updated: 90c15e40f8...5fd2e23a01
-199
View File
@@ -1,199 +0,0 @@
#!/usr/bin/env bash
# Manually record an incoming Lightning payment to credit a lndhub.go user account.
#
# Mirrors the canonical incoming-settlement path in lib/service/invoicesubscription.go
# (ProcessInvoiceUpdate): inserts a settled `invoices` row plus a `transaction_entries`
# row crediting the user's `current` account and debiting their `incoming` account
# (which is exempt from the non-negative check_balance() trigger, so it may go negative).
#
# Usage:
# ./gitno/credit-account.sh --env-file <path/to/.env> \
# --login <login> --sats <N> [--memo "manual credit"]
#
# The --env-file is the lndhub.go .env file (the same one loaded by godotenv in
# cmd/server/main.go). Only DATABASE_URI is read from it; the value from the file
# ALWAYS takes precedence over any DATABASE_URI already present in the environment.
#
# Requires: psql. The whole operation runs in one transaction; ON_ERROR_STOP=1
# aborts on any error so the tx is rolled back and psql exits non-zero.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: credit-account.sh --env-file <path/to/.env> --login <login> --sats <N> [--memo "..."]
Options:
--env-file Path to the lndhub.go .env file (required). Only DATABASE_URI is
read from it; the file value always wins over the environment.
--login User login (required)
--sats Amount in satoshis to credit (required, positive integer)
--memo Memo for the recorded invoice (default: "manual credit")
-h, --help
EOF
exit "${1:-0}"
}
env_file=""
login=""
sats=""
memo="manual credit"
while [[ $# -gt 0 ]]; do
case "$1" in
--env-file) env_file="$2"; shift 2 ;;
--login) login="$2"; shift 2 ;;
--sats) sats="$2"; shift 2 ;;
--memo) memo="$2"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "Unknown argument: $1" >&2; usage 1 ;;
esac
done
[[ -n "$env_file" ]] || { echo "Error: --env-file is required" >&2; usage 1; }
[[ -n "$login" ]] || { echo "Error: --login is required" >&2; usage 1; }
[[ -n "$sats" ]] || { echo "Error: --sats is required" >&2; usage 1; }
[[ "$sats" =~ ^[1-9][0-9]*$ ]] || { echo "Error: --sats must be a positive integer" >&2; usage 1; }
# Sanity-check psql is available.
command -v psql >/dev/null || { echo "Error: psql not found in PATH" >&2; exit 1; }
# Extract a single KEY=VALUE entry from a .env file, matching godotenv semantics:
# - skips blank lines and lines whose first non-whitespace char is '#'
# - tolerates an optional leading 'export '
# - case-insensitive key match
# - strips one pair of surrounding matching quotes (" or ') from the value
# Writes the result to stdout; empty output means "not found".
extract_env_key() {
local file="$1" key="$2" line k v
while IFS= read -r line || [[ -n "$line" ]]; do
# Trim leading whitespace; skip blanks and comments
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
# Strip optional 'export ' prefix
[[ "$line" == export\ * ]] && line="${line#export }"
# Split on the first '='
[[ "$line" != *=* ]] && continue
k="${line%%=*}"
v="${line#*=}"
# Case-insensitive key compare
if [[ "${k,,}" == "${key,,}" ]]; then
# Strip one pair of surrounding matching single or double quotes
if [[ ${#v} -ge 2 ]]; then
if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then v="${v:1:-1}";
elif [[ "${v:0:1}" == "'" && "${v: -1}" == "'" ]]; then v="${v:1:-1}"; fi
fi
printf '%s\n' "$v"
return 0
fi
done < "$file"
return 1
}
[[ -r "$env_file" ]] || { echo "Error: --env-file not readable: $env_file" >&2; exit 1; }
# DATABASE_URI is sourced exclusively from --env-file; the file value always wins
# over any DATABASE_URI already present in the environment.
if ! DATABASE_URI="$(extract_env_key "$env_file" DATABASE_URI)"; then
echo "Error: DATABASE_URI not found in $env_file" >&2
exit 1
fi
[[ -n "$DATABASE_URI" ]] || { echo "Error: DATABASE_URI is empty in $env_file" >&2; exit 1; }
case "$DATABASE_URI" in
postgres://*|postgresql://*) ;;
*) echo "Error: DATABASE_URI must start with postgres:// or postgresql:// (got: $DATABASE_URI)" >&2; exit 1 ;;
esac
psql "$DATABASE_URI" \
-v ON_ERROR_STOP=1 \
-v login="$login" \
-v sats="$sats" \
-v memo="$memo" \
<<'SQL'
\set ON_ERROR_STOP on
\echo Resolving user, accounts, and recording the credit in one transaction...
-- Pass the CLI args into server-side GUCs so the DO block (dollar-quoted, where
-- psql :var substitution does NOT happen) can read them via current_setting().
-- These SETs run outside any dollar-quote, so :'login'/:sats/:'memo' are substituted
-- by psql as a properly-quoted SQL literal / bare token here.
SET app.lndhub_login = :'login';
SET app.lndhub_sats = :sats;
SET app.lndhub_memo = :'memo';
BEGIN;
-- Look up user + accounts, validate, and insert the invoice + ledger entry.
-- Raises an exception (aborting the tx, psql exits non-zero via ON_ERROR_STOP)
-- with a helpful message if the user or either account is missing.
DO $$
DECLARE
v_uid bigint;
v_curr bigint;
v_inc bigint;
v_inv_id bigint;
v_login text := current_setting('app.lndhub_login');
v_sats bigint := current_setting('app.lndhub_sats')::bigint;
v_memo text := current_setting('app.lndhub_memo');
BEGIN
SELECT id INTO v_uid FROM users WHERE login = v_login;
IF NOT FOUND THEN
RAISE EXCEPTION 'no user found with login = %', v_login;
END IF;
SELECT id INTO v_curr FROM accounts WHERE user_id = v_uid AND type = 'current';
IF NOT FOUND THEN
RAISE EXCEPTION 'user % has no current account', v_uid;
END IF;
SELECT id INTO v_inc FROM accounts WHERE user_id = v_uid AND type = 'incoming';
IF NOT FOUND THEN
RAISE EXCEPTION 'user % has no incoming account', v_uid;
END IF;
RAISE NOTICE 'user_id=% current_acct=% incoming_acct=% amount=% sats',
v_uid, v_curr, v_inc, v_sats;
-- Settled incoming invoice. Constraints honored:
-- * destination_pubkey_hex NOT NULL -> 66-hex dummy
-- * settled => preimage NOT NULL (check_primage_exists)
-- * fresh r_hash/preimage per run keeps rows distinct
-- * uses gen_random_uuid() (built-in core since PG13, no pgcrypto needed);
-- 32 hex chars is sufficient for a dummy identifier
INSERT INTO invoices
(type, user_id, amount, memo, destination_pubkey_hex, r_hash, preimage,
state, settled_at, created_at, internal)
VALUES
('incoming', v_uid, v_sats, v_memo,
'000000000000000000000000000000000000000000000000000000000000000000',
replace(gen_random_uuid()::text, '-', ''),
replace(gen_random_uuid()::text, '-', ''),
'settled', now(), now(), false)
RETURNING id INTO v_inv_id;
RAISE NOTICE 'invoice_id=%', v_inv_id;
-- Ledger entry: credit current (+amount), debit incoming (-amount).
-- * debit_account_id != credit_account_id (current != incoming) -> check_not_same_account ok
-- * debit on `incoming` is exempt from check_balance() -> may go negative
-- * fresh invoice_id keeps unique_tx_entry_tuple satisfied
INSERT INTO transaction_entries
(user_id, invoice_id, credit_account_id, debit_account_id, amount, entry_type, created_at)
VALUES
(v_uid, v_inv_id, v_curr, v_inc, v_sats, 'incoming', now());
RAISE NOTICE 'credited % sats to user % (login=%)', v_sats, v_uid, v_login;
END $$;
COMMIT;
-- Resulting current-account balance (same query as CurrentUserBalance in
-- lib/service/user.go: sum over account_ledgers for the user's current account).
SELECT
(SELECT sum(al.amount)
FROM account_ledgers al
JOIN accounts a ON a.id = al.account_id
WHERE a.user_id = (SELECT id FROM users WHERE login = current_setting('app.lndhub_login'))
AND a.type = 'current') AS new_balance_sat;
SQL
@@ -19,7 +19,7 @@ server {
ssl_certificate <%= @ssl_cert %>;
ssl_certificate_key <%= @ssl_key %>;
location ~ "^/(?<sha256>[a-f0-9]{64})(\.[a-zA-Z0-9]+)?$" {
location ~ ^/(?<sha256>[a-f0-9]{64})(\.[a-zA-Z0-9]+)?$ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -29,12 +29,6 @@ template "/etc/spamassassin/local.cf" do
notifies :restart, "service[spamassassin]", :delayed
end
template "/etc/spamassassin/kosmos.cf" do
source "spamassassin_kosmos.cf.erb"
mode 0644
notifies :restart, "service[spamassassin]", :delayed
end
service "spamassassin" do
action [:enable, :start]
end
@@ -30,4 +30,4 @@ PIDFILE="/var/run/spamd.pid"
# Cronjob
# Set to anything but 0 to enable the cron job to automatically update
# spamassassin's rules on a nightly basis
CRON=1
CRON=0
@@ -1,64 +0,0 @@
###########################################################################
# Kosmos custom SpamAssassin rules
#
# The rules below are designed to be individually weak but combine via meta
# rules into strong, low-FP signals.
###########################################################################
# --- Individual signals --------------------------------------------------
# URLs of the form https://////////////... (3+ slashes after the scheme).
# Legitimate mailers never produce this; it is an obfuscation artefact.
rawbody KOSMOS_MULTI_SLASH_URL /https?:\/{3,}/
describe KOSMOS_MULTI_SLASH_URL URL with three or more consecutive slashes
# Authoring-tool fingerprint left in the HTML by the spam toolchain.
body KOSMOS_MSHTML_11_9600 /MSHTML 11\.00\.9600\.17037/
describe KOSMOS_MSHTML_11_9600 HTML generated by MSHTML 11.00.9600.17037
# Display name pattern used by the campaign.
header KOSMOS_FROM_LUXURY_GOODS From:name =~ /Luxury (Watches|Bags|Handbags|Timepieces)\b/i
describe KOSMOS_FROM_LUXURY_GOODS From display name advertises luxury goods
# Base64-encoded unsubscribe links: return.php?p=<long base64>
uri KOSMOS_RETURN_PHP_B64 /return\.php\?p=[A-Za-z0-9+\/=%]{20,}/
describe KOSMOS_RETURN_PHP_B64 Base64-encoded return.php unsubscribe link
# Fabricated "security" headers injected to evade heuristic filters.
# No legitimate MTA or mailing-list manager emits these.
header KOSMOS_FAKE_HDR_PHISHSIM exists:X-PhishSimulator-Mode
header KOSMOS_FAKE_HDR_DECEPTION exists:X-Deception-Asset-Type
header KOSMOS_FAKE_HDR_OBFUSCATION exists:X-Obfuscation-Trace-ID
header KOSMOS_FAKE_HDR_QUARANTINE exists:X-Quarantine-Reason-Code
header KOSMOS_FAKE_HDR_TRUST exists:X-Behavioral-Trust-Index
# --- Scores for individual signals --------------------------------------
score KOSMOS_MULTI_SLASH_URL 2.0
score KOSMOS_MSHTML_11_9600 1.2
score KOSMOS_FROM_LUXURY_GOODS 0.5
score KOSMOS_RETURN_PHP_B64 1.5
score KOSMOS_FAKE_HDR_PHISHSIM 1.0
score KOSMOS_FAKE_HDR_DECEPTION 1.0
score KOSMOS_FAKE_HDR_OBFUSCATION 1.0
score KOSMOS_FAKE_HDR_QUARANTINE 1.0
score KOSMOS_FAKE_HDR_TRUST 1.0
# --- Meta rules ----------------------------------------------------------
# Core campaign signature: luxury-goods From name + MSHTML fingerprint +
# HTML-only body. Covers the bulk of the campaign corpus.
meta KOSMOS_LUXURY_SPAM_CAMPAIGN (KOSMOS_FROM_LUXURY_GOODS && KOSMOS_MSHTML_11_9600 && MIME_HTML_ONLY)
describe KOSMOS_LUXURY_SPAM_CAMPAIGN Luxury-goods From + MSHTML 11.00.9600 + HTML-only
score KOSMOS_LUXURY_SPAM_CAMPAIGN 3.5
# Luxury-goods From name combined with a suspicious URI signal or a
# Spamhaus-listed relay.
meta KOSMOS_LUXURY_SPAM_URI (KOSMOS_FROM_LUXURY_GOODS && (KOSMOS_MULTI_SLASH_URL || KOSMOS_RETURN_PHP_B64 || RCVD_IN_SBL_CSS))
describe KOSMOS_LUXURY_SPAM_URI Luxury-goods From + suspicious URI or SBL relay
score KOSMOS_LUXURY_SPAM_URI 2.5
# Two or more fabricated "security" headers. Genuine mail never carries
# these; their presence indicates a header-injection evasion kit.
meta KOSMOS_FAKE_SECURITY_HEADERS (KOSMOS_FAKE_HDR_PHISHSIM + KOSMOS_FAKE_HDR_DECEPTION + KOSMOS_FAKE_HDR_OBFUSCATION + KOSMOS_FAKE_HDR_QUARANTINE + KOSMOS_FAKE_HDR_TRUST >= 2)
describe KOSMOS_FAKE_SECURITY_HEADERS Two or more fabricated security headers
score KOSMOS_FAKE_SECURITY_HEADERS 4.0
@@ -16,37 +16,6 @@ whitelist_auth <%= @whitelist_auth %>
# _CONTACTADDRESS_ in the report template)
report_contact <%= @report_contact %>
###########################################################################
# Kosmos custom score overrides
#
# The Validity (Return Path / SenderScore) "certified sender" whitelists
# (RCVD_IN_VALIDITY_CERTIFIED, RCVD_IN_VALIDITY_SAFE) hand out up to -5.0
# of credit to sending IPs. These lists are commercially gamed and
# routinely award -5.0 to IPs that are simultaneously listed on Spamhaus
# SBL-CSS, SpamCop, MSPIKE and Validity's own RPBL. Neutralise them.
###########################################################################
score RCVD_IN_VALIDITY_CERTIFIED 0
score RCVD_IN_VALIDITY_SAFE 0
###########################################################################
# Bayes hardening
###########################################################################
use_bayes 1
bayes_auto_learn 1
# Do not let Bayes learn from SpamAssassin's own result headers or from
# Authentication-Results, which leak signal about prior scoring runs.
bayes_ignore_header X-Spam-Flag
bayes_ignore_header X-Spam-Status
bayes_ignore_header X-Spam-Level
bayes_ignore_header X-Spam-Checker-Version
bayes_ignore_header Authentication-Results
# Only learn ham when the message is clearly clean, and only learn spam
# when it is clearly spam. The defaults (0.1 / 6.0) let marginally-spam
# or marginally-ham messages poison the database.
bayes_auto_learn_threshold_nonspam -1.0
bayes_auto_learn_threshold_spam 8.0
# Add *****SPAM***** to the Subject header of spam e-mails
#
@@ -3,7 +3,7 @@
# Recipe:: default
#
node.normal['openresty']['log_formats']['json'] = '{"ip":"$remote_addr","time":"$time_local","host":"$host","method":"$request_method","uri":"$uri","status":$status,"size":$body_bytes_sent,"referer":"$http_referer","upstream_addr":"$upstream_addr","upstream_response_time":"$upstream_response_time","upstream_cache_status":"$upstream_cache_status","ua":"$http_user_agent"}'
node.normal['openresty']['log_formats']['json'] = '{"ip":"$remote_addr","time":"$time_local","host":"$host","method":"$request_method","uri":"$uri","status":$status,"size":$body_bytes_sent,"referer":"$http_referer","upstream_addr":"$upstream_addr","upstream_response_time":"$upstream_response_time","ua":"$http_user_agent"}'
# Install openresty from official packages
include_recipe 'openresty::apt_package'