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
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:
@@ -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
83
app/handle-bunker.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
2
app/web/dist/bundle.css
vendored
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
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
2
app/web/dist/bundle.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQRCodes() {
|
||||||
|
if (clientBunkerURL) {
|
||||||
|
clientQrDataUrl = await QRCode.toDataURL(clientBunkerURL, {
|
||||||
|
width: 280,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: "#000000", light: "#ffffff" }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = true;
|
if (signerBunkerURL) {
|
||||||
error = "";
|
signerQrDataUrl = await QRCode.toDataURL(signerBunkerURL, {
|
||||||
|
width: 280,
|
||||||
try {
|
margin: 2,
|
||||||
await regenerateWireGuard(userSigner, userPubkey);
|
color: { dark: "#000000", light: "#ffffff" }
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,189 +126,118 @@
|
|||||||
<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>
|
</div>
|
||||||
<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" />
|
|
||||||
{: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>
|
||||||
|
{:else}
|
||||||
|
<div class="qr-placeholder">Generating QR...</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 4: Bunker URL -->
|
<div class="url-display">
|
||||||
{#if bunkerInfo}
|
<code class="bunker-url">{signerBunkerURL}</code>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="copy-hint">Click QR code to copy</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Connection Info -->
|
||||||
<div class="danger-zone">
|
<div class="connection-info">
|
||||||
<h4>Danger Zone</h4>
|
<h4>Connection Details</h4>
|
||||||
<p>Regenerate your WireGuard keys if you believe they've been compromised.</p>
|
<div class="info-row">
|
||||||
<button class="danger-btn" on:click={handleRegenerate} disabled={isLoading}>
|
<span class="label">Relay:</span>
|
||||||
Regenerate Keys
|
<code>{bunkerInfo.relay_url}</code>
|
||||||
</button>
|
<button class="copy-btn" on:click={() => copyToClipboard(bunkerInfo.relay_url, "relay")}>
|
||||||
</div>
|
{copiedItem === "relay" ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
<!-- 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>
|
</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}
|
{/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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.40.1
|
v0.41.0
|
||||||
|
|||||||
Reference in New Issue
Block a user