Add simplified NIP-46 bunker page with click-to-copy QR codes (v0.41.0)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Add BunkerView with two QR codes: client (bunker://) and signer (nostr+connect://)
- Add click-to-copy functionality on QR codes with visual "Copied!" feedback
- Add CAT requirement warning (only shows when ACL mode is active)
- Remove WireGuard dependencies from bunker page
- Add /api/bunker/info public endpoint for relay URL, ACL mode, CAT status
- Add Cashu token verification for WebSocket connections
- Add kind permission checking for Cashu token scopes
- Add cashuToken field to Listener for connection-level token tracking

Files modified:
- app/handle-bunker.go: New bunker info endpoint (without WireGuard)
- app/handle-event.go: Add Cashu token kind permission check
- app/handle-websocket.go: Extract and verify Cashu token on WS upgrade
- app/listener.go: Add cashuToken field
- app/server.go: Register bunker info endpoint
- app/web/src/BunkerView.svelte: Complete rewrite with QR codes
- app/web/src/api.js: Add getBunkerInfo() function
- pkg/version/version: Bump to v0.41.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 18:36:04 +02:00
parent ea4a54c5e7
commit 1b17acb50c
12 changed files with 516 additions and 526 deletions

83
app/handle-bunker.go Normal file
View File

@@ -0,0 +1,83 @@
package app
import (
"encoding/json"
"net/http"
"strings"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// BunkerInfoResponse is returned by the /api/bunker/info endpoint.
type BunkerInfoResponse struct {
RelayURL string `json:"relay_url"` // WebSocket URL for NIP-46 connections
RelayNpub string `json:"relay_npub"` // Relay's npub
RelayPubkey string `json:"relay_pubkey"` // Relay's hex pubkey
ACLMode string `json:"acl_mode"` // Current ACL mode
CashuEnabled bool `json:"cashu_enabled"` // Whether CAT is required
Available bool `json:"available"` // Whether bunker is available
}
// handleBunkerInfo returns bunker connection information.
// This is a public endpoint that doesn't require authentication.
func (s *Server) handleBunkerInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get relay identity
relaySecret, err := s.DB.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
log.E.F("failed to get relay identity: %v", err)
http.Error(w, "Failed to get relay identity", http.StatusInternalServerError)
return
}
// Derive public key
sign, err := p8k.New()
if chk.E(err) {
http.Error(w, "Failed to create signer", http.StatusInternalServerError)
return
}
if err := sign.InitSec(relaySecret); chk.E(err) {
http.Error(w, "Failed to initialize signer", http.StatusInternalServerError)
return
}
relayPubkey := sign.Pub()
relayPubkeyHex := hex.Enc(relayPubkey)
// Encode as npub
relayNpubBytes, err := bech32encoding.BinToNpub(relayPubkey)
relayNpub := string(relayNpubBytes)
if chk.E(err) {
relayNpub = relayPubkeyHex // Fallback to hex
}
// Build WebSocket URL from service URL
serviceURL := s.ServiceURL(r)
wsURL := strings.Replace(serviceURL, "https://", "wss://", 1)
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
// Check if Cashu is enabled
cashuEnabled := s.CashuIssuer != nil
// Bunker is available when ACL mode is not "none"
available := s.Config.ACLMode != "none"
resp := BunkerInfoResponse{
RelayURL: wsURL,
RelayNpub: relayNpub,
RelayPubkey: relayPubkeyHex,
ACLMode: s.Config.ACLMode,
CashuEnabled: cashuEnabled,
Available: available,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}

View File

@@ -131,6 +131,15 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
return
}
// Check Cashu token kind permissions if a token was provided
if l.cashuToken != nil && !l.cashuToken.IsKindPermitted(int(env.E.Kind)) {
log.W.F("HandleEvent: rejecting event kind %d - not permitted by Cashu token", env.E.Kind)
if err = Ok.Error(l, env, "event kind not permitted by access token"); chk.E(err) {
return
}
return
}
// Handle NIP-43 special events before ACL checks
switch env.E.Kind {
case nip43.KindJoinRequest:

View File

@@ -12,6 +12,7 @@ import (
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/protocol/publish"
"git.mleku.dev/mleku/nostr/utils/units"
)
@@ -55,6 +56,12 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
return
}
whitelist:
// Extract and verify Cashu access token if verifier is configured
var cashuToken *token.Token
if s.CashuVerifier != nil {
cashuToken = s.extractWebSocketToken(r, remote)
}
// Create an independent context for this connection
// This context will be cancelled when the connection closes or server shuts down
ctx, cancel := context.WithCancel(s.Ctx)
@@ -99,6 +106,7 @@ whitelist:
conn: conn,
remote: remote,
req: r,
cashuToken: cashuToken, // Verified Cashu access token (nil if none provided)
startTime: time.Now(),
writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes
writeDone: make(chan struct{}),
@@ -291,3 +299,54 @@ func (s *Server) Pinger(
}
}
}
// extractWebSocketToken extracts and verifies a Cashu access token from a WebSocket upgrade request.
// Checks query param first (for browser WebSocket clients), then headers.
// Returns nil if no token is provided or if token verification fails.
func (s *Server) extractWebSocketToken(r *http.Request, remote string) *token.Token {
// Try query param first (WebSocket clients often can't set custom headers)
tokenStr := r.URL.Query().Get("token")
// Try X-Cashu-Token header
if tokenStr == "" {
tokenStr = r.Header.Get("X-Cashu-Token")
}
// Try Authorization: Cashu scheme
if tokenStr == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Cashu ") {
tokenStr = strings.TrimPrefix(auth, "Cashu ")
}
}
// No token provided - this is fine, connection proceeds without token
if tokenStr == "" {
return nil
}
// Parse the token
tok, err := token.Parse(tokenStr)
if err != nil {
log.W.F("ws %s: invalid Cashu token format: %v", remote, err)
return nil
}
// Verify token - accept both "relay" and "nip46" scopes for WebSocket connections
// NIP-46 connections are also WebSocket-based
ctx := context.Background()
if err := s.CashuVerifier.Verify(ctx, tok, remote); err != nil {
log.W.F("ws %s: Cashu token verification failed: %v", remote, err)
return nil
}
// Check scope - allow "relay" or "nip46"
if tok.Scope != token.ScopeRelay && tok.Scope != token.ScopeNIP46 {
log.W.F("ws %s: Cashu token has invalid scope %q for WebSocket", remote, tok.Scope)
return nil
}
log.D.F("ws %s: verified Cashu token with scope %q, expires %v",
remote, tok.Scope, tok.ExpiresAt())
return tok
}

View File

@@ -13,6 +13,7 @@ import (
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
@@ -30,6 +31,7 @@ type Listener struct {
req *http.Request
challenge atomicutils.Bytes
authedPubkey atomicutils.Bytes
cashuToken *token.Token // Verified Cashu access token for this connection (nil if no token)
startTime time.Time
isBlacklisted bool // Marker to identify blacklisted IPs
blacklistTimeout time.Time // When to timeout blacklisted connections

View File

@@ -356,6 +356,7 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/wireguard/status", s.handleWireGuardStatus)
s.mux.HandleFunc("/api/wireguard/audit", s.handleWireGuardAudit)
s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL)
s.mux.HandleFunc("/api/bunker/info", s.handleBunkerInfo)
// Cashu access token endpoints (NIP-XX)
s.mux.HandleFunc("/cashu/mint", s.handleCashuMint)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import QRCode from "qrcode";
import { getWireGuardConfig, regenerateWireGuard, getBunkerURL, fetchWireGuardStatus, getWireGuardAudit } from "./api.js";
import { getBunkerInfo } from "./api.js";
export let isLoggedIn = false;
export let userPubkey = "";
@@ -11,14 +11,13 @@
const dispatch = createEventDispatcher();
// State
let wgConfig = null;
let bunkerInfo = null;
let wgStatus = null;
let auditData = null;
let isLoading = false;
let error = "";
let wgQrDataUrl = "";
let bunkerQrDataUrl = "";
let clientQrDataUrl = "";
let signerQrDataUrl = "";
let copiedItem = "";
let bunkerSecret = "";
$: canAccess = isLoggedIn && userPubkey && (
currentEffectiveRole === "write" ||
@@ -26,116 +25,79 @@
currentEffectiveRole === "owner"
);
let hasLoadedOnce = false;
// Generate bunker URLs when bunkerInfo and userPubkey are available
$: clientBunkerURL = bunkerInfo && userPubkey ?
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}` : "";
$: signerBunkerURL = bunkerInfo ?
`nostr+connect://${bunkerInfo.relay_url}` : "";
onMount(async () => {
// Always check status first
await checkStatus();
if (canAccess && wgStatus?.available && !hasLoadedOnce) {
hasLoadedOnce = true;
await loadConfig();
}
await loadBunkerInfo();
});
$: if (canAccess && wgStatus?.available && !hasLoadedOnce && !isLoading) {
hasLoadedOnce = true;
loadConfig();
}
async function checkStatus() {
try {
wgStatus = await fetchWireGuardStatus();
} catch (err) {
console.error("Error checking WireGuard status:", err);
wgStatus = { available: false };
}
}
async function loadConfig() {
if (!userSigner || !userPubkey) return;
async function loadBunkerInfo() {
isLoading = true;
error = "";
try {
// Load WireGuard config, bunker URL, and audit data in parallel
const [wgResult, bunkerResult, auditResult] = await Promise.all([
getWireGuardConfig(userSigner, userPubkey),
getBunkerURL(userSigner, userPubkey),
getWireGuardAudit(userSigner, userPubkey).catch(() => null)
]);
bunkerInfo = await getBunkerInfo();
wgConfig = wgResult;
bunkerInfo = bunkerResult;
auditData = auditResult;
// Generate a random secret for secure connection
if (!bunkerSecret) {
bunkerSecret = generateSecret();
}
// Generate QR codes
if (wgConfig?.config_text) {
wgQrDataUrl = await QRCode.toDataURL(wgConfig.config_text, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
if (bunkerInfo?.url) {
bunkerQrDataUrl = await QRCode.toDataURL(bunkerInfo.url, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
await generateQRCodes();
} catch (err) {
console.error("Error loading bunker config:", err);
error = err.message || "Failed to load configuration";
console.error("Error loading bunker info:", err);
error = err.message || "Failed to load bunker information";
} finally {
isLoading = false;
}
}
function formatDate(timestamp) {
if (!timestamp) return "Never";
return new Date(timestamp * 1000).toLocaleString();
function generateSecret() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
}
async function handleRegenerate() {
if (!confirm("Regenerate your WireGuard keys? Your current keys will stop working.")) {
return;
async function regenerateSecret() {
bunkerSecret = generateSecret();
await generateQRCodes();
}
async function generateQRCodes() {
if (clientBunkerURL) {
clientQrDataUrl = await QRCode.toDataURL(clientBunkerURL, {
width: 280,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
isLoading = true;
error = "";
try {
await regenerateWireGuard(userSigner, userPubkey);
// Reload config after regeneration
hasLoadedOnce = false;
await loadConfig();
} catch (err) {
console.error("Error regenerating keys:", err);
error = err.message || "Failed to regenerate keys";
} finally {
isLoading = false;
if (signerBunkerURL) {
signerQrDataUrl = await QRCode.toDataURL(signerBunkerURL, {
width: 280,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
}
// Regenerate QR codes when URLs change
$: if (clientBunkerURL || signerBunkerURL) {
generateQRCodes();
}
function copyToClipboard(text, label) {
navigator.clipboard.writeText(text);
alert(`${label} copied to clipboard!`);
}
function downloadConfig() {
if (!wgConfig?.config_text) return;
const blob = new Blob([wgConfig.config_text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wg-orly.conf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
copiedItem = label;
setTimeout(() => {
copiedItem = "";
}, 2000);
}
function openLoginModal() {
@@ -143,19 +105,19 @@
}
</script>
{#if !wgStatus?.available}
{#if !bunkerInfo?.available}
<div class="bunker-view">
<div class="unavailable-message">
<h3>Remote Signing Not Available</h3>
<p>This relay does not have WireGuard/Bunker enabled, or ACL mode is set to "none".</p>
<p class="hint">Remote signing requires the relay operator to enable WireGuard VPN and use ACL mode "follows" or "managed".</p>
<p>This relay does not have bunker mode enabled, or ACL mode is set to "none".</p>
<p class="hint">Remote signing requires the relay operator to enable ACL mode "follows" or "managed".</p>
</div>
</div>
{:else if canAccess}
<div class="bunker-view">
<div class="header-section">
<h3>Remote Signing (Bunker)</h3>
<button class="refresh-btn" on:click={loadConfig} disabled={isLoading}>
<h3>Remote Signing (NIP-46 Bunker)</h3>
<button class="refresh-btn" on:click={loadBunkerInfo} disabled={isLoading}>
{isLoading ? "Loading..." : "Refresh"}
</button>
</div>
@@ -164,189 +126,118 @@
<div class="error-message">{error}</div>
{/if}
{#if isLoading && !wgConfig}
<div class="loading">Loading configuration...</div>
{:else if wgConfig}
{#if bunkerInfo?.cashu_enabled && bunkerInfo?.acl_mode !== "none"}
<div class="cat-warning">
<strong>CAT Required:</strong> This relay requires Cashu Access Tokens (CAT) for bunker connections.
Your client must support CAT authentication or connections will be rejected.
</div>
{/if}
{#if isLoading && !bunkerInfo}
<div class="loading">Loading bunker information...</div>
{:else if bunkerInfo}
<div class="instructions">
<p><strong>How it works:</strong> Connect to the relay's private VPN, then use Amber to sign events remotely.</p>
<p><strong>How it works:</strong> Both your signing app (Amber) and your client app connect to this relay.
The relay acts as a secure middleman for NIP-46 remote signing.</p>
</div>
<div class="config-sections">
<!-- Step 1: WireGuard -->
<section class="config-section">
<h4>Step 1: Install WireGuard</h4>
<p class="section-desc">Download the WireGuard app for your device:</p>
<div class="qr-sections">
<!-- Client QR Code -->
<section class="qr-section">
<h4>For Client App</h4>
<p class="section-desc">Scan with your Nostr client to request signatures from Amber:</p>
<div class="client-links">
<a href="https://play.google.com/store/apps/details?id=com.wireguard.android" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Android</span>
<span class="client-store">Google Play</span>
</a>
<a href="https://f-droid.org/packages/com.wireguard.android/" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Android</span>
<span class="client-store">F-Droid</span>
</a>
<a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">iOS</span>
<span class="client-store">App Store</span>
</a>
<a href="https://www.wireguard.com/install/" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Desktop</span>
<span class="client-store">Windows/Mac/Linux</span>
</a>
</div>
</section>
<!-- Step 2: WireGuard Config -->
<section class="config-section">
<h4>Step 2: Add VPN Configuration</h4>
<p class="section-desc">Scan this QR code with the WireGuard app:</p>
<div class="qr-container">
{#if wgQrDataUrl}
<img src={wgQrDataUrl} alt="WireGuard Configuration QR Code" class="qr-code" />
<div
class="qr-container clickable"
on:click={() => copyToClipboard(clientBunkerURL, "client")}
on:keypress={(e) => e.key === 'Enter' && copyToClipboard(clientBunkerURL, "client")}
role="button"
tabindex="0"
title="Click to copy bunker URL"
>
{#if clientQrDataUrl}
<img src={clientQrDataUrl} alt="Client Bunker QR Code" class="qr-code" />
<div class="qr-overlay" class:visible={copiedItem === "client"}>
Copied!
</div>
{:else}
<div class="qr-placeholder">Generating QR...</div>
{/if}
</div>
<div class="config-actions">
<button on:click={() => copyToClipboard(wgConfig.config_text, "Config")}>Copy Config</button>
<button on:click={downloadConfig}>Download .conf</button>
<div class="url-display">
<code class="bunker-url">{clientBunkerURL}</code>
</div>
<details class="config-text-details">
<summary>Show raw config</summary>
<pre class="config-text">{wgConfig.config_text}</pre>
</details>
<div class="copy-hint">Click QR code to copy</div>
</section>
<!-- Step 3: Connect VPN -->
<section class="config-section">
<h4>Step 3: Connect to VPN</h4>
<p class="section-desc">After importing the config, toggle the VPN connection ON in the WireGuard app.</p>
<div class="ip-info">
<span class="label">Your VPN IP:</span>
<code>{wgConfig.interface.address}</code>
<!-- Signer QR Code (Amber) -->
<section class="qr-section">
<h4>For Signer (Amber)</h4>
<p class="section-desc">Scan with <a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">Amber</a> to connect as a signer:</p>
<div
class="qr-container clickable"
on:click={() => copyToClipboard(signerBunkerURL, "signer")}
on:keypress={(e) => e.key === 'Enter' && copyToClipboard(signerBunkerURL, "signer")}
role="button"
tabindex="0"
title="Click to copy connection URL"
>
{#if signerQrDataUrl}
<img src={signerQrDataUrl} alt="Signer Connection QR Code" class="qr-code" />
<div class="qr-overlay" class:visible={copiedItem === "signer"}>
Copied!
</div>
{:else}
<div class="qr-placeholder">Generating QR...</div>
{/if}
</div>
</section>
<!-- Step 4: Bunker URL -->
{#if bunkerInfo}
<section class="config-section">
<h4>Step 4: Add Bunker to Amber</h4>
<p class="section-desc">With VPN connected, scan this QR code in <a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">Amber</a>:</p>
<div class="qr-container">
{#if bunkerQrDataUrl}
<img src={bunkerQrDataUrl} alt="Bunker URL QR Code" class="qr-code" />
{:else}
<div class="qr-placeholder">Generating QR...</div>
{/if}
</div>
<div class="bunker-url-container">
<code class="bunker-url">{bunkerInfo.url}</code>
<button on:click={() => copyToClipboard(bunkerInfo.url, "Bunker URL")}>Copy</button>
</div>
<div class="relay-info">
<span class="label">Relay npub:</span>
<code class="npub">{bunkerInfo.relay_npub}</code>
</div>
</section>
{/if}
<!-- Amber links -->
<section class="config-section">
<h4>Get Amber (NIP-46 Signer)</h4>
<p class="section-desc">Amber is an Android app for secure remote signing:</p>
<div class="client-links">
<a href="https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">Google Play</span>
</a>
<a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">GitHub APK</span>
</a>
<div class="url-display">
<code class="bunker-url">{signerBunkerURL}</code>
</div>
<div class="copy-hint">Click QR code to copy</div>
</section>
</div>
<!-- Danger zone -->
<div class="danger-zone">
<h4>Danger Zone</h4>
<p>Regenerate your WireGuard keys if you believe they've been compromised.</p>
<button class="danger-btn" on:click={handleRegenerate} disabled={isLoading}>
Regenerate Keys
</button>
</div>
<!-- Audit Log Section -->
{#if auditData && (auditData.revoked_keys?.length > 0 || auditData.access_logs?.length > 0)}
<div class="audit-section">
<h4>Key History & Access Log</h4>
<p class="audit-desc">Monitor activity on your old WireGuard keys. High access counts might indicate you left something connected or someone copied your credentials.</p>
{#if auditData.revoked_keys?.length > 0}
<div class="audit-subsection">
<h5>Revoked Keys</h5>
<div class="audit-table-container">
<table class="audit-table">
<thead>
<tr>
<th>Client IP</th>
<th>Created</th>
<th>Revoked</th>
<th>Access Count</th>
<th>Last Access</th>
</tr>
</thead>
<tbody>
{#each auditData.revoked_keys as key}
<tr class:warning={key.access_count > 0}>
<td><code>{key.client_ip}</code></td>
<td>{formatDate(key.created_at)}</td>
<td>{formatDate(key.revoked_at)}</td>
<td class:highlight={key.access_count > 0}>{key.access_count}</td>
<td>{formatDate(key.last_access_at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if auditData.access_logs?.length > 0}
<div class="audit-subsection">
<h5>Recent Access Attempts (Obsolete Addresses)</h5>
<div class="audit-table-container">
<table class="audit-table">
<thead>
<tr>
<th>Client IP</th>
<th>Time</th>
<th>Remote Address</th>
</tr>
</thead>
<tbody>
{#each auditData.access_logs as log}
<tr>
<td><code>{log.client_ip}</code></td>
<td>{formatDate(log.timestamp)}</td>
<td><code>{log.remote_addr}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Connection Info -->
<div class="connection-info">
<h4>Connection Details</h4>
<div class="info-row">
<span class="label">Relay:</span>
<code>{bunkerInfo.relay_url}</code>
<button class="copy-btn" on:click={() => copyToClipboard(bunkerInfo.relay_url, "relay")}>
{copiedItem === "relay" ? "Copied!" : "Copy"}
</button>
</div>
{/if}
<div class="info-row">
<span class="label">Your npub:</span>
<code class="npub">{userPubkey}</code>
</div>
<div class="info-row">
<span class="label">Secret:</span>
<code class="secret">{bunkerSecret}</code>
<button class="copy-btn" on:click={regenerateSecret}>Regenerate</button>
</div>
</div>
<!-- Amber links -->
<section class="amber-section">
<h4>Get Amber (NIP-46 Signer)</h4>
<p class="section-desc">Amber is an Android app for secure remote signing:</p>
<div class="client-links">
<a href="https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">Google Play</span>
</a>
<a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">GitHub APK</span>
</a>
</div>
</section>
{/if}
</div>
{:else if isLoggedIn}
@@ -408,6 +299,16 @@
margin-bottom: 1em;
}
.cat-warning {
background-color: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.5);
color: var(--text-color);
padding: 0.75em 1em;
border-radius: 4px;
margin-bottom: 1em;
font-size: 0.95em;
}
.loading {
text-align: center;
padding: 2em;
@@ -427,19 +328,20 @@
color: var(--text-color);
}
.config-sections {
display: flex;
flex-direction: column;
.qr-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5em;
margin-bottom: 1.5em;
}
.config-section {
.qr-section {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
}
.config-section h4 {
.qr-section h4 {
margin: 0 0 0.5em 0;
color: var(--text-color);
}
@@ -455,6 +357,157 @@
color: var(--primary);
}
.qr-container {
display: flex;
justify-content: center;
margin: 1em 0;
position: relative;
}
.qr-container.clickable {
cursor: pointer;
transition: transform 0.1s;
}
.qr-container.clickable:hover {
transform: scale(1.02);
}
.qr-container.clickable:active {
transform: scale(0.98);
}
.qr-code {
border-radius: 8px;
background: white;
padding: 8px;
}
.qr-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.85);
color: #4ade80;
padding: 0.75em 1.5em;
border-radius: 8px;
font-weight: 600;
font-size: 1.1em;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.qr-overlay.visible {
opacity: 1;
}
.qr-placeholder {
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-color);
border-radius: 8px;
color: var(--text-color);
opacity: 0.5;
}
.url-display {
text-align: center;
margin-top: 0.5em;
}
.bunker-url {
font-family: monospace;
font-size: 0.75em;
word-break: break-all;
padding: 0.5em;
background-color: var(--bg-color);
border-radius: 4px;
display: inline-block;
max-width: 100%;
color: var(--text-color);
}
.copy-hint {
text-align: center;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
margin-top: 0.5em;
}
.connection-info {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
margin-bottom: 1.5em;
}
.connection-info h4 {
margin: 0 0 1em 0;
color: var(--text-color);
}
.info-row {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.75em;
flex-wrap: wrap;
}
.info-row:last-child {
margin-bottom: 0;
}
.label {
color: var(--text-color);
opacity: 0.7;
min-width: 80px;
}
code {
font-family: monospace;
padding: 0.25em 0.5em;
background-color: var(--bg-color);
border-radius: 4px;
color: var(--text-color);
word-break: break-all;
}
.npub, .secret {
font-size: 0.85em;
}
.copy-btn {
padding: 0.3em 0.6em;
background-color: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.copy-btn:hover {
background-color: var(--accent-hover-color);
}
.amber-section {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
}
.amber-section h4 {
margin: 0 0 0.5em 0;
color: var(--text-color);
}
.client-links {
display: flex;
flex-wrap: wrap;
@@ -490,165 +543,6 @@
opacity: 0.7;
}
.qr-container {
display: flex;
justify-content: center;
margin: 1em 0;
}
.qr-code {
border-radius: 8px;
background: white;
padding: 8px;
}
.qr-placeholder {
width: 256px;
height: 256px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-color);
border-radius: 8px;
color: var(--text-color);
opacity: 0.5;
}
.config-actions {
display: flex;
justify-content: center;
gap: 0.75em;
margin-top: 1em;
}
.config-actions button {
padding: 0.5em 1em;
background-color: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.config-actions button:hover {
background-color: var(--accent-hover-color);
}
.config-text-details {
margin-top: 1em;
}
.config-text-details summary {
cursor: pointer;
color: var(--text-color);
opacity: 0.8;
font-size: 0.9em;
}
.config-text {
margin-top: 0.5em;
padding: 1em;
background-color: var(--bg-color);
border-radius: 4px;
font-size: 0.85em;
overflow-x: auto;
white-space: pre;
color: var(--text-color);
}
.ip-info, .relay-info {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.5em;
}
.label {
color: var(--text-color);
opacity: 0.7;
}
code {
font-family: monospace;
padding: 0.25em 0.5em;
background-color: var(--bg-color);
border-radius: 4px;
color: var(--text-color);
}
.bunker-url-container {
display: flex;
align-items: center;
gap: 0.5em;
justify-content: center;
flex-wrap: wrap;
}
.bunker-url {
word-break: break-all;
max-width: 400px;
}
.bunker-url-container button {
padding: 0.4em 0.8em;
background-color: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.bunker-url-container button:hover {
background-color: var(--accent-hover-color);
}
.npub {
word-break: break-all;
font-size: 0.85em;
}
.danger-zone {
margin-top: 2em;
padding: 1.25em;
border: 1px solid var(--warning);
border-radius: 8px;
background-color: rgba(255, 100, 100, 0.05);
}
.danger-zone h4 {
margin: 0 0 0.5em 0;
color: var(--warning);
}
.danger-zone p {
margin: 0 0 1em 0;
color: var(--text-color);
opacity: 0.8;
font-size: 0.95em;
}
.danger-btn {
background-color: transparent;
border: 1px solid var(--warning);
color: var(--warning);
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.danger-btn:hover:not(:disabled) {
background-color: var(--warning);
color: var(--text-color);
}
.danger-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.unavailable-message, .access-denied {
text-align: center;
padding: 2em;
@@ -703,83 +597,11 @@
background-color: var(--accent-hover-color);
}
/* Audit section styles */
.audit-section {
margin-top: 2em;
padding: 1.25em;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--card-bg);
}
.audit-section h4 {
margin: 0 0 0.5em 0;
color: var(--text-color);
}
.audit-desc {
margin: 0 0 1em 0;
color: var(--text-color);
opacity: 0.8;
font-size: 0.9em;
}
.audit-subsection {
margin-bottom: 1.5em;
}
.audit-subsection:last-child {
margin-bottom: 0;
}
.audit-subsection h5 {
margin: 0 0 0.5em 0;
color: var(--text-color);
font-size: 0.95em;
}
.audit-table-container {
overflow-x: auto;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.audit-table th,
.audit-table td {
padding: 0.5em 0.75em;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.audit-table th {
background-color: var(--bg-color);
color: var(--text-color);
font-weight: 500;
}
.audit-table td {
color: var(--text-color);
}
.audit-table td code {
font-size: 0.9em;
padding: 0.15em 0.3em;
}
.audit-table tr.warning {
background-color: rgba(255, 100, 100, 0.1);
}
.audit-table td.highlight {
color: var(--warning);
font-weight: 600;
}
@media (max-width: 600px) {
.qr-sections {
grid-template-columns: 1fr;
}
.client-links {
flex-direction: column;
}
@@ -789,16 +611,12 @@
}
.bunker-url {
font-size: 0.75em;
font-size: 0.65em;
}
.audit-table {
font-size: 0.75em;
}
.audit-table th,
.audit-table td {
padding: 0.4em 0.5em;
.info-row {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -486,3 +486,17 @@ export async function getWireGuardAudit(signer, pubkey) {
}
return await response.json();
}
/**
* Get Bunker connection info (public endpoint)
* @returns {Promise<object>} Bunker info including relay URL, ACL mode, and CAT status
*/
export async function getBunkerInfo() {
const url = `${window.location.origin}/api/bunker/info`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.text();
throw new Error(error || `Failed to get bunker info: ${response.statusText}`);
}
return await response.json();
}