Compare commits
14 commits
master
...
issue-9-fi
| Author | SHA1 | Date | |
|---|---|---|---|
| 65a6966b9f | |||
| fb1c239e15 | |||
| 1792bc489c | |||
| 662dd21a60 | |||
| ccfde02d70 | |||
| 053357899d | |||
| 5e77de1202 | |||
| 0a510b7f9a | |||
| 8caf856ab2 | |||
| e39eaa632d | |||
| 42dbbd7536 | |||
| 960b9399e8 | |||
| 06272c8f2c | |||
| 711a017e8c |
11 changed files with 446 additions and 68 deletions
40
Dockerfile
40
Dockerfile
|
|
@ -1,20 +1,32 @@
|
|||
# Patched from upstream kind-0/nsecbunkerd Dockerfile to use pnpm — the
|
||||
# upstream version uses `npm install` but package.json declares
|
||||
# `@nostr-dev-kit/ndk` as `workspace:*`, which only pnpm understands.
|
||||
# A clean clone of upstream fails to build with `EUNSUPPORTEDPROTOCOL`
|
||||
# under npm. Switching to pnpm matches the lockfile that ships in-repo.
|
||||
# Also drops `--frozen-lockfile` because the upstream pnpm-lock.yaml is
|
||||
# out of date vs. package.json (ERR_PNPM_OUTDATED_LOCKFILE) — bug to
|
||||
# file upstream once we've verified the rest of the stack works.
|
||||
|
||||
FROM node:20.11-bullseye AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# Copy lockfile + manifest first so the install layer caches across
|
||||
# source changes.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Generate prisma client and build the application
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20.11-alpine as runtime
|
||||
FROM node:20.11-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -22,13 +34,25 @@ RUN apk update && \
|
|||
apk add --no-cache openssl && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# Copy built files from the build stage
|
||||
COPY --from=build /app .
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN npm install --only=production
|
||||
# Install all dependencies (including devDeps). The prisma CLI lives in
|
||||
# devDependencies but scripts/start.js invokes `prisma migrate deploy`
|
||||
# at boot, so it must be available at runtime. Dropping --prod adds the
|
||||
# CLI tooling to the runtime image — a modest size cost for the
|
||||
# correctness of the migration step.
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "node", "./dist/index.js" ]
|
||||
# Run via scripts/start.js so `prisma migrate deploy` applies pending
|
||||
# migrations before the daemon spawns. The upstream Dockerfile invokes
|
||||
# ./dist/index.js directly, which silently bypasses the migration step
|
||||
# and leaves the SQLite db empty on first boot — every command that
|
||||
# touches Policy/KeyUser/Token/etc. then throws "table does not exist."
|
||||
# Caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||
ENTRYPOINT [ "node", "./scripts/start.js" ]
|
||||
CMD ["start"]
|
||||
|
|
|
|||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
51
flake.nix
Normal file
51
flake.nix
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
description = "nsecbunkerd — Nostr remote signing daemon (NIP-46)";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let pkgs = pkgsFor system; in
|
||||
rec {
|
||||
default = nsecbunkerd;
|
||||
nsecbunkerd = pkgs.callPackage ./package.nix { };
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = pkgsFor system; in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_20
|
||||
pnpm_8
|
||||
prisma
|
||||
prisma-engines
|
||||
python3
|
||||
gcc
|
||||
pkg-config
|
||||
openssl
|
||||
sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
# Point prisma at the nix-provided engines so it doesn't try to
|
||||
# download them from binaries.prisma.sh on every install.
|
||||
export PRISMA_QUERY_ENGINE_BINARY=${pkgs.prisma-engines}/bin/query-engine
|
||||
export PRISMA_QUERY_ENGINE_LIBRARY=${pkgs.prisma-engines}/lib/libquery_engine.node
|
||||
export PRISMA_SCHEMA_ENGINE_BINARY=${pkgs.prisma-engines}/bin/schema-engine
|
||||
export PRISMA_FMT_BINARY=${pkgs.prisma-engines}/bin/prisma-fmt
|
||||
export PRISMA_INTROSPECTION_ENGINE_BINARY=${pkgs.prisma-engines}/bin/introspection-engine
|
||||
export PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"@fastify/view": "^8.2.0",
|
||||
"@inquirer/password": "^1.1.2",
|
||||
"@inquirer/prompts": "^1.2.3",
|
||||
"@nostr-dev-kit/ndk": "workspace:*",
|
||||
"@nostr-dev-kit/ndk": "2.8.1",
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@types/yargs": "^17.0.24",
|
||||
|
|
|
|||
143
package.nix
Normal file
143
package.nix
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{
|
||||
lib,
|
||||
stdenv,
|
||||
pnpm_8,
|
||||
nodejs_20,
|
||||
makeWrapper,
|
||||
prisma-engines,
|
||||
openssl,
|
||||
sqlite,
|
||||
python311,
|
||||
pkg-config,
|
||||
node-gyp,
|
||||
}:
|
||||
|
||||
let
|
||||
# Fork commit `06272c8` ("pin @nostr-dev-kit/ndk to 2.8.1 instead of
|
||||
# workspace:*") changed package.json to a pinned `"2.8.1"`, but the
|
||||
# pnpm-lock.yaml still expresses the spec as `"^2.8.1"` (the way
|
||||
# `pnpm add` originally generated it). pnpm with --frozen-lockfile
|
||||
# rejects that mismatch. Patching package.json to use the caret form
|
||||
# is non-semantic (2.8.1 is still the resolved version) and aligns
|
||||
# both files. Same fix the Dockerfile-side already handles via
|
||||
# `--no-frozen-lockfile`; in nix we prefer frozen + a targeted patch.
|
||||
patchNdk = ''
|
||||
substituteInPlace package.json \
|
||||
--replace-fail '"@nostr-dev-kit/ndk": "2.8.1"' \
|
||||
'"@nostr-dev-kit/ndk": "^2.8.1"'
|
||||
'';
|
||||
|
||||
prismaEnv = {
|
||||
PRISMA_SCHEMA_ENGINE_BINARY = lib.getExe' prisma-engines "schema-engine";
|
||||
PRISMA_QUERY_ENGINE_BINARY = lib.getExe' prisma-engines "query-engine";
|
||||
PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines}/lib/libquery_engine.node";
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY = lib.getExe' prisma-engines "introspection-engine";
|
||||
PRISMA_FMT_BINARY = lib.getExe' prisma-engines "prisma-fmt";
|
||||
PRISMA_CLIENT_ENGINE_TYPE = "binary";
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "nsecbunkerd";
|
||||
version = "0.10.5";
|
||||
|
||||
src = ./.;
|
||||
|
||||
pnpmDeps = pnpm_8.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 2;
|
||||
prePnpmInstall = patchNdk;
|
||||
hash = "sha256-dQ+TX5jf1ZQKGoPCZgWaFwpAC3uP6iL1ZSxS0mFNdP8=";
|
||||
};
|
||||
|
||||
postPatch = patchNdk;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pnpm_8.configHook
|
||||
pnpm_8
|
||||
nodejs_20
|
||||
makeWrapper
|
||||
node-gyp
|
||||
python311
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
openssl
|
||||
sqlite
|
||||
];
|
||||
|
||||
env = prismaEnv;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
export npm_config_nodedir=${nodejs_20}
|
||||
pnpm config set nodedir ${nodejs_20}
|
||||
|
||||
# configHook ran with --ignore-scripts; re-run install to trigger
|
||||
# native-module postinstall (bcrypt). --offline keeps it inside the
|
||||
# store seeded by configHook.
|
||||
pnpm install --force --offline --frozen-lockfile --reporter=append-only
|
||||
|
||||
pnpm prisma generate
|
||||
pnpm build
|
||||
|
||||
# Do NOT `pnpm prune --prod` here — the prisma CLI lives in
|
||||
# devDependencies and `scripts/start.js` invokes it at boot via
|
||||
# `npx prisma migrate deploy`. Without the CLI, the migration step
|
||||
# silently fails (npx falls back to downloading prisma fresh, which
|
||||
# OOMs on most containers) and the SQLite db stays empty. See
|
||||
# `aiolabs/nsecbunkerd#7` diagnosis 2026-05-27.
|
||||
find node_modules -xtype l -delete
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/{bin,share/nsecbunkerd}
|
||||
# scripts/ MUST be copied — it contains the start.js launcher that
|
||||
# runs `prisma migrate deploy` before spawning the daemon. The
|
||||
# upstream packaging (and the upstream Dockerfile) bypassed this by
|
||||
# invoking dist/index.js directly, leaving migrations unapplied.
|
||||
cp -r dist node_modules prisma scripts templates package.json \
|
||||
$out/share/nsecbunkerd/
|
||||
|
||||
# Wrapper invokes scripts/start.js, which runs `prisma migrate deploy`
|
||||
# then spawns dist/index.js. start.js resolves sibling paths from
|
||||
# __dirname, so the caller (systemd unit, docker compose, etc.) can
|
||||
# set its own WorkingDirectory for the writable state dir without
|
||||
# interfering with how the launcher finds its own package files.
|
||||
# NSEC_BUNKER_CONFIG_DIR can override the config directory location;
|
||||
# by default it's `./config` relative to cwd.
|
||||
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \
|
||||
--add-flags $out/share/nsecbunkerd/scripts/start.js \
|
||||
--set NODE_ENV production \
|
||||
--prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \
|
||||
${
|
||||
lib.concatStringsSep " \\\n " (
|
||||
lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv
|
||||
)
|
||||
}
|
||||
|
||||
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunker-client \
|
||||
--chdir $out/share/nsecbunkerd \
|
||||
--add-flags $out/share/nsecbunkerd/dist/client/client.js \
|
||||
--set NODE_ENV production
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit prisma-engines;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Nostr remote signing daemon (NIP-46)";
|
||||
homepage = "https://github.com/kind-0/nsecbunkerd";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nsecbunkerd";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
|
|
@ -1,20 +1,32 @@
|
|||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Resolve sibling paths from this script's location so the launcher
|
||||
// works whether cwd is /app (docker), the nix store, or a writable
|
||||
// state dir set by systemd's WorkingDirectory. The prisma CLI and
|
||||
// dist/index.js live alongside this file in `<pkg>/share/nsecbunkerd/`
|
||||
// (nix) or `/app/` (docker). The migration-side env knobs:
|
||||
// NSEC_BUNKER_CONFIG_DIR — directory holding nsecbunker.{json,db};
|
||||
// defaults to ./config relative to cwd.
|
||||
// DATABASE_URL — prisma's source of truth for the sqlite
|
||||
// path; honor whatever the caller set.
|
||||
const pkgRoot = path.resolve(__dirname, '..');
|
||||
const configDir = process.env.NSEC_BUNKER_CONFIG_DIR || path.resolve(process.cwd(), 'config');
|
||||
|
||||
try {
|
||||
console.log(`Running migrations`);
|
||||
// check if config folder exists
|
||||
if (!fs.existsSync('./config')) {
|
||||
execSync(`mkdir config`);
|
||||
console.log(`Running migrations`);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
execSync('npm run prisma:migrate');
|
||||
execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(error);
|
||||
// Handle any potential migration errors here
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const childProcess = spawn('node', ['./dist/index.js', ...args], {
|
||||
const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "..";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { setupSkeletonProfile } from "../../lib/profile";
|
||||
|
|
@ -136,7 +136,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
|
|||
}
|
||||
|
||||
/**
|
||||
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen
|
||||
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen — pragma: allowlist secret
|
||||
*/
|
||||
export async function createAccountReal(
|
||||
admin: AdminInterface,
|
||||
|
|
@ -209,11 +209,18 @@ export async function createAccountReal(
|
|||
// access it without having to go through an approval flow
|
||||
await grantPermissions(req, keyName);
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
|
||||
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — it resolves
|
||||
// to `undefined` and sendResponse defaults to NDKKind.NostrConnect
|
||||
// (24133), sending the response on the wrong channel. Mirror the
|
||||
// request's kind so the response goes back on the same channel the
|
||||
// client subscribed for. Filed as part of aiolabs/nsecbunkerd#7
|
||||
// diagnosis 2026-05-27.
|
||||
const originalKind = req.event.kind!;
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, originalKind);
|
||||
} catch (e: any) {
|
||||
console.trace('error', e);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
|
||||
e.message);
|
||||
const originalKind = req.event.kind!;
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,19 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
|||
|
||||
if (!clientName || !policyId) throw new Error("Invalid params");
|
||||
|
||||
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } });
|
||||
const policyIdInt = parseInt(policyId);
|
||||
const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } });
|
||||
|
||||
if (!policy) throw new Error("Policy not found");
|
||||
|
||||
console.log({clientName, policy, durationInHours});
|
||||
|
||||
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
// policyId must be Int per the Prisma schema (Token.policyId references
|
||||
// Policy.id which is autoincrement Int). Upstream passes the raw string
|
||||
// from the wire — caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||
const data: any = {
|
||||
keyName, clientName, policyId,
|
||||
keyName, clientName, policyId: policyIdInt,
|
||||
createdBy: req.pubkey,
|
||||
token
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import "websocket-polyfill";
|
||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk';
|
||||
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
|
||||
import createDebug from 'debug';
|
||||
import { Key, KeyUser } from '../run';
|
||||
|
|
@ -111,8 +111,28 @@ class AdminInterface {
|
|||
return;
|
||||
}
|
||||
|
||||
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready'));
|
||||
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1';
|
||||
|
||||
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7.
|
||||
// NDKNostrRpc.sendResponse calls event.publish() and discards the
|
||||
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without
|
||||
// hooking the underlying per-relay events. Gated by env flag so
|
||||
// production deployments stay quiet.
|
||||
const attachRelayLogging = (relay: any) => {
|
||||
relay.on('published', (event: NDKEvent) => {
|
||||
console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`);
|
||||
});
|
||||
relay.on('publish:failed', (event: NDKEvent, err: any) => {
|
||||
console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`);
|
||||
});
|
||||
};
|
||||
|
||||
this.ndk.pool.on('relay:connect', (relay: any) => {
|
||||
console.log('✅ nsecBunker Admin Interface ready');
|
||||
if (debugTransport) attachRelayLogging(relay);
|
||||
});
|
||||
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
||||
|
||||
this.ndk.connect(2500).then(() => {
|
||||
// connect for whitelisted admins
|
||||
this.rpc.subscribe({
|
||||
|
|
@ -120,9 +140,45 @@ class AdminInterface {
|
|||
"#p": [this.signerUser!.pubkey]
|
||||
});
|
||||
|
||||
this.rpc.on('request', (req) => this.handleRequest(req));
|
||||
// Attach per-relay logging to relays that connected before our
|
||||
// 'relay:connect' listener was registered above (NDK can connect
|
||||
// synchronously inside .connect() under some paths).
|
||||
if (debugTransport) {
|
||||
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
|
||||
|
||||
pingOrDie(this.ndk);
|
||||
// Wrap sendResponse to log id + kind + elapsed time so we
|
||||
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
|
||||
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
|
||||
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await originalSendResponse(id, remotePubkey, result, kind, error);
|
||||
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
|
||||
} catch (e: any) {
|
||||
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.rpc.on('request', (req) => {
|
||||
if (debugTransport) {
|
||||
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
|
||||
}
|
||||
this.handleRequest(req);
|
||||
});
|
||||
|
||||
// Connection watchdog: exit if pool reports no connected relays
|
||||
// for >60s so the process supervisor (systemd / docker restart
|
||||
// policy / k8s) can recover. Replaces the original self-echo
|
||||
// pingOrDie — see relayConnectionWatchdog comment + #4 + #7.
|
||||
// Operators with external liveness checking can disable via
|
||||
// NSEC_BUNKER_DISABLE_WATCHDOG=1.
|
||||
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
|
||||
relayConnectionWatchdog(this.ndk);
|
||||
} else {
|
||||
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log('❌ admin connection failed');
|
||||
console.log(err);
|
||||
|
|
@ -158,7 +214,15 @@ class AdminInterface {
|
|||
}
|
||||
} catch (err: any) {
|
||||
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
|
||||
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it
|
||||
// makes sendResponse fall through to its default of 24133, which
|
||||
// sends the error on a different channel than the request came in
|
||||
// on. Mirror req.event.kind so the response goes back where the
|
||||
// client is listening. Filed as part of aiolabs/nsecbunkerd#7
|
||||
// diagnosis 2026-05-27.
|
||||
const originalKind = req.event.kind!;
|
||||
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -395,44 +459,47 @@ class AdminInterface {
|
|||
}
|
||||
}
|
||||
|
||||
async function pingOrDie(ndk: NDK) {
|
||||
let deathTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function resetDeath() {
|
||||
if (deathTimer) clearTimeout(deathTimer);
|
||||
deathTimer = setTimeout(() => {
|
||||
console.log(`❌ No ping event received in 30 seconds. Exiting.`);
|
||||
process.exit(1);
|
||||
}, 50000);
|
||||
}
|
||||
|
||||
const self = await ndk.signer!.user();
|
||||
const sub = ndk.subscribe({
|
||||
authors: [self.pubkey],
|
||||
kinds: [NDKKind.NostrConnect],
|
||||
"#p": [self.pubkey]
|
||||
});
|
||||
sub.on("event", (event: NDKEvent) => {
|
||||
console.log(`🔔 Received ping event:`, event.created_at);
|
||||
resetDeath();
|
||||
});
|
||||
sub.start();
|
||||
|
||||
resetDeath();
|
||||
/**
|
||||
* Pool-status connection watchdog. Exits the daemon if every relay in
|
||||
* the pool stays disconnected for longer than PARTITION_THRESHOLD_MS.
|
||||
*
|
||||
* Replaces the original `pingOrDie` self-echo watchdog, which published
|
||||
* a kind-24133 event to its own pubkey every 20s and exited if it
|
||||
* didn't see the echo within 50s. That works on public relays but
|
||||
* silently breaks on single-private-relay setups: NDK 2.8.1's outbox
|
||||
* model doesn't reliably route self-publishes back through the
|
||||
* matching subscription, so the watchdog fires false positives and
|
||||
* exits the daemon every 50s while RPCs over the same channel still
|
||||
* work fine. See aiolabs/nsecbunkerd#4 + #7.
|
||||
*
|
||||
* The pool-status approach uses NDK's own connection-lifecycle
|
||||
* tracking — `pool.connectedRelays()` reports relays in
|
||||
* NDKRelayStatus.CONNECTED — which is reliable across all relay
|
||||
* configurations because it doesn't depend on round-trip
|
||||
* publish/subscribe. No event is published; no relay traffic.
|
||||
*
|
||||
* Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms.
|
||||
* Transient disconnects shorter than PARTITION_THRESHOLD don't trip
|
||||
* the watchdog — useful for relays that flap or briefly drop on
|
||||
* network blips.
|
||||
*/
|
||||
async function relayConnectionWatchdog(ndk: NDK) {
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const PARTITION_THRESHOLD_MS = 60_000;
|
||||
let lastConnectedAt = Date.now();
|
||||
|
||||
setInterval(() => {
|
||||
const event = new NDKEvent(ndk, {
|
||||
kind: NDKKind.NostrConnect,
|
||||
tags: [ ["p", self.pubkey] ],
|
||||
content: "ping"
|
||||
} as NostrEvent);
|
||||
event.publish().then(() => {
|
||||
console.log(`🔔 Sent ping event:`, event.created_at);
|
||||
}).catch((e: any) => {
|
||||
console.log(`❌ Failed to send ping event:`, e.message);
|
||||
const connectedCount = ndk.pool.connectedRelays().length;
|
||||
if (connectedCount > 0) {
|
||||
lastConnectedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - lastConnectedAt;
|
||||
if (elapsed > PARTITION_THRESHOLD_MS) {
|
||||
console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`);
|
||||
process.exit(1);
|
||||
});
|
||||
}, 20000);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export default AdminInterface;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,43 @@ export class Backend extends NDKNip46Backend {
|
|||
// this.setStrategy('publish_event', new PublishEventHandlingStrategy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Override NDKNip46Backend.start() to await the kind-24133
|
||||
* subscription's EOSE before resolving. The base implementation
|
||||
* calls `this.ndk.subscribe(...)` and returns immediately — the
|
||||
* NDKSubscription queues a REQ on the relay connection but the
|
||||
* relay's acknowledgement (EOSE) hasn't arrived yet. Any caller
|
||||
* that publishes a NIP-46 event in the immediate window after
|
||||
* `start()` returns races against the relay registering this
|
||||
* subscription.
|
||||
*
|
||||
* aiolabs/lnbits#33's eager-bind chain publishes a NIP-46
|
||||
* `connect` event in the same HTTP round-trip as `create_new_key`,
|
||||
* which loses this race deterministically — the bunker never
|
||||
* sees the connect event because its subscription wasn't yet
|
||||
* registered with the relay when the event was broadcast.
|
||||
*
|
||||
* Awaiting EOSE closes the race: by the time `start()` resolves,
|
||||
* the relay has confirmed it has the bunker's subscription on
|
||||
* file and will route matching kind-24133 events to it.
|
||||
*
|
||||
* See aiolabs/nsecbunkerd#9 for the full diagnosis.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.localUser = await this.signer.user();
|
||||
await new Promise<void>((resolve) => {
|
||||
const sub = this.ndk.subscribe(
|
||||
{
|
||||
kinds: [24133],
|
||||
"#p": [this.localUser!.pubkey],
|
||||
},
|
||||
{ closeOnEose: false }
|
||||
);
|
||||
sub.on("event", (e: any) => this.handleIncomingEvent(e));
|
||||
sub.on("eose", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
private async validateToken(token: string) {
|
||||
if (!token) throw new Error("Invalid token");
|
||||
|
||||
|
|
|
|||
|
|
@ -230,8 +230,14 @@ class Daemon {
|
|||
|
||||
if (nsec.startsWith('nsec1')) {
|
||||
try {
|
||||
const key = new NDKPrivateKeySigner(nsec);
|
||||
hexpk = key.privateKey!;
|
||||
// NDK 2.8.1's NDKPrivateKeySigner constructor passes its
|
||||
// arg straight to nostr-tools getPublicKey() which requires
|
||||
// 32-byte hex / bytes / bigint, not bech32. Without this
|
||||
// decode, every key created via create_new_key fails to
|
||||
// load with the nostr-tools getPublicKey type error, so
|
||||
// the bunker can never sign for any target it provisions.
|
||||
// See aiolabs/nsecbunkerd#8.
|
||||
hexpk = nip19.decode(nsec).data as string;
|
||||
} catch(e) {
|
||||
console.error(`Error loading key ${name}:`, e);
|
||||
return
|
||||
|
|
@ -251,14 +257,14 @@ class Daemon {
|
|||
const nsec = decryptNsec(iv, data, passphrase);
|
||||
this.activeKeys[keyName] = nsec;
|
||||
|
||||
this.startKey(keyName, nsec);
|
||||
await this.startKey(keyName, nsec);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
loadNsec(keyName: string, nsec: string) {
|
||||
async loadNsec(keyName: string, nsec: string) {
|
||||
this.activeKeys[keyName] = nsec;
|
||||
|
||||
this.startKey(keyName, nsec);
|
||||
await this.startKey(keyName, nsec);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue