initial version
This commit is contained in:
commit
9a58dcc097
10 changed files with 823 additions and 0 deletions
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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue