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
5 changes: 5 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,8 @@ CERTBOT_EMAIL=
# Caddy automatically provisions and renews TLS certificates for this domain.
DOMAIN=localhost

# Optional. Public origin of the dashboard, e.g. https://bunker.example.com.
# Only needed behind a reverse proxy that does not forward the Host and
# X-Forwarded-Proto headers (NIP-98 auth compares against the exact public URL).
PUBLIC_URL=

5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ CERTBOT_EMAIL=
# Primary domain for Bilo Bunker (e.g. bunker.example.com or localhost).
DOMAIN=localhost

# Optional. Public origin of the dashboard, e.g. https://bunker.example.com.
# Only needed behind a reverse proxy that does not forward the Host and
# X-Forwarded-Proto headers (NIP-98 auth compares against the exact public URL).
PUBLIC_URL=

1 change: 1 addition & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Produces a clean, transaction-consistent snapshot saved to `./backups/bunker_YYY
|---|---|---|---|
| **App** | Healthcheck failing (`502 Bad Gateway` or `Connection Refused`) | `OWNER_PUBKEY` variable missing or invalid hex length | Check container logs: `docker compose logs app`. Ensure `.env` contains valid 64-char hex public key for `OWNER_PUBKEY`. |
| **Caddy / TLS** | HTTPS certificate errors or TLS challenge timeout | Port 80/443 blocked or DNS A-record not pointing to server IP | Ensure DNS A-record resolves to server IP and inbound ports 80/443 TCP/UDP are open in firewall. Check Caddy logs: `docker compose logs caddy`. |
| **Dashboard** | Logged in, but keypair stays on "Generating...", no QR code, new connections don't appear (API returns `401 NIP-98 URL mismatch`) | A custom reverse proxy (e.g. Nginx Proxy Manager) terminates TLS but the app sees `http://` or a different host | Make the proxy forward the `Host` header and set `X-Forwarded-Proto`, or set `PUBLIC_URL=https://your.domain` in the app environment. |
| **Relay** | NIP-46 signing requests timed out | Outbound WebSocket connection failure or unreachable relay | View logs: `docker compose logs app \| grep "\[relay\]"`. Verify relay URLs start with `wss://`. |
| **DB** | SQLite locks or permission error | File permission error on `/data` volume | Ensure container user (`bunker`) has write ownership of `/data` directory volume. |

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ docker run -d \
ghcr.io/workouse/bilo-bunker:latest
```

> **Using your own reverse proxy (Nginx Proxy Manager, Traefik, nginx, ...)?** Make sure it forwards the original `Host` header and sets `X-Forwarded-Proto` (most do by default). Dashboard requests are authenticated with NIP-98, which signs the exact public URL, so the app has to know it was reached over `https://`. If your proxy can't forward these headers, set `PUBLIC_URL=https://bunker.example.com` instead.

---

## ⚙️ Environment Variables Reference
Expand All @@ -97,6 +99,7 @@ docker run -d \
| `PORT` | Node.js application server internal port | No | `3000` |
| `DB_PATH` | Path to SQLite database file inside container | No | `/data/bunker.db` |
| `LOG_LEVEL` | Application logging verbosity (`error`, `warn`, `info`, `debug`) | No | `info` |
| `PUBLIC_URL` | Public origin the dashboard is served from (e.g. `https://bunker.example.com`). Only needed if your reverse proxy does not forward the `Host` and `X-Forwarded-Proto` headers | No | `""` |

---

Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/__tests__/nip98_auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('NIP-98 Authentication & Authorization Middleware', () => {
delete process.env['NSEC'];
delete process.env['OWNER_NPUB'];
delete process.env['OWNER_PUBKEY'];
delete process.env['PUBLIC_URL'];
});

afterEach(() => {
Expand Down Expand Up @@ -112,4 +113,76 @@ describe('NIP-98 Authentication & Authorization Middleware', () => {
expect(strangerData.error).toBe('Forbidden');
expect(strangerData.message).toContain('Single-user mode');
});

describe('behind a TLS-terminating reverse proxy', () => {
const publicUrl = 'https://bunker.example.com/api/v1/bunker/uri';
const internalUrl = 'http://bunker.example.com/api/v1/bunker/uri';

it('accepts an https-signed request forwarded over http with X-Forwarded-Proto', async () => {
const bunker = new BunkerService(db);
const app = createApp(bunker, new RelayManager(bunker, db));

const res = await app.request(internalUrl, {
headers: {
Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET'),
'X-Forwarded-Proto': 'https',
},
});
expect(res.status).toBe(200);
});

it('uses the first value of a comma-separated X-Forwarded-Proto chain', async () => {
const bunker = new BunkerService(db);
const app = createApp(bunker, new RelayManager(bunker, db));

const res = await app.request(internalUrl, {
headers: {
Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET'),
'X-Forwarded-Proto': 'https, http',
},
});
expect(res.status).toBe(200);
});

it('still rejects an https-signed request without forwarding headers', async () => {
const bunker = new BunkerService(db);
const app = createApp(bunker, new RelayManager(bunker, db));

const res = await app.request(internalUrl, {
headers: { Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET') },
});
expect(res.status).toBe(401);
const body = (await res.json()) as { message: string };
expect(body.message).toContain('NIP-98 URL mismatch');
});

it('does not let X-Forwarded-Host replay a token signed for another host', async () => {
const bunker = new BunkerService(db);
const app = createApp(bunker, new RelayManager(bunker, db));

const res = await app.request(internalUrl, {
headers: {
Authorization: createNip98AuthHeader(
generateSecretKey(),
'https://other-service.example.org/api/v1/bunker/uri',
'GET'
),
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'other-service.example.org',
},
});
expect(res.status).toBe(401);
});

it('uses PUBLIC_URL as the origin when set', async () => {
process.env['PUBLIC_URL'] = 'https://bunker.example.com';
const bunker = new BunkerService(db);
const app = createApp(bunker, new RelayManager(bunker, db));

const res = await app.request('http://app:3000/api/v1/bunker/uri', {
headers: { Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET') },
});
expect(res.status).toBe(200);
});
});
});
29 changes: 28 additions & 1 deletion packages/app/src/middleware/nip98.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,37 @@ import { Context, Next } from 'hono';
import { validateNip98Header } from '../services/nostr.js';
import type { BunkerService } from '../services/bunker.js';

/**
* Reconstructs the URL the client actually called, so it can be compared to
* the NIP-98 `u` tag.
*
* Behind a TLS-terminating reverse proxy (Caddy, Nginx Proxy Manager, ...)
* the app receives plain HTTP, so `c.req.url` starts with `http://` while the
* browser signed `https://`. The scheme is taken from `X-Forwarded-Proto`;
* the host is kept from the Host header (forwarded by the proxy) so a token
* signed for another site cannot be replayed here by spoofing a header.
* `PUBLIC_URL` pins the origin explicitly when the proxy setup is unusual.
*/
export function getExternalRequestUrl(c: Context): string {
const url = new URL(c.req.url);

const publicUrl = process.env.PUBLIC_URL?.trim();
if (publicUrl) {
return new URL(url.pathname + url.search, new URL(publicUrl).origin).toString();
}

const forwardedProto = c.req.header('X-Forwarded-Proto')?.split(',')[0]?.trim().toLowerCase();
if (forwardedProto === 'https' || forwardedProto === 'http') {
url.protocol = `${forwardedProto}:`;
}

return url.toString();
}

export function createNip98AuthMiddleware(bunkerService: BunkerService) {
return async function nip98AuthMiddleware(c: Context, next: Next) {
const authHeader = c.req.header('Authorization') || '';
const url = c.req.url;
const url = getExternalRequestUrl(c);
const method = c.req.method;

const result = validateNip98Header(authHeader, url, method);
Expand Down
Loading