Browse Source

Add simplified NIP-46 bunker page with click-to-copy QR codes (v0.41.0)

- 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>
main
mleku 2 weeks ago
parent
commit
1b17acb50c
  1. 6
      .claude/settings.local.json
  2. 83
      app/handle-bunker.go
  3. 9
      app/handle-event.go
  4. 59
      app/handle-websocket.go
  5. 2
      app/listener.go
  6. 1
      app/server.go
  7. 2
      app/web/dist/bundle.css
  8. 30
      app/web/dist/bundle.js
  9. 2
      app/web/dist/bundle.js.map
  10. 734
      app/web/src/BunkerView.svelte
  11. 14
      app/web/src/api.js
  12. 2
      pkg/version/version

6
.claude/settings.local.json

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

83
app/handle-bunker.go

@ -0,0 +1,83 @@ @@ -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)
}

9
app/handle-event.go

@ -131,6 +131,15 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { @@ -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:

59
app/handle-websocket.go

@ -12,6 +12,7 @@ import ( @@ -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) { @@ -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: @@ -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( @@ -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
}

2
app/listener.go

@ -13,6 +13,7 @@ import ( @@ -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 { @@ -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

1
app/server.go

@ -356,6 +356,7 @@ func (s *Server) UserInterface() { @@ -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)

2
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

30
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

734
app/web/src/BunkerView.svelte

@ -1,7 +1,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 QR codes
if (wgConfig?.config_text) {
wgQrDataUrl = await QRCode.toDataURL(wgConfig.config_text, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
// Generate a random secret for secure connection
if (!bunkerSecret) {
bunkerSecret = generateSecret();
}
if (bunkerInfo?.url) {
bunkerQrDataUrl = await QRCode.toDataURL(bunkerInfo.url, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
// Generate QR codes
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();
}
isLoading = true;
error = "";
async function generateQRCodes() {
if (clientBunkerURL) {
clientQrDataUrl = await QRCode.toDataURL(clientBunkerURL, {
width: 280,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
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" }
});
}
}
function copyToClipboard(text, label) {
navigator.clipboard.writeText(text);
alert(`${label} copied to clipboard!`);
// Regenerate QR codes when URLs change
$: if (clientBunkerURL || signerBunkerURL) {
generateQRCodes();
}
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);
function copyToClipboard(text, label) {
navigator.clipboard.writeText(text);
copiedItem = label;
setTimeout(() => {
copiedItem = "";
}, 2000);
}
function openLoginModal() {
@ -143,19 +105,19 @@ @@ -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 @@ @@ -164,189 +126,118 @@
<div class="error-message">{error}</div>
{/if}
{#if isLoading && !wgConfig}
<div class="loading">Loading configuration...</div>
{:else if wgConfig}
<div class="instructions">
<p><strong>How it works:</strong> Connect to the relay's private VPN, then use Amber to sign events remotely.</p>
{#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}
<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="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>
{#if isLoading && !bunkerInfo}
<div class="loading">Loading bunker information...</div>
{:else if bunkerInfo}
<div class="instructions">
<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="qr-container">
{#if wgQrDataUrl}
<img src={wgQrDataUrl} alt="WireGuard Configuration QR Code" class="qr-code" />
<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="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>
<!-- 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>
<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>
<!-- 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}
<!-- 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>
{/if}
</section>
{/if}
</div>
{:else if isLoggedIn}
@ -408,6 +299,16 @@ @@ -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 @@ @@ -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,45 +357,24 @@ @@ -455,45 +357,24 @@
color: var(--primary);
}
.client-links {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.client-link {
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75em 1em;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
text-decoration: none;
color: var(--text-color);
transition: border-color 0.2s, background-color 0.2s;
min-width: 100px;
}
.client-link:hover {
border-color: var(--primary);
background-color: var(--sidebar-bg);
justify-content: center;
margin: 1em 0;
position: relative;
}
.client-icon {
font-weight: 500;
margin-bottom: 0.25em;
.qr-container.clickable {
cursor: pointer;
transition: transform 0.1s;
}
.client-store {
font-size: 0.8em;
opacity: 0.7;
.qr-container.clickable:hover {
transform: scale(1.02);
}
.qr-container {
display: flex;
justify-content: center;
margin: 1em 0;
.qr-container.clickable:active {
transform: scale(0.98);
}
.qr-code {
@ -502,9 +383,29 @@ @@ -502,9 +383,29 @@
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: 256px;
height: 256px;
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
@ -514,59 +415,59 @@ @@ -514,59 +415,59 @@
opacity: 0.5;
}
.config-actions {
display: flex;
justify-content: center;
gap: 0.75em;
margin-top: 1em;
.url-display {
text-align: center;
margin-top: 0.5em;
}
.config-actions button {
padding: 0.5em 1em;
background-color: var(--primary);
color: var(--text-color);
border: none;
.bunker-url {
font-family: monospace;
font-size: 0.75em;
word-break: break-all;
padding: 0.5em;
background-color: var(--bg-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.config-actions button:hover {
background-color: var(--accent-hover-color);
display: inline-block;
max-width: 100%;
color: var(--text-color);
}
.config-text-details {
margin-top: 1em;
.copy-hint {
text-align: center;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
margin-top: 0.5em;
}
.config-text-details summary {
cursor: pointer;
color: var(--text-color);
opacity: 0.8;
font-size: 0.9em;
.connection-info {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
margin-bottom: 1.5em;
}
.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;
.connection-info h4 {
margin: 0 0 1em 0;
color: var(--text-color);
}
.ip-info, .relay-info {
.info-row {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 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 {
@ -575,78 +476,71 @@ @@ -575,78 +476,71 @@
background-color: var(--bg-color);
border-radius: 4px;
color: var(--text-color);
word-break: break-all;
}
.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;
.npub, .secret {
font-size: 0.85em;
}
.bunker-url-container button {
padding: 0.4em 0.8em;
.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.85em;
font-size: 0.8em;
}
.bunker-url-container button:hover {
.copy-btn:hover {
background-color: var(--accent-hover-color);
}
.npub {
word-break: break-all;
font-size: 0.85em;
}
.danger-zone {
margin-top: 2em;
.amber-section {
background-color: var(--card-bg);
padding: 1.25em;
border: 1px solid var(--warning);
border-radius: 8px;
background-color: rgba(255, 100, 100, 0.05);
}
.danger-zone h4 {
.amber-section h4 {
margin: 0 0 0.5em 0;
color: var(--warning);
color: var(--text-color);
}
.danger-zone p {
margin: 0 0 1em 0;
.client-links {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.client-link {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75em 1em;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
text-decoration: none;
color: var(--text-color);
opacity: 0.8;
font-size: 0.95em;
transition: border-color 0.2s, background-color 0.2s;
min-width: 100px;
}
.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;
.client-link:hover {
border-color: var(--primary);
background-color: var(--sidebar-bg);
}
.danger-btn:hover:not(:disabled) {
background-color: var(--warning);
color: var(--text-color);
.client-icon {
font-weight: 500;
margin-bottom: 0.25em;
}
.danger-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.client-store {
font-size: 0.8em;
opacity: 0.7;
}
.unavailable-message, .access-denied {
@ -703,83 +597,11 @@ @@ -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 @@ @@ -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>

14
app/web/src/api.js

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

2
pkg/version/version

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

Loading…
Cancel
Save