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

View File

@@ -2,7 +2,11 @@
"permissions": { "permissions": {
"allow": [], "allow": [],
"deny": [], "deny": [],
"ask": [] "ask": [],
"additionalDirectories": [
"/home/mleku/smesh",
"/home/mleku/Tourmaline"
]
}, },
"outputStyle": "Default", "outputStyle": "Default",
"MAX_THINKING_TOKENS": "8000" "MAX_THINKING_TOKENS": "8000"

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 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 // Handle NIP-43 special events before ACL checks
switch env.E.Kind { switch env.E.Kind {
case nip43.KindJoinRequest: case nip43.KindJoinRequest:

View File

@@ -12,6 +12,7 @@ import (
"lol.mleku.dev/log" "lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope" "git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/hex" "git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/protocol/publish" "next.orly.dev/pkg/protocol/publish"
"git.mleku.dev/mleku/nostr/utils/units" "git.mleku.dev/mleku/nostr/utils/units"
) )
@@ -55,6 +56,12 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
return return
} }
whitelist: 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 // Create an independent context for this connection
// This context will be cancelled when the connection closes or server shuts down // This context will be cancelled when the connection closes or server shuts down
ctx, cancel := context.WithCancel(s.Ctx) ctx, cancel := context.WithCancel(s.Ctx)
@@ -99,6 +106,7 @@ whitelist:
conn: conn, conn: conn,
remote: remote, remote: remote,
req: r, req: r,
cashuToken: cashuToken, // Verified Cashu access token (nil if none provided)
startTime: time.Now(), startTime: time.Now(),
writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes
writeDone: make(chan struct{}), 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/errorf"
"lol.mleku.dev/log" "lol.mleku.dev/log"
"next.orly.dev/pkg/acl" "next.orly.dev/pkg/acl"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/database" "next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/encoders/event" "git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter" "git.mleku.dev/mleku/nostr/encoders/filter"
@@ -30,6 +31,7 @@ type Listener struct {
req *http.Request req *http.Request
challenge atomicutils.Bytes challenge atomicutils.Bytes
authedPubkey atomicutils.Bytes authedPubkey atomicutils.Bytes
cashuToken *token.Token // Verified Cashu access token for this connection (nil if no token)
startTime time.Time startTime time.Time
isBlacklisted bool // Marker to identify blacklisted IPs isBlacklisted bool // Marker to identify blacklisted IPs
blacklistTimeout time.Time // When to timeout blacklisted connections 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/status", s.handleWireGuardStatus)
s.mux.HandleFunc("/api/wireguard/audit", s.handleWireGuardAudit) s.mux.HandleFunc("/api/wireguard/audit", s.handleWireGuardAudit)
s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL) s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL)
s.mux.HandleFunc("/api/bunker/info", s.handleBunkerInfo)
// Cashu access token endpoints (NIP-XX) // Cashu access token endpoints (NIP-XX)
s.mux.HandleFunc("/cashu/mint", s.handleCashuMint) 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> <script>
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { getWireGuardConfig, regenerateWireGuard, getBunkerURL, fetchWireGuardStatus, getWireGuardAudit } from "./api.js"; import { getBunkerInfo } from "./api.js";
export let isLoggedIn = false; export let isLoggedIn = false;
export let userPubkey = ""; export let userPubkey = "";
@@ -11,14 +11,13 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// State // State
let wgConfig = null;
let bunkerInfo = null; let bunkerInfo = null;
let wgStatus = null;
let auditData = null;
let isLoading = false; let isLoading = false;
let error = ""; let error = "";
let wgQrDataUrl = ""; let clientQrDataUrl = "";
let bunkerQrDataUrl = ""; let signerQrDataUrl = "";
let copiedItem = "";
let bunkerSecret = "";
$: canAccess = isLoggedIn && userPubkey && ( $: canAccess = isLoggedIn && userPubkey && (
currentEffectiveRole === "write" || currentEffectiveRole === "write" ||
@@ -26,116 +25,79 @@
currentEffectiveRole === "owner" 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 () => { onMount(async () => {
// Always check status first await loadBunkerInfo();
await checkStatus();
if (canAccess && wgStatus?.available && !hasLoadedOnce) {
hasLoadedOnce = true;
await loadConfig();
}
}); });
$: if (canAccess && wgStatus?.available && !hasLoadedOnce && !isLoading) { async function loadBunkerInfo() {
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;
isLoading = true; isLoading = true;
error = ""; error = "";
try { try {
// Load WireGuard config, bunker URL, and audit data in parallel bunkerInfo = await getBunkerInfo();
const [wgResult, bunkerResult, auditResult] = await Promise.all([
getWireGuardConfig(userSigner, userPubkey),
getBunkerURL(userSigner, userPubkey),
getWireGuardAudit(userSigner, userPubkey).catch(() => null)
]);
wgConfig = wgResult; // Generate a random secret for secure connection
bunkerInfo = bunkerResult; if (!bunkerSecret) {
auditData = auditResult; bunkerSecret = generateSecret();
}
// Generate QR codes // Generate QR codes
if (wgConfig?.config_text) { await generateQRCodes();
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" }
});
}
} catch (err) { } catch (err) {
console.error("Error loading bunker config:", err); console.error("Error loading bunker info:", err);
error = err.message || "Failed to load configuration"; error = err.message || "Failed to load bunker information";
} finally { } finally {
isLoading = false; isLoading = false;
} }
} }
function formatDate(timestamp) { function generateSecret() {
if (!timestamp) return "Never"; const array = new Uint8Array(16);
return new Date(timestamp * 1000).toLocaleString(); crypto.getRandomValues(array);
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
} }
async function handleRegenerate() { async function regenerateSecret() {
if (!confirm("Regenerate your WireGuard keys? Your current keys will stop working.")) { bunkerSecret = generateSecret();
return; await generateQRCodes();
} }
isLoading = true; async function generateQRCodes() {
error = ""; if (clientBunkerURL) {
clientQrDataUrl = await QRCode.toDataURL(clientBunkerURL, {
try { width: 280,
await regenerateWireGuard(userSigner, userPubkey); margin: 2,
// Reload config after regeneration color: { dark: "#000000", light: "#ffffff" }
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) { function copyToClipboard(text, label) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
alert(`${label} copied to clipboard!`); copiedItem = label;
} setTimeout(() => {
copiedItem = "";
function downloadConfig() { }, 2000);
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);
} }
function openLoginModal() { function openLoginModal() {
@@ -143,19 +105,19 @@
} }
</script> </script>
{#if !wgStatus?.available} {#if !bunkerInfo?.available}
<div class="bunker-view"> <div class="bunker-view">
<div class="unavailable-message"> <div class="unavailable-message">
<h3>Remote Signing Not Available</h3> <h3>Remote Signing Not Available</h3>
<p>This relay does not have WireGuard/Bunker enabled, or ACL mode is set to "none".</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 WireGuard VPN and use ACL mode "follows" or "managed".</p> <p class="hint">Remote signing requires the relay operator to enable ACL mode "follows" or "managed".</p>
</div> </div>
</div> </div>
{:else if canAccess} {:else if canAccess}
<div class="bunker-view"> <div class="bunker-view">
<div class="header-section"> <div class="header-section">
<h3>Remote Signing (Bunker)</h3> <h3>Remote Signing (NIP-46 Bunker)</h3>
<button class="refresh-btn" on:click={loadConfig} disabled={isLoading}> <button class="refresh-btn" on:click={loadBunkerInfo} disabled={isLoading}>
{isLoading ? "Loading..." : "Refresh"} {isLoading ? "Loading..." : "Refresh"}
</button> </button>
</div> </div>
@@ -164,101 +126,104 @@
<div class="error-message">{error}</div> <div class="error-message">{error}</div>
{/if} {/if}
{#if isLoading && !wgConfig} {#if bunkerInfo?.cashu_enabled && bunkerInfo?.acl_mode !== "none"}
<div class="loading">Loading configuration...</div> <div class="cat-warning">
{:else if wgConfig} <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"> <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>
<div class="config-sections"> <div class="qr-sections">
<!-- Step 1: WireGuard --> <!-- Client QR Code -->
<section class="config-section"> <section class="qr-section">
<h4>Step 1: Install WireGuard</h4> <h4>For Client App</h4>
<p class="section-desc">Download the WireGuard app for your device:</p> <p class="section-desc">Scan with your Nostr client to request signatures from Amber:</p>
<div class="client-links"> <div
<a href="https://play.google.com/store/apps/details?id=com.wireguard.android" target="_blank" rel="noopener noreferrer" class="client-link"> class="qr-container clickable"
<span class="client-icon">Android</span> on:click={() => copyToClipboard(clientBunkerURL, "client")}
<span class="client-store">Google Play</span> on:keypress={(e) => e.key === 'Enter' && copyToClipboard(clientBunkerURL, "client")}
</a> role="button"
<a href="https://f-droid.org/packages/com.wireguard.android/" target="_blank" rel="noopener noreferrer" class="client-link"> tabindex="0"
<span class="client-icon">Android</span> title="Click to copy bunker URL"
<span class="client-store">F-Droid</span> >
</a> {#if clientQrDataUrl}
<a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener noreferrer" class="client-link"> <img src={clientQrDataUrl} alt="Client Bunker QR Code" class="qr-code" />
<span class="client-icon">iOS</span> <div class="qr-overlay" class:visible={copiedItem === "client"}>
<span class="client-store">App Store</span> Copied!
</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> </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" />
{:else} {:else}
<div class="qr-placeholder">Generating QR...</div> <div class="qr-placeholder">Generating QR...</div>
{/if} {/if}
</div> </div>
<div class="config-actions"> <div class="url-display">
<button on:click={() => copyToClipboard(wgConfig.config_text, "Config")}>Copy Config</button> <code class="bunker-url">{clientBunkerURL}</code>
<button on:click={downloadConfig}>Download .conf</button>
</div> </div>
<div class="copy-hint">Click QR code to copy</div>
<details class="config-text-details">
<summary>Show raw config</summary>
<pre class="config-text">{wgConfig.config_text}</pre>
</details>
</section> </section>
<!-- Step 3: Connect VPN --> <!-- Signer QR Code (Amber) -->
<section class="config-section"> <section class="qr-section">
<h4>Step 3: Connect to VPN</h4> <h4>For Signer (Amber)</h4>
<p class="section-desc">After importing the config, toggle the VPN connection ON in the WireGuard app.</p> <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="ip-info">
<span class="label">Your VPN IP:</span> <div
<code>{wgConfig.interface.address}</code> 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> </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} {:else}
<div class="qr-placeholder">Generating QR...</div> <div class="qr-placeholder">Generating QR...</div>
{/if} {/if}
</div> </div>
<div class="bunker-url-container"> <div class="url-display">
<code class="bunker-url">{bunkerInfo.url}</code> <code class="bunker-url">{signerBunkerURL}</code>
<button on:click={() => copyToClipboard(bunkerInfo.url, "Bunker URL")}>Copy</button> </div>
<div class="copy-hint">Click QR code to copy</div>
</section>
</div> </div>
<div class="relay-info"> <!-- Connection Info -->
<span class="label">Relay npub:</span> <div class="connection-info">
<code class="npub">{bunkerInfo.relay_npub}</code> <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>
<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> </div>
</section>
{/if}
<!-- Amber links --> <!-- Amber links -->
<section class="config-section"> <section class="amber-section">
<h4>Get Amber (NIP-46 Signer)</h4> <h4>Get Amber (NIP-46 Signer)</h4>
<p class="section-desc">Amber is an Android app for secure remote signing:</p> <p class="section-desc">Amber is an Android app for secure remote signing:</p>
@@ -273,80 +238,6 @@
</a> </a>
</div> </div>
</section> </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}
</div>
{/if}
{/if} {/if}
</div> </div>
{:else if isLoggedIn} {:else if isLoggedIn}
@@ -408,6 +299,16 @@
margin-bottom: 1em; 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 { .loading {
text-align: center; text-align: center;
padding: 2em; padding: 2em;
@@ -427,19 +328,20 @@
color: var(--text-color); color: var(--text-color);
} }
.config-sections { .qr-sections {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5em; gap: 1.5em;
margin-bottom: 1.5em;
} }
.config-section { .qr-section {
background-color: var(--card-bg); background-color: var(--card-bg);
padding: 1.25em; padding: 1.25em;
border-radius: 8px; border-radius: 8px;
} }
.config-section h4 { .qr-section h4 {
margin: 0 0 0.5em 0; margin: 0 0 0.5em 0;
color: var(--text-color); color: var(--text-color);
} }
@@ -455,6 +357,157 @@
color: var(--primary); 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 { .client-links {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -490,165 +543,6 @@
opacity: 0.7; 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 { .unavailable-message, .access-denied {
text-align: center; text-align: center;
padding: 2em; padding: 2em;
@@ -703,83 +597,11 @@
background-color: var(--accent-hover-color); 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) { @media (max-width: 600px) {
.qr-sections {
grid-template-columns: 1fr;
}
.client-links { .client-links {
flex-direction: column; flex-direction: column;
} }
@@ -789,16 +611,12 @@
} }
.bunker-url { .bunker-url {
font-size: 0.75em; font-size: 0.65em;
} }
.audit-table { .info-row {
font-size: 0.75em; flex-direction: column;
} align-items: flex-start;
.audit-table th,
.audit-table td {
padding: 0.4em 0.5em;
} }
} }
</style> </style>

View File

@@ -486,3 +486,17 @@ export async function getWireGuardAudit(signer, pubkey) {
} }
return await response.json(); 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();
}

View File

@@ -1 +1 @@
v0.40.1 v0.41.0