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
24 changes: 22 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ OWNER_NPUB="npub1utx00neqgqln72j22kej3ux7803c2k986henvvha4thuwfkper4s7r50e8"
RELAY_URL="relay.utxo.one"
RELAY_PORT=3355
RELAY_BIND_ADDRESS="0.0.0.0" # Can be set to a specific IP4 or IP6 address ("" for all interfaces)
DB_ENGINE="badger" # badger, lmdb (lmdb works best with an nvme, otherwise you might have stability issues)
DB_ENGINE="lmdb" # lmdb is the only engine (works best with an nvme, otherwise you might have stability issues)
LMDB_MAPSIZE=0 # 0 for default (currently ~273GB), or set to a different size in bytes, e.g. 10737418240 for 10GB
BLOSSOM_PATH="blossom/"

Expand Down Expand Up @@ -103,9 +103,29 @@ WOT_REFRESH_INTERVAL="24h"
WHITELISTED_NPUBS_FILE="whitelisted_npubs.json"
BLACKLISTED_NPUBS_FILE="blacklisted_npubs.json"

## Relay Management API (NIP-86) and the /admin page
MANAGEMENT_API_ENABLED=true # set to false to make the endpoint and the admin page unreachable
# Where the relay stores what you change over the management API. Created on
# first use; do not edit it by hand while the relay is running.
MANAGEMENT_STATE_FILE="management.json"

## Analytics, and the dashboard on the admin page
ANALYTICS_ENABLED=true # set to false to stop counting anything at all
# Where the counters are written so they survive a restart. Created on first use;
# do not edit it by hand while the relay is running. If it cannot be read at
# startup Haven keeps counting and keeps serving, but stops writing to it.
ANALYTICS_STATE_FILE="metrics.json"
ANALYTICS_FLUSH_MINUTES=5 # how often the file is written; a crash loses at most this much
ANALYTICS_HOURLY_RETENTION_DAYS=8 # full hourly resolution, for the 24h and 7d charts
ANALYTICS_DAILY_RETENTION_DAYS=90 # hours older than the above are rolled up into days
ANALYTICS_AGGREGATE_MINUTES=15 # how often every database is walked for the kind mix,
# top authors and stored totals
ANALYTICS_MAX_KINDS=64 # distinct kinds tracked before the rest become "other"
ANALYTICS_MAX_AUTHORS=100 # how many authors the top-authors list holds

## LOGGING
HAVEN_LOG_LEVEL="INFO" # DEBUG, INFO, WARNING or ERROR

## PROXY / TOR (Optional)
# Set a SOCKS5 proxy URL to route all outgoing connections (e.g., Tor daemon)
PROXY_URL=""
PROXY_URL=""
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
.DS_Store
.env
.claude/
.idea/
db.zip
dist/
blacklisted_npubs.json
haven
management.json
management.json.tmp
metrics.json
metrics.json.tmp
relays_blastr.json
relays_import.json
whitelisted_npubs.json
71 changes: 45 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ relay, but anyone can view the images and videos.
**Web of Trust**: Protected from DM and Inbox spam by using a Web of Trust (WoT). See the [Web of Trust
Documentation](docs/wot.md) for more details.

**Access Control**: Whitelist and blacklist npubs. See the [Access Control Documentation](docs/access-control.md)
for more details.
**Access Control**: Whitelist and blacklist npubs, ban them from a nostr list you publish, and delete any event from
your relay. See the [Access Control Documentation](docs/access-control.md) for more details.

**Relay Management**: Run the relay from a web page signed in with a nostr browser extension, or over the
NIP-86 API: ban pubkeys, drop events, block addresses and rename each relay. The same page also **browses the notes
each relay is storing** — filter, search, inspect and delete them — and graphs the relay on a **dashboard**: traffic,
connections, accepted against rejected, the kind mix, top authors and storage, with 24-hour, 7-day and 30-day
history that survives a restart. See the [Relay Management Documentation](docs/relay-management.md) for more
details.

**Inbox Relay**: Notes are pulled from other relays and stored in the inbox relay.

Expand Down Expand Up @@ -262,7 +269,7 @@ server {
proxy_pass http://localhost:3355;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
Expand All @@ -273,11 +280,11 @@ server {

Replace `yourdomain.com` with your actual domain name.

> **Note:**
> [`client_max_body_size`](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size) is set to
> `100m` to allow for larger media files to be uploaded to Blossom. `0` can be used to allow for unlimited file
> sizes. If you are using Cloudflare proxy, be mindful of
> [upload limits](https://community.cloudflare.com/t/maximum-upload-size-is-limit/418490/2).
> [!IMPORTANT]
> `X-Forwarded-For` is set to `$remote_addr`, not the more common `$proxy_add_x_forwarded_for`. The latter *appends*
> the real client address to whatever the client sent, which lets a client prepend a forged one — Haven reads the
> leftmost entry, so the forgery would win. This matters for the rate limiters and for
> [IP blocking](docs/relay-management.md#blocking-addresses).

After adding the configuration, restart nginx:

Expand Down Expand Up @@ -308,6 +315,10 @@ sudo systemctl restart nginx
ProxyPass / http://localhost:3355/
ProxyPassReverse / http://localhost:3355/

# Overwrite whatever the client sent, so a forged X-Forwarded-For
# cannot win: mod_proxy appends to the header rather than replacing it
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"

# Optional: Add HSTS header for enhanced security
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

Expand Down Expand Up @@ -431,16 +442,18 @@ Once everything is set up, the relay will be running on `localhost:3355` with th
- `localhost:3355/private`
- `localhost:3355/chat`
- `localhost:3355/inbox`
- `localhost:3355/admin` (relay management, notes browser and dashboard, signed in with a nostr browser extension)

## Database
See [Relay Management](docs/relay-management.md) for the admin page and the NIP-86 API behind it.

Haven currently supports [BadgerDB](https://github.com/dgraph-io/badger) and [LMDB](https://www.symas.com/mdb) as embedded
databases, meaning no external database is required.
## Database

By default, Haven uses BadgerDB. To switch to LMDB, set the `DB_ENGINE` environment variable to `lmdb` in the `.env` file.
Haven stores notes in [LMDB](https://www.symas.com/mdb), an embedded database, meaning no external database is
required. BadgerDB is no longer supported; to move an existing BadgerDB relay over, [back it up](docs/backup.md) with a
Haven version that still reads it and restore the backup into the new one.

LMDB can be faster than BadgerDB but performs best with NVMe drives and may require fine-tuning based on factors such as
database size, operating system, file system, and hardware.
LMDB performs best with NVMe drives and may require fine-tuning based on factors such as database size, operating
system, file system, and hardware.

### LMDB Map Size

Expand All @@ -455,23 +468,15 @@ defines an upper limit for the database size. For more information about LMDB’

### Migrating from databases created in older versions of Haven

Haven uses [Khatru's event store](https://github.com/fiatjaf/eventstore) to store notes. The way events are stored evolves
over time, and occasionally this introduces breaking changes.
Haven uses the [fiatjaf.com/nostr event store](https://pkg.go.dev/fiatjaf.com/nostr/eventstore) to store notes. The
way events are stored evolves over time, and occasionally this introduces breaking changes.

As a precaution, before upgrading to a newer version of Haven, you should back up the `db` folder.

Haven versions 1.0.3 and earlier did not replace outdated notes. While this does not affect the relay's core
functionality, it can result in a bloated database, reduced performance, and bugs in some clients. For this reason, it
is recommended to delete old databases and start fresh.

BadgerDB users upgrading from Haven version 1.0.5 or earlier may encounter a critical error when starting the relay:

```
error running migrations: failed to delete index key xxxx: Txn is too big to fit into one request
```

As a workaround, you can delete the `db` folder and start fresh, optionally [re-importing](#8-import-your-old-notes-optional) your
previous notes.
is recommended to delete old databases and start fresh, optionally [re-importing](#8-import-your-old-notes-optional)
your previous notes.

## Blossom Media Server

Expand All @@ -482,6 +487,20 @@ but anyone can view the hosted images and videos.
Media files are stored in the file system based on the `BLOSSOM_PATH` environment variable set in the `.env` file.
The default path is `./blossom`.

> [!IMPORTANT]
> Give `BLOSSOM_PATH` a trailing slash, as `.env.example` does. The path is joined to the file name by
> concatenation, so `BLOSSOM_PATH="blossom"` writes media *beside* the `blossom` directory rather than inside it,
> as `./blossom<hash>`. Haven warns at startup when it sees this. It is not corrected automatically, because doing
> so would hide every file already written that way.

Browse and manage what has been uploaded from the **Media** view of the [admin page](docs/relay-management.md#media):
filter, search and sort the whole library, delete media, block a hash so the same bytes cannot be uploaded again,
and reconcile the index against what is actually on disk.

> [!WARNING]
> Cloud backups cover the media **index**, not the media **files**. Back up your `BLOSSOM_PATH` directory
> separately. Restoring a backup without it leaves every entry pointing at a file that is no longer there.

## Cloud Backups

Haven can back up and restore your notes using a portable JSONL format. This can be done either with the built-in
Expand Down
160 changes: 160 additions & 0 deletions admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package main

import (
"bytes"
"html/template"
"log/slog"
"net/http"
"os"
"strconv"
"sync"
)

// adminRelay is one entry in the admin page's relay switcher. Path is the
// same-origin path the browser POSTs to; ServiceURL is the absolute URL the
// relay expects in the NIP-98 u tag. They are deliberately different, and the
// page must sign ServiceURL rather than anything derived from window.location:
// an owner reaching the relay through an SSH tunnel is browsing
// http://localhost:3355 while ServiceURL still says https://relay.example.com.
type adminRelay struct {
Key string
Label string
Path string
ServiceURL string
}

type adminPageData struct {
OwnerPubKey string
OwnerNpub string
RelayHost string
RelayVersion string
Relays []adminRelay
Scripts []adminScript
}

// adminScript is one of the page's classic scripts, with the cache buster for
// the file currently on disk.
type adminScript struct {
Src string
Version string
}

// adminScripts lists the page's scripts in load order.
//
// They are classic scripts sharing one global scope, not modules, so two of them
// declaring the same top level const would throw and the second would not execute
// at all. Each file therefore prefixes its own bindings, and the order here is
// only about readability — boot() waits for DOMContentLoaded, by which point
// every deferred script has run, so nothing can execute out of order.
func adminScripts() []adminScript {
paths := []string{
"/static/admin-charts.js",
"/static/admin.js",
"/static/admin-notes.js",
"/static/admin-dashboard.js",
}
out := make([]adminScript, 0, len(paths))
for _, p := range paths {
out = append(out, adminScript{Src: p, Version: assetVersion("templates" + p)})
}
return out
}

// adminCSP limits what a compromised third party script could do with the
// window.nostr handle this page holds: connect-src 'self' means it cannot send
// anything it signs anywhere, and frame-ancestors blocks clickjacking, which
// matters on a page whose buttons sit one habitual signer approval away from
// taking effect.
//
// media-src is listed because default-src 'none' otherwise blocks every <video>
// and <audio> the media browser tries to play, silently.
//
// 'unsafe-inline' for styles is unavoidable — the Tailwind CDN injects a style
// element at runtime, and the page carries its own inline fallback styles. If
// the CDN ever needs more than this the page renders unstyled, which it is
// built to survive.
const adminCSP = "default-src 'none'; " +
"script-src 'self' https://cdn.tailwindcss.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src https://fonts.gstatic.com; " +
"img-src 'self' https: data:; " +
// blobs are served from this origin and nowhere else, so 'self' is the whole
// of it. media-src is a real exfiltration channel — new Audio("https://…"+x)
// is a beacon — and this page holds a live window.nostr handle behind a
// signer prompt the owner has probably taught themselves to click through,
// which is exactly what connect-src 'self' is here to contain.
"media-src 'self'; " +
"connect-src 'self'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"

// adminTemplate parses the admin page once, lazily, so a missing template file
// is an error on that one page rather than a relay that will not boot.
var adminTemplate = sync.OnceValues(func() (*template.Template, error) {
return template.ParseFiles("templates/admin.html")
})

// adminHandler serves the relay management UI. The page itself is static and
// gated by nothing: every call it makes carries a NIP-98 event signed in the
// browser, and it holds no relay data until one of those calls comes back.
func adminHandler(w http.ResponseWriter, r *http.Request) {
if !config.ManagementAPIEnabled {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

tmpl, err := adminTemplate()
if err != nil {
slog.Error("🚫 error parsing the admin page", "error", err)
http.Error(w, "the admin page is unavailable", http.StatusInternalServerError)
return
}

data := adminPageData{
OwnerPubKey: config.OwnerPubKey,
OwnerNpub: config.OwnerNpub,
RelayHost: config.RelayURL,
RelayVersion: config.RelayVersion,
Scripts: adminScripts(),
// read from the relays themselves rather than rebuilt from config, so
// the u tag the page signs cannot drift from what the API compares it
// against if ServiceURL is ever computed differently
Relays: []adminRelay{
{relayOutbox, "Outbox", "/", outboxRelay.ServiceURL},
{relayPrivate, "Private", "/private", privateRelay.ServiceURL},
{relayChat, "Chat", "/chat", chatRelay.ServiceURL},
{relayInbox, "Inbox", "/inbox", inboxRelay.ServiceURL},
},
}

var page bytes.Buffer
if err := tmpl.Execute(&page, data); err != nil {
slog.Error("🚫 error rendering the admin page", "error", err)
http.Error(w, "the admin page is unavailable", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", adminCSP)

if _, err := w.Write(page.Bytes()); err != nil {
slog.Debug("🚫 error writing the admin page", "error", err)
}
}

// assetVersion busts the browser cache whenever the file on disk changes,
// including between local builds where RelayVersion is always "(devel)".
func assetVersion(path string) string {
if info, err := os.Stat(path); err == nil {
return strconv.FormatInt(info.ModTime().Unix(), 10)
}
return config.RelayVersion
}
Loading
Loading