initial commit

This commit is contained in:
Danny Morabito 2025-08-05 23:47:38 +02:00
commit 65d9eca142
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
8 changed files with 292 additions and 0 deletions

36
.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
allowed-pubkeys.json

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM oven/bun:canary-debian AS builder
WORKDIR /app
ADD package.json .
RUN bun install
ADD . .
RUN bun build index.ts --compile
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/index /app/index
EXPOSE 3000
CMD ["/app/index"]

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# NIP-42 Proxy
This project is a NIP-42 proxy for Nostr relays. It provides an authentication layer in front of a public relay, allowing only authenticated users to connect and interact with it.
## Features
- **NIP-42 Authentication**: Enforces NIP-42 authentication, ensuring that only authorized users can access the relay.
- **Proxy Layer**: Acts as a proxy, forwarding messages between authenticated clients and the main relay.
- **Whitelist**: Filters access based on a whitelist of public keys defined in `allowed-pubkeys.json`.
## Prerequisites
- [Docker](https://www.docker.com/) installed on your system.
## Installation
1. **Clone the repository**:
```bash
git clone https://github.com/your-username/nip42-proxy.git
cd nip42-proxy
```
## Configuration
1. **Whitelist (Optional)**:
- Create a file named `allowed-pubkeys.json` in the root directory.
- Add an array of whitelisted public keys in the following format:
```json
[
"pubkey1",
"pubkey2"
]
```
- If this file does not exist, the proxy will allow any user to authenticate.
2. **Relay URL**:
- The proxy can be configured to connect to a specific relay using one of the following methods (in order of priority):
1. **Environment Variable**: Set the `RELAY_URL` environment variable when running the Docker container:
```bash
docker run -e RELAY_URL="wss://your-relay-url.com" ...
```
2. **Default**: If no URL is provided, the proxy will connect to the default relay: `wss://relay.arx-ccn.com`.
## Usage
To run the proxy using Docker, follow these steps:
1. **Build the Docker image**:
```bash
docker build -t nip42-proxy .
```
2. **Run the Docker container**:
```bash
docker run -p 3000:3000 -v $(pwd)/allowed-pubkeys.json:/app/allowed-pubkeys.json --name nip42-proxy nip42-proxy
```
- This command maps port `3000` on your local machine to port `3000` in the container.
- It also mounts the `allowed-pubkeys.json` file from your local directory into the container.
To run with a custom relay URL, use the `-e` flag:
```bash
docker run -p 3000:3000 -e RELAY_URL="wss://your-relay-url.com" -v $(pwd)/allowed-pubkeys.json:/app/allowed-pubkeys.json --name nip42-proxy nip42-proxy
```
The server will start, and you can connect to it using a Nostr client that supports NIP-42 authentication.
## How It Works
1. **Client Connection**: When a client connects to the proxy, it is initially in an unauthenticated state.
2. **Authentication Request**: The proxy sends an `AUTH` challenge to the client.
3. **Client Authentication**: The client must respond with a valid `AUTH` event, signed with a whitelisted public key.
4. **Authenticated State**: Once authenticated, the client can send and receive messages from the main relay through the proxy.
5. **Message Forwarding**: All messages from the authenticated client are forwarded to the main relay, and all messages from the main relay are forwarded to the client.
## Contributing
Contributions are welcome! Please open an issue or submit a pull request with your improvements.

29
bun.lock Normal file
View file

@ -0,0 +1,29 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "nip42-proxy",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="],
"@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
}
}

7
index.ts Normal file
View file

@ -0,0 +1,7 @@
import { main } from "./src/main.ts";
let relay = process.env.RELAY_URL ?? Bun.argv[Bun.argv.length - 1];
if (!relay?.startsWith("wss://"))
relay = "wss://relay.arx-ccn.com";
main(relay)

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "nip42-proxy",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

81
src/main.ts Normal file
View file

@ -0,0 +1,81 @@
import type { ServerWebSocket } from "bun";
interface Event {
kind: number;
tags: string[][];
content: string;
created_at: number;
pubkey: string;
id: string;
sig: string;
}
type Nip42ProxySocketData = {
authenticated: boolean;
authToken: string;
remoteWs: WebSocket;
};
async function validateAuthEvent(event: Event, challenge: string): boolean {
if (event.kind !== 22242) return false;
const last30Seconds = Math.floor(Date.now() / 1000) - 30;
if (event.created_at < last30Seconds) return false;
const challengeTag = event.tags.find(tag => tag[0] === 'challange')?.[1];
if (challengeTag !== challenge) return false;
const file = Bun.file("./allowed-pubkeys.json");
if (!file.exists()) return true;
const allowedPubkeys = JSON.parse(await file.text());
if (!allowedPubkeys.includes(event.pubkey)) return false;
return true;
}
const sendMessage = (ws: ServerWebSocket<Nip42ProxySocketData>, message: any[]) => ws.send(JSON.stringify(message), true);
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) => sendMessage(ws, ["AUTH", ws.data.authToken, "This is an authenticated relay."]);
export function main(mainRelayUrl: string) {
const server = Bun.serve<Nip42ProxySocketData, {}>({
fetch(req, server) {
const upgrade = server.upgrade(req, {
data: {
authenticated: false,
authToken: Bun.randomUUIDv7()
}
});
if (!upgrade) console.log(`http request`)
return new Response("this is a proxy. Use http to access it");
},
websocket: {
async message(ws, msg) {
const [command, ...data] = JSON.parse(msg);
if (!ws.data.authenticated) {
if (command === "REQ") {
const [name, ...filters] = data;
sendMessage(ws, ["CLOSED", name, 'auth-required: you must authenticate first']);
}
if (command === "EVENT") {
const [event] = data;
sendMessage(ws, ["OK", event.id, false, 'auth-required: you must authenticate first']);
}
if (command === "AUTH") {
const [event] = data;
const valid = await validateAuthEvent(event, ws.data.authToken);
if (!valid) return sendMessage(ws, ['NOTICE', "Invalid auth event"]);
ws.data.authenticated = true;
return;
}
return sendAuth(ws);
}
ws.data.remoteWs.send(msg);
},
open(ws) {
sendAuth(ws);
ws.data.remoteWs = new WebSocket(mainRelayUrl);
ws.data.remoteWs.onmessage = (data) => ws.send(data.data, true);
},
perMessageDeflate: true,
perMessageDeflateCompressionLevel: 9
}
})
console.log('Server listening @', server.url.host)
}

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}