🐦‍⬛

誰でも作れる Nostr リレーサーバの作り方

に公開

はじめに

この記事は Nostr Advent Calendar 2025 の8日目の記事です。

こんにちは。皆さん Nostr はご存じでしょうか。分散型ソーシャルネットワーキングサービスの1つで、中央集権的なサーバを持たず、ユーザが自由にサーバを立てて参加できる仕組みになっています。

簡単に説明した物を以下のリンクに書いてありますので、興味がある方はご覧ください。

https://zenn.dev/mattn/articles/cf43423178d65c

この記事の中にも書かれていますが、Nostr は中央サーバに依存しない設計のため、誰でも自由にリレーサーバを立てることができ、それがエコシステムの強みでもあります。

この記事では、初心者でも理解しやすいレベルで「最小限動く Nostr リレーサーバ」の作り方を解説します。完璧な実装を目指すのではなく、まずは「動くもの」を作って仕組みを体感することを目的としています。

リレーサーバとは

分かりやすく説明すると Nostr のリレーサーバは、クライアント(Damus, Amethyst, Primal, Nostter, Lumilumi など)と、イベント(投稿、DM、フォロー情報など)の中継点です。

特徴は以下の通り

  • クライアントは複数のリレーに接続できる
  • リレーはイベントを保存し、他のクライアントに配信する
  • リレーは「信頼できない」存在でよい(クライアントが署名を検証する)
  • プロトコルは NIP と呼ばれる仕様で定義されている

つまり、リレーは「ただのイベントの郵便ポスト + 配信サーバ」だと考えれば OK です。そしてリレーサーバだけでは何もできない存在であり、クライアントがあって初めて意味を持ちます。

X の様に x.com というドメイン1つに対して X というサービスがある訳ではなく、リレーサーバとそこに投稿する人々によりネットワークが生まれるという面白い SNS なのです。

通信形式

Nostr は WebSocket を使ってクライアントとリレーサーバが通信します。通信は JSON で行われ、以下のようなメッセージタイプがあります。

["EVENT", <subscription_id>, <event>]
["REQ", <subscription_id>, <filters>...]
["CLOSE", <subscription_id>]
["NOTICE", <message>]
["EOSE", <subscription_id>]

EVENT は投稿やリポスト、いいねや DM などを示し、REQ はクライアントがリレーに対して特定のイベントを要求するために使用されます。CLOSE は特定のサブスクリプションを終了するために使用され、NOTICE はリレーからクライアントへの通知メッセージです。

EOSE は「End Of Stored Events」の略で、リレーが保存されたイベントの送信を完了したことを示します。例えばリレーサーバに保存された kind 1 つまり投稿のイベントをクライアントが REQ メッセージで要求した場合、リレーサーバは該当するイベントを送信した後に EOSE メッセージを送信します。これにより、クライアントはリレーサーバからのイベントの送信が完了したことを認識できます。

=> ["REQ", "sub_123", {"kinds": [1]}] # kind 1 で問い合わせ
<= ["EVENT", "sub_123", {...}]        # 過去イベント
<= ["EVENT", "sub_123", {...}]        #     ...
<= ["EVENT", "sub_123", {...}]        #     ...
<= ["EVENT", "sub_123", {...}]        #     ...
<= ["EOSE", "sub_123"]                # 過去データ終了
<= ["EVENT", "sub_123", {...}]        # 新規イベント

この WebSocket のセッションは、クライアントが接続を閉じるまで継続されます。この接続を閉じなければリレーサーバは kinds 1 で待機している全てのクライアントに対して、新しい投稿イベントが届くたびに配信し続けます。クライアントはこの REQ を閉じたい場合には、CLOSE メッセージを送信します。

["CLOSE", "sub_123"]

イベント投稿

Nostr の投稿は「イベント」と呼ばれ、JSON 形式で表現されます。リレーサーバは、クライアントから送信された EVENT の命令を受け取り、保存し、REQ の命令を受けてクライアントに配信します。

{
  "content": "こんにちは",
  "created_at": 1764908159,
  "id": "db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8",
  "kind": 1,
  "pubkey": "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc",
  "sig": "ceb4df8536458166bd908e5500cde60ea8dac9a564d4740755eab4f83dbfec4314f287266538991358053406eaa26bc58915bdc0afcbe4028d0c96490db8f87b",
  "tags": []
}

content は見たまま本文ですね。created_at は投稿時間のタイムスタンプ、id はイベントのハッシュ値、kind はイベントの種類(1はテキスト投稿)、pubkey は投稿者の公開鍵、tags はタグ情報、そして sig が署名です。

投稿者は、contentpubkey、必要であれば tags を含むイベントデータを作成し、created_at に現在のタイムスタンプを設定します。次に、これらのフィールドを使ってイベントのハッシュ値(id)を計算し、そのハッシュ値に対して秘密鍵を使って署名(sig)を生成します。最終的に、これらすべてのフィールドを含む JSON オブジェクトが完成します。

署名は以下の構造を JSON シリアライズし、SHA256 ハッシュを計算し、それに対してシュノア署名( Nostr では secp256k1 を使用)を行います。

[0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]]

つまりリレーサーバは、受け取ったイベントの idpubkey を用いて sig を検証し、正当なものであれば保存・配信します。実はこの過程で作られる SHA256 ハッシュが id になるのです。つまりリレーサーバでの署名検証は、id と sig と pubkey の3つがあれば可能なのです。

そして気付いた方がいるかもしれませんが、実はこの JSON は誰がどのリレーサーバに対しても送信することが出来ます。

ただしこの JSON を作る事ができるのは pubkey とペアになった秘密鍵を持っている人だけです。これにより、Nostr は分散型でありながら、なりすましを防止することができます。

逆に言うと、Nostr は内緒話をするのが難しいのです。誰かが誰かの不満を Nostr で投稿し、他の誰かが別のリレーサーバに送信してしまう事ができます。一応、Notr にも NIP-29 という仕様があり、非公開部屋や参加型の部屋を作る事もできますし、NIP-70 により勝手にリポストさせない仕組みも用意されています。

リレーサーバはデータベース

Nostr のリレーサーバには色々な情報が保存されます。主に以下の情報です。

  • プロフィール情報 (kind 0)
  • 投稿 (kind 1)
  • フォロー情報 (kind 3)
  • DM (kind 4, kind 17, kind)
  • 削除情報 (kind 5)
  • リポスト (kind 6, kind 16)
  • リアクション (kind 7)
  • 利用しているリレー情報 (kind 10002)
  • ステータス情報 (kind 30315)

他にもたくさん保存されます。NIP に登録されている物は以下のリンクから確認できます。

https://github.com/nostr-protocol/nips/

Nostr のリレーにはこのオフィシャルとして定義された kind 以外にも、独自に定義された kind も存在し、無数の情報が飛び交っています。

もしリレーサーバを実装し、インターネットに公開されるのであれば、ある程度の容量のデータベースが必要になります。

署名と秘密鍵

Nostr にはパスワードという概念がありません。ユーザは秘密鍵を使ってイベントに署名し、その対応する公開鍵を使って署名の検証を行います。リレーサーバは署名の検証を行い、正当なものであればイベントを保存・配信します。

ですので秘密鍵を安全に管理することが非常に重要です。秘密鍵が漏洩すると、その鍵に対応する公開鍵を持つユーザになりすますことが可能になります。

秘密鍵から公開鍵を生成するには、secp256k1 の楕円曲線暗号を使用します。多くの Nostr クライアントは、ユーザが秘密鍵を安全に管理できるように設計されています。

Nostr で秘密鍵は 32 バイトのランダムなバイト列であり、プロトコル上では 16 進数で表現されますが、表現として nsec 形式が用いられます。慣れる為にもこの操作を試してみましょう。以下のコマンドは nak という Nostr クライアントを使って秘密鍵と公開鍵を生成します。

# 秘密鍵を生成

$ nak key generate
6cf81a6df4280378e5f0ecf2fbe971e1408eb0005075b7e134f0971d978521b5
# hex 形式の秘密鍵を nsec 形式に変換

$ nak encode nsec 6cf81a6df4280378e5f0ecf2fbe971e1408eb0005075b7e134f0971d978521b5
nsec1dnup5m059qph3e0sane0h6t3u9qgavqq2p6m0cf57zt3m9u9yx6sl98wny
# 秘密鍵から公開鍵を生成

$ nak key public 6cf81a6df4280378e5f0ecf2fbe971e1408eb0005075b7e134f0971d978521b5
e02672bb222a82e788c847376b3c20feb4083d99e5260a2bdeff5cbfac3a1935
# hex 形式の公開鍵を npub 形式に変換

$ nak encode npub e02672bb222a82e788c847376b3c20feb4083d99e5260a2bdeff5cbfac3a1935
npub1uqn89wez92pw0zxggumkk0pql66qs0veu5nq5277lawtltp6ry6smsqnqr

また Nostr のイベント JSON の中に現れる idnevent 形式で扱われます。

# id を nevent 形式に変換

$ nak encode nevent db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8
nevent1qqsdkm3evu07wr475970rj7mdkvcwqx0vfmv2ef7hhs8xru0m4fvdjqp33xwt
# nevent 形式を id に変換

$ nak decode nevent1qqsdkm3evu07wr475970rj7mdkvcwqx0vfmv2ef7hhs8xru0m4fvdjqp33xwt
{
  "id": "db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8"
}
# id にリレーURLを付与して nevent+ 形式に変換

$ nak encode nevent --relay wss://yabu.me db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8
nevent1qqsdkm3evu07wr475970rj7mdkvcwqx0vfmv2ef7hhs8xru0m4fvdjqpp4mhxue69uhhjctzw5hx6eg8g2ht5
# id にリレーURLと公開鍵を付与して nevent++ 形式に変換

$ nak encode nevent \
    --relay wss://yabu.me \
    --author 2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc \
    db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8
nevent1qqsdkm3evu07wr475970rj7mdkvcwqx0vfmv2ef7hhs8xru0m4fvdjqpp4mhxue69uhhjctzw5hx6egzyqk8e332d9l28fuzv5sl8lf57r9jwd5ne0j7jvg0x4zf7smz9fwdcs3e4av
# nevent++ 形式を id に変換

$ nak decode nevent1qqsdkm3evu07wr475970rj7mdkvcwqx0vfmv2ef7hhs8xru0m4fvdjqpp4mhxue69uhhjctzw5hx6egzyqk8e332d9l28fuzv5sl8lf57r9jwd5ne0j7jvg0x4zf7smz9fwdcs3e4av
{
  "id": "db6e39671fe70ebea17cf1cbdb6d998700cf6276c5653ebde0730f8fdd52c6c8",
  "relays": [
    "wss://yabu.me"
  ],
  "author": "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc"
}

これは何を意味しているかというと、Nostr ではイベント ID にリレーサーバの情報や投稿者の公開鍵を紐づけることができる、ということです。これにより、特定のリレーサーバで保存されている特定のイベントや投稿者を一意に識別することができます。

シーケンス

Nostr クライアントとリレーサーバ間の基本的なシーケンスは以下の通りです。

全体シーケンス

plantuml
@startuml
' === スタイル設定 ===
skinparam backgroundColor #FEFEFE
skinparam shadowing false
skinparam sequenceArrowThickness 2
skinparam sequenceParticipant {
    backgroundColor #E3F2FD
    borderColor #1565C0
}
skinparam noteBackgroundColor #FFF9C4
skinparam noteBorderColor #FFA000

' === 登場者 ===
actor ユーザーA as A
participant "クライアントA\n(Damusなど)" as CA
participant "リレー1\nwss://relay.damus.io" as R1
participant "リレー2\nwss://nostr-relay.jp" as R2
participant "リレー3\nwss://relay.snort.social" as R3
actor ユーザーB as B
participant "クライアントB\n(Amethystなど)" as CB

' === シーケンス ===
note right: 1. クライアントが任意の複数リレーに接続\n(WebSocket常時接続)
CA --> R1: WebSocket接続 (wss://...)
CA --> R2: WebSocket接続
CA --> R3: WebSocket接続
CB --> R1: WebSocket接続
CB --> R2: WebSocket接続

note right: 2. ユーザーAがノートを投稿
A -> CA: 「こんにちはNostr!」と投稿
CA -> CA: イベント作成 (kind=1)\n+ 秘密鍵で署名
CA -> R1: ["EVENT", {id:..., pubkey:..., kind:1, content:"こんにちはNostr!", sig:...}]
CA -> R2: ["EVENT", 同上]
CA --> R3: ["EVENT", 同上] #lightgray

R1 --> R1: 署名検証 → OK → 保存
R2 --> R2: 署名検証 → OK → 保存

note right: 3. リレーが他の接続中クライアントにブロードキャスト
R1 --> CA: ["OK", id, true, ""]
R1 --> CB: ["EVENT", pubkeyA, event...]   (ユーザーBへ配信)
R2 --> CA: ["OK", id, true, ""]
R2 --> CB: ["EVENT", 同上]

note right: 4. ユーザーBのクライアントが過去イベントを取得したいとき\n(フィルタ付きサブスクリプション)
CB -> R1: ["REQ", "sub1", {"kinds":[1], "authors":["Aのpubkey"]}]
CB -> R2: ["REQ", "sub1", 同上]

R1 --> CB: ["EVENT", "sub1", event...]
R2 --> CB: ["EVENT", "sub1", event...]
R1 --> CB: ["EOSE", "sub1"]  (End Of Stored Events)
R2 --> CB: ["EOSE", "sub1"]

CB -> R1: ["CLOSE", "sub1"]
CB -> R2: ["CLOSE", "sub1"]

note right: 5. 以降はリアルタイムで新着イベントがPUSHされる\n(接続中は常時)

@enduml

難しく感じるかもしれませんが、後で見返すと簡単な話です。

実装してみよう

だいたいの仕組みがわかったところで、実際にリレーサーバを実装してみましょう。ここでは Go を使った簡単な例を示します。

後々の事を先に言っておくと、実は Nostr のリレーサーバはウェブソケットサーバだけで実装できる物ではありません。必要な技術スタックとしては以下が必要です。

  • WebSocket サーバを作れるライブラリ
  • Shnoar 署名の検証が行えるライブラリ
  • HTTP サーバ (NIP-11 で必要)
  • クエリによる読み書きが可能なデータベース

今回の例では、最低限の WebSocket サーバと署名検証だけを行い、イベントはメモリに蓄積する例として実装します。

event 構造

Go には Nostr を扱う上で便利なライブラリが既に用意されています。

  • github.com/nbd-wtf/go-nostr

このパッケージを使うと Nostr のイベントは以下の様に扱えます。

var event nostr.Event
event.Content = "こんにちは"
event.CreatedAt = nostr.Now()
event.Kind = nostr.KindTextNote
event.PubKey = pubkey
event.Tags = nostr.Tags{}
event.Sign(sk)  // Id と Sig が計算され設定される

websocket サーバ

リレーサーバの骨組みとしては以下になります。

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/websocket"
	"github.com/nbd-wtf/go-nostr"
)

var (
	upgrader = websocket.Upgrader{
		CheckOrigin: func(r *http.Request) bool {
			return true
		},
	}
	subscriptions = make(map[*websocket.Conn]*Client)
    events        = make([]nostr.Event, 0)
	mu            sync.Mutex
)

type Client struct {
	conn    *websocket.Conn
    subID   string
	filters nostr.Filters
}

func main() {
	http.HandleFunc("/", handleWebSocket)
	log.Println("Nostr Relay starting on ws://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, _ := upgrader.Upgrade(w, r, nil)
	client := &Client{
		conn:    conn,
        subID:   "",
		filters: nostr.Filters{},
	}
	mu.Lock()
	subscriptions[conn] = client
	mu.Unlock()

	defer func() {
		mu.Lock()
		delete(subscriptions, conn)
		mu.Unlock()
		conn.Close()
	}()

	for {
		_, message, err := conn.ReadMessage()
		if err != nil {
			break
		}

		var msg []json.RawMessage
		if json.Unmarshal(message, &msg) != nil {
			continue
		}

		var msgType string
		json.Unmarshal(msg[0], &msgType)

		switch msgType {
		case "EVENT":
			//handleEvent(client, msg[1])
		case "REQ":
			//handleReq(client, msg)
		case "CLOSE":
			//handleClose(client, msg[1])
		case "AUTH":
			//handleAuth(client)
		}
	}
}

ここから先は、EVENT, REQ, CLOSE の各ハンドラを実装していきます。動作確認の為に毎回ブラウザを操作するのも面倒だと思います。以下の nak というクライアントを使える様にしておくと捗ります。

https://github.com/fiatjaf/nak

今回作成するサーバに対して EVENT を投稿するのであれば以下を実行します。

$ nak event -c ハローワールド ws://localhost:8080

あれ?自分の秘密鍵を設定してないのに?と思うかもしれませんが、nak は内部で固定の秘密鍵(実質公開)を使っておりサンプル用のアカウントとして実行できます。(設定次第で自分の秘密鍵で投稿する事もできます)

handleEvent 関数

まずは EVENT を受け付けましょう。

		switch msgType {
		case "EVENT":
			handleEvent(client, msg[1])
		case "REQ":
			//handleReq(client, msg)
		case "CLOSE":
			//handleClose(client, msg[1])
		case "AUTH":
			//handleAuth(client)
		}
func handleEvent(client *Client, raw json.RawMessage) {
    var ev nostr.Event
    if err := json.Unmarshal(raw, &ev); err != nil { return }

    if !ev.CheckSignature() {
        sendNotice(client.conn, "invalid: bad signature")
        return
    }

    events = append(events, ev)
    sendOK(client.conn, ev.ID, true, "")
}

ここでは受け取ったイベントの署名を検証し、成功すればメモリ上の events スライスに保存し、クライアントに OK メッセージを返しています。当然ですが沢山受信するとメモリが足りなくなるので、実際のリレーサーバではデータベースに保存する必要があります。

このイベント受信のシーケンスは以下の様になります。

EVENTシーケンス

plantuml
@startuml
skinparam backgroundColor #FFFFFF
skinparam shadowing false
skinparam sequenceArrowThickness 2
skinparam ParticipantPadding 30

actor ユーザー
participant "クライアント\n(Damus等)" as Client
participant "リレー\n(wss://relay.damus.io)" as Relay

ユーザー -> Client: 「こんにちはNostr!」と投稿

note right of Client: イベントJSONを組み立て\n→ 秘密鍵で署名
Client -> Client: イベント作成\n(kind=1, content, tags...)\n+ Schnorr署名

Client -> Relay: ["EVENT", <イベントJSON>]\n例:\n{\n  "id": "4f3a1...", \n  "pubkey": "...", \n  "created_at": 1736000000,\n  "kind": 1,\n  "content": "こんにちはNostr!",\n  "sig": "8b2c5..."\n}

note right of Relay: NIP-01 検証\n1. id == SHA256(イベント)\n2. sig が pubkey で検証OK\n3. created_at が妥当\n4. (任意) POW/支払いなど

alt 検証成功 → 保存
    Relay --> Client: ["OK", "4f3a1...", true, ""]
    note right: true = 受け入れ成功
else 検証失敗(例:署名不正)
    Relay --> Client: ["OK", "4f3a1...", false, "invalid: bad signature"]
else 重複イベント
    Relay --> Client: ["OK", "4f3a1...", false, "duplicate: already have this event"]
end alt

note right of Client: OK受信 → UIで「投稿完了」表示\n(この時点ではまだ他の人には届いていない)

@enduml

冒頭で説明した EVENT の2つ目の値を受け取り event に蓄積するだけです。go-nostr ライブラリには CheckSignature が用意されているので、署名の検証も簡単に行えます。

handleReq 関数

次に REQ に対する応答を実装します。

		switch msgType {
		case "EVENT":
			handleEvent(client, msg[1])
		case "REQ":
			handleReq(client, msg)
		case "CLOSE":
			//handleClose(client, msg[1])
		case "AUTH":
			//handleAuth(client)
		}

handleReq 関数は以下の様に実装します。

func handleReq(client *Client, msg []json.RawMessage) {
    client.subID = ""
    json.Unmarshal(msg[1], &client.subID)

    var filters nostr.Filters
    for _, rawFilter := range msg[2:] {
        var filter nostr.Filter
        if err := json.Unmarshal(rawFilter, &filter); err == nil {
            filters = append(filters, filter)
        }
    }
    client.filters = filters

    for _, ev := range events {
        if filters.Match(&ev) {
            sendEvent(client.conn, client.subID, &ev)
        }
    }
    sendEOSE(client.conn, client.subID)
}

REQシーケンス

plantuml
@startuml
skinparam backgroundColor #FFFFFF
skinparam shadowing false
skinparam sequenceArrowThickness 2
skinparam ParticipantPadding 30
skinparam noteBackgroundColor #E8F5E9
skinparam noteBorderColor #4CAF50

actor ユーザー
participant "クライアント\n(起動直後 or タイムライン更新)" as Client
participant "リレー\n(wss://relay.damus.io)" as Relay

note right of Client: ユーザーがアプリを開く\nor 手動リフレッシュ

Client -> Client: 欲しいイベントのフィルタを作成
note right of Client
  例:ホームタイムライン用フィルタ
  {
    "kinds": [1],
    "limit": 50,
    "since": 1736000000   (前回取得時刻)
  }
  またはフォロー中ユーザーのみ
  {
    "kinds": [1],
    "authors": ["...", "..."],
    "limit": 100
  }
end note

' 1. REQ送信(サブスクリプション開始)
Client -> Relay: ["REQ", "sub_001", {フィルタ1}]\n(任意で複数フィルタも可)

note right of Relay: この subscription ID "sub_001" を\nクライアントと紐づけて記憶

' 2. 保存済みイベントを順番に送信(新しい順が一般的)
Relay --> Client: ["EVENT", "sub_001", <イベントA>]
Relay --> Client: ["EVENT", "sub_001", <イベントB>]
Relay --> Client: ["EVENT", "sub_001", <イベントC>]
... --> ...: ...(最大 limit 個まで)
Relay --> Client: ["EOSE", "sub_001"]

note right of Relay
  EOSE = End Of Stored Events
  「これで過去分は全部送ったよ」
  これ以降はリアルタイム新着のみ
end note

note right of Client: EOSE受信時点で\nローディングスピナー停止\n→ タイムライン表示完了

' 3. 以降のリアルタイム配信
activate Relay
note over Client,Relay: 接続が生きている限り継続
Relay --> Client: ["EVENT", "sub_001", <新着イベントX>]
Relay --> Client: ["EVENT", "sub_001", <新着イベントY>]

' 4. ユーザーが画面を閉じたりアプリを終了
ユーザー -> Client: 画面を離れる / アプリ終了

Client -> Relay: ["CLOSE", "sub_001"]
deactivate Relay

note right of Client: CLOSE送信でリレーは\nsubscription IDを破棄\n→ 以降PUSHされなくなる

@enduml

これもまた go-nostr が便利すぎるのですが、filter.Match(&ev) でフィルタにマッチするかどうかを簡単に判定できます。マッチしたイベントは sendEvent 関数でクライアントに送信し、最後に sendEOSE 関数で EOSE メッセージを送信します。

type Filter struct {
	IDs     []string
	Kinds   []int
	Authors []string
	Tags    TagMap
	Since   *Timestamp
	Until   *Timestamp
	Limit   int
	Search  string

	// LimitZero is or must be set when there is a "limit":0 in the filter, and not when "limit" is just omitted
	LimitZero bool `json:"-"`
}

参考までに nostr.Filter の構造は上記の様になっています。idskindsauthors などで複数のフィルタリングが可能です。REQ 命令は複数のフィルタを受け付けますが、リレーサーバは以下の様にふるまう必要があります。

  • フィルタ内の配列は OR 条件
  • フィルタ内の異なる条件は AND 条件
  • 複数のフィルタは OR 条件

つまり例えば、以下の様な REQ メッセージが来た場合

["REQ", "sub_123", {"kinds": [1, 7], "authors": ["A", "B"]}, {"kinds": [2], "authors": ["B"]}]

リレーサーバはこの sub_123 に対して以下の条件にマッチするイベントを送信します。

  • kind が 1 または 7 で author が A または B のイベント
  • kind が 2 で author が B のイベント

もし SQL で実装するのであれば、以下の様なクエリになるでしょう。

SELECT * FROM events
WHERE (kind IN (1, 7) AND pubkey IN ('A', 'B'))
   OR (kind IN (2) AND pubkey IN ('B'));

handleClose 関数

さて最後に CLOSE 命令を実装しましょう。

		switch msgType {
		case "EVENT":
			handleEvent(client, msg[1])
		case "REQ":
			handleReq(client, msg)
		case "CLOSE":
			handleClose(client, msg[1])
		case "AUTH":
			//handleAuth(client)
		}
func handleClose(client *Client, raw json.RawMessage) {
    var subID string
    json.Unmarshal(raw, &subID)

    // 指定された subID を持っているクライアントからフィルタを削除
    for _, client := range subscriptions {
        if client.subID == subID {
            client.filters = nostr.Filters{}
        }
    }
}

REQ 命令で登録されたフィルタを削除するだけです。NIP による定義ではリレーサーバでは、クライアントごとに複数のサブスクリプション ID を管理するとされています。

念の為、接続を勝手に閉じられた場合にもフィルタを削除する様にしておきましょう。handleWebSocket 関数の defer 部分を以下の様に修正します。

	defer func() {
		mu.Lock()
		delete(subscriptions, conn)
		mu.Unlock()
		// フィルタも削除
		client.filters = nostr.Filters{}
		conn.Close()
	}()

最後に sendNotice, sendOK, sendEvent, sendEOSE 関数も以下に示しておきます。

func sendEvent(conn *websocket.Conn, subID string, ev *nostr.Event) error {
	return conn.WriteJSON([]interface{}{"EVENT", subID, ev})
}

func sendEOSE(conn *websocket.Conn, subID string) error {
	return conn.WriteJSON([]interface{}{"EOSE", subID})
}

func sendOK(conn *websocket.Conn, id string, ok bool, message string) error {
	return conn.WriteJSON([]interface{}{"OK", id, ok, message})
}

func sendNotice(conn *websocket.Conn, notice string) error {
	return conn.WriteJSON([]interface{}{"NOTICE", notice})
}

試してみよう

この記事を読むまで皆さんはこう感じていたかもしれません。

リレーサーバって難しい

しかし実はこれだけで、ひとまずは動くのです。

上記のコードを main.go というファイルに保存し、Go モジュールを初期化して必要なパッケージをインストールします。

go mod init my-nostr-relay
go mod tidy
go build

では起動しましょう。

./my-nostr-relay
2025/12/05 15:08:59 Nostr Relay starting on ws://localhost:8080

別のターミナルを開いて、nak コマンドで投稿してみましょう。

$ nak event -c テスト1 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"706dfaa06fcce3ec51f65dfadd64c953b0138ebe9dba539e7260fec3f0c372ac","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764914981,"tags":[],"content":"テスト1","sig":"686bb62a78e0f9381fb7fdc685dad535554e357d7057617b08693309563458d2adaf87787afd809aa4bb8953a4730b1b6042d87e7d57f73e2f57af481ee3a04e"}
publishing to ws://localhost:8080... success.

成功しました。テスト1 の次は テスト2 を投稿してみましょう。

$ nak event -c テスト2 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"0a06d1eec3ec275df99f878f806388f5234b17a44f9e5dc2d795328f9bab7eff","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764914984,"tags":[],"content":"テスト2","sig":"8f5a84fc0b66e00fd8d9e34bf03d8614196bfd590549e5eb98071d50cb133cf89cb21930be4b2ed5e6e583d02fadfb58ffa5b8124601e0dde5adc8223cebf69b"}
publishing to ws://localhost:8080... success.

もしリレーサーバが正しく動いているならば、リレーサーバの events には2つのイベントが保存されているはずです。では nak コマンドで REQ を送信してみましょう。

$ nak req -k 1 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"706dfaa06fcce3ec51f65dfadd64c953b0138ebe9dba539e7260fec3f0c372ac","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764914981,"tags":[],"content":"テスト1","sig":"686bb62a78e0f9381fb7fdc685dad535554e357d7057617b08693309563458d2adaf87787afd809aa4bb8953a4730b1b6042d87e7d57f73e2f57af481ee3a04e"}
{"kind":1,"id":"0a06d1eec3ec275df99f878f806388f5234b17a44f9e5dc2d795328f9bab7eff","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764914984,"tags":[],"content":"テスト2","sig":"8f5a84fc0b66e00fd8d9e34bf03d8614196bfd590549e5eb98071d50cb133cf89cb21930be4b2ed5e6e583d02fadfb58ffa5b8124601e0dde5adc8223cebf69b"}

ちゃんと2つのイベントが返ってきましたね。これで最低限の Nostr リレーサーバが動作しました。

nostr のリレーサーバである為には

さて簡易のリレーサーバが実装できてしまいました。しかし、Nostr のリレーサーバとして認識されるためには、いくつかの追加要件があります。以下に主要なポイントを挙げます。

  • Broadcast の実装
  • Replacable Events のサポート
  • Epheveral Events のサポート
  • Parameterized Replaceable Events のサポート
  • Protected Events のサポート (NIP-70)
  • AUTH 命令のサポート (NIP-42)

Nostr にいる有識者によって色々な意見があるかもしれませんが、僕が考える最低限の要件は上記の通りです。これらさえ満たせば、主要なクライアントからリレーサーバとして認識され、利用する事ができる様になります。

これらを順番に解説していきます。

Broadcast の実装

前述の様にクライアントが REQ 命令でイベントの配信を要求するとリレーサーバはフィルタにマッチした過去のイベントを配信した後に EOSE を送信し、以降はフィルタにマッチする新規のイベントがイベントが届くたびに接続中の全てのクライアントに対して配信する必要があります。これをブロードキャストと呼びます。

ブロードキャストシーケンス

plantuml
@startuml
actor User
participant ClientA as "Client A"
participant ClientB as "Client B"
participant ClientC as "Client C (フィルタ不一致)"
participant Relay

User -> ClientA: 投稿作成
ClientA -> Relay: ["EVENT", <event_data_without_signature>]
Relay -> Relay: ブロードキャスト(フィルタチェック)
Relay -> ClientA: ["EVENT", <event_data>]
Relay -> ClientB: ["EVENT", <event_data>]
note right of Relay: ClientCのフィルタにマッチせず送信なし

@enduml

一見、難しそうに感じると思います。

  • イベントを待機している接続しているクライアント全てに分散して送信しなければならない
  • スレッドやキューが要る

そんな風に感じるかもしれません。実はそんな事はありません。よく考えて下さい。受信待ちをしている WebSocket のクライアントに対してイベントを送信しないといけないのはどんなタイミングなのか。それは別のクライアントからイベントを受信した時だけです。

つまりどのクライアントからか、イベントが送られてきたらついでに送ってしまえばいいのです。イベントを受信したときにやるのはこれです。

  • 署名を検証して
  • データストア(今回は配列)に保存して
  • OK 応答を返して
  • subscriptions に格納されているクライアントに送る

では handleEvent 関数を修正してみましょう。

func handleEvent(client *Client, raw json.RawMessage) {
    var ev nostr.Event
    if err := json.Unmarshal(raw, &ev); err != nil { return }

    if !ev.CheckSignature() {
        sendNotice(client.conn, "invalid: bad signature")
        return
    }

    events = append(events, ev)
    sendOK(client.conn, ev.ID, true, "")

    // broadcast
    for conn, client := range subscriptions {
        if client.filters.Match(&ev) {
            sendEvent(client.conn, client.subID, &ev)
        }
    }
}

たったこれだけです。もしこの送信処理を高速に行いたいのであれば、この部分だけ並列もしくは平行処理すれば良いです。確認してみましょう。

./my-nostr-relay
2025/12/05 15:31:24 Nostr Relay starting on ws://localhost:8080

リレーサーバを起動したら別のターミナルから前述の様にイベントを2個投入します。

$ nak event -c うんこ1 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"566b5677f95a998f762c58271a384d5667709c3be34aff3f665ef6828571e900","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764934243,"tags":[],"content":"うんこ1","sig":"7318c98d9113d56ea6f75158e7ad140782bacd827b720e44b34d31922bae1805487bc3db97c57ea323d77be1b2b0f7e68d4d0bf090bca171134b63dc9aae64ea"}
publishing to ws://localhost:8080... success.

$ nak event -c うんこ2 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"12b83e4bd72f63079ba8edbe3fd1c10250b60aa6a335ed7538b38e29220d4f7b","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764934247,"tags":[],"content":"うんこ2","sig":"e6d9f24b2a83c6bf105accc44116a55bfca91ed7f1411deb6e3196a6d4308016d633a686a74814cecddbeb44bfebfc593816acd6d35e1b3d54dbdafafb582b95"}
publishing to ws://localhost:8080... success.

そして別のターミナルで nak に --stream フラグを付けて実行します。

$ nak req --stream ws://localhost:8080
{"kind":1,"id":"566b5677f95a998f762c58271a384d5667709c3be34aff3f665ef6828571e900","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764934243,"tags":[],"content":"うんこ1","sig":"7318c98d9113d56ea6f75158e7ad140782bacd827b720e44b34d31922bae1805487bc3db97c57ea323d77be1b2b0f7e68d4d0bf090bca171134b63dc9aae64ea"}
{"kind":1,"id":"12b83e4bd72f63079ba8edbe3fd1c10250b60aa6a335ed7538b38e29220d4f7b","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764934247,"tags":[],"content":"うんこ2","sig":"e6d9f24b2a83c6bf105accc44116a55bfca91ed7f1411deb6e3196a6d4308016d633a686a74814cecddbeb44bfebfc593816acd6d35e1b3d54dbdafafb582b95"}

過去の投稿が表示された後にプログラムが待ちになります。元のターミナルに戻ってさらにイベントを投稿しましょう。

$ nak event -c うんこ3 ws://localhost:8080
connecting to ws://localhost:8080... ok.
{"kind":1,"id":"9f3f18dbddfbc1ac46e653c7b00c3d3f48462de750f3b447abae8db161c5a046","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1764934375,"tags":[],"content":"うんこ3","sig":"0ab4e8ba44bddbebfb123f86e4e7fffb5456fff963a2a47fa7917b2c795c4a6015e68ae822cb83998bf2c79c76fb198b39b718a1e0cde1ec886c47261a5c80af"}
publishing to ws://localhost:8080... success.

すると --stream を付けた nak コマンドに「うんこ3」が追加されるのが分かると思います。これがブロードキャストです。

Replacable Events のサポート

kind は 0 と 3 と、10000 <= kind < 20000 の範囲。

Nostr では、同じ公開鍵から送信された特定の種類のイベントが新しいもので置き換えられる仕組みがあります。これを Replacable Events と呼びます。例えば、世の中に1つしか存在しえないプロフィール情報やステータスメッセージなどがこれに該当します。

例えばプロフィール情報は kind 0 のイベントとして定義されており、ユーザがプロフィールを更新するたびに新しいイベントが送信されます。リレーサーバは、同じ公開鍵から送信された最新のプロフィールイベントのみを保存し、過去のものは削除または上書きします。

ちなみに、プロフィール情報は kind 0 の content の中に JSON 形式で様々な情報が含まれます。例えば以下の様な情報です。

$ PK=2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc
$ nak req -k 0 -a $PK wss://yabu.me | jq -r .content
connecting to wss://yabu.me... ok.
{
  "about": "Long-time #Golang user&contributor,  #GoogleDevExpert  Go, #Vim, #Windows hacker, ex-#GitHubStars, #runner.",
  "banner": "https://image.nostr.build/db1328a9d52cb42619c6485ab4ca5c9f60809a2a6caf4443adf363755a5e9cbe.jpg",
  "created_at": 1689299849,
  "display_name": "mattn",
  "github:mattn": "13ec5d5e1c77d12d8546b99b64c3d372",
  "identities": [
    {
      "claim": "mattn",
      "proof": "https://github.com/mattn",
      "type": "github"
    }
  ],
  "lud16": "grimyend76@walletofsatoshi.com",
  "name": "mattn",
  "nip05": "_@compile-error.net",
  "nip05valid": true,
  "picture": "https://image.nostr.build/1baac39fc410daa02b0b336a9e2e4c16d964d41aba3fae19794ac8f56875464a.jpg",
  "website": "https://compile-error.net"
}

各クライアントは、この kind 0 の name もしくは display_name フィールドを使ってユーザ名を表示します。

これは余談になりますが、例えばこのプロフィール情報が本当に僕の情報であるかどうかを判定するにはどうするのでしょうか。

Nostr には NIP-05 という仕様があり、これを使うとドメイン名と公開鍵を紐づけることができます。例えば上記のプロフィール情報には nip05 フィールドが含まれており、僕が保有する compile-error.net ドメイン全体に対して pubkey を紐づけています。

$ curl -s https://compile-error.net/.well-known/nostr.json | jq .
{
  "names": {
    "_": "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc"
    ...
  }
}

Epheveral Events のサポート

kind は、20000 <= kind < 30000 の範囲。

ストレージに保存されないイベントです。リレーサーバはこのイベントを保存せずブロードキャストのみを行う事が推奨されています。

Parameterized Replaceable Events のサポート

kind は、30000 <= kind < 40000 の範囲。

Nostr にはステータス表示という機能があります。これは前述の Replacable Events の一種ですが、パラメータ化された置き換え可能なイベントとして定義されています。例えば、ユーザが現在のステータスを更新するたびに新しいイベントが送信されますが、特定のパラメータに基づいて置き換えられます。

例として挙げると、ユーザが今聴いている音楽の情報をステータスとして表示する場合があります。これらのイベントは kind 30315 として定義されており、ユーザが新しい音楽を聴き始めるたびに新しいイベントが送信されます。リレーサーバは、同じ公開鍵とパラメータに基づいて最新のステータスイベントのみを保存し、過去のものは削除または上書きします。

試しに kind 30315 かつ d=music のステータスイベントを取得してみましょう。

$ nak req -k 30315 -t d=music -a $PK wss://yabu.me | jq .
connecting to wss://yabu.me... ok.
{
  "kind": 30315,
  "id": "9b548d2b0c2c36a9c3ddca0c9723001eb1c6dac0e0f84e4ba137efdcd92c0b67",
  "pubkey": "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc",
  "created_at": 1764922021,
  "tags": [
    [
      "d",
      "music"
    ],
    [
      "expiration",
      "1764922321"
    ],
    [
      "r",
      "spotify:search:Ozzy+Osbourne+-+No+More+Tears"
    ]
  ],
  "content": "Ozzy Osbourne - No More Tears",
  "sig": "953f66e6444d8dcdabea1adb6069972776f289c7b185ed17f0f4d688083810b1376c02473143289dbac3dfc39b9db92f6409fd1df4438becfd8b13231a458387"
}

僕が聴いていた音楽が返ってきましたね。tags の中に d=music というタグが含まれている事がわかります。

Replaceable Events はリレー内で pubkeykind の組み合わせでユニークとなるのに対し、Parameterized Replaceable Events は pubkeykind に加えて特定のタグ(上記の例では d タグ)もユニークになる点が異なります。

Protected Events のサポート (NIP-70)

さてここまで実装されていれば、主要な Nostr クライアントからリレーサーバとして認識され、誰でも利用する事ができるリレーを実装できたと言って良いと思います。

ただしどんな簡素なリレーサーバであっても、皆に使ってもらおうと公開するのであれば、できればこの機能だけは実装して欲しいと思うのが Protected Events のサポートです。

前述の様に Nostr は基本的に公開型の分散型 SNS です。誰かが誰かの不満を Nostr で投稿し、他の誰かが別のリレーサーバに送信してしまう事ができます。これを防止する為に、NIP-70 では Protected Events という仕組みが定義されています。

NIP-70 ではイベントのタグに - が含まれている場合、そのイベントは Protected Event と見なされ、リレーサーバは Protected Event を受信した場合、そのイベントを他のクライアントに配信する前に、イベントの発行者がそのリレーサーバに対して明示的に許可を与えたかどうかを確認する必要があります。その為には、リレーサーバはイベントの発行者に対して AUTH 命令を送信し、発行者がそのリレーサーバに対して許可を与えた場合にのみ、そのイベントを他のクライアントに配信できるというルールを設ける事ができます。

現状のクライアントではまだ NIP-70 に対応している物は少ないですが、将来的にはこの仕組みをサポートするクライアントが増えてくると思います。リレーサーバを公開する際には、ぜひこの機能も実装してみてください。

AUTH 命令のサポート (NIP-42)

前述の通り、リレーサーバは特定のクライアントに対して追加の権限を付与したり、アクセス制御を行う必要がある場合があります。その為に AUTH 命令をサポートする事が推奨されます。

リレーサーバは権限が必要と判断したクライアントに対して、まず NOTICE メッセージで認証が必要である事を通知し、続いて AUTH 命令を送信します。クライアントはこの AUTH 命令に応じて、AUTH 命令に付属する challenge を秘密鍵で署名した AUTH イベントを生成し、リレーサーバに送信します。リレーサーバは受信した AUTH イベントの署名と challenge を検証し、さらにサービス URL (リレーのURL) が同一である事を確認し、成功すればクライアントに対して OK メッセージを送信します。

シーケンス図は以下の通りです。

AUTHシーケンス

plantuml
@startuml
actor User
participant Client as "Client"
participant Relay as "Relay"

== 認証要求 ==
Relay -> Client: ["NOTICE", "auth-required"]
Relay -> Client: ["AUTH", {challenge: "..."}]

== AUTH イベント生成 ==
User -> Client: 認証要求に応じる
Client -> Client: AUTHイベント生成(challenge を署名)

== AUTH 送信 ==
Client -> Relay: ["EVENT", {kind: 22242, challenge: "...", sig: "..."}]

== リレー側検証 ==
Relay -> Relay: 署名と challenge の検証
Relay -> Client: ["OK", <event_id>, true, "authenticated"]

@enduml

今回実装した、簡易のリレーサーバは GitHub に置いておきます。適当にいじって遊んで下さい。ちなみにですが、Go のリレーサーバ界隈にはすでにリレーサーバを実装する為に必要となるデータベース周りや、フレームワークが用意されており、それらを組み合わせるだけで簡単にオリジナルのリレーサーバを実装する事ができます。

今回の記事では、handleAuth の実装は省略します。この解説を読んでぜひご自分で実装してみて下さい。以下で紹介する僕の実装のどこかにに答えがあります。

既存実装

https://github.com/fiaitjaf/relayer

Nostr の主要開発者の1人 fiatjaf 氏が開発する Go で書かれたリレーサーバフレームワーク。僕 mattn もメンテナを務めています。

https://github.com/fiaitjaf/khatu

同じく fiatjaf 氏が開発する Go で書かれた別のリレーサーバフレームワーク。

https://github.com/fiaitjaf/eventstore

同じく fiatjaf 氏が開発する Go で書かれた Nostr 向けのストレージハブ。PostgreSQL や SQLite3、lmdb 等を透過的に Nostr のバックエンドストレージとして扱える様になっています。僕 mattn もメンテナです。

後に紹介する nostr-relay は、relayer と eventstore を組み合わせたリレーサーバです。

リレーの実装あれこれ

リレーサーバは世の中に既に沢山実装されています。有名な物としては strfly が挙げられます。僕も幾らか実装しています。もしリレーサーバを実装してみたいなと思ったら参考にしてみてください。

おわりに

Nostr のクライアントとリレーサーバの間で送受信されるメッセージの仕組みと、簡単なリレーサーバの実装方法について解説しました。Nostr はシンプルでありながら強力な分散型 SNS の基盤を提供しています。ぜひ自分だけのリレーサーバを立ち上げて、Nostr エコシステムに参加してみてください。

Nostr の住人の中には、この NIP に精通した人達が沢山います。もしわからない事があれば、Nostr でつぶやいてみると良いでしょう。きっと誰かが助けてくれるはずです。

Discussion