v2.1.12 - Implement multi-admin auth cache and checks, add admin management commands, and add multi-admin integration test

This commit is contained in:
Laan Tungir
2026-04-03 15:46:33 -04:00
parent 232f93c16f
commit fd3efdd01f
15 changed files with 2008 additions and 145 deletions
+813
View File
@@ -0,0 +1,813 @@
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>TEMPLATE</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[template.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles are public-readable by default.
if (hasTargetPubkeyInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[template.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[template.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[template.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
</body>
</html>
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
curl -H "Accept: application/nostr+json" http://localhost:8888/
curl -H "Accept: application/nostr+json" https://relay.laantungir.net
+240
View File
@@ -0,0 +1,240 @@
# C-Relay-PG Admin Page Migration Plan
## Overview
Migrate the relay admin dashboard from an embedded HTTP-served page (`api/index.html` + `api/index.js`) to a full client-ndk page (`c-relay-pg.html`) built on the client-ndk template. The page will live in the client-ndk project (`~/lt/client-ndk/www/`) alongside other pages and communicate with the relay purely through Nostr protocol subscriptions.
## Why This Migration
The current `/api` page is served directly by the relay's `lws-main` thread, which causes:
- **Slow page delivery** — HTTP file serving competes with WebSocket event processing on a single thread
- **Incomplete page loads** — 10-second `timeout_secs` kills connections when the main thread is busy
- **6+ sequential HTTP round-trips** — each asset requires a two-phase header/body delivery cycle
- **Unnecessary complexity** — embedded file generation, `lws_set_wsi_user()` overwriting, session data type dispatch
The admin page already communicates entirely via Nostr protocol:
- **Kind 23456** → Admin commands (encrypted, sent by admin)
- **Kind 23457** → Admin responses (encrypted, sent by relay)
- **Kind 24567** → Monitoring/stats events (ephemeral, broadcast by relay)
- **NIP-11** → Relay info (fetched via HTTP, independent of file serving)
- **NIP-17** → DM admin commands (gift-wrapped)
Moving to client-ndk means the relay serves zero HTML — it only speaks Nostr protocol.
## Architecture
```mermaid
graph LR
subgraph client-ndk project
A[c-relay-pg.html] --> B[init-ndk.mjs]
B --> C[NDK SharedWorker]
end
subgraph c-relay-pg
D[WebSocket Server]
E[api-worker thread]
end
C -->|kind 23456 admin cmds| D
D -->|kind 23457 responses| C
E -->|kind 24567 monitoring| D
D -->|kind 24567 events| C
C -->|kind 1059 NIP-17 DMs| D
```
## Current Admin Page Sections
| Section | Data Source | Communication |
|---------|-----------|---------------|
| Statistics | kind 24567 monitoring events with d-tags: event_kinds, time_stats, top_pubkeys, cpu_metrics | Subscribe to kind 24567 |
| Subscriptions | kind 24567 monitoring events with d-tag: subscription_details | Subscribe to kind 24567 |
| Configuration | kind 23457 admin responses | Send kind 23456 config_query, receive kind 23457 |
| Authorization | kind 23457 admin responses | Send kind 23456 auth rule commands, receive kind 23457 |
| IP Bans | kind 23457 admin responses | Send kind 23456 ip_ban_* commands, receive kind 23457 |
| Relay Events | Live subscription to all events + kind 0/10050/10002 management | Standard Nostr subscriptions |
| DM | NIP-17 gift-wrapped DMs kind 1059 | NIP-17 encrypt/wrap/publish |
| SQL Query | kind 23457 admin responses | Send kind 23456 sql_query, receive kind 23457 |
## Design Decisions
1. **Full client-ndk page** — Uses `client.css`, `init-ndk.mjs`, `HamburgerMorphing`, `relay-ui.mjs`, `blossom-ui.mjs`, `ai-ui.mjs` — all standard template infrastructure stays intact
2. **Sidenav preserved** — All existing sidenav sections (Relay, Blossom, AI) remain. Admin page navigation links are added to `divSideNavBody` as clickable items
3. **Page-specific CSS** — Follows client-ndk convention: page styles in inline `<style>` block, using only CSS variables from `client.css`
4. **Hardcoded relay URL**`wss://relay.laantungir.net` for now; future enhancement to support relay selection
5. **Auth mode: required** — Admin page requires authentication
## Migration Steps
### Step 1: Create `c-relay-pg.html` from Template
Start from `template.html` in client-ndk. Customize:
- **Title**: `C-Relay-PG`
- **Header text**: `C-Relay-PG Admin`
- **Auth mode**: `required` (default)
- **`divBody` content**: All 8 admin sections, each in its own container div, all `display:none` except Statistics (default)
- **`divSideNavBody`**: Add navigation links for each section:
```html
<div id="divAdminNav">
<div class="adminNavItem active" data-section="statistics">Statistics</div>
<div class="adminNavItem" data-section="subscriptions">Subscriptions</div>
<div class="adminNavItem" data-section="configuration">Configuration</div>
<div class="adminNavItem" data-section="authorization">Authorization</div>
<div class="adminNavItem" data-section="ip-bans">IP Bans</div>
<div class="adminNavItem" data-section="relay-events">Relay Events</div>
<div class="adminNavItem" data-section="dm">DM</div>
<div class="adminNavItem" data-section="database">Database Query</div>
</div>
```
### Step 2: Port Admin Communication Layer
Replace `SimplePool` + `nostr-lite.js` with NDK equivalents:
| Current index.js | New c-relay-pg.html |
|-----------------|---------------------|
| `new SimplePool()` | `subscribe()` from init-ndk.mjs |
| `relayPool.subscribeMany()` | `subscribe(filter, opts)` |
| `relayPool.publish()` | `publishEvent(event)` |
| `window.nostr.signEvent()` | Handled by NDK MessageBasedSigner |
| `window.NostrTools.nip42` | Handled by NDK NIP-42 support |
| `window.NostrTools.nip44` | `window.NostrTools.nip44` from nostr.bundle.js — same as before |
| `nlLite` nostr-login-lite | `initNDKPage()` + `getPubkey()` |
Key function — `sendAdminCommand(commandArray)`:
```javascript
async function sendAdminCommand(commandArray) {
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
adminPrivateKey, relayPubkey
);
const encrypted = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(commandArray), conversationKey
);
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 23456,
tags: [['p', relayPubkey]],
content: encrypted
};
return await publishEvent(event);
}
```
Note: NIP-44 encryption requires the admin's private key. The current page uses `window.nostr.nip44.encrypt()` which delegates to the browser extension. NDK's `publishEvent` handles signing, but encryption must be done before publishing. We'll use `window.nostr.nip44.encrypt()` if available (NIP-07 extension), same as the current page does.
### Step 3: Port Subscription Setup
Using NDK's `subscribe()` and `window.addEventListener('ndkEvent', ...)`:
```javascript
// Subscribe to admin responses + monitoring events from relay
const adminSub = subscribe(
{ kinds: [23457, 24567], authors: [RELAY_PUBKEY] },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Live event feed
const liveSub = subscribe(
{ limit: 50 },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Route events to handlers
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (evt.kind === 23457) processAdminResponse(evt);
else if (evt.kind === 24567) processMonitoringEvent(evt);
else addEventToLiveFeed(evt);
});
```
### Step 4: Port NIP-44 Encryption
Admin commands require NIP-44 encryption. The template already loads `nostr.bundle.js` which includes `window.NostrTools.nip44`. Additionally, `window.nostr.nip44.encrypt()` is available from NIP-07 extensions. Use the same approach as the current page.
### Step 5: Port UI Sections into divBody
Each section's HTML goes into `divBody` as a container div. Page-specific styles go in the inline `<style>` block following client-ndk conventions:
- Use CSS variables only (no hardcoded colors)
- Tables use `var(--primary-color)`, `var(--muted-color)`, `var(--accent-color)`
- Buttons follow the `var(--button-*)` pattern
- Font is always `var(--font-family)`
Sections to port:
1. **Statistics** — DB overview table, event kinds table, time stats table, top pubkeys table, event rate chart area
2. **Subscriptions** — Active subscription details table
3. **Configuration** — Config key/value table with inline edit, refresh button
4. **Authorization** — Auth rules table, whitelist/blacklist inputs, WoT level selector
5. **IP Bans** — Ban stats, manual ban form, IP whitelist, ban list table with filters
6. **Relay Events** — Live event feed table, kind 0/10050/10002 management forms
7. **DM** — NIP-17 message textarea + send button, inbox display
8. **SQL Query** — Query dropdown, SQL textarea, execute button, results table
### Step 6: Page-Specific CSS
Following client-ndk rules from `client.css`:
- Override `#divBody` layout: `flex-direction: column; overflow-y: auto; padding: 20px;`
- Admin section containers: hidden by default, shown when nav item clicked
- Table styles: use `var(--primary-color)` for headers, `var(--muted-color)` for borders
- Button styles: border + transparent background, hover with `var(--accent-color)`
- All in inline `<style media="screen">` block in `<head>`
### Step 7: Relay URL — Hardcoded for Now
```javascript
const RELAY_WS_URL = 'wss://relay.laantungir.net';
const RELAY_HTTP_URL = 'https://relay.laantungir.net';
```
NIP-11 fetch to get relay pubkey:
```javascript
const nip11 = await fetch(RELAY_HTTP_URL, {
headers: { 'Accept': 'application/nostr+json' }
}).then(r => r.json());
const RELAY_PUBKEY = nip11.pubkey;
```
### Step 8: Admin Verification
Same flow as current page:
1. Get relay pubkey from NIP-11
2. Send kind 23456 `system_status` command
3. If relay responds with kind 23457, user is verified as admin
4. Show admin sections; otherwise show access denied
### Step 9 (Optional, Later): Remove Embedded HTTP Serving from Relay
After migration is complete and tested:
- Remove `handle_embedded_file_request()` from `src/api.c`
- Remove `handle_embedded_file_writeable()` from `src/api.c`
- Remove embedded file dispatch from `LWS_CALLBACK_HTTP` in `src/websockets.c`
- Remove `embed_web_files.sh` and `src/embedded_web_content.c`
- Keep NIP-11 serving (lightweight and required by protocol)
## File Changes Summary
### New File (in client-ndk project: ~/lt/client-ndk/www/)
- `c-relay-pg.html` — Complete admin page, self-contained with inline `<style>` and `<script type="module">`
### Files Unchanged
- All relay C code for kind 23456/23457/24567 processing — stays exactly the same
- `src/dm_admin.c` — NIP-17 DM handling stays the same
- `src/nip011.c` — NIP-11 stays the same
- All client-ndk shared files (client.css, init-ndk.mjs, relay-ui.mjs, etc.) — no modifications needed
### Files to Modify Later (optional relay cleanup)
- `src/api.c` — Remove embedded file serving functions
- `src/websockets.c` — Remove embedded file dispatch from HTTP callback
- `Makefile` — Remove embedded web content compilation step
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| NIP-44 encryption via window.nostr.nip44 | Same mechanism as current page — no change in encryption approach |
| NDK SharedWorker relay connection | Use ONLY_RELAY cache usage to ensure direct relay communication |
| Large page size with all sections inline | Sections are display:none by default — no performance impact |
| Event rate chart from text_graph.js | Port chart logic inline or skip initially |
| Relay pubkey needed before subscriptions | Fetch NIP-11 first in initialization sequence |
+201
View File
@@ -0,0 +1,201 @@
# Multi-Admin Support Plan
## Overview
Enable multiple Nostr pubkeys to act as relay administrators. Currently the relay stores a single `admin_pubkey` string in the `config` table and uses `strcmp()` equality checks throughout the codebase. This plan converts that to a comma-separated list of pubkeys stored in the same config key, with a centralized `is_admin_pubkey()` helper that all authorization gates call.
## Design Principles
1. **Minimal schema change** — keep using the existing `config` table with key `admin_pubkey`. The value changes from a single 64-char hex string to a comma-separated list of 64-char hex strings.
2. **Primary admin concept** — the first pubkey in the list is the "primary admin" (the original one). Only the primary admin can add/remove other admins. This prevents privilege escalation.
3. **Centralized check** — introduce one function `is_admin_pubkey(const char* pubkey)` that all 8 authorization gates call instead of inline `strcmp()`.
4. **In-memory cache** — parse the comma-separated list once on startup/config-change into a static array for O(1)-ish lookups without repeated string parsing.
5. **Backward compatible** — a single pubkey with no commas works identically to today.
## Data Model
```
config table:
key = "admin_pubkey"
value = "hex1,hex2,hex3" (comma-separated, no spaces)
data_type = "string"
```
Maximum admins: 10 (compile-time constant `MAX_ADMIN_PUBKEYS`).
## Architecture
```mermaid
flowchart TD
A[Config Table: admin_pubkey = hex1,hex2,hex3] --> B[reload_admin_pubkeys_cache]
B --> C[Static array: g_admin_pubkeys with count]
D[Any authorization check] --> E[is_admin_pubkey - pubkey]
E --> C
F[CLI: -a flag] --> G[first_time_startup_sequence]
G --> A
H[Admin command: add_admin / remove_admin] --> I[modify_admin_list]
I --> A
I --> B
```
## Implementation Steps
### Step 1: Add centralized admin check infrastructure in config.c / config.h
**New constants and types:**
```c
#define MAX_ADMIN_PUBKEYS 10
```
**New functions in config.h:**
```c
int is_admin_pubkey(const char* pubkey); // Returns 1 if pubkey is in admin list
int get_admin_pubkey_count(void); // Returns number of admins
const char* get_primary_admin_pubkey(void); // Returns first admin pubkey
int reload_admin_pubkeys_cache(void); // Parse config value into cache
int add_admin_pubkey(const char* pubkey); // Add pubkey to admin list (primary admin only)
int remove_admin_pubkey(const char* pubkey); // Remove pubkey from admin list (primary admin only)
```
**Implementation in config.c:**
- Static array `g_admin_pubkeys[MAX_ADMIN_PUBKEYS][65]` and `g_admin_pubkey_count`
- `reload_admin_pubkeys_cache()` reads `admin_pubkey` from config table, splits on commas, validates each is 64 hex chars, populates array
- `is_admin_pubkey()` iterates the cached array with `strcmp()`
- `get_primary_admin_pubkey()` returns `g_admin_pubkeys[0]` — used where only the primary admin matters (WoT sync, etc.)
- `add_admin_pubkey()` / `remove_admin_pubkey()` modify the comma-separated string in the config table and call `reload_admin_pubkeys_cache()`
### Step 2: Replace all 8 authorization strcmp gates
Each of these currently does:
```c
const char* admin_pubkey = get_config_value("admin_pubkey");
if (strcmp(sender_pubkey, admin_pubkey) == 0) { /* authorized */ }
```
Replace with:
```c
if (is_admin_pubkey(sender_pubkey)) { /* authorized */ }
```
**Files and locations:**
| File | Function | Line | Change |
|------|----------|------|--------|
| src/config.c | `process_admin_config_event()` | ~1441 | Replace strcmp with `is_admin_pubkey()` |
| src/config.c | `handle_kind_23456_unified()` | ~2727 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | `verify_admin_event()` | ~1905 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | Event storage / WoT trigger | ~1174 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-42 auth bypass | ~2324 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-17 DM admin check | ~3560 | Replace strcmp with `is_admin_pubkey()` |
| src/dm_admin.c | `process_admin_dm()` | ~503 | Replace strcmp with `is_admin_pubkey()` |
| src/request_validator.c | Event validation bypass | ~307 | Replace strcmp with `is_admin_pubkey()` |
Each replacement also eliminates the `get_config_value("admin_pubkey")` call and its associated `free()`, simplifying the code.
### Step 3: Update startup / initialization pipeline
**config.c `first_time_startup_sequence()`:**
- No change needed — it already stores a single pubkey. The comma-separated format with one entry is identical to the current format.
**config.c `populate_all_config_values_atomic()`:**
- No change needed — inserts the initial admin_pubkey as a single value.
**config.c `add_pubkeys_to_config_table()`:**
- No change needed — stores single pubkey on migration.
**config.c `apply_cli_overrides_atomic()`:**
- No change needed — CLI override replaces the entire admin_pubkey value.
**main.c startup validation:**
- Update the pre-warm validation at ~line 2438 to call `reload_admin_pubkeys_cache()` and validate that at least one admin pubkey exists, rather than checking for exactly 64 chars.
**Call `reload_admin_pubkeys_cache()`** after:
- `populate_all_config_values_atomic()` completes
- `reload_config_from_table()` completes
- Any admin add/remove operation
### Step 4: Add admin management commands to kind 23456 API
Add two new admin commands that can be sent as encrypted kind 23456 events:
```json
["add_admin", "pubkey_hex"]
["remove_admin", "pubkey_hex"]
["list_admins"]
```
**Authorization rule:** Only the primary admin (first in list) can add/remove other admins. This is enforced in the command handler, not in the general `is_admin_pubkey()` check.
**Implementation location:** `handle_system_command_unified()` in config.c, alongside existing commands like `system_status`, `graceful_shutdown`, etc.
### Step 5: Update WoT sync to handle multiple admins
In `wot_sync_from_admin_kind3()`:
- Currently queries kind 3 events from the single admin pubkey
- Change to use `get_primary_admin_pubkey()` — WoT trust anchor should remain the primary admin
- This is a deliberate design choice: secondary admins can manage the relay but the WoT trust graph is rooted in the primary admin's social graph
### Step 6: Update CLI to accept multiple admin pubkeys
**Option A - Comma-separated single flag (recommended):**
```bash
./c_relay_pg -a npub1...,npub2...,npub3...
```
**Changes:**
- `cli_options_t.admin_pubkey_override` becomes `char admin_pubkeys_override[MAX_ADMIN_PUBKEYS * 65]` (larger buffer for comma-separated list)
- CLI parsing in main.c splits on commas, decodes each npub/hex, reassembles as comma-separated hex
- `make_and_restart_relay.sh` `-a` flag documentation updated
### Step 7: Update config validation in api.c
In the config validation table at line ~912:
```c
{"admin_pubkey", "string", 0, 65},
```
Change max length to accommodate multiple pubkeys:
```c
{"admin_pubkey", "string", 0, MAX_ADMIN_PUBKEYS * 65},
```
### Step 8: Update get_admin_pubkey_cached
`get_admin_pubkey_cached()` currently returns the raw config value. Update to return only the primary admin pubkey for backward compatibility with any callers that expect a single 64-char string.
### Step 9: Update test scripts
Update test scripts that hardcode admin keys to work with the multi-admin system:
- `tests/sql_test.sh`
- `tests/white_black_test.sh`
- `tests/17_nip_test.sh`
- `tests/run_all_tests.sh`
- `tests/.test_keys.txt`
Add new test cases for:
- Adding a second admin via kind 23456 command
- Verifying second admin can send admin commands
- Verifying non-admin cannot send admin commands
- Removing a secondary admin
- Verifying primary admin cannot be removed
- Verifying secondary admin cannot add/remove admins
### Step 10: Update documentation
- `AGENTS.md` — update admin API event structure section
- `docs/configuration_guide.md` — document multi-admin setup
- `docs/user_guide.md` — document admin management commands
- `API.md` — document new add_admin/remove_admin/list_admins commands
- `README.md` — mention multi-admin capability
## Migration / Backward Compatibility
- Existing databases with a single `admin_pubkey` value work without any migration — a string with no commas is treated as a single-admin list
- The `is_admin_pubkey()` function handles both formats transparently
- No schema changes needed — the config table already stores strings of arbitrary length
## Security Considerations
1. **Primary admin privilege** — only the first pubkey in the list can add/remove admins, preventing secondary admins from locking out the primary
2. **No self-removal** — the primary admin cannot remove themselves (prevents accidental lockout)
3. **Max admin limit** — capped at 10 to prevent abuse and keep the linear scan fast
4. **Validation** — each pubkey in the list is validated as 64 hex characters before storage
+1 -1
View File
@@ -1 +1 @@
1542644
1643897
+1 -1
View File
@@ -909,7 +909,7 @@ static const config_definition_t known_configs[] = {
// Relay keys
{"relay_pubkey", "string", 0, 65},
{"relay_privkey", "string", 0, 65},
{"admin_pubkey", "string", 0, 65},
{"admin_pubkey", "string", 0, ADMIN_PUBKEY_LIST_MAX_LENGTH},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "int", 0, 8760},
+427 -75
View File
@@ -110,6 +110,11 @@ static char g_temp_relay_privkey[65] = {0};
// Runtime cache for relay private key to support strict mode (g_db = NULL)
static char g_cached_relay_privkey[65] = {0};
// Runtime cache for multi-admin pubkeys
static char g_admin_pubkeys[MAX_ADMIN_PUBKEYS][ADMIN_PUBKEY_HEX_LEN + 1];
static int g_admin_pubkey_count = 0;
static pthread_mutex_t g_admin_pubkeys_mutex = PTHREAD_MUTEX_INITIALIZER;
static int config_open_temp_db_connection(void** out_db) {
return db_open_worker_connection(db_get_database_path(), out_db);
}
@@ -668,26 +673,59 @@ int first_time_startup_sequence(const cli_options_t* cli_options, char* admin_pu
// 1. Generate or use provided admin keypair
unsigned char admin_privkey_bytes[32];
char admin_privkey[65], admin_pubkey[65];
char admin_privkey[65], admin_pubkey[ADMIN_PUBKEY_LIST_MAX_LENGTH];
int generated_admin_key = 0; // Track if we generated a new admin key
if (cli_options && strlen(cli_options->admin_pubkey_override) == 64) {
// Use provided admin public key directly - skip private key generation entirely
if (cli_options && cli_options->admin_pubkey_override[0] != '\0') {
// Use provided admin public key list directly - skip private key generation entirely
strncpy(admin_pubkey, cli_options->admin_pubkey_override, sizeof(admin_pubkey) - 1);
admin_pubkey[sizeof(admin_pubkey) - 1] = '\0';
// Validate the public key format (must be 64 hex characters)
for (int i = 0; i < 64; i++) {
char c = admin_pubkey[i];
// Validate comma-separated public keys (each must be 64 hex chars)
char* keys_copy = strdup(admin_pubkey);
if (!keys_copy) {
DEBUG_ERROR("Failed to allocate memory for admin pubkey validation");
return -1;
}
int key_count = 0;
char* saveptr = NULL;
char* token = strtok_r(keys_copy, ",", &saveptr);
while (token) {
if (strlen(token) != ADMIN_PUBKEY_HEX_LEN) {
free(keys_copy);
DEBUG_ERROR("Invalid admin public key format - each pubkey must be 64 hex characters");
return -1;
}
for (int i = 0; i < ADMIN_PUBKEY_HEX_LEN; i++) {
char c = token[i];
if (!((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) {
free(keys_copy);
DEBUG_ERROR("Invalid admin public key format - must contain only hex characters");
return -1;
}
}
// Skip private key generation - we only need the pubkey for admin verification
key_count++;
if (key_count > MAX_ADMIN_PUBKEYS) {
free(keys_copy);
DEBUG_ERROR("Too many admin pubkeys provided (max %d)", MAX_ADMIN_PUBKEYS);
return -1;
}
token = strtok_r(NULL, ",", &saveptr);
}
free(keys_copy);
if (key_count == 0) {
DEBUG_ERROR("No valid admin pubkeys provided");
return -1;
}
// Skip private key generation - we only need the pubkey list for admin verification
// Set a dummy private key that will never be used (not displayed or stored)
memset(admin_privkey_bytes, 0, 32); // Zero out for security
memset(admin_privkey, 0, sizeof(admin_privkey)); // Zero out the hex string
@@ -761,8 +799,8 @@ int first_time_startup_sequence(const cli_options_t* cli_options, char* admin_pu
// Copy keys to output parameters
if (admin_pubkey_out) {
strncpy(admin_pubkey_out, admin_pubkey, 64);
admin_pubkey_out[64] = '\0';
strncpy(admin_pubkey_out, admin_pubkey, ADMIN_PUBKEY_LIST_MAX_LENGTH - 1);
admin_pubkey_out[ADMIN_PUBKEY_LIST_MAX_LENGTH - 1] = '\0';
}
if (relay_pubkey_out) {
strncpy(relay_pubkey_out, relay_pubkey, 64);
@@ -1438,13 +1476,10 @@ int process_configuration_event(const cJSON* event) {
// Verify it's from the admin
const char* event_pubkey = cJSON_GetStringValue(pubkey_obj);
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strlen(admin_pubkey) > 0) {
if (strcmp(event_pubkey, admin_pubkey) != 0) {
if (!is_admin_pubkey(event_pubkey)) {
DEBUG_ERROR("Configuration event not from authorized admin");
return -1;
}
}
// Comprehensive event validation using nostr_core_lib
@@ -2109,10 +2144,10 @@ int wot_sync_from_admin_kind3(void) {
return 0; // WoT disabled, nothing to do
}
// Get admin pubkey from config
const char* admin_pubkey = get_config_value("admin_pubkey");
// Get primary admin pubkey from config
const char* admin_pubkey = get_primary_admin_pubkey();
if (!admin_pubkey) {
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
DEBUG_ERROR("WoT sync: primary admin pubkey not configured");
return -1;
}
@@ -2725,18 +2760,10 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
const char* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey) {
DEBUG_ERROR("error: admin pubkey not available for authorization check");
snprintf(error_message, error_size, "error: admin pubkey not available for authorization check");
return -1;
}
if (strcmp(sender_pubkey, admin_pubkey) != 0) {
DEBUG_ERROR("invalid: unauthorized admin event - sender pubkey does not match admin pubkey");
if (!is_admin_pubkey(sender_pubkey)) {
DEBUG_ERROR("invalid: unauthorized admin event - sender pubkey does not match any configured admin pubkey");
printf(" Sender pubkey: %.16s...\n", sender_pubkey);
printf(" Admin pubkey: %.16s...\n", admin_pubkey);
snprintf(error_message, error_size, "invalid: unauthorized admin event");
return -1;
}
@@ -3334,6 +3361,126 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
return -1;
}
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* sender_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!sender_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
if (strcmp(command, "list_admins") == 0) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "list_admins");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
cJSON* admins_array = cJSON_CreateArray();
pthread_mutex_lock(&g_admin_pubkeys_mutex);
for (int i = 0; i < g_admin_pubkey_count; i++) {
cJSON_AddItemToArray(admins_array, cJSON_CreateString(g_admin_pubkeys[i]));
}
int admin_count = g_admin_pubkey_count;
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
cJSON_AddItemToObject(response, "admins", admins_array);
cJSON_AddNumberToObject(response, "admin_count", admin_count);
const char* primary_admin = get_primary_admin_pubkey();
if (primary_admin) {
cJSON_AddStringToObject(response, "primary_admin", primary_admin);
free((char*)primary_admin);
}
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send list_admins response");
return -1;
}
else if (strcmp(command, "add_admin") == 0) {
const char* new_admin_pubkey = get_tag_value(event, "system_command", 2);
if (!new_admin_pubkey) {
snprintf(error_message, error_size, "invalid: add_admin requires target pubkey");
return -1;
}
const char* primary_admin = get_primary_admin_pubkey();
if (!primary_admin) {
snprintf(error_message, error_size, "primary admin pubkey not configured");
return -1;
}
if (strcmp(sender_pubkey, primary_admin) != 0) {
free((char*)primary_admin);
snprintf(error_message, error_size, "unauthorized: only primary admin may add admins");
return -1;
}
free((char*)primary_admin);
if (add_admin_pubkey(new_admin_pubkey) != 0) {
snprintf(error_message, error_size, "failed to add admin pubkey");
return -1;
}
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "add_admin");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddStringToObject(response, "added_admin", new_admin_pubkey);
cJSON_AddNumberToObject(response, "admin_count", get_admin_pubkey_count());
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send add_admin response");
return -1;
}
else if (strcmp(command, "remove_admin") == 0) {
const char* target_admin_pubkey = get_tag_value(event, "system_command", 2);
if (!target_admin_pubkey) {
snprintf(error_message, error_size, "invalid: remove_admin requires target pubkey");
return -1;
}
const char* primary_admin = get_primary_admin_pubkey();
if (!primary_admin) {
snprintf(error_message, error_size, "primary admin pubkey not configured");
return -1;
}
if (strcmp(sender_pubkey, primary_admin) != 0) {
free((char*)primary_admin);
snprintf(error_message, error_size, "unauthorized: only primary admin may remove admins");
return -1;
}
free((char*)primary_admin);
if (remove_admin_pubkey(target_admin_pubkey) != 0) {
snprintf(error_message, error_size, "failed to remove admin pubkey");
return -1;
}
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "remove_admin");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddStringToObject(response, "removed_admin", target_admin_pubkey);
cJSON_AddNumberToObject(response, "admin_count", get_admin_pubkey_count());
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send remove_admin response");
return -1;
}
if (strcmp(command, "clear_all_auth_rules") == 0) {
// Count existing rules first
int rule_count = 0;
@@ -3356,18 +3503,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
cJSON_Delete(response);
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
@@ -3423,18 +3560,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
cJSON_Delete(response);
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
@@ -3476,18 +3603,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Database: %s\n", db_is_available() ? "Connected" : "Not available");
printf("Cache status: Not used (direct database queries)\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
cJSON_Delete(response);
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
@@ -3508,18 +3625,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Admin requested system restart\n");
printf("Sending acknowledgment and initiating shutdown...\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
cJSON_Delete(response);
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
// Send acknowledgment response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
// Trigger graceful shutdown by setting the global shutdown flag
g_shutdown_flag = 1;
@@ -3575,15 +3682,28 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
return -1;
}
else if (strcmp(command, "wot_sync") == 0) {
// Get admin pubkey from event
// Get sender pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
const char* sender_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
if (!sender_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
return -1;
}
// Only primary admin can trigger WoT sync because WoT anchor is primary admin kind-3
const char* primary_admin = get_primary_admin_pubkey();
if (!primary_admin) {
snprintf(error_message, error_size, "primary admin pubkey not configured");
return -1;
}
if (strcmp(sender_pubkey, primary_admin) != 0) {
free((char*)primary_admin);
snprintf(error_message, error_size, "unauthorized: only primary admin may trigger wot_sync");
return -1;
}
free((char*)primary_admin);
// Check if WoT is enabled
int wot_enabled = get_config_int("wot_enabled", 0);
if (wot_enabled <= 0) {
@@ -3610,7 +3730,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
if (send_admin_response_event(response, sender_pubkey, wsi) == 0) {
cJSON_Delete(response);
return sync_result;
}
@@ -4648,6 +4768,12 @@ int reload_config_from_table(void) {
pthread_mutex_unlock(&g_config_cache_mutex);
cJSON_Delete(rows);
// Keep admin pubkey cache aligned with refreshed config values
if (reload_admin_pubkeys_cache() != 0) {
DEBUG_WARN("Failed to reload admin pubkeys cache from config table");
}
return 0;
}
@@ -4696,8 +4822,234 @@ const char* get_config_value_from_table(const char* key) {
return db_value;
}
static int is_valid_hex_pubkey(const char* pubkey) {
if (!pubkey || strlen(pubkey) != ADMIN_PUBKEY_HEX_LEN) {
return 0;
}
for (int i = 0; i < ADMIN_PUBKEY_HEX_LEN; i++) {
char c = pubkey[i];
if (!((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) {
return 0;
}
}
return 1;
}
int reload_admin_pubkeys_cache(void) {
const char* admin_pubkeys_str = get_config_value_from_table("admin_pubkey");
if (!admin_pubkeys_str) {
pthread_mutex_lock(&g_admin_pubkeys_mutex);
g_admin_pubkey_count = 0;
memset(g_admin_pubkeys, 0, sizeof(g_admin_pubkeys));
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return -1;
}
char* list_copy = strdup(admin_pubkeys_str);
free((char*)admin_pubkeys_str);
if (!list_copy) {
return -1;
}
char parsed_pubkeys[MAX_ADMIN_PUBKEYS][ADMIN_PUBKEY_HEX_LEN + 1] = {{0}};
int parsed_count = 0;
char* saveptr = NULL;
char* token = strtok_r(list_copy, ",", &saveptr);
while (token && parsed_count < MAX_ADMIN_PUBKEYS) {
if (!is_valid_hex_pubkey(token)) {
free(list_copy);
return -1;
}
int duplicate = 0;
for (int i = 0; i < parsed_count; i++) {
if (strcmp(parsed_pubkeys[i], token) == 0) {
duplicate = 1;
break;
}
}
if (!duplicate) {
strncpy(parsed_pubkeys[parsed_count], token, ADMIN_PUBKEY_HEX_LEN);
parsed_pubkeys[parsed_count][ADMIN_PUBKEY_HEX_LEN] = '\0';
parsed_count++;
}
token = strtok_r(NULL, ",", &saveptr);
}
free(list_copy);
pthread_mutex_lock(&g_admin_pubkeys_mutex);
memset(g_admin_pubkeys, 0, sizeof(g_admin_pubkeys));
g_admin_pubkey_count = parsed_count;
for (int i = 0; i < parsed_count; i++) {
strncpy(g_admin_pubkeys[i], parsed_pubkeys[i], ADMIN_PUBKEY_HEX_LEN);
g_admin_pubkeys[i][ADMIN_PUBKEY_HEX_LEN] = '\0';
}
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return parsed_count > 0 ? 0 : -1;
}
int is_admin_pubkey(const char* pubkey) {
if (!is_valid_hex_pubkey(pubkey)) {
return 0;
}
pthread_mutex_lock(&g_admin_pubkeys_mutex);
for (int i = 0; i < g_admin_pubkey_count; i++) {
if (strcmp(g_admin_pubkeys[i], pubkey) == 0) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return 1;
}
}
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return 0;
}
int get_admin_pubkey_count(void) {
pthread_mutex_lock(&g_admin_pubkeys_mutex);
int count = g_admin_pubkey_count;
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return count;
}
const char* get_primary_admin_pubkey(void) {
pthread_mutex_lock(&g_admin_pubkeys_mutex);
if (g_admin_pubkey_count <= 0) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return NULL;
}
const char* result = strdup(g_admin_pubkeys[0]);
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return result;
}
static int rebuild_admin_pubkey_list(char* output, size_t output_size,
char pubkeys[MAX_ADMIN_PUBKEYS][ADMIN_PUBKEY_HEX_LEN + 1],
int count) {
if (!output || output_size == 0 || !pubkeys || count <= 0 || count > MAX_ADMIN_PUBKEYS) {
return -1;
}
output[0] = '\0';
for (int i = 0; i < count; i++) {
size_t need = strlen(output) + strlen(pubkeys[i]) + (i > 0 ? 1 : 0) + 1;
if (need > output_size) {
return -1;
}
if (i > 0) {
strcat(output, ",");
}
strcat(output, pubkeys[i]);
}
return 0;
}
int add_admin_pubkey(const char* pubkey) {
if (!is_valid_hex_pubkey(pubkey)) {
return -1;
}
pthread_mutex_lock(&g_admin_pubkeys_mutex);
char pubkeys[MAX_ADMIN_PUBKEYS][ADMIN_PUBKEY_HEX_LEN + 1] = {{0}};
int count = g_admin_pubkey_count;
if (count <= 0 || count > MAX_ADMIN_PUBKEYS) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return -1;
}
for (int i = 0; i < count; i++) {
strncpy(pubkeys[i], g_admin_pubkeys[i], ADMIN_PUBKEY_HEX_LEN);
pubkeys[i][ADMIN_PUBKEY_HEX_LEN] = '\0';
if (strcmp(pubkeys[i], pubkey) == 0) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return 0; // already present
}
}
if (count >= MAX_ADMIN_PUBKEYS) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return -1;
}
strncpy(pubkeys[count], pubkey, ADMIN_PUBKEY_HEX_LEN);
pubkeys[count][ADMIN_PUBKEY_HEX_LEN] = '\0';
count++;
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
char list[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
if (rebuild_admin_pubkey_list(list, sizeof(list), pubkeys, count) != 0) {
return -1;
}
if (update_config_in_table("admin_pubkey", list) != 0) {
return -1;
}
return reload_admin_pubkeys_cache();
}
int remove_admin_pubkey(const char* pubkey) {
if (!is_valid_hex_pubkey(pubkey)) {
return -1;
}
pthread_mutex_lock(&g_admin_pubkeys_mutex);
if (g_admin_pubkey_count <= 1) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return -1;
}
// Primary admin cannot be removed
if (strcmp(g_admin_pubkeys[0], pubkey) == 0) {
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
return -1;
}
char pubkeys[MAX_ADMIN_PUBKEYS][ADMIN_PUBKEY_HEX_LEN + 1] = {{0}};
int out_count = 0;
int removed = 0;
for (int i = 0; i < g_admin_pubkey_count; i++) {
if (strcmp(g_admin_pubkeys[i], pubkey) == 0) {
removed = 1;
continue;
}
strncpy(pubkeys[out_count], g_admin_pubkeys[i], ADMIN_PUBKEY_HEX_LEN);
pubkeys[out_count][ADMIN_PUBKEY_HEX_LEN] = '\0';
out_count++;
}
pthread_mutex_unlock(&g_admin_pubkeys_mutex);
if (!removed || out_count <= 0) {
return removed ? -1 : 0;
}
char list[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
if (rebuild_admin_pubkey_list(list, sizeof(list), pubkeys, out_count) != 0) {
return -1;
}
if (update_config_in_table("admin_pubkey", list) != 0) {
return -1;
}
return reload_admin_pubkeys_cache();
}
const char* get_admin_pubkey_cached(void) {
return get_config_value_from_table("admin_pubkey");
return get_primary_admin_pubkey();
}
const char* get_relay_pubkey_cached(void) {
+12 -1
View File
@@ -15,6 +15,9 @@ struct lws;
#define RELAY_URL_MAX_LENGTH 512
#define RELAY_PUBKEY_MAX_LENGTH 65
#define RELAY_CONTACT_MAX_LENGTH 256
#define MAX_ADMIN_PUBKEYS 10
#define ADMIN_PUBKEY_HEX_LEN 64
#define ADMIN_PUBKEY_LIST_MAX_LENGTH ((MAX_ADMIN_PUBKEYS * ADMIN_PUBKEY_HEX_LEN) + (MAX_ADMIN_PUBKEYS - 1) + 1)
#define SUBSCRIPTION_ID_MAX_LENGTH 64
#define CLIENT_IP_MAX_LENGTH 46
#define MAX_SUBSCRIPTIONS_PER_CLIENT 25
@@ -29,7 +32,7 @@ extern char g_database_path[512];
// Command line options structure for first-time startup
typedef struct {
int port_override; // -1 = not set, >0 = port value
char admin_pubkey_override[65]; // Empty string = not set, 64-char hex = override
char admin_pubkey_override[ADMIN_PUBKEY_LIST_MAX_LENGTH]; // Empty string = not set, comma-separated 64-char hex pubkeys
char relay_privkey_override[65]; // Empty string = not set, 64-char hex = override
int strict_port; // 0 = allow port increment, 1 = fail if exact port unavailable
int debug_level; // 0-5, default 0 (no debug output)
@@ -134,6 +137,14 @@ const char* get_relay_pubkey_cached(void);
void invalidate_config_cache(void);
int reload_config_from_table(void);
// Multi-admin helpers
int reload_admin_pubkeys_cache(void);
int is_admin_pubkey(const char* pubkey);
int get_admin_pubkey_count(void);
const char* get_primary_admin_pubkey(void);
int add_admin_pubkey(const char* pubkey);
int remove_admin_pubkey(const char* pubkey);
// Hybrid config access functions
const char* get_config_value_hybrid(const char* key);
int is_config_table_ready(void);
+1 -2
View File
@@ -501,8 +501,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
// Check if sender is admin
const char* admin_pubkey = get_config_value("admin_pubkey");
int is_admin = admin_pubkey && strlen(admin_pubkey) > 0 && strcmp(sender_pubkey, admin_pubkey) == 0;
int is_admin = is_admin_pubkey(sender_pubkey);
DEBUG_INFO("DM_ADMIN: Sender pubkey: %.16s... (admin: %s)", sender_pubkey, is_admin ? "YES" : "NO");
File diff suppressed because one or more lines are too long
+68 -26
View File
@@ -1172,13 +1172,11 @@ void store_event_post_actions(cJSON* event) {
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
if (is_admin_pubkey(cJSON_GetStringValue(pubkey_obj))) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
}
@@ -1902,25 +1900,21 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
return -1;
}
// Get admin pubkey from configuration
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey || strlen(admin_pubkey) == 0) {
// Verify sender is one of configured admins
if (get_admin_pubkey_count() <= 0) {
DEBUG_WARN("Unauthorized admin event attempt: no admin pubkey configured");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: no admin configured");
if (admin_pubkey) free((char*)admin_pubkey);
return -1;
}
// Compare pubkeys
if (strcmp(pubkey_json->valuestring, admin_pubkey) != 0) {
if (!is_admin_pubkey(pubkey_json->valuestring)) {
DEBUG_WARN("Unauthorized admin event attempt: pubkey mismatch");
char warning_msg[256];
snprintf(warning_msg, sizeof(warning_msg),
"Unauthorized admin event attempt from pubkey: %.32s...", pubkey_json->valuestring);
DEBUG_WARN(warning_msg);
DEBUG_INFO("DEBUG: Pubkey comparison failed - event pubkey != admin pubkey");
DEBUG_INFO("DEBUG: Pubkey comparison failed - event pubkey not in admin list");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: invalid admin pubkey");
free((char*)admin_pubkey);
return -1;
}
@@ -1929,12 +1923,9 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
if (nostr_verify_event_signature(event) != 0) {
DEBUG_WARN("Unauthorized admin event attempt: invalid signature");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: signature verification failed");
free((char*)admin_pubkey);
return -1;
}
free((char*)admin_pubkey);
// All checks passed - authorized admin event
return 0;
}
@@ -1959,7 +1950,7 @@ void print_usage(const char* program_name) {
printf(" -v, --version Show version information\n");
printf(" -p, --port PORT Override relay port (first-time startup and existing relay restarts)\n");
printf(" --strict-port Fail if exact port is unavailable (no port increment)\n");
printf(" -a, --admin-pubkey KEY Override admin public key (64-char hex or npub)\n");
printf(" -a, --admin-pubkey KEYS Override admin public key(s) (64-char hex or npub, comma-separated)\n");
printf(" -r, --relay-privkey KEY Override relay private key (64-char hex or nsec)\n");
printf(" --debug-level=N Set debug output level (0-5, default: 0)\n");
printf(" 0=none, 1=errors, 2=warnings, 3=info, 4=debug, 5=trace\n");
@@ -2050,25 +2041,79 @@ int main(int argc, char* argv[]) {
return 1;
}
const char* input_key = argv[i + 1];
char decoded_key[65] = {0}; // Buffer for decoded hex key
const char* input_keys = argv[i + 1];
char decoded_keys[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
// Try to decode the input as either hex or npub format
char* keys_copy = strdup(input_keys);
if (!keys_copy) {
DEBUG_ERROR("Failed to allocate memory for admin pubkey parsing.");
return 1;
}
int key_count = 0;
char* saveptr = NULL;
char* token = strtok_r(keys_copy, ",", &saveptr);
while (token) {
char decoded_key[65] = {0};
unsigned char pubkey_bytes[32];
if (nostr_decode_npub(input_key, pubkey_bytes) == NOSTR_SUCCESS) {
// Convert bytes back to hex string
if (nostr_decode_npub(token, pubkey_bytes) == NOSTR_SUCCESS) {
char* hex_ptr = decoded_key;
for (int j = 0; j < 32; j++) {
sprintf(hex_ptr, "%02x", pubkey_bytes[j]);
hex_ptr += 2;
}
} else {
// Fallback: accept raw 64-char hex pubkey input as well
unsigned char hex_pubkey_bytes[32];
if (strlen(token) == 64 &&
nostr_hex_to_bytes(token, hex_pubkey_bytes, 32) == NOSTR_SUCCESS) {
char* hex_ptr = decoded_key;
for (int j = 0; j < 32; j++) {
sprintf(hex_ptr, "%02x", hex_pubkey_bytes[j]);
hex_ptr += 2;
}
} else {
free(keys_copy);
DEBUG_ERROR("Invalid admin public key format. Must be 64 hex characters or valid npub format.");
print_usage(argv[0]);
return 1;
}
}
strncpy(cli_options.admin_pubkey_override, decoded_key, sizeof(cli_options.admin_pubkey_override) - 1);
size_t current_len = strlen(decoded_keys);
size_t append_len = strlen(decoded_key) + (current_len > 0 ? 1 : 0);
if (current_len + append_len >= sizeof(decoded_keys)) {
free(keys_copy);
DEBUG_ERROR("Too many admin public keys provided (max %d).", MAX_ADMIN_PUBKEYS);
print_usage(argv[0]);
return 1;
}
if (current_len > 0) {
strcat(decoded_keys, ",");
}
strcat(decoded_keys, decoded_key);
key_count++;
if (key_count > MAX_ADMIN_PUBKEYS) {
free(keys_copy);
DEBUG_ERROR("Too many admin public keys provided (max %d).", MAX_ADMIN_PUBKEYS);
print_usage(argv[0]);
return 1;
}
token = strtok_r(NULL, ",", &saveptr);
}
free(keys_copy);
if (key_count == 0) {
DEBUG_ERROR("No valid admin public key provided.");
print_usage(argv[0]);
return 1;
}
strncpy(cli_options.admin_pubkey_override, decoded_keys, sizeof(cli_options.admin_pubkey_override) - 1);
cli_options.admin_pubkey_override[sizeof(cli_options.admin_pubkey_override) - 1] = '\0';
i++; // Skip the key argument
@@ -2229,7 +2274,7 @@ int main(int argc, char* argv[]) {
}
// Run first-time startup sequence (generates keys, sets up database path, but doesn't store private key yet)
char admin_pubkey[65] = {0};
char admin_pubkey[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
char relay_pubkey[65] = {0};
char relay_privkey[65] = {0};
if (first_time_startup_sequence(&cli_options, admin_pubkey, relay_pubkey, relay_privkey) != 0) {
@@ -2436,11 +2481,9 @@ int main(int argc, char* argv[]) {
}
const char* prewarmed_relay_pubkey = get_config_value("relay_pubkey");
const char* prewarmed_admin_pubkey = get_config_value("admin_pubkey");
if (!prewarmed_relay_pubkey || strlen(prewarmed_relay_pubkey) != 64 ||
!prewarmed_admin_pubkey || strlen(prewarmed_admin_pubkey) != 64) {
reload_admin_pubkeys_cache() != 0 || get_admin_pubkey_count() <= 0) {
if (prewarmed_relay_pubkey) free((char*)prewarmed_relay_pubkey);
if (prewarmed_admin_pubkey) free((char*)prewarmed_admin_pubkey);
DEBUG_ERROR("Critical config pre-warm validation failed (relay_pubkey/admin_pubkey missing)");
cleanup_configuration_system();
nostr_cleanup();
@@ -2448,7 +2491,6 @@ int main(int argc, char* argv[]) {
return 1;
}
free((char*)prewarmed_relay_pubkey);
free((char*)prewarmed_admin_pubkey);
// Preload relay private key runtime cache while DB is still available.
// Required for admin command decrypt/encrypt after strict mode sets g_db = NULL.
+3 -3
View File
@@ -13,12 +13,12 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 11
#define CRELAY_VERSION "v2.1.11"
#define CRELAY_VERSION_PATCH 12
#define CRELAY_VERSION "v2.1.12"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"
#define RELAY_DESCRIPTION "High-performance C Nostr relay with SQLite storage"
#define RELAY_DESCRIPTION "High-performance C Nostr relay with PostgreSQL storage"
#define RELAY_CONTACT ""
#define RELAY_SOFTWARE "https://git.laantungir.net/laantungir/c-relay-pg.git"
#define RELAY_VERSION CRELAY_VERSION // Use the same version as the build
+1 -4
View File
@@ -305,14 +305,11 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
// 8. Check if this is a kind 23456 admin event from authorized admin
// This must happen AFTER signature validation but BEFORE auth rules
if (event_kind == 23456) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
if (is_admin_pubkey(event_pubkey)) {
// Valid admin event - bypass remaining validation
free((char*)admin_pubkey);
cJSON_Delete(event);
return NOSTR_SUCCESS;
}
if (admin_pubkey) free((char*)admin_pubkey);
// Not from admin - continue with normal validation
}
+2 -8
View File
@@ -2322,13 +2322,11 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Special case: allow kind 23456 admin events from authorized admin to bypass auth
if (event_kind == 23456 && event_pubkey) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
if (is_admin_pubkey(event_pubkey)) {
bypass_auth = 1;
} else {
DEBUG_INFO("DEBUG: Kind 23456 event but pubkey mismatch or no admin pubkey");
}
if (admin_pubkey) free((char*)admin_pubkey);
}
if (pss && auth_required && !pss->authenticated && !bypass_auth) {
@@ -3558,14 +3556,10 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
const char* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
// Check if sender is admin
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey || strlen(admin_pubkey) == 0 ||
strcmp(sender_pubkey, admin_pubkey) != 0) {
if (admin_pubkey) free((char*)admin_pubkey);
if (!is_admin_pubkey(sender_pubkey)) {
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
return -1;
}
free((char*)admin_pubkey);
// Get relay private key for decryption
char* relay_privkey_hex = get_relay_private_key();
+208
View File
@@ -0,0 +1,208 @@
#!/bin/bash
# Multi-admin integration test for c-relay-pg
# Validates:
# 1) primary admin can add a secondary admin
# 2) secondary admin can execute non-privileged admin command (system_status)
# 3) secondary admin cannot mutate admin list (remove_admin)
# 4) primary admin can remove secondary admin
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[PASS]${NC} $1"
}
log_error() {
echo -e "${RED}[FAIL]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
RELAY_URL="ws://127.0.0.1:8888"
# test key set used by ./make_and_restart_relay.sh -t
PRIMARY_ADMIN_PRIVKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
PRIMARY_ADMIN_PUBKEY="6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3"
RELAY_PUBKEY="4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
# secondary admin keypair generated per test run
SECONDARY_ADMIN_PRIVKEY=""
SECONDARY_ADMIN_PUBKEY=""
# postgres defaults from make_and_restart_relay.sh
PGHOST="localhost"
PGPORT="5432"
PGDATABASE="crelay"
PGUSER="crelay"
PGPASSWORD="crelay"
require_bin() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "Required command not found: $1"
exit 1
fi
}
get_admin_list_from_db() {
PGPASSWORD="$PGPASSWORD" psql \
-h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \
-tAc "SELECT value FROM config WHERE key='admin_pubkey';" 2>/dev/null | tr -d '[:space:]'
}
send_admin_command() {
local signer_privkey="$1"
local command_json="$2"
local encrypted
encrypted=$(nak encrypt "$command_json" --sec "$signer_privkey" --recipient-pubkey "$RELAY_PUBKEY" 2>/dev/null)
if [[ -z "${encrypted:-}" ]]; then
log_error "Failed to encrypt command: $command_json"
return 1
fi
local event_json
event_json=$(nak event --kind 23456 --content "$encrypted" --sec "$signer_privkey" --tag "p=$RELAY_PUBKEY" 2>/dev/null)
if [[ -z "${event_json:-}" ]]; then
log_error "Failed to build admin event for command: $command_json"
return 1
fi
local result
result=$(echo "$event_json" | nak event "$RELAY_URL" 2>&1 || true)
if echo "$result" | grep -Eiq "error|failed|denied|invalid|unauthorized"; then
log_warn "Relay returned non-success for command '$command_json': $result"
return 1
fi
return 0
}
contains_pubkey() {
local csv="$1"
local key="$2"
IFS=',' read -r -a arr <<< "$csv"
for k in "${arr[@]}"; do
[[ "$k" == "$key" ]] && return 0
done
return 1
}
main() {
log_info "Starting multi-admin integration test"
require_bin nak
require_bin psql
require_bin grep
require_bin tr
SECONDARY_ADMIN_PRIVKEY=$(nak key generate 2>/dev/null || true)
if [[ -z "$SECONDARY_ADMIN_PRIVKEY" ]]; then
log_error "Could not generate secondary admin private key"
exit 1
fi
SECONDARY_ADMIN_PUBKEY=$(nak key public "$SECONDARY_ADMIN_PRIVKEY" 2>/dev/null || true)
if [[ -z "$SECONDARY_ADMIN_PUBKEY" ]]; then
log_error "Could not derive secondary admin pubkey from generated private key"
exit 1
fi
log_info "Primary admin pubkey: ${PRIMARY_ADMIN_PUBKEY:0:16}..."
log_info "Secondary admin pubkey: ${SECONDARY_ADMIN_PUBKEY:0:16}..."
local current_admins
current_admins=$(get_admin_list_from_db)
if [[ -z "$current_admins" ]]; then
log_error "Could not read admin_pubkey from database"
exit 1
fi
if ! contains_pubkey "$current_admins" "$PRIMARY_ADMIN_PUBKEY"; then
log_error "Primary admin missing from DB admin list: $current_admins"
exit 1
fi
log_success "Primary admin exists in config table"
# Clean slate: if secondary already present from prior run, remove via primary
if contains_pubkey "$current_admins" "$SECONDARY_ADMIN_PUBKEY"; then
log_info "Secondary admin already present; removing for clean start"
send_admin_command "$PRIMARY_ADMIN_PRIVKEY" "[\"system_command\",\"remove_admin\",\"$SECONDARY_ADMIN_PUBKEY\"]" || true
sleep 2
fi
log_info "Adding secondary admin via primary admin command"
send_admin_command "$PRIMARY_ADMIN_PRIVKEY" "[\"system_command\",\"add_admin\",\"$SECONDARY_ADMIN_PUBKEY\"]"
sleep 2
current_admins=$(get_admin_list_from_db)
if ! contains_pubkey "$current_admins" "$SECONDARY_ADMIN_PUBKEY"; then
log_error "Secondary admin was not added. admin_pubkey='$current_admins'"
exit 1
fi
log_success "Secondary admin was added"
# Verify secondary can execute a standard admin command with measurable side effect
# Use whitelist insertion and verify via DB.
local test_whitelist_pubkey
test_whitelist_pubkey=$(nak key public "$(nak key generate 2>/dev/null)" 2>/dev/null || true)
if [[ -z "$test_whitelist_pubkey" ]]; then
log_error "Failed to generate test pubkey for secondary-admin whitelist command"
exit 1
fi
log_info "Sending whitelist command as secondary admin"
send_admin_command "$SECONDARY_ADMIN_PRIVKEY" "[\"whitelist\",\"pubkey\",\"$test_whitelist_pubkey\"]"
sleep 2
local whitelist_count
whitelist_count=$(PGPASSWORD="$PGPASSWORD" psql \
-h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \
-tAc "SELECT COUNT(*) FROM auth_rules WHERE rule_type='whitelist' AND pattern_type='pubkey' AND pattern_value='$test_whitelist_pubkey' AND active=1;" 2>/dev/null | tr -d '[:space:]')
if [[ "${whitelist_count:-0}" -lt 1 ]]; then
log_error "Secondary admin did not create whitelist rule as expected"
exit 1
fi
log_success "Secondary admin executed whitelist admin command"
# Verify secondary cannot mutate admin list (remove_admin restricted to primary)
log_info "Attempting remove_admin as secondary (should be rejected)"
send_admin_command "$SECONDARY_ADMIN_PRIVKEY" "[\"system_command\",\"remove_admin\",\"$SECONDARY_ADMIN_PUBKEY\"]" || true
sleep 2
current_admins=$(get_admin_list_from_db)
if ! contains_pubkey "$current_admins" "$SECONDARY_ADMIN_PUBKEY"; then
log_error "Secondary admin was able to remove admin (unexpected). admin_pubkey='$current_admins'"
exit 1
fi
log_success "Secondary admin could not remove admin list entry"
# Cleanup: primary removes secondary
log_info "Removing secondary admin via primary admin"
send_admin_command "$PRIMARY_ADMIN_PRIVKEY" "[\"system_command\",\"remove_admin\",\"$SECONDARY_ADMIN_PUBKEY\"]"
sleep 2
current_admins=$(get_admin_list_from_db)
if contains_pubkey "$current_admins" "$SECONDARY_ADMIN_PUBKEY"; then
log_error "Secondary admin still present after primary remove_admin. admin_pubkey='$current_admins'"
exit 1
fi
log_success "Primary admin removed secondary admin"
log_success "Multi-admin integration test passed"
}
main "$@"