Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ RELAY_PUBKEY="e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"
RELAY_DESCRIPTION="all my bots will use this relay"
RELAY_URL="wss://bots.utxo.one"
RELAY_ICON="https://pfp.nostr.build/d8fb3b6100a0eb9e652bbc34a0c043b7f225dc74e4ed6d733d0e059f9bd444d4.jpg"
RELAY_CONTACT="https://utxo.one"
RELAY_CONTACT="https://utxo.one"

# Optional: comma-separated hex pubkeys; when set (at least one entry) these
# take precedence over write_whitelist.json / read_whitelist.json
# WRITE_WHITELIST_PUBKEYS="pubkey1hex,pubkey2hex"
# READ_WHITELIST_PUBKEYS="pubkey1hex"
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
push:
branches: [master, main]
pull_request:

jobs:
Expand Down Expand Up @@ -36,3 +37,6 @@ jobs:

- name: e2e - legacy whitelist primacy
run: go run ./e2e -binary ./sw2 -legacy

- name: e2e - env-var whitelists
run: go run ./e2e -binary ./sw2 -env
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ If the `write_whitelist.json` contains no pubkeys `{"pubkeys": []}`, then all us

To maintain compatibliity with previous versions of SW2, a file `whitelist.json` can be used instead of `write_whitelist.json` if you prefer.

### 4.3 Whitelist via Environment Variables

Both whitelists can alternatively be supplied in environment variables, which
is convenient for hosted platforms (Railway, Fly, etc.) where per-environment
config lives in env vars rather than files. The format is a single string of
hex pubkeys separated by commas — one entry or a hundred, no brackets or
quotes around individual keys; whitespace around entries and a trailing comma
are ignored:

```bash
# one pubkey
READ_WHITELIST_PUBKEYS="1c6cb22996baabe921bcd45c8b6213b2dab096f88e4ba5678d43d195a1868551"

# several pubkeys, comma-separated
WRITE_WHITELIST_PUBKEYS="1c6cb22996baabe921bcd45c8b6213b2dab096f88e4ba5678d43d195a1868551,9c5d0b120f01b75292d2a2bc32972bf918c8dd8927eaa633d3f62e181a292b27,ede41352397758154514148b24112308ced96d121229b0e6a66bc5a2b40c03ec"
```

When a variable contains at least one entry it takes precedence over the
corresponding JSON file; when unset, blank, or containing only commas and
whitespace, the file is used as before. Note that the "empty list = allow
everyone" behaviour can only be expressed via the files — a blank variable
deliberately falls back to the file rather than opening the relay to everyone.

### 5. Build the project

Run the following command to build the relay:
Expand Down
59 changes: 56 additions & 3 deletions e2e/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// go build -o sw2 . && go run ./e2e -binary ./sw2 -matrix
// go run ./e2e -binary ./sw2 -open # empty lists: anyone writes, reads are public
// go run ./e2e -binary ./sw2 -legacy # whitelist.json takes primacy over write_whitelist.json
// go run ./e2e -binary ./sw2 -env # *_WHITELIST_PUBKEYS env vars override the files
//
// The relay listens on the fixed port 3334 (sw2 behaviour), so run one mode
// at a time.
Expand Down Expand Up @@ -40,7 +41,10 @@ func check(name string, ok bool, detail string) {

// startRelay runs the sw2 binary in a temp dir seeded with the given
// whitelist files, waits for the port to accept, and returns a stop func.
func startRelay(binary string, files map[string]string) func() {
// The whitelist env vars are always scrubbed from the inherited environment
// so ambient shell state cannot contaminate file-based modes; extraEnv
// entries ("KEY=value") are appended after the scrub.
func startRelay(binary string, files map[string]string, extraEnv ...string) func() {
dir, err := os.MkdirTemp("", "sw2-e2e-")
if err != nil {
panic(err)
Expand All @@ -56,6 +60,13 @@ func startRelay(binary string, files map[string]string) func() {
}
cmd := exec.Command(abs)
cmd.Dir = dir
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "WRITE_WHITELIST_PUBKEYS=") || strings.HasPrefix(kv, "READ_WHITELIST_PUBKEYS=") {
continue
}
cmd.Env = append(cmd.Env, kv)
}
cmd.Env = append(cmd.Env, extraEnv...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
Expand Down Expand Up @@ -237,15 +248,54 @@ func runLegacy(binary string) {
fmt.Sprintf("expected rejection, got %v", err))
}

// runEnv: WRITE/READ_WHITELIST_PUBKEYS take priority over the files, and a
// blank variable falls back to the files rather than opening the relay.
func runEnv(binary string) {
skEnv, skFile := nostr.Generate(), nostr.Generate()
files := map[string]string{
"write_whitelist.json": pubkeysJSON(skFile),
"read_whitelist.json": pubkeysJSON(skFile),
}

stop := startRelay(binary, files,
"WRITE_WHITELIST_PUBKEYS="+nostr.GetPublicKey(skEnv).Hex(),
"READ_WHITELIST_PUBKEYS="+nostr.GetPublicKey(skEnv).Hex(),
)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

check("env: env-listed author can write", tryWrite(ctx, skEnv) == nil, "publish rejected")
err := tryWrite(ctx, skFile)
check("env: file-listed author is rejected (env takes priority)",
err != nil && strings.Contains(err.Error(), "not whitelisted"),
fmt.Sprintf("expected rejection, got %v", err))
allowed, reason := tryRead(ctx, &skEnv)
check("env: env-listed reader can read", allowed, "CLOSED "+reason)
allowed, reason = tryRead(ctx, &skFile)
check("env: file-listed reader is rejected (env takes priority)",
!allowed && strings.Contains(reason, "not authorized"),
fmt.Sprintf("expected restricted, got allowed=%v %q", allowed, reason))
stop()

stop = startRelay(binary, files, "WRITE_WHITELIST_PUBKEYS= ", "READ_WHITELIST_PUBKEYS=")
defer stop()
check("blank env: file-listed author can write (fallback to files)", tryWrite(ctx, skFile) == nil, "publish rejected")
err = tryWrite(ctx, skEnv)
check("blank env: env key is not whitelisted",
err != nil && strings.Contains(err.Error(), "not whitelisted"),
fmt.Sprintf("expected rejection, got %v", err))
}

func main() {
binary := flag.String("binary", "./sw2", "path to the sw2 binary")
matrix := flag.Bool("matrix", false, "run the 4-key read/write permission matrix")
open := flag.Bool("open", false, "run the empty-whitelists checks")
legacy := flag.Bool("legacy", false, "run the legacy whitelist.json primacy check")
env := flag.Bool("env", false, "run the env-var whitelist priority checks")
flag.Parse()

if !*matrix && !*open && !*legacy {
fmt.Println("pick a mode: -matrix, -open, or -legacy")
if !*matrix && !*open && !*legacy && !*env {
fmt.Println("pick a mode: -matrix, -open, -legacy, or -env")
os.Exit(2)
}
if *matrix {
Expand All @@ -257,6 +307,9 @@ func main() {
if *legacy {
runLegacy(*binary)
}
if *env {
runEnv(*binary)
}
if failures > 0 {
fmt.Printf("\n%d failure(s)\n", failures)
os.Exit(1)
Expand Down
26 changes: 26 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
"strings"

"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/lmdb"
Expand All @@ -18,7 +19,28 @@ type WriteWhitelist struct {
Pubkeys []string `json:"pubkeys"`
}

// pubkeysFromEnv reads a comma-separated pubkey list from the named env var.
// A set-but-blank variable reports ok=false so the file path is used instead —
// an accidentally empty variable must not turn an empty whitelist into
// allow-everyone semantics.
func pubkeysFromEnv(key string) (pubkeys []string, ok bool) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return nil, false
}
for _, entry := range strings.Split(raw, ",") {
if entry = strings.TrimSpace(entry); entry != "" {
pubkeys = append(pubkeys, entry)
}
}
return pubkeys, len(pubkeys) > 0
}

func loadWriteWhitelist() (*WriteWhitelist, error) {
if pubkeys, ok := pubkeysFromEnv("WRITE_WHITELIST_PUBKEYS"); ok {
return &WriteWhitelist{Pubkeys: pubkeys}, nil
}

// Try opening "whitelist.json" first
file, err := os.Open("whitelist.json")
if err != nil {
Expand Down Expand Up @@ -52,6 +74,10 @@ type ReadWhitelist struct {
}

func loadReadWhitelist(filename string) (*ReadWhitelist, error) {
if pubkeys, ok := pubkeysFromEnv("READ_WHITELIST_PUBKEYS"); ok {
return &ReadWhitelist{Pubkeys: pubkeys}, nil
}

file, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("could not open file: %w", err)
Expand Down
88 changes: 88 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func writeFile(t *testing.T, dir, name, content string) {
}

func TestLoadWriteWhitelist(t *testing.T) {
t.Setenv("WRITE_WHITELIST_PUBKEYS", "") // isolate from ambient shell env
t.Run("legacy whitelist.json takes primacy", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "whitelist.json", `{"pubkeys":["aa"]}`)
Expand Down Expand Up @@ -53,6 +54,7 @@ func TestLoadWriteWhitelist(t *testing.T) {
}

func TestLoadReadWhitelist(t *testing.T) {
t.Setenv("READ_WHITELIST_PUBKEYS", "") // isolate from ambient shell env
dir := t.TempDir()
writeFile(t, dir, "read_whitelist.json", `{"pubkeys":["dd"]}`)
t.Chdir(dir)
Expand All @@ -65,6 +67,92 @@ func TestLoadReadWhitelist(t *testing.T) {
}
}

func TestWhitelistsFromEnv(t *testing.T) {
t.Run("WRITE_WHITELIST_PUBKEYS wins over files", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "whitelist.json", `{"pubkeys":["aa"]}`)
t.Chdir(dir)
t.Setenv("WRITE_WHITELIST_PUBKEYS", " bb , cc ,")
wl, err := loadWriteWhitelist()
if err != nil {
t.Fatal(err)
}
if len(wl.Pubkeys) != 2 || wl.Pubkeys[0] != "bb" || wl.Pubkeys[1] != "cc" {
t.Errorf("env entries must be trimmed and empties dropped, got %v", wl.Pubkeys)
}
})

t.Run("blank WRITE_WHITELIST_PUBKEYS falls back to files", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "write_whitelist.json", `{"pubkeys":["aa"]}`)
t.Chdir(dir)
t.Setenv("WRITE_WHITELIST_PUBKEYS", " ")
wl, err := loadWriteWhitelist()
if err != nil {
t.Fatal(err)
}
if len(wl.Pubkeys) != 1 || wl.Pubkeys[0] != "aa" {
t.Errorf("blank env must not shadow the file, got %v", wl.Pubkeys)
}
})

t.Run("env of only commas and spaces falls back to files", func(t *testing.T) {
// this branch is what guarantees the env path can never yield an
// empty list — i.e. can never trip the allow-everyone rule
dir := t.TempDir()
writeFile(t, dir, "write_whitelist.json", `{"pubkeys":["aa"]}`)
t.Chdir(dir)
t.Setenv("WRITE_WHITELIST_PUBKEYS", ",, ,")
wl, err := loadWriteWhitelist()
if err != nil {
t.Fatal(err)
}
if len(wl.Pubkeys) != 1 || wl.Pubkeys[0] != "aa" {
t.Errorf("entry-less env must not shadow the file, got %v", wl.Pubkeys)
}
})

t.Run("blank READ_WHITELIST_PUBKEYS falls back to file", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "read_whitelist.json", `{"pubkeys":["dd"]}`)
t.Chdir(dir)
t.Setenv("READ_WHITELIST_PUBKEYS", "")
rl, err := loadReadWhitelist("read_whitelist.json")
if err != nil {
t.Fatal(err)
}
if len(rl.Pubkeys) != 1 || rl.Pubkeys[0] != "dd" {
t.Errorf("blank env must not shadow the file, got %v", rl.Pubkeys)
}
})

t.Run("env with no file works", func(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("WRITE_WHITELIST_PUBKEYS", "aa")
wl, err := loadWriteWhitelist()
if err != nil {
t.Fatal(err)
}
if len(wl.Pubkeys) != 1 || wl.Pubkeys[0] != "aa" {
t.Errorf("got %v", wl.Pubkeys)
}
})

t.Run("READ_WHITELIST_PUBKEYS wins over file", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "read_whitelist.json", `{"pubkeys":["dd"]}`)
t.Chdir(dir)
t.Setenv("READ_WHITELIST_PUBKEYS", "ee")
rl, err := loadReadWhitelist("read_whitelist.json")
if err != nil {
t.Fatal(err)
}
if len(rl.Pubkeys) != 1 || rl.Pubkeys[0] != "ee" {
t.Errorf("got %v", rl.Pubkeys)
}
})
}

func TestParsePubkeySet(t *testing.T) {
pk := nostr.GetPublicKey(nostr.Generate())
set := parsePubkeySet([]string{pk.Hex(), "not-hex", ""})
Expand Down
Loading