From 7d4175f66ad98095e5ee901b4760899f93c73352 Mon Sep 17 00:00:00 2001 From: Rod Bishop Date: Tue, 25 Aug 2026 15:35:43 +1000 Subject: [PATCH 1/2] feat: whitelists from WRITE/READ_WHITELIST_PUBKEYS env vars Comma-separated hex pubkeys in WRITE_WHITELIST_PUBKEYS / READ_WHITELIST_PUBKEYS take precedence over the JSON whitelist files, for hosted platforms where per-environment config lives in env vars. A set-but-blank variable falls back to the files rather than becoming an empty (allow-everyone) whitelist, so an accidentally empty variable fails closed. Covered by unit tests and a new e2e -env mode, wired into CI alongside the existing three modes. Co-Authored-By: Claude Fable 5 --- .env.example | 7 +++- .github/workflows/ci.yml | 3 ++ README.md | 23 +++++++++++ e2e/main.go | 59 +++++++++++++++++++++++++-- main.go | 26 ++++++++++++ main_test.go | 88 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f0a1449..49a1e79 100644 --- a/.env.example +++ b/.env.example @@ -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" \ No newline at end of file +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" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f158c88..a8e3715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,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 diff --git a/README.md b/README.md index 157bb15..256758d 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/e2e/main.go b/e2e/main.go index 2defaa3..58566d1 100644 --- a/e2e/main.go +++ b/e2e/main.go @@ -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. @@ -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) @@ -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 { @@ -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 { @@ -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) diff --git a/main.go b/main.go index 602173d..524f5ea 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore/lmdb" @@ -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 { @@ -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) diff --git a/main_test.go b/main_test.go index 12335f1..f81ca85 100644 --- a/main_test.go +++ b/main_test.go @@ -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"]}`) @@ -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) @@ -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", ""}) From 8aaaf14ae4e4df0980de6ccea4b58ac88bf55b43 Mon Sep 17 00:00:00 2001 From: Rod Bishop Date: Tue, 25 Aug 2026 15:36:28 +1000 Subject: [PATCH 2/2] ci: run push builds only on master/main Pushes to PR branches were triggering both the push and pull_request workflows, running CI twice per change. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e3715..eab0345 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [master, main] pull_request: jobs: