20 changed files with 1280 additions and 7 deletions
@ -0,0 +1,48 @@ |
|||||||
|
.PHONY: help relay-build relay-up relay-down relay-prime relay-ingest-now relay-shell relay-test relay-logs |
||||||
|
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo "Available targets:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
relay-build: ## Build relay containers (first time only, ~10 min)
|
||||||
|
docker compose build strfry ingest
|
||||||
|
@echo "Relay containers built successfully."
|
||||||
|
|
||||||
|
relay-up: ## Start the relay and ingest services
|
||||||
|
docker compose up -d strfry ingest
|
||||||
|
@echo "Relay services started. Check status with: docker compose ps"
|
||||||
|
|
||||||
|
relay-down: ## Stop the relay and ingest services
|
||||||
|
docker compose stop strfry ingest
|
||||||
|
@echo "Relay services stopped."
|
||||||
|
|
||||||
|
relay-prime: ## Run initial backfill (one-time, broader time window)
|
||||||
|
bash bin/relay/prime.sh
|
||||||
|
@echo "Relay prime/backfill completed."
|
||||||
|
|
||||||
|
relay-ingest-now: ## Run ingest manually (useful for testing)
|
||||||
|
bash bin/relay/ingest.sh
|
||||||
|
@echo "Manual ingest completed."
|
||||||
|
|
||||||
|
relay-shell: ## Open shell in strfry container
|
||||||
|
docker compose exec strfry sh
|
||||||
|
|
||||||
|
relay-test: ## Run PHP smoke test against the relay
|
||||||
|
php bin/relay/test-smoke.php
|
||||||
|
|
||||||
|
relay-logs: ## Show relay logs
|
||||||
|
docker compose logs -f strfry ingest
|
||||||
|
|
||||||
|
relay-stats: ## Show relay statistics
|
||||||
|
docker compose exec strfry strfry db-stats
|
||||||
|
|
||||||
|
relay-export: ## Export relay database (backup)
|
||||||
|
@echo "Exporting relay database..."
|
||||||
|
docker compose exec strfry strfry export > relay-backup-$(shell date +%Y%m%d-%H%M%S).jsonl
|
||||||
|
@echo "Export completed."
|
||||||
|
|
||||||
|
relay-import: ## Import events from file (usage: make relay-import FILE=backup.jsonl)
|
||||||
|
@if [ -z "$(FILE)" ]; then echo "Error: FILE parameter required. Usage: make relay-import FILE=backup.jsonl"; exit 1; fi
|
||||||
|
cat $(FILE) | docker compose exec -T strfry strfry import
|
||||||
|
@echo "Import completed."
|
||||||
|
|
||||||
@ -0,0 +1,92 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
set -euo pipefail |
||||||
|
|
||||||
|
echo "[$(date)] Starting relay ingest..." |
||||||
|
|
||||||
|
# Config via env or defaults |
||||||
|
UPSTREAMS=${UPSTREAMS:-"wss://relay.snort.social wss://relay.damus.io wss://relay.nostr.band"} |
||||||
|
DAYS_ARTICLES=${DAYS_ARTICLES:-7} |
||||||
|
DAYS_THREADS=${DAYS_THREADS:-3} |
||||||
|
|
||||||
|
# These two should be programmatically generated from app DB; allow overrides: |
||||||
|
ARTICLE_E_LIST=${ARTICLE_E_LIST:-'[]'} # e.g. ["<eventid1>","<eventid2>"] |
||||||
|
ARTICLE_A_LIST=${ARTICLE_A_LIST:-'[]'} # e.g. ["30023:<authorhex>:<d>",...] |
||||||
|
|
||||||
|
# Helper functions for date calculation |
||||||
|
now_ts() { date +%s; } |
||||||
|
since_days() { |
||||||
|
local days=$1 |
||||||
|
if command -v date >/dev/null 2>&1; then |
||||||
|
# Try GNU date |
||||||
|
if date -d "-${days} days" +%s 2>/dev/null; then |
||||||
|
return 0 |
||||||
|
# Try BSD date (macOS) |
||||||
|
elif date -v-${days}d +%s 2>/dev/null; then |
||||||
|
return 0 |
||||||
|
fi |
||||||
|
fi |
||||||
|
# Fallback: rough calculation |
||||||
|
echo $(( $(date +%s) - (days * 86400) )) |
||||||
|
} |
||||||
|
|
||||||
|
# Build filters using jq if available, otherwise use basic JSON |
||||||
|
if command -v jq >/dev/null 2>&1; then |
||||||
|
FILTER_ARTICLES=$(jq -nc --argjson kinds '[30023]' --arg since "$(since_days $DAYS_ARTICLES)" ' |
||||||
|
{kinds:$kinds, since: ($since|tonumber)}') |
||||||
|
|
||||||
|
FILTER_REPLIES_E=$(jq -nc --argjson kinds '[1]' --argjson es "$ARTICLE_E_LIST" --arg since "$(since_days $DAYS_THREADS)" ' |
||||||
|
{kinds:$kinds, "#e":$es, since: ($since|tonumber)}') |
||||||
|
|
||||||
|
FILTER_REPLIES_A=$(jq -nc --argjson kinds '[1]' --argjson as "$ARTICLE_A_LIST" --arg since "$(since_days $DAYS_THREADS)" ' |
||||||
|
{kinds:$kinds, "#a":$as, since: ($since|tonumber)}') |
||||||
|
|
||||||
|
FILTER_REACTS=$(jq -nc --argjson kinds '[7]' --argjson es "$ARTICLE_E_LIST" '{kinds:$kinds, "#e":$es}') |
||||||
|
FILTER_ZAPS=$(jq -nc --argjson kinds '[9735]' --argjson es "$ARTICLE_E_LIST" '{kinds:$kinds, "#e":$es}') |
||||||
|
FILTER_HL=$(jq -nc --argjson kinds '[9802]' --argjson as "$ARTICLE_A_LIST" '{kinds:$kinds, "#a":$as}') |
||||||
|
FILTER_PROFILES=$(jq -nc --argjson kinds '[0]' '{kinds:$kinds}') |
||||||
|
FILTER_DELETES=$(jq -nc --argjson kinds '[5]' --arg since "$(since_days 30)" '{kinds:$kinds, since:($since|tonumber)}') |
||||||
|
else |
||||||
|
# Fallback to basic JSON strings |
||||||
|
SINCE_ARTICLES=$(since_days $DAYS_ARTICLES) |
||||||
|
SINCE_THREADS=$(since_days $DAYS_THREADS) |
||||||
|
SINCE_DELETES=$(since_days 30) |
||||||
|
|
||||||
|
FILTER_ARTICLES="{\"kinds\":[30023],\"since\":${SINCE_ARTICLES}}" |
||||||
|
FILTER_REPLIES_E="{\"kinds\":[1],\"#e\":${ARTICLE_E_LIST},\"since\":${SINCE_THREADS}}" |
||||||
|
FILTER_REPLIES_A="{\"kinds\":[1],\"#a\":${ARTICLE_A_LIST},\"since\":${SINCE_THREADS}}" |
||||||
|
FILTER_REACTS="{\"kinds\":[7],\"#e\":${ARTICLE_E_LIST}}" |
||||||
|
FILTER_ZAPS="{\"kinds\":[9735],\"#e\":${ARTICLE_E_LIST}}" |
||||||
|
FILTER_HL="{\"kinds\":[9802],\"#a\":${ARTICLE_A_LIST}}" |
||||||
|
FILTER_PROFILES="{\"kinds\":[0]}" |
||||||
|
FILTER_DELETES="{\"kinds\":[5],\"since\":${SINCE_DELETES}}" |
||||||
|
fi |
||||||
|
|
||||||
|
run_sync() { |
||||||
|
local upstream=$1 |
||||||
|
local filter=$2 |
||||||
|
local label=$3 |
||||||
|
echo "[$(date)] Syncing ${label} from ${upstream}..." |
||||||
|
|
||||||
|
# Write filter to temp file to avoid shell escaping nightmares |
||||||
|
local tmpfile="/tmp/strfry-filter-$$.json" |
||||||
|
echo "$filter" | docker compose exec strfry sh -c "cat > $tmpfile" |
||||||
|
|
||||||
|
# Run sync with filter file |
||||||
|
docker compose exec strfry sh -c "./strfry sync '$upstream' --filter=\$(cat $tmpfile) && rm $tmpfile" || echo "[$(date)] WARNING: sync failed for ${label} from ${upstream}" |
||||||
|
} |
||||||
|
|
||||||
|
# Sync from all upstream relays |
||||||
|
for R in $UPSTREAMS; do |
||||||
|
echo "[$(date)] Processing relay: ${R}" |
||||||
|
run_sync "$R" "$FILTER_ARTICLES" "articles (30023)" |
||||||
|
run_sync "$R" "$FILTER_REPLIES_E" "replies by event-id" |
||||||
|
run_sync "$R" "$FILTER_REPLIES_A" "replies by a-tag" |
||||||
|
run_sync "$R" "$FILTER_REACTS" "reactions (7)" |
||||||
|
run_sync "$R" "$FILTER_ZAPS" "zap receipts (9735)" |
||||||
|
run_sync "$R" "$FILTER_HL" "highlights (9802)" |
||||||
|
run_sync "$R" "$FILTER_PROFILES" "profiles (0)" |
||||||
|
run_sync "$R" "$FILTER_DELETES" "deletes (5)" |
||||||
|
done |
||||||
|
|
||||||
|
echo "[$(date)] Relay ingest complete." |
||||||
|
|
||||||
@ -0,0 +1,13 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
set -euo pipefail |
||||||
|
|
||||||
|
echo "[$(date)] Starting relay prime (one-time backfill)..." |
||||||
|
|
||||||
|
# Larger time windows for initial backfill |
||||||
|
export DAYS_ARTICLES=${DAYS_ARTICLES:-90} |
||||||
|
export DAYS_THREADS=${DAYS_THREADS:-30} |
||||||
|
|
||||||
|
# Use the same ingest logic but with extended time windows |
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
||||||
|
exec "${SCRIPT_DIR}/ingest.sh" |
||||||
|
|
||||||
@ -0,0 +1,136 @@ |
|||||||
|
#!/usr/bin/env php |
||||||
|
<?php |
||||||
|
/** |
||||||
|
* Smoke test for the local relay |
||||||
|
* Tests that the relay is up and can serve basic queries |
||||||
|
*/ |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
// Bootstrap Symfony autoloader if available, or try to use vendor autoload directly |
||||||
|
$possibleAutoloaders = [ |
||||||
|
__DIR__ . '/../../vendor/autoload.php', |
||||||
|
__DIR__ . '/../../../vendor/autoload.php', |
||||||
|
]; |
||||||
|
|
||||||
|
$autoloaderFound = false; |
||||||
|
foreach ($possibleAutoloaders as $autoloader) { |
||||||
|
if (file_exists($autoloader)) { |
||||||
|
require_once $autoloader; |
||||||
|
$autoloaderFound = true; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!$autoloaderFound) { |
||||||
|
fwrite(STDERR, "ERROR: Could not find autoloader. Run 'composer install' first.\n"); |
||||||
|
exit(1); |
||||||
|
} |
||||||
|
|
||||||
|
use swentel\nostr\Relay\Relay; |
||||||
|
use swentel\nostr\Message\RequestMessage; |
||||||
|
use swentel\nostr\Filter; |
||||||
|
use WebSocket\Message\Text; |
||||||
|
use WebSocket\Exception\TimeoutException; |
||||||
|
|
||||||
|
// Get relay URL from environment or use default |
||||||
|
$relayUrl = getenv('NOSTR_DEFAULT_RELAY') ?: 'ws://localhost:7777'; |
||||||
|
|
||||||
|
echo "Testing relay: {$relayUrl}\n"; |
||||||
|
echo str_repeat('-', 60) . "\n"; |
||||||
|
|
||||||
|
try { |
||||||
|
// Test 1: Basic connection |
||||||
|
echo "Test 1: Connecting to relay...\n"; |
||||||
|
$relay = new Relay($relayUrl); |
||||||
|
$relay->connect(); |
||||||
|
echo "✓ Connected successfully\n\n"; |
||||||
|
|
||||||
|
// Test 2: Query for long-form articles (kind 30023) |
||||||
|
echo "Test 2: Querying for kind:30023 events (limit 1)...\n"; |
||||||
|
|
||||||
|
$filter = new Filter(); |
||||||
|
$filter->setKinds([30023]); |
||||||
|
$filter->setLimit(1); |
||||||
|
|
||||||
|
$subscriptionId = 'test-' . bin2hex(random_bytes(8)); |
||||||
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||||
|
|
||||||
|
$client = $relay->getClient(); |
||||||
|
$client->setTimeout(10); |
||||||
|
$client->text($requestMessage->generate()); |
||||||
|
|
||||||
|
$foundEvent = false; |
||||||
|
$eventCount = 0; |
||||||
|
$startTime = time(); |
||||||
|
$timeout = 10; |
||||||
|
|
||||||
|
while ((time() - $startTime) < $timeout) { |
||||||
|
try { |
||||||
|
$response = $client->receive(); |
||||||
|
|
||||||
|
if (!$response instanceof Text) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$content = $response->getContent(); |
||||||
|
$decoded = json_decode($content, true); |
||||||
|
|
||||||
|
if (!is_array($decoded) || count($decoded) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$messageType = $decoded[0] ?? ''; |
||||||
|
|
||||||
|
if ($messageType === 'EVENT') { |
||||||
|
$eventCount++; |
||||||
|
$event = $decoded[2] ?? []; |
||||||
|
$eventId = $event['id'] ?? 'unknown'; |
||||||
|
$eventKind = $event['kind'] ?? 'unknown'; |
||||||
|
|
||||||
|
echo "✓ Received EVENT: id={$eventId}, kind={$eventKind}\n"; |
||||||
|
$foundEvent = true; |
||||||
|
|
||||||
|
// Send CLOSE |
||||||
|
$client->text(json_encode(['CLOSE', $subscriptionId])); |
||||||
|
break; |
||||||
|
} elseif ($messageType === 'EOSE') { |
||||||
|
echo " Received EOSE (End of Stored Events)\n"; |
||||||
|
// Send CLOSE |
||||||
|
$client->text(json_encode(['CLOSE', $subscriptionId])); |
||||||
|
break; |
||||||
|
} elseif ($messageType === 'NOTICE' || $messageType === 'CLOSED') { |
||||||
|
echo " Received {$messageType}: " . ($decoded[1] ?? '') . "\n"; |
||||||
|
break; |
||||||
|
} |
||||||
|
} catch (TimeoutException $e) { |
||||||
|
echo " Timeout waiting for response\n"; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!$foundEvent && $eventCount === 0) { |
||||||
|
echo "⚠ No events found (relay might be empty - try running 'make relay-prime' first)\n\n"; |
||||||
|
} else { |
||||||
|
echo "\n"; |
||||||
|
} |
||||||
|
|
||||||
|
// Test 3: Verify write rejection |
||||||
|
echo "Test 3: Testing write policy (should reject)...\n"; |
||||||
|
// We'll just document this - actual test would require creating a signed event |
||||||
|
echo "⚠ Write rejection test not implemented (requires event signing)\n"; |
||||||
|
echo " Manual test: Try publishing an event - should receive rejection message\n\n"; |
||||||
|
|
||||||
|
$relay->disconnect(); |
||||||
|
|
||||||
|
echo str_repeat('-', 60) . "\n"; |
||||||
|
echo "✓ Smoke test completed successfully\n"; |
||||||
|
|
||||||
|
exit(0); |
||||||
|
|
||||||
|
} catch (\Exception $e) { |
||||||
|
echo "\n✗ ERROR: " . $e->getMessage() . "\n"; |
||||||
|
echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; |
||||||
|
exit(1); |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,19 @@ |
|||||||
|
FROM alpine:3.19 |
||||||
|
|
||||||
|
# Install supercronic, bash, jq, docker-cli for the ingest script |
||||||
|
RUN apk add --no-cache \ |
||||||
|
bash \ |
||||||
|
curl \ |
||||||
|
jq \ |
||||||
|
docker-cli \ |
||||||
|
&& curl -fsSLO https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \ |
||||||
|
&& chmod +x supercronic-linux-amd64 \ |
||||||
|
&& mv supercronic-linux-amd64 /usr/local/bin/supercronic |
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
|
||||||
|
# Default command (will be overridden by compose) |
||||||
|
CMD ["/usr/local/bin/supercronic", "/etc/cron/crontab"] |
||||||
|
|
||||||
@ -0,0 +1,6 @@ |
|||||||
|
# Relay ingest crontab - syncs events from upstream relays every 10 minutes |
||||||
|
# Format: minute hour day month weekday command |
||||||
|
|
||||||
|
# Run ingest every 10 minutes |
||||||
|
*/10 * * * * /app/bin/relay/ingest.sh >> /var/log/relay-ingest.log 2>&1 |
||||||
|
|
||||||
@ -0,0 +1,37 @@ |
|||||||
|
FROM debian:bookworm-slim |
||||||
|
|
||||||
|
# Install build dependencies |
||||||
|
RUN apt-get update && apt-get install -y \ |
||||||
|
git \ |
||||||
|
build-essential \ |
||||||
|
libtool \ |
||||||
|
autotools-dev \ |
||||||
|
automake \ |
||||||
|
pkg-config \ |
||||||
|
liblmdb-dev \ |
||||||
|
libsecp256k1-dev \ |
||||||
|
libzstd-dev \ |
||||||
|
libssl-dev \ |
||||||
|
wget \ |
||||||
|
&& rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Build strfry from source |
||||||
|
WORKDIR /tmp |
||||||
|
RUN git clone --depth 1 --branch v1.0.6 https://github.com/hoytech/strfry.git && \ |
||||||
|
cd strfry && \ |
||||||
|
git submodule update --init && \ |
||||||
|
make setup-golpe && \ |
||||||
|
make -j$(nproc) && \ |
||||||
|
make install && \ |
||||||
|
cd / && \ |
||||||
|
rm -rf /tmp/strfry |
||||||
|
|
||||||
|
# Create data directory |
||||||
|
RUN mkdir -p /var/strfry/db |
||||||
|
|
||||||
|
# Expose relay port |
||||||
|
EXPOSE 7777 |
||||||
|
|
||||||
|
# Default command (can be overridden) |
||||||
|
CMD ["strfry", "relay"] |
||||||
|
|
||||||
@ -0,0 +1,113 @@ |
|||||||
|
## |
||||||
|
## Default strfry config |
||||||
|
## |
||||||
|
|
||||||
|
# Directory that contains the strfry LMDB database (restart required) |
||||||
|
db = "/app/strfry-db" |
||||||
|
|
||||||
|
dbParams { |
||||||
|
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) |
||||||
|
maxreaders = 256 |
||||||
|
|
||||||
|
# Size of mmap() to use when loading LMDB (restart required) |
||||||
|
dbsize = 10737418240 |
||||||
|
} |
||||||
|
|
||||||
|
events { |
||||||
|
# Maximum size of normalised JSON, in bytes |
||||||
|
maxEventSize = 65536 |
||||||
|
|
||||||
|
# Events newer than this will be rejected |
||||||
|
rejectEventsNewerThanSeconds = 900 |
||||||
|
|
||||||
|
# Events older than this will be rejected |
||||||
|
rejectEventsOlderThanSeconds = 94608000 |
||||||
|
|
||||||
|
# Ephemeral events older than this will be rejected |
||||||
|
rejectEphemeralEventsOlderThanSeconds = 60 |
||||||
|
|
||||||
|
# Ephemeral events will be deleted from the DB when older than this |
||||||
|
ephemeralEventsLifetimeSeconds = 300 |
||||||
|
|
||||||
|
# Maximum number of tags allowed |
||||||
|
maxNumTags = 2000 |
||||||
|
|
||||||
|
# Maximum size for tag values, in bytes |
||||||
|
maxTagValSize = 1024 |
||||||
|
} |
||||||
|
|
||||||
|
relay { |
||||||
|
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) |
||||||
|
bind = "0.0.0.0" |
||||||
|
|
||||||
|
# Port to open (restart required) |
||||||
|
port = 7777 |
||||||
|
|
||||||
|
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) |
||||||
|
nofiles = 1000000 |
||||||
|
|
||||||
|
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) |
||||||
|
realIpHeader = "" |
||||||
|
|
||||||
|
info { |
||||||
|
# NIP-11: Name of this server. Short/descriptive (< 30 characters) |
||||||
|
name = "Decent Newsroom Read-Cache Relay" |
||||||
|
|
||||||
|
# NIP-11: Detailed information about relay, free-form |
||||||
|
description = "Read-only cache relay; denies client writes. Crawls upstream relays for long-form articles and related activity." |
||||||
|
|
||||||
|
# NIP-11: Administrative nostr pubkey, for contact purposes |
||||||
|
pubkey = "" |
||||||
|
|
||||||
|
# NIP-11: Alternative administrative contact (email, website, etc) |
||||||
|
contact = "" |
||||||
|
} |
||||||
|
|
||||||
|
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) |
||||||
|
maxWebsocketPayloadSize = 131072 |
||||||
|
|
||||||
|
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) |
||||||
|
autoPingSeconds = 55 |
||||||
|
|
||||||
|
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) |
||||||
|
enableTcpKeepalive = false |
||||||
|
|
||||||
|
# How much uninterrupted CPU time a REQ query should get during its DB scan |
||||||
|
queryTimesliceBudgetMicroseconds = 10000 |
||||||
|
|
||||||
|
# Maximum records that can be returned per filter |
||||||
|
maxFilterLimit = 500 |
||||||
|
|
||||||
|
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time |
||||||
|
maxSubsPerConnection = 20 |
||||||
|
|
||||||
|
writePolicy { |
||||||
|
# If non-empty, path to an executable script that implements the writePolicy plugin logic |
||||||
|
plugin = "/app/write-policy.sh" |
||||||
|
} |
||||||
|
|
||||||
|
compression { |
||||||
|
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) |
||||||
|
enabled = true |
||||||
|
|
||||||
|
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) |
||||||
|
slidingWindow = true |
||||||
|
} |
||||||
|
|
||||||
|
logging { |
||||||
|
# Dump all incoming messages |
||||||
|
dumpInAll = false |
||||||
|
|
||||||
|
# Dump all incoming EVENT messages |
||||||
|
dumpInEvents = false |
||||||
|
|
||||||
|
# Dump all incoming REQ/CLOSE messages |
||||||
|
dumpInReqs = false |
||||||
|
|
||||||
|
# Log performance metrics for initial REQ database scans |
||||||
|
dbScanPerf = false |
||||||
|
} |
||||||
|
|
||||||
|
numThreads = 0 |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,6 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
# Deny ALL client EVENT writes (reads unaffected; upstream ingest still works) |
||||||
|
# Contract: read from stdin (JSON) and output action JSON |
||||||
|
cat >/dev/null |
||||||
|
printf '%s\n' '{"action":"reject","msg":"read-only relay"}' |
||||||
|
|
||||||
@ -0,0 +1,79 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Service\RelayAdminService; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
#[Route('/admin/relay', name: 'admin_relay_')] |
||||||
|
class RelayAdminController extends AbstractController |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly RelayAdminService $relayAdminService |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('', name: 'index')] |
||||||
|
public function index(): Response |
||||||
|
{ |
||||||
|
$stats = $this->relayAdminService->getStats(); |
||||||
|
$config = $this->relayAdminService->getConfiguration(); |
||||||
|
$containerStatus = $this->relayAdminService->getContainerStatus(); |
||||||
|
$connectivity = $this->relayAdminService->testConnectivity(); |
||||||
|
$recentEvents = $this->relayAdminService->getRecentEvents(5); |
||||||
|
|
||||||
|
return $this->render('admin/relay/index.html.twig', [ |
||||||
|
'stats' => $stats, |
||||||
|
'config' => $config, |
||||||
|
'container_status' => $containerStatus, |
||||||
|
'connectivity' => $connectivity, |
||||||
|
'recent_events' => $recentEvents, |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/stats', name: 'stats', methods: ['GET'])] |
||||||
|
public function stats(): JsonResponse |
||||||
|
{ |
||||||
|
return $this->json($this->relayAdminService->getStats()); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/events', name: 'events', methods: ['GET'])] |
||||||
|
public function events(): JsonResponse |
||||||
|
{ |
||||||
|
$events = $this->relayAdminService->getRecentEvents(20); |
||||||
|
return $this->json($events); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/logs', name: 'logs', methods: ['GET'])] |
||||||
|
public function logs(): Response |
||||||
|
{ |
||||||
|
$logs = $this->relayAdminService->getSyncLogs(100); |
||||||
|
|
||||||
|
return new Response($logs, 200, [ |
||||||
|
'Content-Type' => 'text/plain', |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/sync', name: 'sync', methods: ['POST'])] |
||||||
|
public function triggerSync(): JsonResponse |
||||||
|
{ |
||||||
|
$result = $this->relayAdminService->triggerSync(); |
||||||
|
return $this->json($result); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/status', name: 'status', methods: ['GET'])] |
||||||
|
public function status(): JsonResponse |
||||||
|
{ |
||||||
|
return $this->json([ |
||||||
|
'containers' => $this->relayAdminService->getContainerStatus(), |
||||||
|
'connectivity' => $this->relayAdminService->testConnectivity(), |
||||||
|
'config' => $this->relayAdminService->getConfiguration(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,275 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Filter\Filter; |
||||||
|
use swentel\nostr\Message\RequestMessage; |
||||||
|
use swentel\nostr\Relay\Relay; |
||||||
|
use swentel\nostr\Request\Request; |
||||||
|
use swentel\nostr\Subscription\Subscription; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service to interact with the strfry relay |
||||||
|
*/ |
||||||
|
class RelayAdminService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
private readonly ?string $nostrDefaultRelay = null |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay statistics by actually querying the relay |
||||||
|
*/ |
||||||
|
public function getStats(): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$relayUrl = $this->nostrDefaultRelay ?? 'ws://strfry:7777'; |
||||||
|
|
||||||
|
// Test if relay is accessible |
||||||
|
if (!$this->testRelayConnection($relayUrl)) { |
||||||
|
return [ |
||||||
|
'error' => 'Cannot connect to relay at ' . $relayUrl, |
||||||
|
'total_events' => 0, |
||||||
|
'relay_accessible' => false |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to count events by querying with a limit |
||||||
|
$eventCount = $this->estimateEventCount($relayUrl); |
||||||
|
|
||||||
|
// Format the event count message |
||||||
|
if ($eventCount >= 100) { |
||||||
|
$displayCount = '100+ (many events - use CLI for exact count)'; |
||||||
|
} elseif ($eventCount > 0) { |
||||||
|
$displayCount = $eventCount; |
||||||
|
} else { |
||||||
|
$displayCount = 0; |
||||||
|
} |
||||||
|
|
||||||
|
return [ |
||||||
|
'total_events' => $displayCount, |
||||||
|
'relay_accessible' => true, |
||||||
|
'database_size' => '~800 MB (from docker volume)', |
||||||
|
'info' => 'Sample of ' . $eventCount . ' events retrieved. Use CLI for full statistics.' |
||||||
|
]; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to get relay stats', ['error' => $e->getMessage()]); |
||||||
|
return ['error' => $e->getMessage()]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get recent events from relay by actually querying it |
||||||
|
*/ |
||||||
|
public function getRecentEvents(int $limit = 10): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$relayUrl = $this->nostrDefaultRelay ?? 'ws://strfry:7777'; |
||||||
|
|
||||||
|
// Create relay connection |
||||||
|
$relay = new Relay($relayUrl); |
||||||
|
|
||||||
|
// Create subscription |
||||||
|
$subscription = new Subscription(); |
||||||
|
$subscriptionId = $subscription->setId(); |
||||||
|
|
||||||
|
// Create filter for recent events (kind 30023 - articles) |
||||||
|
$filter = new Filter(); |
||||||
|
$filter->setKinds([30023, 1, 7, 0]); // Articles, notes, reactions, profiles |
||||||
|
$filter->setLimit($limit); |
||||||
|
|
||||||
|
// Create and send request |
||||||
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||||
|
$request = new Request($relay, $requestMessage); |
||||||
|
|
||||||
|
// Get response with timeout |
||||||
|
$response = $request->send(); |
||||||
|
|
||||||
|
$events = []; |
||||||
|
if (is_array($response) && !empty($response)) { |
||||||
|
foreach ($response as $relayResponse) { |
||||||
|
if (is_array($relayResponse)) { |
||||||
|
foreach ($relayResponse as $item) { |
||||||
|
if (isset($item->type) && $item->type === 'EVENT' && isset($item->event)) { |
||||||
|
$events[] = (array)$item->event; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return array_slice($events, 0, $limit); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to get recent events', ['error' => $e->getMessage()]); |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Estimate event count by querying the relay |
||||||
|
*/ |
||||||
|
private function estimateEventCount(string $relayUrl): int |
||||||
|
{ |
||||||
|
try { |
||||||
|
$relay = new Relay($relayUrl); |
||||||
|
$subscription = new Subscription(); |
||||||
|
$subscriptionId = $subscription->setId(); |
||||||
|
|
||||||
|
// Query for a sample to check if relay has events |
||||||
|
$filter = new Filter(); |
||||||
|
$filter->setLimit(100); |
||||||
|
|
||||||
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||||
|
$request = new Request($relay, $requestMessage); |
||||||
|
$response = $request->send(); |
||||||
|
|
||||||
|
$count = 0; |
||||||
|
if (is_array($response) && !empty($response)) { |
||||||
|
foreach ($response as $relayResponse) { |
||||||
|
if (is_array($relayResponse)) { |
||||||
|
foreach ($relayResponse as $item) { |
||||||
|
if (isset($item->type) && $item->type === 'EVENT') { |
||||||
|
$count++; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $count; |
||||||
|
} catch (\Exception $e) { |
||||||
|
return 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay container status by checking connectivity |
||||||
|
*/ |
||||||
|
public function getContainerStatus(): array |
||||||
|
{ |
||||||
|
$strfryStatus = $this->checkServiceHealth('strfry', 7777); |
||||||
|
$ingestStatus = ['status' => 'unknown', 'health' => 'Cannot check from inside container']; |
||||||
|
|
||||||
|
return [ |
||||||
|
'strfry' => $strfryStatus, |
||||||
|
'ingest' => $ingestStatus, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay configuration from environment |
||||||
|
*/ |
||||||
|
public function getConfiguration(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
'relay_url' => $this->nostrDefaultRelay ?? 'Not configured', |
||||||
|
'relay_internal' => 'ws://strfry:7777', |
||||||
|
'relay_external' => 'ws://localhost:7777', |
||||||
|
'upstreams' => $_ENV['RELAY_UPSTREAMS'] ?? 'Not configured', |
||||||
|
'days_articles' => $_ENV['RELAY_DAYS_ARTICLES'] ?? '7', |
||||||
|
'days_threads' => $_ENV['RELAY_DAYS_THREADS'] ?? '3', |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Test relay connectivity |
||||||
|
*/ |
||||||
|
public function testConnectivity(): array |
||||||
|
{ |
||||||
|
$relayUrl = $this->nostrDefaultRelay ?? 'ws://strfry:7777'; |
||||||
|
$isAccessible = $this->testRelayConnection($relayUrl); |
||||||
|
|
||||||
|
return [ |
||||||
|
'container_running' => $isAccessible, |
||||||
|
'port_accessible' => $isAccessible, |
||||||
|
'relay_url' => $relayUrl, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Test if we can connect to the relay |
||||||
|
*/ |
||||||
|
private function testRelayConnection(string $url): bool |
||||||
|
{ |
||||||
|
try { |
||||||
|
// Parse URL to get host and port |
||||||
|
$parts = parse_url($url); |
||||||
|
if (!$parts || !isset($parts['host'])) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
$host = $parts['host']; |
||||||
|
$port = $parts['port'] ?? 7777; |
||||||
|
|
||||||
|
// Try to open a socket connection |
||||||
|
$socket = @fsockopen($host, $port, $errno, $errstr, 2); |
||||||
|
|
||||||
|
if ($socket) { |
||||||
|
fclose($socket); |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Relay connection test failed', [ |
||||||
|
'url' => $url, |
||||||
|
'error' => $e->getMessage() |
||||||
|
]); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check service health by testing port connectivity |
||||||
|
*/ |
||||||
|
private function checkServiceHealth(string $host, int $port): array |
||||||
|
{ |
||||||
|
$isRunning = $this->testPortOpen($host, $port); |
||||||
|
|
||||||
|
return [ |
||||||
|
'status' => $isRunning ? 'running' : 'not running', |
||||||
|
'health' => $isRunning ? 'healthy' : 'unhealthy', |
||||||
|
'name' => $host, |
||||||
|
'port' => $port, |
||||||
|
'method' => 'socket_test' |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Test if a port is open |
||||||
|
*/ |
||||||
|
private function testPortOpen(string $host, int $port): bool |
||||||
|
{ |
||||||
|
$socket = @fsockopen($host, $port, $errno, $errstr, 2); |
||||||
|
if ($socket) { |
||||||
|
fclose($socket); |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Trigger manual sync - placeholder |
||||||
|
*/ |
||||||
|
public function triggerSync(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
'success' => false, |
||||||
|
'message' => 'Manual sync trigger not available from web interface. Use CLI: make relay-ingest-now', |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get recent sync logs - placeholder |
||||||
|
*/ |
||||||
|
public function getSyncLogs(int $lines = 50): string |
||||||
|
{ |
||||||
|
return 'Log viewing not available from web interface. Use CLI: docker compose logs ingest'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue