initial version
This commit is contained in:
commit
9a58dcc097
10 changed files with 823 additions and 0 deletions
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
20
Dockerfile
Normal 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"]
|
128
README.md
Normal file
128
README.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
# 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.
|
||||
- **Dynamic Whitelist**: Manage allowed public keys and event kinds dynamically through an admin RPC interface.
|
||||
- **Admin RPC Interface**: A NIP-98-protected RPC interface for administering the proxy.
|
||||
- **Docker Support**: Comes with a `Dockerfile` for easy deployment.
|
||||
- **Bun Support**: Can be run directly with `bun`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) installed on your system.
|
||||
- [Docker](https://www.docker.com/) (optional) installed on your system.
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone the repository**:
|
||||
|
||||
```bash
|
||||
git clone https://git.arx-ccn.com/Arx/nip42-proxy.git
|
||||
cd nip42-proxy
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The proxy is configured through environment variables.
|
||||
|
||||
- `ALLOW_UNAUTHED_PUBLISH`: (Optional) Set to `true` to allow unauthenticated clients to publish events. Defaults to `false`.
|
||||
- `RELAY_URL`: The URL of the relay that the proxy will connect to.
|
||||
- `RELAY_OUTSIDE_URL`: (Optional) The URL that clients use to connect to the proxy. Defaults to the `RELAY_URL`.
|
||||
- `RELAY_NAME`: (Optional) The name of the relay.
|
||||
- `RELAY_DESCRIPTION`: (Optional) A description of the relay.
|
||||
- `RELAY_BANNER`: (Optional) A URL to a banner image for the relay.
|
||||
- `RELAY_ICON`: (Optional) A URL to an icon for the relay.
|
||||
- `RELAY_CONTACT`: (Optional) A contact email or address for the relay.
|
||||
- `RELAY_POLICY`: (Optional) A URL to the relay's policy document.
|
||||
- `ADMIN_PUBKEY`: (Optional) The public key of the administrator of the relay.
|
||||
|
||||
## Usage
|
||||
|
||||
### With Bun
|
||||
|
||||
To run the proxy directly with Bun:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
You can also set environment variables in the same command:
|
||||
|
||||
```bash
|
||||
RELAY_URL="wss://my-relay.com" ADMIN_PUBKEY="my-admin-pubkey" bun run index.ts
|
||||
```
|
||||
|
||||
### With Docker
|
||||
|
||||
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 --name nip42-proxy nip42-proxy
|
||||
```
|
||||
|
||||
- This command maps port `3000` on your local machine to port `3000` in the container.
|
||||
|
||||
To run with a custom relay URL and other environment variables, use the `-e` flag:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -e RELAY_URL="wss://your-relay-url.com" -e ADMIN_PUBKEY="my-admin-pubkey" --name nip42-proxy nip42-proxy
|
||||
```
|
||||
|
||||
The server will start, and you can connect to it using a Nostr client that supports NIP-42 authentication.
|
||||
|
||||
## Admin RPC Interface
|
||||
|
||||
The proxy exposes a NIP-98-protected RPC interface for administration. To use it, you need to send a `POST` request to the root URL (`/`) with the `Content-Type` header set to `application/nostr+json+rpc`. The `Authorization` header must contain a NIP-98 token.
|
||||
|
||||
**Available Methods:**
|
||||
|
||||
- `supportedmethods`: Get a list of supported RPC methods.
|
||||
- `getinfo`: Get the relay's information document.
|
||||
- `banpubkey`: Ban a public key.
|
||||
- `allowpubkey`: Allow a public key.
|
||||
- `listallowedpubkeys`: List all allowed public keys.
|
||||
- `allowkind`: Allow a specific event kind.
|
||||
- `disallowkind`: Disallow a specific event kind.
|
||||
- `listallowedkinds`: List all allowed event kinds.
|
||||
|
||||
**Example using `curl`:**
|
||||
|
||||
```bash
|
||||
# First, generate a NIP-98 token. This is usually done with a Nostr library.
|
||||
# For this example, let's assume you have a valid token in the $TOKEN variable.
|
||||
|
||||
# Get the list of allowed pubkeys
|
||||
curl -X POST -H "Content-Type: application/nostr+json+rpc" -H "Authorization: $TOKEN" -d '{"method": "listallowedpubkeys", "params": []}' http://localhost:3000/
|
||||
|
||||
# Allow a new pubkey
|
||||
curl -X POST -H "Content-Type: application/nostr+json+rpc" -H "Authorization: $TOKEN" -d '{"method": "allowpubkey", "params": ["new-pubkey-to-allow"]}' http://localhost:3000/
|
||||
```
|
||||
|
||||
## 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 an allowed 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.
|
31
biome.json
Normal file
31
biome.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": []
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
58
bun.lock
Normal file
58
bun.lock
Normal file
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "nip42-proxy",
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.16.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
|
||||
|
||||
"@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||
|
||||
"@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
|
||||
|
||||
"@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"nostr-tools": ["nostr-tools@2.16.2", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw=="],
|
||||
|
||||
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||
|
||||
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
|
||||
|
||||
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||
|
||||
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||
|
||||
"@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
|
||||
}
|
||||
}
|
22
index.ts
Normal file
22
index.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { type RelayConfig, main } from "./src/main.ts";
|
||||
|
||||
const config: RelayConfig = {
|
||||
allowUnauthedPublish: Boolean(process.env.ALLOW_UNAUTHED_PUBLISH) || false,
|
||||
outsideURL: process.env.RELAY_OUTSIDE_URL || process.env.RELAY_URL!,
|
||||
relay: process.env.RELAY_URL!,
|
||||
name: process.env.RELAY_NAME,
|
||||
description: process.env.RELAY_DESCRIPTION,
|
||||
banner: process.env.RELAY_BANNER,
|
||||
icon: process.env.RELAY_ICON,
|
||||
contact: process.env.RELAY_CONTACT,
|
||||
policy: process.env.RELAY_POLICY,
|
||||
adminPubkey: process.env.ADMIN_PUBKEY,
|
||||
};
|
||||
|
||||
if (
|
||||
!config.relay ||
|
||||
(!config.relay?.startsWith("wss://") && !config.relay?.startsWith("ws://"))
|
||||
)
|
||||
config.relay = "wss://relay.arx-ccn.com";
|
||||
|
||||
main(config);
|
15
package.json
Normal file
15
package.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "nip42-proxy",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.16.2"
|
||||
}
|
||||
}
|
371
src/main.ts
Normal file
371
src/main.ts
Normal file
|
@ -0,0 +1,371 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
import { nip98, type Event } from "nostr-tools";
|
||||
import {
|
||||
allowKind,
|
||||
allowPubkey,
|
||||
banPubkey,
|
||||
disallowKind,
|
||||
getAllAllowedKinds,
|
||||
getAllAllowedPubkeys,
|
||||
isKindAllowed,
|
||||
isPubkeyAllowed,
|
||||
validateAuthEvent,
|
||||
} from "./utils.ts";
|
||||
import { getGitCommitHash } from "./utils.ts" with { type: "macro" };
|
||||
|
||||
type Nip42ProxySocketData = {
|
||||
authenticated: boolean;
|
||||
authToken: string;
|
||||
remoteWs?: WebSocket;
|
||||
};
|
||||
|
||||
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 interface RelayConfig {
|
||||
adminPubkey?: string;
|
||||
outsideURL: string;
|
||||
relay: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
contact?: string;
|
||||
policy?: string;
|
||||
allowUnauthedPublish: boolean;
|
||||
}
|
||||
|
||||
export function main({
|
||||
relay,
|
||||
outsideURL,
|
||||
adminPubkey,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
banner,
|
||||
contact,
|
||||
policy,
|
||||
allowUnauthedPublish,
|
||||
}: RelayConfig) {
|
||||
function relayInfo(returnJson: boolean = false) {
|
||||
const json = {
|
||||
name,
|
||||
description,
|
||||
banner,
|
||||
pubkey: adminPubkey,
|
||||
contact,
|
||||
software: "https://git.arx-ccn.com/Arx/nip42-proxy.git",
|
||||
supported_nips: [1, 2, 4, 9, 11, 22, 28, 40, 42, 70, 77, 86],
|
||||
version: `nip42-proxy - ${getGitCommitHash()}`,
|
||||
posting_policy: policy,
|
||||
icon,
|
||||
limitation: {
|
||||
auth_required: true,
|
||||
restricted_writes: true,
|
||||
},
|
||||
};
|
||||
if (returnJson) return json;
|
||||
return new Response(JSON.stringify(json), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
async function relayRPC(
|
||||
adminPubkey: string | undefined,
|
||||
token: string,
|
||||
url: string,
|
||||
method: string,
|
||||
params: any[],
|
||||
) {
|
||||
if (method === "supportedmethods")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: [
|
||||
"supportedmethods",
|
||||
"getinfo",
|
||||
"banpubkey",
|
||||
"allowpubkey",
|
||||
"listallowedpubkeys",
|
||||
"allowkind",
|
||||
"disallowkind",
|
||||
"listallowedkinds",
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (method === "getinfo")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: relayInfo(true),
|
||||
}),
|
||||
);
|
||||
const valid = await nip98.validateToken(token, url, method);
|
||||
if (!token || !valid)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
const event = await nip98.unpackEventFromToken(token);
|
||||
if (adminPubkey && event.pubkey !== adminPubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
if (!(await isPubkeyAllowed(event)))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
|
||||
if (method === "allowpubkey") {
|
||||
const [pubkey] = params;
|
||||
if (!pubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing pubkey parameter",
|
||||
}),
|
||||
);
|
||||
await allowPubkey(pubkey);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "listallowedpubkeys") {
|
||||
const resp = await getAllAllowedPubkeys();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: resp.map((k: string) => ({ pubkey: k })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "banpubkey") {
|
||||
const [pubkey] = params;
|
||||
if (!pubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing pubkey parameter",
|
||||
}),
|
||||
);
|
||||
await banPubkey(pubkey);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "allowkind") {
|
||||
const [kind] = params;
|
||||
if (typeof kind !== "number")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing or invalid kind parameter",
|
||||
}),
|
||||
);
|
||||
await allowKind(kind);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "disallowkind") {
|
||||
const [kind] = params;
|
||||
if (typeof kind !== "number")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing or invalid kind parameter",
|
||||
}),
|
||||
);
|
||||
await disallowKind(kind);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "listallowedkinds") {
|
||||
const resp = (await getAllAllowedKinds()).sort((a, b) => a - b);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: resp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Unsupported method ${method}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const server = Bun.serve<Nip42ProxySocketData>({
|
||||
async fetch(req, server) {
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
authenticated: false,
|
||||
authToken: Bun.randomUUIDv7(),
|
||||
},
|
||||
});
|
||||
if (upgraded) return new Response("Upgraded");
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "") url.pathname = "/";
|
||||
if (url.pathname === "/") {
|
||||
if (req.headers.get("accept") === "application/nostr+json")
|
||||
return relayInfo() as Response;
|
||||
switch (req.headers.get("content-type")) {
|
||||
case "application/nostr+json":
|
||||
return relayInfo() as Response;
|
||||
case "application/nostr+json+rpc": {
|
||||
const token = req.headers.get("Authorization") || "";
|
||||
const data: { method?: string; params?: any[] } = await req.json();
|
||||
if (!data || !data.method)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Please send a valid nostr rpc request",
|
||||
}),
|
||||
);
|
||||
let { method, params } = data;
|
||||
params ||= [];
|
||||
const urlForRequest = new URL(req.url);
|
||||
urlForRequest.hostname = new URL(outsideURL).hostname;
|
||||
urlForRequest.protocol = "https";
|
||||
return relayRPC(
|
||||
adminPubkey,
|
||||
token,
|
||||
urlForRequest.toString(),
|
||||
method,
|
||||
params,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return new Response("Nip42 Proxy");
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/health")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
activeRequests: server.pendingRequests,
|
||||
activeWebsockets: server.pendingWebSockets,
|
||||
}),
|
||||
);
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
async message(ws, msg) {
|
||||
const [command, ...data] = JSON.parse(msg as string);
|
||||
if (!ws.data.authenticated) {
|
||||
if (command === "REQ") {
|
||||
const [name] = data;
|
||||
sendMessage(ws, [
|
||||
"CLOSED",
|
||||
name,
|
||||
"auth-required: you must authenticate first",
|
||||
]);
|
||||
|
||||
} else if (command === "EVENT") {
|
||||
const [event] = data as [Event];
|
||||
if (
|
||||
allowUnauthedPublish &&
|
||||
(await isPubkeyAllowed(event)) &&
|
||||
(await isKindAllowed(event))
|
||||
) {
|
||||
if (ws.data.remoteWs) {
|
||||
ws.data.remoteWs.send(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"auth-required: you must authenticate first",
|
||||
]);
|
||||
} else if (command === "AUTH") {
|
||||
const [event] = data as [Event];
|
||||
const valid = await validateAuthEvent(event, ws.data.authToken);
|
||||
if (!valid) {
|
||||
sendMessage(ws, ["NOTICE", "Invalid auth event"]);
|
||||
return;
|
||||
}
|
||||
sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
true,
|
||||
"successully authenticated. welcome",
|
||||
]);
|
||||
ws.data.authenticated = true;
|
||||
} else {
|
||||
sendAuth(ws);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.data.authenticated && command === "AUTH") {
|
||||
const [event] = data as [Event];
|
||||
sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
true,
|
||||
"you were already authenticated",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.data.remoteWs) {
|
||||
const [event] = data as [Event];
|
||||
if(command === "EVENT") {
|
||||
if(!await isPubkeyAllowed(event))
|
||||
return sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"pubkey not allowed",
|
||||
]);
|
||||
if(!await isKindAllowed(event))
|
||||
return sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"kind not allowed",
|
||||
]);
|
||||
}
|
||||
ws.data.remoteWs.send(msg);
|
||||
}
|
||||
},
|
||||
open(ws) {
|
||||
const remoteWs = new WebSocket(relay);
|
||||
ws.data.remoteWs = remoteWs;
|
||||
|
||||
remoteWs.addEventListener("message", (data) =>
|
||||
ws.send(
|
||||
(data as MessageEvent).data as string | ArrayBufferLike,
|
||||
true,
|
||||
),
|
||||
);
|
||||
remoteWs.addEventListener("open", () => {
|
||||
sendAuth(ws);
|
||||
});
|
||||
remoteWs.addEventListener("error", console.error);
|
||||
},
|
||||
perMessageDeflate: true,
|
||||
close(ws) {
|
||||
ws.data.remoteWs?.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("Server listening @", server.url.host);
|
||||
}
|
113
src/utils.ts
Normal file
113
src/utils.ts
Normal file
|
@ -0,0 +1,113 @@
|
|||
import type { Event } from "nostr-tools";
|
||||
|
||||
export function getGitCommitHash() {
|
||||
try {
|
||||
const { stdout } = Bun.spawnSync({
|
||||
cmd: ["git", "rev-parse", "HEAD"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const out = stdout?.toString()?.trim();
|
||||
return out?.split("\n")[0] || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAuthEvent(
|
||||
event: Event,
|
||||
challenge: string,
|
||||
): Promise<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] === "challenge")?.[1];
|
||||
if (challengeTag !== challenge) return false;
|
||||
if(!await isPubkeyAllowed(event))
|
||||
return false;
|
||||
return await isKindAllowed(event);
|
||||
}
|
||||
|
||||
export async function isPubkeyAllowed(event: Event): Promise<boolean> {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
if (!(await file.exists())) return true;
|
||||
try {
|
||||
const allowedPubkeys = JSON.parse(await file.text());
|
||||
return (
|
||||
Array.isArray(allowedPubkeys) && allowedPubkeys.includes(event.pubkey)
|
||||
);
|
||||
} catch {
|
||||
// If the file is malformed, default to deny
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllAllowedPubkeys() {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
try {
|
||||
if (await file.exists()) return JSON.parse(await file.text());
|
||||
} catch {}
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
export async function saveAllowedPubkeys(pubkeys: string[]): Promise<number> {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
const unique = Array.from(new Set(pubkeys));
|
||||
return await file.write(JSON.stringify(unique));
|
||||
}
|
||||
|
||||
export async function allowPubkey(pubkey: string): Promise<number> {
|
||||
const pubkeys = await getAllAllowedPubkeys();
|
||||
if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey);
|
||||
return await saveAllowedPubkeys(pubkeys);
|
||||
}
|
||||
|
||||
|
||||
export async function banPubkey(pubkey: string): Promise<number> {
|
||||
const pubkeys = await getAllAllowedPubkeys();
|
||||
const filtered = pubkeys.filter((p: string) => p !== pubkey);
|
||||
return await saveAllowedPubkeys(filtered);
|
||||
}
|
||||
|
||||
|
||||
export async function isKindAllowed(event: Event): Promise<boolean> {
|
||||
const file = Bun.file("./allowed-kinds.json");
|
||||
if (!(await file.exists())) return true;
|
||||
try {
|
||||
const allowedKinds = JSON.parse(await file.text());
|
||||
if(!Array.isArray(allowedKinds)) return true;
|
||||
if(allowedKinds.length === 0) return true;
|
||||
return (
|
||||
Array.isArray(allowedKinds) && allowedKinds.includes(event.kind)
|
||||
);
|
||||
} catch {
|
||||
// If the file is malformed, default to allow
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllAllowedKinds(): Promise<number[]> {
|
||||
const file = Bun.file("./allowed-kinds.json");
|
||||
try {
|
||||
if (await file.exists()) return JSON.parse(await file.text());
|
||||
} catch {}
|
||||
return [] as number[];
|
||||
}
|
||||
|
||||
export async function saveAllowedKinds(kinds: number[]): Promise<number> {
|
||||
const file = Bun.file("./allowed-kinds.json");
|
||||
const unique = Array.from(new Set(kinds));
|
||||
return await file.write(JSON.stringify(unique));
|
||||
}
|
||||
|
||||
export async function allowKind(kind: number): Promise<number> {
|
||||
const kinds = await getAllAllowedKinds();
|
||||
if (!kinds.includes(kind)) kinds.push(kind);
|
||||
return await saveAllowedKinds(kinds);
|
||||
}
|
||||
|
||||
export async function disallowKind(kind: number): Promise<number> {
|
||||
const kinds = await getAllAllowedKinds();
|
||||
const filtered = kinds.filter((p: number) => p !== kind);
|
||||
return await saveAllowedKinds(filtered);
|
||||
}
|
29
tsconfig.json
Normal file
29
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue