mirror of
https://github.com/fiatjaf/nak.git
synced 2026-10-30 01:37:33 +00:00
238 lines
6.3 KiB
Go
238 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"fiatjaf.com/nostr/nip11"
|
|
)
|
|
|
|
const nip66CheckTimeout = 7 * time.Second
|
|
|
|
type nip66Checks struct {
|
|
open, read, write, nip11 bool
|
|
}
|
|
|
|
// parseNIP66Checks parses a comma-separated list of checks, like "open,read,nip11" or "all".
|
|
func parseNIP66Checks(spec string) (nip66Checks, error) {
|
|
var checks nip66Checks
|
|
for _, name := range strings.Split(spec, ",") {
|
|
name = strings.TrimSpace(name)
|
|
switch name {
|
|
case "open":
|
|
checks.open = true
|
|
case "read":
|
|
checks.read = true
|
|
case "write":
|
|
checks.write = true
|
|
case "nip11":
|
|
checks.nip11 = true
|
|
case "all":
|
|
checks = nip66Checks{open: true, read: true, write: true, nip11: true}
|
|
default:
|
|
return checks, fmt.Errorf("unknown nip66 check '%s', must be one of open, read, write, nip11 or all", name)
|
|
}
|
|
}
|
|
return checks, nil
|
|
}
|
|
|
|
// probeRelayNIP66 measures a relay and returns an unsigned kind:30166 relay discovery event.
|
|
// the relay is always connected to, but only the selected checks end up as tags.
|
|
func probeRelayNIP66(ctx context.Context, url string, checks nip66Checks) (nostr.Event, error) {
|
|
url = nostr.NormalizeURL(url)
|
|
if url == "" {
|
|
return nostr.Event{}, fmt.Errorf("invalid relay url")
|
|
}
|
|
|
|
evt := nostr.Event{
|
|
Kind: 30166,
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{{"d", url}, {"n", nip66NetworkType(url)}},
|
|
}
|
|
|
|
requirements := map[string]bool{}
|
|
|
|
// nip11 (a missing or broken document doesn't prevent the other checks)
|
|
if checks.nip11 {
|
|
nip11Ctx, cancel := context.WithTimeout(ctx, nip66CheckTimeout)
|
|
info, err := nip11.Fetch(nip11Ctx, url)
|
|
cancel()
|
|
if err != nil {
|
|
logverbose("nip11 check on %s failed: %s\n", url, err)
|
|
} else {
|
|
if j, err := json.Marshal(info); err == nil {
|
|
evt.Content = string(j)
|
|
}
|
|
for _, n := range info.SupportedNIPs {
|
|
switch v := n.(type) {
|
|
case string:
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"N", v})
|
|
case int:
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"N", strconv.Itoa(v)})
|
|
}
|
|
}
|
|
if l := info.Limitation; l != nil {
|
|
requirements["auth"] = l.AuthRequired
|
|
requirements["payment"] = l.PaymentRequired
|
|
requirements["writes"] = l.RestrictedWrites
|
|
requirements["pow"] = l.MinPowDifficulty > 0
|
|
}
|
|
for _, t := range info.Tags {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"t", t})
|
|
}
|
|
}
|
|
}
|
|
|
|
// open
|
|
connectCtx, cancel := context.WithTimeout(ctx, nip66CheckTimeout)
|
|
start := time.Now()
|
|
relay, err := nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions)
|
|
cancel()
|
|
if err != nil {
|
|
return nostr.Event{}, fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
defer relay.Close()
|
|
if checks.open {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"rtt-open", nip66Millis(time.Since(start))})
|
|
}
|
|
|
|
// read
|
|
readNeedsAuth := false
|
|
if checks.read {
|
|
if rtt, err := nip66MeasureRead(ctx, relay); err == nil {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"rtt-read", nip66Millis(rtt)})
|
|
} else {
|
|
logverbose("read check on %s failed: %s\n", url, err)
|
|
if strings.Contains(err.Error(), "auth-required:") {
|
|
readNeedsAuth = true
|
|
requirements["auth"] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// write (this publishes an event to the relay)
|
|
if checks.write {
|
|
rtt, err := nip66MeasureWrite(ctx, relay)
|
|
if err == nil {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"rtt-write", nip66Millis(rtt)})
|
|
// what actually happened trumps what the nip11 document says
|
|
// (but only when we have actually checked reads, otherwise we know nothing about them)
|
|
if checks.read {
|
|
requirements["auth"] = readNeedsAuth
|
|
}
|
|
requirements["writes"] = false
|
|
} else {
|
|
logverbose("write check on %s failed: %s\n", url, err)
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "auth-required:") {
|
|
requirements["auth"] = true
|
|
} else if strings.Contains(msg, "restricted:") || strings.Contains(msg, "blocked:") {
|
|
requirements["writes"] = true
|
|
} else if strings.Contains(msg, "pow:") {
|
|
requirements["pow"] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, key := range []string{"auth", "writes", "pow", "payment"} {
|
|
value, ok := requirements[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !value {
|
|
key = "!" + key
|
|
}
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"R", key})
|
|
}
|
|
|
|
return evt, nil
|
|
}
|
|
|
|
func nip66MeasureRead(ctx context.Context, relay *nostr.Relay) (time.Duration, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, nip66CheckTimeout)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
sub, err := relay.Subscribe(ctx, nostr.Filter{Limit: 1}, nostr.SubscriptionOptions{
|
|
Label: "nak-nip66",
|
|
// don't let the library fake an EOSE, we want the real one
|
|
MaxWaitForEOSE: time.Duration(math.MaxInt64),
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer sub.Unsub()
|
|
|
|
for {
|
|
select {
|
|
case _, ok := <-sub.Events:
|
|
// the read round-trip ends at EOSE, ignore the events themselves
|
|
if !ok {
|
|
return 0, fmt.Errorf("subscription ended before EOSE")
|
|
}
|
|
case <-sub.EndOfStoredEvents:
|
|
return time.Since(start), nil
|
|
case reason := <-sub.ClosedReason:
|
|
return 0, fmt.Errorf("subscription closed: %s", reason)
|
|
case <-ctx.Done():
|
|
return 0, fmt.Errorf("timed out waiting for EOSE")
|
|
}
|
|
}
|
|
}
|
|
|
|
func nip66MeasureWrite(ctx context.Context, relay *nostr.Relay) (time.Duration, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, nip66CheckTimeout)
|
|
defer cancel()
|
|
|
|
// an ephemeral event signed by a throwaway key, so nothing gets stored
|
|
evt := nostr.Event{
|
|
Kind: 20166,
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{},
|
|
Content: "nak nip66 write check",
|
|
}
|
|
if err := evt.Sign(nostr.Generate()); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
start := time.Now()
|
|
if err := relay.Publish(ctx, evt); err != nil {
|
|
// relays like khatru answer "mute:" when nobody is listening for an ephemeral event,
|
|
// that is still an accepted write
|
|
if !strings.Contains(err.Error(), "mute:") {
|
|
return 0, err
|
|
}
|
|
}
|
|
return time.Since(start), nil
|
|
}
|
|
|
|
func nip66NetworkType(url string) string {
|
|
host := url
|
|
if _, rest, ok := strings.Cut(host, "://"); ok {
|
|
host = rest
|
|
}
|
|
host, _, _ = strings.Cut(host, "/")
|
|
if i := strings.LastIndex(host, ":"); i != -1 {
|
|
host = host[:i]
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(host, ".onion"):
|
|
return "tor"
|
|
case strings.HasSuffix(host, ".i2p"):
|
|
return "i2p"
|
|
case strings.HasSuffix(host, ".loki"):
|
|
return "loki"
|
|
default:
|
|
return "clearnet"
|
|
}
|
|
}
|
|
|
|
func nip66Millis(d time.Duration) string {
|
|
return strconv.FormatInt(d.Milliseconds(), 10)
|
|
}
|