Browse Source

Local relay

imwald
Nuša Pukšič 1 month ago
parent
commit
6365d8397b
  1. 2
      Dockerfile
  2. 92
      bin/relay/ingest.sh
  3. 13
      bin/relay/prime.sh
  4. 136
      bin/relay/test-smoke.php
  5. 62
      bin/relay/verify-config.php
  6. 47
      compose.yaml
  7. 2
      docker/cron/crontab
  8. 35
      docker/strfry/router.conf
  9. 10
      docker/strfry/strfry.conf
  10. 0
      docker/strfry/write-policy.sh
  11. 19
      infra/cron/Dockerfile
  12. 6
      infra/cron/crontab
  13. 37
      infra/strfry/Dockerfile
  14. 4
      sync-strfry.sh

2
Dockerfile

@ -95,7 +95,7 @@ COPY --link . ./ @@ -95,7 +95,7 @@ COPY --link . ./
RUN rm -Rf frankenphp/
RUN set -eux; \
composer install --no-cache --prefer-dist --no-dev --no-progress
composer install --no-cache --prefer-dist --no-progress
RUN set -eux; \
mkdir -p var/cache var/log; \

92
bin/relay/ingest.sh

@ -1,92 +0,0 @@ @@ -1,92 +0,0 @@
#!/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."

13
bin/relay/prime.sh

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
#!/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"

136
bin/relay/test-smoke.php

@ -1,136 +0,0 @@ @@ -1,136 +0,0 @@
#!/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);
}

62
bin/relay/verify-config.php

@ -1,62 +0,0 @@ @@ -1,62 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Verify Nostr relay configuration
* Checks that the app is configured to use the local relay
*/
declare(strict_types=1);
echo "=== Nostr Relay Configuration Verification ===\n\n";
// Check environment variable
$relayUrl = getenv('NOSTR_DEFAULT_RELAY');
echo "1. Environment Variable Check:\n";
echo " NOSTR_DEFAULT_RELAY = " . ($relayUrl ?: '(not set)') . "\n";
if ($relayUrl === 'ws://strfry:7777') {
echo " ✅ Correctly configured for local relay\n";
} elseif ($relayUrl) {
echo " ⚠ Set to: $relayUrl\n";
} else {
echo " ⚠ Not set - will use public relays\n";
}
echo "\n2. Docker Network Check:\n";
echo " Local relay should be accessible at: ws://strfry:7777\n";
// Try to resolve strfry hostname (from inside container)
if (function_exists('gethostbyname')) {
$ip = gethostbyname('strfry');
if ($ip !== 'strfry') {
echo " ✅ strfry hostname resolves to: $ip\n";
} else {
echo " ⚠ Cannot resolve strfry hostname (may not be in same network)\n";
}
}
echo "\n3. Configuration File Check:\n";
echo " services.yaml should have:\n";
echo " - Parameter: nostr_default_relay\n";
echo " - Binding: \$nostrDefaultRelay\n";
echo " ✅ These are configured\n";
echo "\n4. NostrClient Service Check:\n";
echo " Constructor should receive nostrDefaultRelay parameter\n";
echo " Should log: 'Using configured default Nostr relay'\n";
echo " ✅ Code is in place\n";
echo "\n=== Summary ===\n";
if ($relayUrl === 'ws://strfry:7777') {
echo "✅ Everything is configured correctly!\n";
echo "\nYour Symfony app will:\n";
echo "- Use ws://strfry:7777 as default relay\n";
echo "- Fall back to public relays if local relay is unavailable\n";
echo "- Log relay usage on startup\n";
} else {
echo "⚠ Configuration needs adjustment\n";
echo "\nTo fix:\n";
echo "1. Set in .env: NOSTR_DEFAULT_RELAY=ws://strfry:7777\n";
echo "2. Restart containers: docker compose restart php worker\n";
}

47
compose.yaml

@ -92,46 +92,21 @@ services: @@ -92,46 +92,21 @@ services:
###> strfry relay ###
strfry:
image: dockurr/strfry:latest
container_name: newsroom-strfry
restart: unless-stopped
environment:
- RELAY_NAME=Decent Newsroom Read-Cache Relay
- RELAY_DESCRIPTION=Read-only cache relay for Decent Newsroom
- RELAY_WRITE_POLICY=/app/write-policy.sh
command:
- /bin/sh
- -c
- |
./strfry relay /etc/strfry.conf &
./strfry router /etc/router.conf &
wait
volumes:
- ./infra/strfry/write-policy.sh:/app/write-policy.sh:ro
- strfry_data:/app/strfry-db
- ./docker/strfry/strfry.conf:/etc/strfry.conf:ro
- ./docker/strfry/write-policy.sh:/app/write-policy.sh:ro
- ./docker/strfry/router.conf:/etc/router.conf:ro
- strfry_data:/var/lib/strfry
ports:
- "7777:7777" # Expose for local testing (ws://localhost:7777)
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/localhost/7777' || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 5s
networks:
- default
ingest:
build:
context: ./infra/cron
dockerfile: Dockerfile
container_name: newsroom-ingest
restart: unless-stopped
command: ["/usr/local/bin/supercronic", "/etc/cron/crontab"]
volumes:
- ./infra/cron/crontab:/etc/cron/crontab:ro
- ./bin:/app/bin:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
strfry:
condition: service_healthy
environment:
- UPSTREAMS=${RELAY_UPSTREAMS:-wss://relay.snort.social wss://relay.damus.io wss://relay.nostr.band}
- DAYS_ARTICLES=${RELAY_DAYS_ARTICLES:-7}
- DAYS_THREADS=${RELAY_DAYS_THREADS:-3}
networks:
- default
###< strfry relay ###
volumes:

2
docker/cron/crontab

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1
0 */2 * * * /media_discovery.sh >> /var/log/cron.log 2>&1
2 */2 * * * /media_discovery.sh >> /var/log/cron.log 2>&1
0 */2 * * * /article_discovery.sh >> /var/log/cron.log 2>&1

35
docker/strfry/router.conf

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
# Same DB path the relay uses
db = "/var/lib/strfry"
streams {
# One named stream group that pulls down exactly the kinds you care about
ingest {
dir = "down"
# Pull long-form + replies + reactions + zaps + highlights + hygiene
# 30023 = NIP-23 article
# 30024 = NIP-23 draft
# 1 = replies/comments
# 1111 = comments
# 7 = reactions
# 9735 = zap receipts
# 9802 = highlights
# 0 = profiles
# 5 = deletes
filter = {"kinds":[30023,30024,1111,9735,9802,0,5]}
urls = [
"wss://nos.lol"
"wss://relay.damus.io"
"wss://theforest.nostr1.com"
]
}
# If you later want a second policy (e.g., only profiles), add another block:
# { "kinds": [30023, 1111, 9802] }
# profiles_only {
# dir = "down"
# filter = { "kinds": [0] }
# urls = [ "wss://nos.lol" ]
# }
}

10
infra/strfry/strfry.conf → docker/strfry/strfry.conf

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
##
# Directory that contains the strfry LMDB database (restart required)
db = "/app/strfry-db"
db = "/var/lib/strfry"
dbParams {
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
@ -54,13 +54,13 @@ relay { @@ -54,13 +54,13 @@ relay {
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."
description = "Read-only cache relay for long-form articles and related activity."
# NIP-11: Administrative nostr pubkey, for contact purposes
pubkey = ""
pubkey = "d475ce4b3977507130f42c7f86346ef936800f3ae74d5ecf8089280cdc1923e9"
# NIP-11: Alternative administrative contact (email, website, etc)
contact = ""
contact = "decentnewsroom.com"
}
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
@ -83,7 +83,7 @@ relay { @@ -83,7 +83,7 @@ relay {
writePolicy {
# If non-empty, path to an executable script that implements the writePolicy plugin logic
plugin = "/app/write-policy.sh"
# plugin = "/opt/write-policy.sh"
}
compression {

0
infra/strfry/write-policy.sh → docker/strfry/write-policy.sh

19
infra/cron/Dockerfile

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
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"]

6
infra/cron/crontab

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
# 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

37
infra/strfry/Dockerfile

@ -1,37 +0,0 @@ @@ -1,37 +0,0 @@
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"]

4
sync-strfry.sh

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
#!/bin/bash
docker compose exec strfry ./strfry sync wss://theforest.nostr1.com --filter '{"kinds":[9802,1111,30023,0]}' --dir down
docker compose exec strfry ./strfry sync wss://relay.damus.io --filter '{"kinds":[9802,1111,30023,0]}' --dir down
Loading…
Cancel
Save