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:
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
curl -H "Accept: application/nostr+json" http://localhost:8888/
|
curl -H "Accept: application/nostr+json" https://relay.laantungir.net
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -909,7 +909,7 @@ static const config_definition_t known_configs[] = {
|
|||||||
// Relay keys
|
// Relay keys
|
||||||
{"relay_pubkey", "string", 0, 65},
|
{"relay_pubkey", "string", 0, 65},
|
||||||
{"relay_privkey", "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
|
||||||
{"kind_1_status_posts_hours", "int", 0, 8760},
|
{"kind_1_status_posts_hours", "int", 0, 8760},
|
||||||
|
|||||||
+443
-91
@@ -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)
|
// Runtime cache for relay private key to support strict mode (g_db = NULL)
|
||||||
static char g_cached_relay_privkey[65] = {0};
|
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) {
|
static int config_open_temp_db_connection(void** out_db) {
|
||||||
return db_open_worker_connection(db_get_database_path(), 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
|
// 1. Generate or use provided admin keypair
|
||||||
unsigned char admin_privkey_bytes[32];
|
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
|
int generated_admin_key = 0; // Track if we generated a new admin key
|
||||||
|
|
||||||
if (cli_options && strlen(cli_options->admin_pubkey_override) == 64) {
|
if (cli_options && cli_options->admin_pubkey_override[0] != '\0') {
|
||||||
// Use provided admin public key directly - skip private key generation entirely
|
// Use provided admin public key list directly - skip private key generation entirely
|
||||||
strncpy(admin_pubkey, cli_options->admin_pubkey_override, sizeof(admin_pubkey) - 1);
|
strncpy(admin_pubkey, cli_options->admin_pubkey_override, sizeof(admin_pubkey) - 1);
|
||||||
admin_pubkey[sizeof(admin_pubkey) - 1] = '\0';
|
admin_pubkey[sizeof(admin_pubkey) - 1] = '\0';
|
||||||
|
|
||||||
// Validate the public key format (must be 64 hex characters)
|
// Validate comma-separated public keys (each must be 64 hex chars)
|
||||||
for (int i = 0; i < 64; i++) {
|
char* keys_copy = strdup(admin_pubkey);
|
||||||
char c = admin_pubkey[i];
|
if (!keys_copy) {
|
||||||
if (!((c >= '0' && c <= '9') ||
|
DEBUG_ERROR("Failed to allocate memory for admin pubkey validation");
|
||||||
(c >= 'a' && c <= 'f') ||
|
return -1;
|
||||||
(c >= 'A' && c <= 'F'))) {
|
|
||||||
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
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
// 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_bytes, 0, 32); // Zero out for security
|
||||||
memset(admin_privkey, 0, sizeof(admin_privkey)); // Zero out the hex string
|
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
|
// Copy keys to output parameters
|
||||||
if (admin_pubkey_out) {
|
if (admin_pubkey_out) {
|
||||||
strncpy(admin_pubkey_out, admin_pubkey, 64);
|
strncpy(admin_pubkey_out, admin_pubkey, ADMIN_PUBKEY_LIST_MAX_LENGTH - 1);
|
||||||
admin_pubkey_out[64] = '\0';
|
admin_pubkey_out[ADMIN_PUBKEY_LIST_MAX_LENGTH - 1] = '\0';
|
||||||
}
|
}
|
||||||
if (relay_pubkey_out) {
|
if (relay_pubkey_out) {
|
||||||
strncpy(relay_pubkey_out, relay_pubkey, 64);
|
strncpy(relay_pubkey_out, relay_pubkey, 64);
|
||||||
@@ -1438,12 +1476,9 @@ int process_configuration_event(const cJSON* event) {
|
|||||||
|
|
||||||
// Verify it's from the admin
|
// Verify it's from the admin
|
||||||
const char* event_pubkey = cJSON_GetStringValue(pubkey_obj);
|
const char* event_pubkey = cJSON_GetStringValue(pubkey_obj);
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (!is_admin_pubkey(event_pubkey)) {
|
||||||
if (admin_pubkey && strlen(admin_pubkey) > 0) {
|
DEBUG_ERROR("Configuration event not from authorized admin");
|
||||||
if (strcmp(event_pubkey, admin_pubkey) != 0) {
|
return -1;
|
||||||
DEBUG_ERROR("Configuration event not from authorized admin");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comprehensive event validation using nostr_core_lib
|
// 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
|
return 0; // WoT disabled, nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin pubkey from config
|
// Get primary admin pubkey from config
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
const char* admin_pubkey = get_primary_admin_pubkey();
|
||||||
if (!admin_pubkey) {
|
if (!admin_pubkey) {
|
||||||
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
|
DEBUG_ERROR("WoT sync: primary admin pubkey not configured");
|
||||||
return -1;
|
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* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
|
||||||
|
|
||||||
if (!admin_pubkey) {
|
if (!is_admin_pubkey(sender_pubkey)) {
|
||||||
DEBUG_ERROR("error: admin pubkey not available for authorization check");
|
DEBUG_ERROR("invalid: unauthorized admin event - sender pubkey does not match any configured admin pubkey");
|
||||||
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");
|
|
||||||
printf(" Sender pubkey: %.16s...\n", sender_pubkey);
|
printf(" Sender pubkey: %.16s...\n", sender_pubkey);
|
||||||
printf(" Admin pubkey: %.16s...\n", admin_pubkey);
|
|
||||||
snprintf(error_message, error_size, "invalid: unauthorized admin event");
|
snprintf(error_message, error_size, "invalid: unauthorized admin event");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -3333,6 +3360,126 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
|||||||
snprintf(error_message, error_size, "database not available");
|
snprintf(error_message, error_size, "database not available");
|
||||||
return -1;
|
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) {
|
if (strcmp(command, "clear_all_auth_rules") == 0) {
|
||||||
// Count existing rules first
|
// Count existing rules first
|
||||||
@@ -3356,18 +3503,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
|||||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
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
|
// 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);
|
cJSON_Delete(response);
|
||||||
return 0;
|
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));
|
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
|
// 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);
|
cJSON_Delete(response);
|
||||||
return 0;
|
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("Database: %s\n", db_is_available() ? "Connected" : "Not available");
|
||||||
printf("Cache status: Not used (direct database queries)\n");
|
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
|
// 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);
|
cJSON_Delete(response);
|
||||||
return 0;
|
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("Admin requested system restart\n");
|
||||||
printf("Sending acknowledgment and initiating shutdown...\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
|
// 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
|
// Trigger graceful shutdown by setting the global shutdown flag
|
||||||
g_shutdown_flag = 1;
|
g_shutdown_flag = 1;
|
||||||
|
|
||||||
@@ -3575,28 +3682,41 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
else if (strcmp(command, "wot_sync") == 0) {
|
else if (strcmp(command, "wot_sync") == 0) {
|
||||||
// Get admin pubkey from event
|
// Get sender pubkey from event
|
||||||
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
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");
|
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
|
||||||
return -1;
|
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
|
// Check if WoT is enabled
|
||||||
int wot_enabled = get_config_int("wot_enabled", 0);
|
int wot_enabled = get_config_int("wot_enabled", 0);
|
||||||
if (wot_enabled <= 0) {
|
if (wot_enabled <= 0) {
|
||||||
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
|
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger WoT sync
|
// Trigger WoT sync
|
||||||
int sync_result = wot_sync_from_admin_kind3();
|
int sync_result = wot_sync_from_admin_kind3();
|
||||||
|
|
||||||
// Get updated whitelist count
|
// Get updated whitelist count
|
||||||
int wot_whitelist_count = db_count_wot_whitelist_rules();
|
int wot_whitelist_count = db_count_wot_whitelist_rules();
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
cJSON* response = cJSON_CreateObject();
|
cJSON* response = cJSON_CreateObject();
|
||||||
cJSON_AddStringToObject(response, "command", "wot_sync");
|
cJSON_AddStringToObject(response, "command", "wot_sync");
|
||||||
@@ -3604,17 +3724,17 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
|||||||
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
|
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
|
||||||
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
|
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
|
||||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||||
|
|
||||||
if (sync_result != 0) {
|
if (sync_result != 0) {
|
||||||
cJSON_AddStringToObject(response, "message", "WoT sync failed");
|
cJSON_AddStringToObject(response, "message", "WoT sync failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send response as signed kind 23457 event
|
// 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);
|
cJSON_Delete(response);
|
||||||
return sync_result;
|
return sync_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
cJSON_Delete(response);
|
cJSON_Delete(response);
|
||||||
snprintf(error_message, error_size, "failed to send WoT sync response");
|
snprintf(error_message, error_size, "failed to send WoT sync response");
|
||||||
return -1;
|
return -1;
|
||||||
@@ -4648,6 +4768,12 @@ int reload_config_from_table(void) {
|
|||||||
pthread_mutex_unlock(&g_config_cache_mutex);
|
pthread_mutex_unlock(&g_config_cache_mutex);
|
||||||
|
|
||||||
cJSON_Delete(rows);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4696,8 +4822,234 @@ const char* get_config_value_from_table(const char* key) {
|
|||||||
return db_value;
|
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) {
|
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) {
|
const char* get_relay_pubkey_cached(void) {
|
||||||
|
|||||||
+12
-1
@@ -15,6 +15,9 @@ struct lws;
|
|||||||
#define RELAY_URL_MAX_LENGTH 512
|
#define RELAY_URL_MAX_LENGTH 512
|
||||||
#define RELAY_PUBKEY_MAX_LENGTH 65
|
#define RELAY_PUBKEY_MAX_LENGTH 65
|
||||||
#define RELAY_CONTACT_MAX_LENGTH 256
|
#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 SUBSCRIPTION_ID_MAX_LENGTH 64
|
||||||
#define CLIENT_IP_MAX_LENGTH 46
|
#define CLIENT_IP_MAX_LENGTH 46
|
||||||
#define MAX_SUBSCRIPTIONS_PER_CLIENT 25
|
#define MAX_SUBSCRIPTIONS_PER_CLIENT 25
|
||||||
@@ -29,7 +32,7 @@ extern char g_database_path[512];
|
|||||||
// Command line options structure for first-time startup
|
// Command line options structure for first-time startup
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int port_override; // -1 = not set, >0 = port value
|
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
|
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 strict_port; // 0 = allow port increment, 1 = fail if exact port unavailable
|
||||||
int debug_level; // 0-5, default 0 (no debug output)
|
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);
|
void invalidate_config_cache(void);
|
||||||
int reload_config_from_table(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
|
// Hybrid config access functions
|
||||||
const char* get_config_value_hybrid(const char* key);
|
const char* get_config_value_hybrid(const char* key);
|
||||||
int is_config_table_ready(void);
|
int is_config_table_ready(void);
|
||||||
|
|||||||
+1
-2
@@ -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);
|
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
|
||||||
|
|
||||||
// Check if sender is admin
|
// Check if sender is admin
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
int is_admin = is_admin_pubkey(sender_pubkey);
|
||||||
int is_admin = admin_pubkey && strlen(admin_pubkey) > 0 && strcmp(sender_pubkey, admin_pubkey) == 0;
|
|
||||||
|
|
||||||
DEBUG_INFO("DM_ADMIN: Sender pubkey: %.16s... (admin: %s)", sender_pubkey, is_admin ? "YES" : "NO");
|
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
+75
-33
@@ -1172,13 +1172,11 @@ void store_event_post_actions(cJSON* event) {
|
|||||||
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
|
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
|
||||||
int wot_level = get_config_int("wot_enabled", 0);
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
if (wot_level > 0) {
|
if (wot_level > 0) {
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (is_admin_pubkey(cJSON_GetStringValue(pubkey_obj))) {
|
||||||
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
|
|
||||||
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
|
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
|
||||||
extern int wot_sync_from_admin_kind3(void);
|
extern int wot_sync_from_admin_kind3(void);
|
||||||
wot_sync_from_admin_kind3();
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin pubkey from configuration
|
// Verify sender is one of configured admins
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (get_admin_pubkey_count() <= 0) {
|
||||||
if (!admin_pubkey || strlen(admin_pubkey) == 0) {
|
|
||||||
DEBUG_WARN("Unauthorized admin event attempt: no admin pubkey configured");
|
DEBUG_WARN("Unauthorized admin event attempt: no admin pubkey configured");
|
||||||
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: no admin configured");
|
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: no admin configured");
|
||||||
if (admin_pubkey) free((char*)admin_pubkey);
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare pubkeys
|
if (!is_admin_pubkey(pubkey_json->valuestring)) {
|
||||||
if (strcmp(pubkey_json->valuestring, admin_pubkey) != 0) {
|
|
||||||
DEBUG_WARN("Unauthorized admin event attempt: pubkey mismatch");
|
DEBUG_WARN("Unauthorized admin event attempt: pubkey mismatch");
|
||||||
char warning_msg[256];
|
char warning_msg[256];
|
||||||
snprintf(warning_msg, sizeof(warning_msg),
|
snprintf(warning_msg, sizeof(warning_msg),
|
||||||
"Unauthorized admin event attempt from pubkey: %.32s...", pubkey_json->valuestring);
|
"Unauthorized admin event attempt from pubkey: %.32s...", pubkey_json->valuestring);
|
||||||
DEBUG_WARN(warning_msg);
|
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");
|
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: invalid admin pubkey");
|
||||||
free((char*)admin_pubkey);
|
|
||||||
return -1;
|
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) {
|
if (nostr_verify_event_signature(event) != 0) {
|
||||||
DEBUG_WARN("Unauthorized admin event attempt: invalid signature");
|
DEBUG_WARN("Unauthorized admin event attempt: invalid signature");
|
||||||
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: signature verification failed");
|
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: signature verification failed");
|
||||||
free((char*)admin_pubkey);
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
free((char*)admin_pubkey);
|
|
||||||
|
|
||||||
// All checks passed - authorized admin event
|
// All checks passed - authorized admin event
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1959,7 +1950,7 @@ void print_usage(const char* program_name) {
|
|||||||
printf(" -v, --version Show version information\n");
|
printf(" -v, --version Show version information\n");
|
||||||
printf(" -p, --port PORT Override relay port (first-time startup and existing relay restarts)\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(" --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(" -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(" --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");
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* input_key = argv[i + 1];
|
const char* input_keys = argv[i + 1];
|
||||||
char decoded_key[65] = {0}; // Buffer for decoded hex key
|
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);
|
||||||
unsigned char pubkey_bytes[32];
|
if (!keys_copy) {
|
||||||
if (nostr_decode_npub(input_key, pubkey_bytes) == NOSTR_SUCCESS) {
|
DEBUG_ERROR("Failed to allocate memory for admin pubkey parsing.");
|
||||||
// Convert bytes back to hex string
|
return 1;
|
||||||
char* hex_ptr = decoded_key;
|
}
|
||||||
for (int j = 0; j < 32; j++) {
|
|
||||||
sprintf(hex_ptr, "%02x", pubkey_bytes[j]);
|
int key_count = 0;
|
||||||
hex_ptr += 2;
|
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(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
DEBUG_ERROR("Invalid admin public key format. Must be 64 hex characters or valid npub format.");
|
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]);
|
print_usage(argv[0]);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
strncpy(cli_options.admin_pubkey_override, decoded_key, sizeof(cli_options.admin_pubkey_override) - 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';
|
cli_options.admin_pubkey_override[sizeof(cli_options.admin_pubkey_override) - 1] = '\0';
|
||||||
i++; // Skip the key argument
|
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)
|
// 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_pubkey[65] = {0};
|
||||||
char relay_privkey[65] = {0};
|
char relay_privkey[65] = {0};
|
||||||
if (first_time_startup_sequence(&cli_options, admin_pubkey, relay_pubkey, relay_privkey) != 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_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 ||
|
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_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)");
|
DEBUG_ERROR("Critical config pre-warm validation failed (relay_pubkey/admin_pubkey missing)");
|
||||||
cleanup_configuration_system();
|
cleanup_configuration_system();
|
||||||
nostr_cleanup();
|
nostr_cleanup();
|
||||||
@@ -2448,7 +2491,6 @@ int main(int argc, char* argv[]) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
free((char*)prewarmed_relay_pubkey);
|
free((char*)prewarmed_relay_pubkey);
|
||||||
free((char*)prewarmed_admin_pubkey);
|
|
||||||
|
|
||||||
// Preload relay private key runtime cache while DB is still available.
|
// Preload relay private key runtime cache while DB is still available.
|
||||||
// Required for admin command decrypt/encrypt after strict mode sets g_db = NULL.
|
// Required for admin command decrypt/encrypt after strict mode sets g_db = NULL.
|
||||||
|
|||||||
+3
-3
@@ -13,12 +13,12 @@
|
|||||||
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||||
#define CRELAY_VERSION_MAJOR 2
|
#define CRELAY_VERSION_MAJOR 2
|
||||||
#define CRELAY_VERSION_MINOR 1
|
#define CRELAY_VERSION_MINOR 1
|
||||||
#define CRELAY_VERSION_PATCH 11
|
#define CRELAY_VERSION_PATCH 12
|
||||||
#define CRELAY_VERSION "v2.1.11"
|
#define CRELAY_VERSION "v2.1.12"
|
||||||
|
|
||||||
// Relay metadata (authoritative source for NIP-11 information)
|
// Relay metadata (authoritative source for NIP-11 information)
|
||||||
#define RELAY_NAME "C-Relay-PG"
|
#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_CONTACT ""
|
||||||
#define RELAY_SOFTWARE "https://git.laantungir.net/laantungir/c-relay-pg.git"
|
#define RELAY_SOFTWARE "https://git.laantungir.net/laantungir/c-relay-pg.git"
|
||||||
#define RELAY_VERSION CRELAY_VERSION // Use the same version as the build
|
#define RELAY_VERSION CRELAY_VERSION // Use the same version as the build
|
||||||
|
|||||||
@@ -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
|
// 8. Check if this is a kind 23456 admin event from authorized admin
|
||||||
// This must happen AFTER signature validation but BEFORE auth rules
|
// This must happen AFTER signature validation but BEFORE auth rules
|
||||||
if (event_kind == 23456) {
|
if (event_kind == 23456) {
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (is_admin_pubkey(event_pubkey)) {
|
||||||
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
|
|
||||||
// Valid admin event - bypass remaining validation
|
// Valid admin event - bypass remaining validation
|
||||||
free((char*)admin_pubkey);
|
|
||||||
cJSON_Delete(event);
|
cJSON_Delete(event);
|
||||||
return NOSTR_SUCCESS;
|
return NOSTR_SUCCESS;
|
||||||
}
|
}
|
||||||
if (admin_pubkey) free((char*)admin_pubkey);
|
|
||||||
// Not from admin - continue with normal validation
|
// Not from admin - continue with normal validation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -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
|
// Special case: allow kind 23456 admin events from authorized admin to bypass auth
|
||||||
if (event_kind == 23456 && event_pubkey) {
|
if (event_kind == 23456 && event_pubkey) {
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (is_admin_pubkey(event_pubkey)) {
|
||||||
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
|
|
||||||
bypass_auth = 1;
|
bypass_auth = 1;
|
||||||
} else {
|
} else {
|
||||||
DEBUG_INFO("DEBUG: Kind 23456 event but pubkey mismatch or no admin pubkey");
|
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) {
|
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);
|
const char* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
|
||||||
|
|
||||||
// Check if sender is admin
|
// Check if sender is admin
|
||||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
if (!is_admin_pubkey(sender_pubkey)) {
|
||||||
if (!admin_pubkey || strlen(admin_pubkey) == 0 ||
|
|
||||||
strcmp(sender_pubkey, admin_pubkey) != 0) {
|
|
||||||
if (admin_pubkey) free((char*)admin_pubkey);
|
|
||||||
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
|
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
free((char*)admin_pubkey);
|
|
||||||
|
|
||||||
// Get relay private key for decryption
|
// Get relay private key for decryption
|
||||||
char* relay_privkey_hex = get_relay_private_key();
|
char* relay_privkey_hex = get_relay_private_key();
|
||||||
|
|||||||
Executable
+208
@@ -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 "$@"
|
||||||
Reference in New Issue
Block a user