Browse Source

session-block anything unresponsive

imwald
Silberengel 3 weeks ago
parent
commit
87d41e307d
  1. 3
      src/constants.ts
  2. 19
      src/lib/read-only-relay-personal.test.ts
  3. 23
      src/lib/relay-strikes.test.ts
  4. 42
      src/lib/relay-strikes.ts
  5. 172
      src/services/client.service.ts

3
src/constants.ts

@ -518,8 +518,7 @@ export const PROFILE_RELAY_URLS = [
'wss://purplepag.es', 'wss://purplepag.es',
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',
'wss://indexer.coracle.social/', 'wss://indexer.coracle.social/',
'wss://relay.primal.net', 'wss://thecitadel.nostr1.com'
'wss://relay.damus.io'
] ]
export const FOLLOWS_HISTORY_RELAY_URLS = [ export const FOLLOWS_HISTORY_RELAY_URLS = [

19
src/lib/read-only-relay-personal.test.ts

@ -84,7 +84,7 @@ describe('read-only-relay-personal', () => {
] ]
expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls)
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
@ -107,10 +107,25 @@ describe('read-only-relay-personal', () => {
it('metadata-only policy allows profile bootstrap relays at connect time', () => { it('metadata-only policy allows profile bootstrap relays at connect time', () => {
setRestrictConnectionsToMetadataRelaysOnly(true) setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
}) })
it('metadata-only policy allows viewer cache and HTTP index relays', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(
buildPersonalRelayKeySet([
'ws://localhost:4869/',
'https://index.example.com/',
'wss://nostr.land/'
]),
{ viewerActive: true }
)
expect(isRelayConnectionAllowedForViewer('ws://localhost:4869/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('https://index.example.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
})
it('metadata-only bypass allows relays outside personal lists', () => { it('metadata-only bypass allows relays outside personal lists', () => {
setRestrictConnectionsToMetadataRelaysOnly(true) setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })

23
src/lib/relay-strikes.test.ts

@ -57,6 +57,29 @@ describe('relaySessionStrikes HTTP read failures', () => {
}) })
}) })
describe('relaySessionStrikes cache and localhost', () => {
beforeEach(() => {
relaySessionStrikes.reset()
})
it('session-skips cache relay after two connection failures', () => {
const url = 'ws://localhost:4869/'
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432({
kind: 10432,
tags: [['relay', url]],
content: '',
created_at: 1,
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
sig: 'c'.repeat(128)
})
relaySessionStrikes.recordReadFailure(url, 'connection')
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false)
relaySessionStrikes.recordReadFailure(url, 'connection')
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true)
})
})
describe('relaySessionStrikes.clearKey', () => { describe('relaySessionStrikes.clearKey', () => {
beforeEach(() => { beforeEach(() => {
relaySessionStrikes.reset() relaySessionStrikes.reset()

42
src/lib/relay-strikes.ts

@ -7,11 +7,15 @@ import {
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { getRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch } from '@/lib/url' import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url'
import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service'
/** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */ /** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */
const STRIKE_FAILURES_THRESHOLD = 5 const STRIKE_FAILURES_THRESHOLD = 5
/** Kind 10432 cache relays (often localhost): skip after fewer failures — no point hammering a dead socket. */
const CACHE_RELAY_STRIKE_FAILURES_THRESHOLD = 2
/** LAN / loopback WS: same fast skip on connection refused. */
const LOCAL_NETWORK_STRIKE_FAILURES_THRESHOLD = 2
const STRIKE_COOLDOWN_MS = 3 * 60 * 1000 const STRIKE_COOLDOWN_MS = 3 * 60 * 1000
/** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */ /** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */
@ -160,13 +164,17 @@ class RelaySessionStrikes {
} }
/** WS connect failure, HTTP transport failure, etc. */ /** WS connect failure, HTTP transport failure, etc. */
recordReadFailure(url: string, _source: 'connection' | 'notice' | 'http'): void { recordReadFailure(url: string, source: 'connection' | 'notice' | 'http'): void {
const key = sessionKey(url) const key = sessionKey(url)
if (!key) return if (!key) return
this.recordReadFailureKey(key, _source) this.recordReadFailureKey(key, source, url)
} }
private recordReadFailureKey(key: string, _source: 'connection' | 'notice' | 'http'): void { private recordReadFailureKey(
key: string,
source: 'connection' | 'notice' | 'http',
urlForLocalCheck?: string
): void {
const now = Date.now() const now = Date.now()
const e = this.getEntry(key) const e = this.getEntry(key)
// During rate-limit cooldown, do not add strikes for normal relays (relay can catch up). // During rate-limit cooldown, do not add strikes for normal relays (relay can catch up).
@ -177,8 +185,8 @@ class RelaySessionStrikes {
// HTTP index failures often arrive in parallel; count each so session skip engages quickly. // HTTP index failures often arrive in parallel; count each so session skip engages quickly.
// Connection refused / unreachable: do not debounce — profile feeds open many relays at once. // Connection refused / unreachable: do not debounce — profile feeds open many relays at once.
if ( if (
_source !== 'http' && source !== 'http' &&
_source !== 'connection' && source !== 'connection' &&
now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS
) { ) {
return return
@ -187,12 +195,25 @@ class RelaySessionStrikes {
} }
e.readFailures += 1 e.readFailures += 1
if (e.readFailures >= STRIKE_FAILURES_THRESHOLD) { const threshold = this.readStrikeThresholdForKey(key, source, urlForLocalCheck)
if (e.readFailures >= threshold) {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures, threshold })
} }
} }
private readStrikeThresholdForKey(
key: string,
source: 'connection' | 'notice' | 'http',
urlForLocalCheck?: string
): number {
if (this.cacheRelayKeys.has(key)) return CACHE_RELAY_STRIKE_FAILURES_THRESHOLD
if (source === 'connection' && urlForLocalCheck && isLocalNetworkUrl(urlForLocalCheck)) {
return LOCAL_NETWORK_STRIKE_FAILURES_THRESHOLD
}
return STRIKE_FAILURES_THRESHOLD
}
recordReadSuccess(url: string): void { recordReadSuccess(url: string): void {
const key = sessionKey(url) const key = sessionKey(url)
if (!key) return if (!key) return
@ -238,7 +259,7 @@ class RelaySessionStrikes {
if (timedOut || slowEose) { if (timedOut || slowEose) {
const parked = this.recordSlowSignalKey(key, now) const parked = this.recordSlowSignalKey(key, now)
if (parked) socketsToClose.push(row.relayUrl) if (parked) socketsToClose.push(row.relayUrl)
if (timedOut) this.recordReadFailureKey(key, 'connection') if (timedOut) this.recordReadFailureKey(key, 'connection', row.relayUrl)
continue continue
} }
@ -305,8 +326,7 @@ class RelaySessionStrikes {
const http = httpIndexRelayBasesInUrlBatch(urls, httpIndexBases) const http = httpIndexRelayBasesInUrlBatch(urls, httpIndexBases)
const httpKeys = new Set(http.map((u) => canonicalRelaySessionKey(u))) const httpKeys = new Set(http.map((u) => canonicalRelaySessionKey(u)))
const ws = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u))) const ws = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u)))
const singleWsRelay = ws.length <= 1 const wsOut = ws.filter((u) => !this.isReadHttpSkipped(u))
const wsOut = singleWsRelay ? [...ws] : ws.filter((u) => !this.isReadHttpSkipped(u))
const httpOut = http.filter((u) => !this.isReadHttpSkipped(u)) const httpOut = http.filter((u) => !this.isReadHttpSkipped(u))
const merged = [...wsOut, ...httpOut] const merged = [...wsOut, ...httpOut]
return merged.length > 0 ? merged : [...urls] return merged.length > 0 ? merged : [...urls]

172
src/services/client.service.ts

@ -450,6 +450,9 @@ class ClientService extends EventTarget {
if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) { if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) {
throw new Error(`[metadata-relays-only] skipping relay ${url}`) throw new Error(`[metadata-relays-only] skipping relay ${url}`)
} }
if (params?.purpose !== 'write' && relaySessionStrikes.isReadHttpSkipped(url)) {
throw new Error(`[relay-strike] skipping unresponsive relay ${url}`)
}
if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) {
throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`) throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`)
} }
@ -458,10 +461,25 @@ class ClientService extends EventTarget {
const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n) const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)
? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS) ? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS)
: base : base
const relay = await rawEnsureRelay(url, { let relay
...params, try {
connectionTimeout relay = await rawEnsureRelay(url, {
}) ...params,
connectionTimeout
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (
params?.purpose !== 'write' &&
!msg.includes('[metadata-relays-only]') &&
!msg.includes('[relay-strike]') &&
!msg.includes('[offline]') &&
!msg.includes('[http-index-relay]')
) {
relaySessionStrikes.recordReadFailure(url, 'connection')
}
throw err
}
patchPoolRelayAuthRaceAndFeedback(relay) patchPoolRelayAuthRaceAndFeedback(relay)
applyRelayNip42AckTimeout(relay) applyRelayNip42AckTimeout(relay)
touchRelayPoolActivity(url) touchRelayPoolActivity(url)
@ -631,22 +649,50 @@ class ClientService extends EventTarget {
setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
syncViewerRelayStackNostrLandAggrEligible([]) syncViewerRelayStackNostrLandAggrEligible([])
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(null)
return return
} }
/** Engage policy before any await so session hydrate cannot open PROFILE/FAST_WRITE stacks first. */
if (isRestrictConnectionsToMetadataRelaysOnly()) { /** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk)
} this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), {
viewerActive: isRestrictConnectionsToMetadataRelaysOnly()
})
syncViewerRelayStackNostrLandAggrEligible(storageUrls.all)
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent)
this.closeMetadataPolicyDisallowedRelayConnections()
try { try {
const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS) const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS)
setViewerBlockedRelayUrls(parseBlockedRelayUrlsFromEvent(blockedEvt ?? null)) setViewerBlockedRelayUrls(parseBlockedRelayUrlsFromEvent(blockedEvt ?? null))
} catch { } catch {
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
} }
const urls = [...storageUrls.all]
try {
urls.push(...(await this.fetchFavoriteRelays(pk)))
} catch {
// ignore
}
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(urls)
this.closeMetadataPolicyDisallowedRelayConnections()
}
/** NIP-65 / 10243 / 10432 / favorites (10012) from IndexedDB only — no network. */
private async collectViewerPersonalRelayUrlsFromStorage(pubkey: string): Promise<{
all: string[]
httpIndexBases: string[]
cacheRelayEvent: NEvent | undefined
}> {
const urls: string[] = [] const urls: string[] = []
let httpIndexBases: string[] = []
let cacheRelayEvent: NEvent | undefined
try { try {
const rl = await this.peekRelayListFromStorage(pk) const rl = await this.peekRelayListFromStorage(pubkey)
this.viewerHttpIndexRelayBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])] httpIndexBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])]
.map((u) => normalizeHttpRelayUrl(u) || u) .map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean) .filter(Boolean)
urls.push( urls.push(
@ -656,22 +702,32 @@ class ClientService extends EventTarget {
...(rl.httpWrite ?? []) ...(rl.httpWrite ?? [])
) )
} catch { } catch {
this.viewerHttpIndexRelayBases = [] httpIndexBases = []
// ignore
} }
try { try {
urls.push(...(await this.fetchFavoriteRelays(pk))) urls.push(...(await this.fetchFavoriteRelaysFromStorage(pubkey)))
} catch { } catch {
// ignore // ignore
} }
try { try {
urls.push(...(await getCacheRelayUrls(pk))) cacheRelayEvent = (await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) ?? undefined
urls.push(...(await getCacheRelayUrls(pubkey)))
} catch { } catch {
// ignore cacheRelayEvent = undefined
}
const all = Array.from(new Set(urls.map((u) => u.trim()).filter(Boolean)))
return { all, httpIndexBases, cacheRelayEvent }
}
/** Kind 10012 + embedded NIP-51 relay sets from IndexedDB only. */
private async fetchFavoriteRelaysFromStorage(pubkey: string): Promise<string[]> {
try {
const favoriteRelaysEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS)
if (!favoriteRelaysEvent) return []
return await this.expandFavoriteRelayUrlsFromEvent(pubkey, favoriteRelaysEvent)
} catch {
return []
} }
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(urls)
this.closeMetadataPolicyDisallowedRelayConnections()
} }
/** Drop pooled WebSocket connections that violate the metadata-only read policy. */ /** Drop pooled WebSocket connections that violate the metadata-only read policy. */
@ -3565,49 +3621,57 @@ class ClientService extends EventTarget {
async fetchFavoriteRelays(pubkey: string): Promise<string[]> { async fetchFavoriteRelays(pubkey: string): Promise<string[]> {
try { try {
const favoriteRelaysEvent = await this.replaceableEventService.fetchReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS) const favoriteRelaysEvent = await this.replaceableEventService.fetchReplaceableEvent(
pubkey,
ExtendedKind.FAVORITE_RELAYS
)
if (!favoriteRelaysEvent) return [] if (!favoriteRelaysEvent) return []
return await this.expandFavoriteRelayUrlsFromEvent(pubkey, favoriteRelaysEvent)
} catch {
return []
}
}
const relays: string[] = [] private async expandFavoriteRelayUrlsFromEvent(
const relaySetIds: string[] = [] pubkey: string,
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => { favoriteRelaysEvent: NEvent
if (tagName === 'relay' && tagValue) { ): Promise<string[]> {
const normalized = normalizeUrl(tagValue) const relays: string[] = []
if (normalized) { const relaySetIds: string[] = []
relays.push(normalized) favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => {
} if (tagName === 'relay' && tagValue) {
} else if (tagName === 'a' && tagValue) { const normalized = normalizeUrl(tagValue)
const [kindStr, author, d] = tagValue.split(':') if (normalized) {
if ( relays.push(normalized)
kindStr === String(kinds.Relaysets) &&
author === pubkey &&
d &&
!relaySetIds.includes(d)
) {
relaySetIds.push(d)
}
} }
}) } else if (tagName === 'a' && tagValue) {
const [kindStr, author, d] = tagValue.split(':')
// NIP-51 relay sets on kind 10012: same expansion as {@link FavoriteRelaysProvider} (not only `relay` tags). if (
for (const id of relaySetIds) { kindStr === String(kinds.Relaysets) &&
try { author === pubkey &&
const ev = await indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id) d &&
if (!ev || shouldDropEventOnIngest(ev)) continue !relaySetIds.includes(d)
const set = getRelaySetFromEvent(ev) ) {
for (const u of set.relayUrls) { relaySetIds.push(d)
const n = normalizeUrl(u) || normalizeAnyRelayUrl(u)
if (n && !relays.includes(n)) relays.push(n)
}
} catch {
/* ignore */
} }
} }
})
return Array.from(new Set(relays)) for (const id of relaySetIds) {
} catch { try {
return [] const ev = await indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id)
if (!ev || shouldDropEventOnIngest(ev)) continue
const set = getRelaySetFromEvent(ev)
for (const u of set.relayUrls) {
const n = normalizeUrl(u) || normalizeAnyRelayUrl(u)
if (n && !relays.includes(n)) relays.push(n)
}
} catch {
/* ignore */
}
} }
return Array.from(new Set(relays))
} }

Loading…
Cancel
Save