Browse Source
- Add embedded WireGuard VPN server using wireguard-go + netstack - Implement deterministic /31 subnet allocation from seed + sequence - Use Badger's built-in Sequence for atomic counter allocation - Add NIP-46 bunker server for remote signing over VPN - Add revoked key tracking and access audit logging for users - Add Bunker tab to web UI with WireGuard/bunker QR codes - Support key regeneration with old keypair archiving New environment variables: - ORLY_WG_ENABLED: Enable WireGuard VPN server - ORLY_WG_PORT: UDP port for WireGuard (default 51820) - ORLY_WG_ENDPOINT: Public endpoint for WireGuard - ORLY_WG_NETWORK: Base network for subnet pool (default 10.0.0.0/8) - ORLY_BUNKER_ENABLED: Enable NIP-46 bunker - ORLY_BUNKER_PORT: WebSocket port for bunker (default 3335) Files added: - pkg/wireguard/: WireGuard server, keygen, subnet pool, errors - pkg/bunker/: NIP-46 bunker server and session handling - pkg/database/wireguard.go: Peer storage with audit logging - app/handle-wireguard.go: API endpoints for config/regenerate/audit - app/wireguard-helpers.go: Key derivation helpers - app/web/src/BunkerView.svelte: Bunker UI with QR codes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>main
23 changed files with 3498 additions and 25 deletions
@ -0,0 +1,514 @@
@@ -0,0 +1,514 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"git.mleku.dev/mleku/nostr/encoders/bech32encoding" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"git.mleku.dev/mleku/nostr/httpauth" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
|
||||
"next.orly.dev/pkg/acl" |
||||
"next.orly.dev/pkg/database" |
||||
) |
||||
|
||||
// WireGuardConfigResponse is returned by the /api/wireguard/config endpoint.
|
||||
type WireGuardConfigResponse struct { |
||||
ConfigText string `json:"config_text"` |
||||
Interface WGInterface `json:"interface"` |
||||
Peer WGPeer `json:"peer"` |
||||
} |
||||
|
||||
// WGInterface represents the [Interface] section of a WireGuard config.
|
||||
type WGInterface struct { |
||||
Address string `json:"address"` |
||||
PrivateKey string `json:"private_key"` |
||||
} |
||||
|
||||
// WGPeer represents the [Peer] section of a WireGuard config.
|
||||
type WGPeer struct { |
||||
PublicKey string `json:"public_key"` |
||||
Endpoint string `json:"endpoint"` |
||||
AllowedIPs string `json:"allowed_ips"` |
||||
} |
||||
|
||||
// BunkerURLResponse is returned by the /api/bunker/url endpoint.
|
||||
type BunkerURLResponse struct { |
||||
URL string `json:"url"` |
||||
RelayNpub string `json:"relay_npub"` |
||||
RelayPubkey string `json:"relay_pubkey"` |
||||
InternalIP string `json:"internal_ip"` |
||||
} |
||||
|
||||
// handleWireGuardConfig returns the user's WireGuard configuration.
|
||||
// Requires NIP-98 authentication and write+ access.
|
||||
func (s *Server) handleWireGuardConfig(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != http.MethodGet { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Check if WireGuard is enabled
|
||||
if !s.Config.WGEnabled { |
||||
http.Error(w, "WireGuard is not enabled on this relay", http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
// Check if ACL mode supports WireGuard
|
||||
if s.Config.ACLMode == "none" { |
||||
http.Error(w, "WireGuard requires ACL mode 'follows' or 'managed'", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r) |
||||
if chk.E(err) || !valid { |
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized) |
||||
return |
||||
} |
||||
|
||||
// Check user has write+ access
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr) |
||||
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" { |
||||
http.Error(w, "Write access required for WireGuard", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Type assert to Badger database for WireGuard methods
|
||||
badgerDB, ok := s.DB.(*database.D) |
||||
if !ok { |
||||
http.Error(w, "WireGuard requires Badger database backend", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Check subnet pool is available
|
||||
if s.subnetPool == nil { |
||||
http.Error(w, "WireGuard subnet pool not initialized", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Get or create WireGuard peer for this user
|
||||
peer, err := badgerDB.GetOrCreateWireGuardPeer(pubkey, s.subnetPool) |
||||
if chk.E(err) { |
||||
log.E.F("failed to get/create WireGuard peer: %v", err) |
||||
http.Error(w, "Failed to create WireGuard configuration", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Derive subnet IPs from sequence
|
||||
subnet := s.subnetPool.SubnetForSequence(peer.Sequence) |
||||
clientIP := subnet.ClientIP.String() |
||||
serverIP := subnet.ServerIP.String() |
||||
|
||||
// Get server public key
|
||||
serverKey, err := badgerDB.GetOrCreateWireGuardServerKey() |
||||
if chk.E(err) { |
||||
log.E.F("failed to get WireGuard server key: %v", err) |
||||
http.Error(w, "WireGuard server not configured", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
serverPubKey, err := deriveWGPublicKey(serverKey) |
||||
if chk.E(err) { |
||||
log.E.F("failed to derive server public key: %v", err) |
||||
http.Error(w, "WireGuard server error", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Build endpoint
|
||||
endpoint := fmt.Sprintf("%s:%d", s.Config.WGEndpoint, s.Config.WGPort) |
||||
|
||||
// Build response
|
||||
resp := WireGuardConfigResponse{ |
||||
Interface: WGInterface{ |
||||
Address: clientIP + "/32", |
||||
PrivateKey: base64.StdEncoding.EncodeToString(peer.WGPrivateKey), |
||||
}, |
||||
Peer: WGPeer{ |
||||
PublicKey: base64.StdEncoding.EncodeToString(serverPubKey), |
||||
Endpoint: endpoint, |
||||
AllowedIPs: serverIP + "/32", // Only route bunker traffic to this peer's server IP
|
||||
}, |
||||
} |
||||
|
||||
// Generate config text
|
||||
resp.ConfigText = fmt.Sprintf(`[Interface] |
||||
Address = %s |
||||
PrivateKey = %s |
||||
|
||||
[Peer] |
||||
PublicKey = %s |
||||
Endpoint = %s |
||||
AllowedIPs = %s |
||||
PersistentKeepalive = 25 |
||||
`, resp.Interface.Address, resp.Interface.PrivateKey, |
||||
resp.Peer.PublicKey, resp.Peer.Endpoint, resp.Peer.AllowedIPs) |
||||
|
||||
// If WireGuard server is running, add the peer
|
||||
if s.wireguardServer != nil && s.wireguardServer.IsRunning() { |
||||
if err := s.wireguardServer.AddPeer(pubkey, peer.WGPublicKey, clientIP); chk.E(err) { |
||||
log.W.F("failed to add peer to running WireGuard server: %v", err) |
||||
} |
||||
} |
||||
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
json.NewEncoder(w).Encode(resp) |
||||
} |
||||
|
||||
// handleWireGuardRegenerate generates a new WireGuard keypair for the user.
|
||||
// Requires NIP-98 authentication and write+ access.
|
||||
func (s *Server) handleWireGuardRegenerate(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Check if WireGuard is enabled
|
||||
if !s.Config.WGEnabled { |
||||
http.Error(w, "WireGuard is not enabled on this relay", http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
// Check if ACL mode supports WireGuard
|
||||
if s.Config.ACLMode == "none" { |
||||
http.Error(w, "WireGuard requires ACL mode 'follows' or 'managed'", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r) |
||||
if chk.E(err) || !valid { |
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized) |
||||
return |
||||
} |
||||
|
||||
// Check user has write+ access
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr) |
||||
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" { |
||||
http.Error(w, "Write access required for WireGuard", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Type assert to Badger database for WireGuard methods
|
||||
badgerDB, ok := s.DB.(*database.D) |
||||
if !ok { |
||||
http.Error(w, "WireGuard requires Badger database backend", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Check subnet pool is available
|
||||
if s.subnetPool == nil { |
||||
http.Error(w, "WireGuard subnet pool not initialized", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Remove old peer from running server if exists
|
||||
oldPeer, err := badgerDB.GetWireGuardPeer(pubkey) |
||||
if err == nil && oldPeer != nil && s.wireguardServer != nil && s.wireguardServer.IsRunning() { |
||||
s.wireguardServer.RemovePeer(oldPeer.WGPublicKey) |
||||
} |
||||
|
||||
// Regenerate keypair
|
||||
peer, err := badgerDB.RegenerateWireGuardPeer(pubkey, s.subnetPool) |
||||
if chk.E(err) { |
||||
log.E.F("failed to regenerate WireGuard peer: %v", err) |
||||
http.Error(w, "Failed to regenerate WireGuard configuration", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Derive subnet IPs from sequence (same sequence as before)
|
||||
subnet := s.subnetPool.SubnetForSequence(peer.Sequence) |
||||
clientIP := subnet.ClientIP.String() |
||||
|
||||
log.I.F("regenerated WireGuard keypair for user: %s", hex.Enc(pubkey[:8])) |
||||
|
||||
// Return success with IP (same subnet as before)
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
json.NewEncoder(w).Encode(map[string]string{ |
||||
"status": "regenerated", |
||||
"assigned_ip": clientIP, |
||||
}) |
||||
} |
||||
|
||||
// handleBunkerURL returns the bunker connection URL.
|
||||
// Requires NIP-98 authentication and write+ access.
|
||||
func (s *Server) handleBunkerURL(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != http.MethodGet { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Check if bunker is enabled
|
||||
if !s.Config.BunkerEnabled { |
||||
http.Error(w, "Bunker is not enabled on this relay", http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
// Check if WireGuard is enabled (required for bunker)
|
||||
if !s.Config.WGEnabled { |
||||
http.Error(w, "WireGuard is required for bunker access", http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
// Check if ACL mode supports WireGuard
|
||||
if s.Config.ACLMode == "none" { |
||||
http.Error(w, "Bunker requires ACL mode 'follows' or 'managed'", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r) |
||||
if chk.E(err) || !valid { |
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized) |
||||
return |
||||
} |
||||
|
||||
// Check user has write+ access
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr) |
||||
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" { |
||||
http.Error(w, "Write access required for bunker", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Type assert to Badger database for WireGuard methods
|
||||
badgerDB, ok := s.DB.(*database.D) |
||||
if !ok { |
||||
http.Error(w, "Bunker requires Badger database backend", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Check subnet pool is available
|
||||
if s.subnetPool == nil { |
||||
http.Error(w, "WireGuard subnet pool not initialized", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Get or create WireGuard peer to get their subnet
|
||||
peer, err := badgerDB.GetOrCreateWireGuardPeer(pubkey, s.subnetPool) |
||||
if chk.E(err) { |
||||
log.E.F("failed to get/create WireGuard peer for bunker: %v", err) |
||||
http.Error(w, "Failed to get WireGuard configuration", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Derive server IP for this peer's subnet
|
||||
subnet := s.subnetPool.SubnetForSequence(peer.Sequence) |
||||
serverIP := subnet.ServerIP.String() |
||||
|
||||
// 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 |
||||
} |
||||
|
||||
relayPubkey, err := deriveNostrPublicKey(relaySecret) |
||||
if chk.E(err) { |
||||
log.E.F("failed to derive relay public key: %v", err) |
||||
http.Error(w, "Failed to derive relay public key", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Encode as npub
|
||||
relayNpubBytes, err := bech32encoding.BinToNpub(relayPubkey) |
||||
relayNpub := string(relayNpubBytes) |
||||
if chk.E(err) { |
||||
relayNpub = hex.Enc(relayPubkey) // Fallback to hex
|
||||
} |
||||
|
||||
// Build bunker URL using this peer's server IP
|
||||
// Format: bunker://<relay-pubkey-hex>?relay=ws://<server-ip>:3335
|
||||
relayPubkeyHex := hex.Enc(relayPubkey) |
||||
bunkerURL := fmt.Sprintf("bunker://%s?relay=ws://%s:%d", |
||||
relayPubkeyHex, |
||||
serverIP, |
||||
s.Config.BunkerPort, |
||||
) |
||||
|
||||
resp := BunkerURLResponse{ |
||||
URL: bunkerURL, |
||||
RelayNpub: relayNpub, |
||||
RelayPubkey: relayPubkeyHex, |
||||
InternalIP: serverIP, |
||||
} |
||||
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
json.NewEncoder(w).Encode(resp) |
||||
} |
||||
|
||||
// handleWireGuardStatus returns whether WireGuard/Bunker are available.
|
||||
func (s *Server) handleWireGuardStatus(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != http.MethodGet { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
resp := map[string]interface{}{ |
||||
"wireguard_enabled": s.Config.WGEnabled, |
||||
"bunker_enabled": s.Config.BunkerEnabled, |
||||
"acl_mode": s.Config.ACLMode, |
||||
"available": s.Config.WGEnabled && s.Config.ACLMode != "none", |
||||
} |
||||
|
||||
if s.wireguardServer != nil { |
||||
resp["wireguard_running"] = s.wireguardServer.IsRunning() |
||||
resp["peer_count"] = s.wireguardServer.PeerCount() |
||||
} |
||||
|
||||
if s.bunkerServer != nil { |
||||
resp["bunker_sessions"] = s.bunkerServer.SessionCount() |
||||
} |
||||
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
json.NewEncoder(w).Encode(resp) |
||||
} |
||||
|
||||
// RevokedKeyResponse is the JSON response for revoked keys.
|
||||
type RevokedKeyResponse struct { |
||||
NostrPubkey string `json:"nostr_pubkey"` |
||||
WGPublicKey string `json:"wg_public_key"` |
||||
Sequence uint32 `json:"sequence"` |
||||
ClientIP string `json:"client_ip"` |
||||
ServerIP string `json:"server_ip"` |
||||
CreatedAt int64 `json:"created_at"` |
||||
RevokedAt int64 `json:"revoked_at"` |
||||
AccessCount int `json:"access_count"` |
||||
LastAccessAt int64 `json:"last_access_at"` |
||||
} |
||||
|
||||
// AccessLogResponse is the JSON response for access logs.
|
||||
type AccessLogResponse struct { |
||||
NostrPubkey string `json:"nostr_pubkey"` |
||||
WGPublicKey string `json:"wg_public_key"` |
||||
Sequence uint32 `json:"sequence"` |
||||
ClientIP string `json:"client_ip"` |
||||
Timestamp int64 `json:"timestamp"` |
||||
RemoteAddr string `json:"remote_addr"` |
||||
} |
||||
|
||||
// handleWireGuardAudit returns the user's own revoked keys and access logs.
|
||||
// This lets users see if their old WireGuard keys are still being used,
|
||||
// which could indicate they left something on or someone copied their credentials.
|
||||
// Requires NIP-98 authentication and write+ access.
|
||||
func (s *Server) handleWireGuardAudit(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != http.MethodGet { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Check if WireGuard is enabled
|
||||
if !s.Config.WGEnabled { |
||||
http.Error(w, "WireGuard is not enabled on this relay", http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r) |
||||
if chk.E(err) || !valid { |
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized) |
||||
return |
||||
} |
||||
|
||||
// Check user has write+ access (same as other WireGuard endpoints)
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr) |
||||
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" { |
||||
http.Error(w, "Write access required", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Type assert to Badger database for WireGuard methods
|
||||
badgerDB, ok := s.DB.(*database.D) |
||||
if !ok { |
||||
http.Error(w, "WireGuard requires Badger database backend", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Check subnet pool is available
|
||||
if s.subnetPool == nil { |
||||
http.Error(w, "WireGuard subnet pool not initialized", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Get this user's revoked keys only
|
||||
revokedKeys, err := badgerDB.GetRevokedKeys(pubkey) |
||||
if chk.E(err) { |
||||
log.E.F("failed to get revoked keys: %v", err) |
||||
http.Error(w, "Failed to get revoked keys", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Get this user's access logs only
|
||||
accessLogs, err := badgerDB.GetAccessLogs(pubkey) |
||||
if chk.E(err) { |
||||
log.E.F("failed to get access logs: %v", err) |
||||
http.Error(w, "Failed to get access logs", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Convert to response format
|
||||
var revokedResp []RevokedKeyResponse |
||||
for _, key := range revokedKeys { |
||||
subnet := s.subnetPool.SubnetForSequence(key.Sequence) |
||||
revokedResp = append(revokedResp, RevokedKeyResponse{ |
||||
NostrPubkey: hex.Enc(key.NostrPubkey), |
||||
WGPublicKey: hex.Enc(key.WGPublicKey), |
||||
Sequence: key.Sequence, |
||||
ClientIP: subnet.ClientIP.String(), |
||||
ServerIP: subnet.ServerIP.String(), |
||||
CreatedAt: key.CreatedAt, |
||||
RevokedAt: key.RevokedAt, |
||||
AccessCount: key.AccessCount, |
||||
LastAccessAt: key.LastAccessAt, |
||||
}) |
||||
} |
||||
|
||||
var accessResp []AccessLogResponse |
||||
for _, logEntry := range accessLogs { |
||||
subnet := s.subnetPool.SubnetForSequence(logEntry.Sequence) |
||||
accessResp = append(accessResp, AccessLogResponse{ |
||||
NostrPubkey: hex.Enc(logEntry.NostrPubkey), |
||||
WGPublicKey: hex.Enc(logEntry.WGPublicKey), |
||||
Sequence: logEntry.Sequence, |
||||
ClientIP: subnet.ClientIP.String(), |
||||
Timestamp: logEntry.Timestamp, |
||||
RemoteAddr: logEntry.RemoteAddr, |
||||
}) |
||||
} |
||||
|
||||
resp := map[string]interface{}{ |
||||
"revoked_keys": revokedResp, |
||||
"access_logs": accessResp, |
||||
} |
||||
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
json.NewEncoder(w).Encode(resp) |
||||
} |
||||
|
||||
// deriveWGPublicKey derives a Curve25519 public key from a private key.
|
||||
func deriveWGPublicKey(privateKey []byte) ([]byte, error) { |
||||
if len(privateKey) != 32 { |
||||
return nil, fmt.Errorf("invalid private key length: %d", len(privateKey)) |
||||
} |
||||
|
||||
// Use wireguard package
|
||||
return derivePublicKey(privateKey) |
||||
} |
||||
|
||||
// deriveNostrPublicKey derives a secp256k1 public key from a secret key.
|
||||
func deriveNostrPublicKey(secretKey []byte) ([]byte, error) { |
||||
if len(secretKey) != 32 { |
||||
return nil, fmt.Errorf("invalid secret key length: %d", len(secretKey)) |
||||
} |
||||
|
||||
// Use nostr library's key derivation
|
||||
pk, err := deriveSecp256k1PublicKey(secretKey) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return pk, nil |
||||
} |
||||
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
@ -0,0 +1,804 @@
@@ -0,0 +1,804 @@
|
||||
<script> |
||||
import { createEventDispatcher, onMount } from "svelte"; |
||||
import QRCode from "qrcode"; |
||||
import { getWireGuardConfig, regenerateWireGuard, getBunkerURL, fetchWireGuardStatus, getWireGuardAudit } from "./api.js"; |
||||
|
||||
export let isLoggedIn = false; |
||||
export let userPubkey = ""; |
||||
export let userSigner = null; |
||||
export let currentEffectiveRole = ""; |
||||
|
||||
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 = ""; |
||||
|
||||
$: canAccess = isLoggedIn && userPubkey && ( |
||||
currentEffectiveRole === "write" || |
||||
currentEffectiveRole === "admin" || |
||||
currentEffectiveRole === "owner" |
||||
); |
||||
|
||||
let hasLoadedOnce = false; |
||||
|
||||
onMount(async () => { |
||||
// Always check status first |
||||
await checkStatus(); |
||||
if (canAccess && wgStatus?.available && !hasLoadedOnce) { |
||||
hasLoadedOnce = true; |
||||
await loadConfig(); |
||||
} |
||||
}); |
||||
|
||||
$: 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; |
||||
|
||||
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) |
||||
]); |
||||
|
||||
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" } |
||||
}); |
||||
} |
||||
|
||||
if (bunkerInfo?.url) { |
||||
bunkerQrDataUrl = await QRCode.toDataURL(bunkerInfo.url, { |
||||
width: 256, |
||||
margin: 2, |
||||
color: { dark: "#000000", light: "#ffffff" } |
||||
}); |
||||
} |
||||
} catch (err) { |
||||
console.error("Error loading bunker config:", err); |
||||
error = err.message || "Failed to load configuration"; |
||||
} finally { |
||||
isLoading = false; |
||||
} |
||||
} |
||||
|
||||
function formatDate(timestamp) { |
||||
if (!timestamp) return "Never"; |
||||
return new Date(timestamp * 1000).toLocaleString(); |
||||
} |
||||
|
||||
async function handleRegenerate() { |
||||
if (!confirm("Regenerate your WireGuard keys? Your current keys will stop working.")) { |
||||
return; |
||||
} |
||||
|
||||
isLoading = true; |
||||
error = ""; |
||||
|
||||
try { |
||||
await regenerateWireGuard(userSigner, userPubkey); |
||||
// Reload config after regeneration |
||||
hasLoadedOnce = false; |
||||
await loadConfig(); |
||||
} catch (err) { |
||||
console.error("Error regenerating keys:", err); |
||||
error = err.message || "Failed to regenerate keys"; |
||||
} finally { |
||||
isLoading = false; |
||||
} |
||||
} |
||||
|
||||
function copyToClipboard(text, label) { |
||||
navigator.clipboard.writeText(text); |
||||
alert(`${label} copied to clipboard!`); |
||||
} |
||||
|
||||
function downloadConfig() { |
||||
if (!wgConfig?.config_text) return; |
||||
|
||||
const blob = new Blob([wgConfig.config_text], { type: "text/plain" }); |
||||
const url = URL.createObjectURL(blob); |
||||
const a = document.createElement("a"); |
||||
a.href = url; |
||||
a.download = "wg-orly.conf"; |
||||
document.body.appendChild(a); |
||||
a.click(); |
||||
document.body.removeChild(a); |
||||
URL.revokeObjectURL(url); |
||||
} |
||||
|
||||
function openLoginModal() { |
||||
dispatch("openLoginModal"); |
||||
} |
||||
</script> |
||||
|
||||
{#if !wgStatus?.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> |
||||
</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}> |
||||
{isLoading ? "Loading..." : "Refresh"} |
||||
</button> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<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> |
||||
</div> |
||||
|
||||
<div class="config-sections"> |
||||
<!-- Step 1: WireGuard --> |
||||
<section class="config-section"> |
||||
<h4>Step 1: Install WireGuard</h4> |
||||
<p class="section-desc">Download the WireGuard app for your device:</p> |
||||
|
||||
<div class="client-links"> |
||||
<a href="https://play.google.com/store/apps/details?id=com.wireguard.android" target="_blank" rel="noopener noreferrer" class="client-link"> |
||||
<span class="client-icon">Android</span> |
||||
<span class="client-store">Google Play</span> |
||||
</a> |
||||
<a href="https://f-droid.org/packages/com.wireguard.android/" target="_blank" rel="noopener noreferrer" class="client-link"> |
||||
<span class="client-icon">Android</span> |
||||
<span class="client-store">F-Droid</span> |
||||
</a> |
||||
<a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener noreferrer" class="client-link"> |
||||
<span class="client-icon">iOS</span> |
||||
<span class="client-store">App Store</span> |
||||
</a> |
||||
<a href="https://www.wireguard.com/install/" target="_blank" rel="noopener noreferrer" class="client-link"> |
||||
<span class="client-icon">Desktop</span> |
||||
<span class="client-store">Windows/Mac/Linux</span> |
||||
</a> |
||||
</div> |
||||
</section> |
||||
|
||||
<!-- Step 2: WireGuard Config --> |
||||
<section class="config-section"> |
||||
<h4>Step 2: Add VPN Configuration</h4> |
||||
<p class="section-desc">Scan this QR code with the WireGuard app:</p> |
||||
|
||||
<div class="qr-container"> |
||||
{#if wgQrDataUrl} |
||||
<img src={wgQrDataUrl} alt="WireGuard Configuration QR Code" class="qr-code" /> |
||||
{: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> |
||||
|
||||
<details class="config-text-details"> |
||||
<summary>Show raw config</summary> |
||||
<pre class="config-text">{wgConfig.config_text}</pre> |
||||
</details> |
||||
</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> |
||||
</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> |
||||
</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} |
||||
</div> |
||||
{:else if isLoggedIn} |
||||
<div class="bunker-view"> |
||||
<div class="access-denied"> |
||||
<h3>Access Denied</h3> |
||||
<p>You need write access to use remote signing. Your current access level: <strong>{currentEffectiveRole || "read-only"}</strong></p> |
||||
</div> |
||||
</div> |
||||
{:else} |
||||
<div class="login-prompt"> |
||||
<p>Please log in to access remote signing.</p> |
||||
<button class="login-btn" on:click={openLoginModal}>Log In</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.bunker-view { |
||||
padding: 1em; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.header-section { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
.header-section h3 { |
||||
margin: 0; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.refresh-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.refresh-btn:hover:not(:disabled) { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.refresh-btn:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.error-message { |
||||
background-color: var(--warning); |
||||
color: var(--text-color); |
||||
padding: 0.75em 1em; |
||||
border-radius: 4px; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
.loading { |
||||
text-align: center; |
||||
padding: 2em; |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.instructions { |
||||
background-color: var(--card-bg); |
||||
padding: 1em; |
||||
border-radius: 6px; |
||||
margin-bottom: 1.5em; |
||||
} |
||||
|
||||
.instructions p { |
||||
margin: 0; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.config-sections { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5em; |
||||
} |
||||
|
||||
.config-section { |
||||
background-color: var(--card-bg); |
||||
padding: 1.25em; |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.config-section h4 { |
||||
margin: 0 0 0.5em 0; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.section-desc { |
||||
margin: 0 0 1em 0; |
||||
color: var(--text-color); |
||||
opacity: 0.8; |
||||
font-size: 0.95em; |
||||
} |
||||
|
||||
.section-desc a { |
||||
color: var(--primary); |
||||
} |
||||
|
||||
.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); |
||||
transition: border-color 0.2s, background-color 0.2s; |
||||
min-width: 100px; |
||||
} |
||||
|
||||
.client-link:hover { |
||||
border-color: var(--primary); |
||||
background-color: var(--sidebar-bg); |
||||
} |
||||
|
||||
.client-icon { |
||||
font-weight: 500; |
||||
margin-bottom: 0.25em; |
||||
} |
||||
|
||||
.client-store { |
||||
font-size: 0.8em; |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.qr-container { |
||||
display: flex; |
||||
justify-content: center; |
||||
margin: 1em 0; |
||||
} |
||||
|
||||
.qr-code { |
||||
border-radius: 8px; |
||||
background: white; |
||||
padding: 8px; |
||||
} |
||||
|
||||
.qr-placeholder { |
||||
width: 256px; |
||||
height: 256px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
background-color: var(--bg-color); |
||||
border-radius: 8px; |
||||
color: var(--text-color); |
||||
opacity: 0.5; |
||||
} |
||||
|
||||
.config-actions { |
||||
display: flex; |
||||
justify-content: center; |
||||
gap: 0.75em; |
||||
margin-top: 1em; |
||||
} |
||||
|
||||
.config-actions button { |
||||
padding: 0.5em 1em; |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.config-actions button:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.config-text-details { |
||||
margin-top: 1em; |
||||
} |
||||
|
||||
.config-text-details summary { |
||||
cursor: pointer; |
||||
color: var(--text-color); |
||||
opacity: 0.8; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.config-text { |
||||
margin-top: 0.5em; |
||||
padding: 1em; |
||||
background-color: var(--bg-color); |
||||
border-radius: 4px; |
||||
font-size: 0.85em; |
||||
overflow-x: auto; |
||||
white-space: pre; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.ip-info, .relay-info { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5em; |
||||
margin-top: 0.5em; |
||||
} |
||||
|
||||
.label { |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
code { |
||||
font-family: monospace; |
||||
padding: 0.25em 0.5em; |
||||
background-color: var(--bg-color); |
||||
border-radius: 4px; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.bunker-url-container { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5em; |
||||
justify-content: center; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.bunker-url { |
||||
word-break: break-all; |
||||
max-width: 400px; |
||||
} |
||||
|
||||
.bunker-url-container button { |
||||
padding: 0.4em 0.8em; |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.85em; |
||||
} |
||||
|
||||
.bunker-url-container button:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.npub { |
||||
word-break: break-all; |
||||
font-size: 0.85em; |
||||
} |
||||
|
||||
.danger-zone { |
||||
margin-top: 2em; |
||||
padding: 1.25em; |
||||
border: 1px solid var(--warning); |
||||
border-radius: 8px; |
||||
background-color: rgba(255, 100, 100, 0.05); |
||||
} |
||||
|
||||
.danger-zone h4 { |
||||
margin: 0 0 0.5em 0; |
||||
color: var(--warning); |
||||
} |
||||
|
||||
.danger-zone p { |
||||
margin: 0 0 1em 0; |
||||
color: var(--text-color); |
||||
opacity: 0.8; |
||||
font-size: 0.95em; |
||||
} |
||||
|
||||
.danger-btn { |
||||
background-color: transparent; |
||||
border: 1px solid var(--warning); |
||||
color: var(--warning); |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.danger-btn:hover:not(:disabled) { |
||||
background-color: var(--warning); |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.danger-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.unavailable-message, .access-denied { |
||||
text-align: center; |
||||
padding: 2em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.unavailable-message h3, .access-denied h3 { |
||||
margin: 0 0 0.5em 0; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.unavailable-message p, .access-denied p { |
||||
margin: 0.5em 0; |
||||
color: var(--text-color); |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.hint { |
||||
font-size: 0.9em; |
||||
opacity: 0.6 !important; |
||||
} |
||||
|
||||
.login-prompt { |
||||
text-align: center; |
||||
padding: 2em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 8px; |
||||
border: 1px solid var(--border-color); |
||||
max-width: 32em; |
||||
margin: 1em; |
||||
} |
||||
|
||||
.login-prompt p { |
||||
margin: 0 0 1.5rem 0; |
||||
color: var(--text-color); |
||||
font-size: 1.1rem; |
||||
} |
||||
|
||||
.login-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
padding: 0.75em 1.5em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-weight: bold; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.login-btn:hover { |
||||
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) { |
||||
.client-links { |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.client-link { |
||||
width: 100%; |
||||
} |
||||
|
||||
.bunker-url { |
||||
font-size: 0.75em; |
||||
} |
||||
|
||||
.audit-table { |
||||
font-size: 0.75em; |
||||
} |
||||
|
||||
.audit-table th, |
||||
.audit-table td { |
||||
padding: 0.4em 0.5em; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"golang.org/x/crypto/curve25519" |
||||
|
||||
"git.mleku.dev/mleku/nostr/crypto/keys" |
||||
) |
||||
|
||||
// derivePublicKey derives a Curve25519 public key from a private key.
|
||||
func derivePublicKey(privateKey []byte) ([]byte, error) { |
||||
publicKey := make([]byte, 32) |
||||
curve25519.ScalarBaseMult((*[32]byte)(publicKey), (*[32]byte)(privateKey)) |
||||
return publicKey, nil |
||||
} |
||||
|
||||
// deriveSecp256k1PublicKey derives a secp256k1 public key from a secret key.
|
||||
func deriveSecp256k1PublicKey(secretKey []byte) ([]byte, error) { |
||||
return keys.SecretBytesToPubKeyBytes(secretKey) |
||||
} |
||||
@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
|
||||
// Package bunker provides a NIP-46 remote signing service that listens
|
||||
// only on the WireGuard VPN network for secure access.
|
||||
package bunker |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"net" |
||||
"net/http" |
||||
"sync" |
||||
"time" |
||||
|
||||
"github.com/gorilla/websocket" |
||||
"golang.zx2c4.com/wireguard/tun/netstack" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
|
||||
"git.mleku.dev/mleku/nostr/interfaces/signer" |
||||
) |
||||
|
||||
var upgrader = websocket.Upgrader{ |
||||
ReadBufferSize: 4096, |
||||
WriteBufferSize: 4096, |
||||
CheckOrigin: func(r *http.Request) bool { return true }, |
||||
} |
||||
|
||||
// Server is the NIP-46 bunker server.
|
||||
type Server struct { |
||||
relaySigner signer.I // Relay's signer for signing events
|
||||
relayPubkey []byte // Relay's public key
|
||||
netstack *netstack.Net // WireGuard netstack for listening
|
||||
listenAddr string // e.g., "10.73.0.1:3335"
|
||||
|
||||
sessions map[string]*Session // Connection ID -> Session
|
||||
sessionsMu sync.RWMutex |
||||
|
||||
server *http.Server |
||||
ctx context.Context |
||||
cancel context.CancelFunc |
||||
wg sync.WaitGroup |
||||
} |
||||
|
||||
// Config holds bunker server configuration.
|
||||
type Config struct { |
||||
RelaySigner signer.I |
||||
RelayPubkey []byte |
||||
Netstack *netstack.Net |
||||
ListenAddr string // IP:port on WireGuard network
|
||||
} |
||||
|
||||
// New creates a new bunker server.
|
||||
func New(cfg *Config) *Server { |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
|
||||
return &Server{ |
||||
relaySigner: cfg.RelaySigner, |
||||
relayPubkey: cfg.RelayPubkey, |
||||
netstack: cfg.Netstack, |
||||
listenAddr: cfg.ListenAddr, |
||||
sessions: make(map[string]*Session), |
||||
ctx: ctx, |
||||
cancel: cancel, |
||||
} |
||||
} |
||||
|
||||
// Start begins listening for bunker connections on the WireGuard network.
|
||||
func (s *Server) Start() error { |
||||
// Parse listen address
|
||||
host, port, err := net.SplitHostPort(s.listenAddr) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid listen address: %w", err) |
||||
} |
||||
|
||||
ip := net.ParseIP(host) |
||||
if ip == nil { |
||||
return fmt.Errorf("invalid IP address: %s", host) |
||||
} |
||||
|
||||
portNum := 0 |
||||
if _, err := fmt.Sscanf(port, "%d", &portNum); err != nil { |
||||
return fmt.Errorf("invalid port: %s", port) |
||||
} |
||||
|
||||
// Create TCP listener on netstack (WireGuard network only)
|
||||
listener, err := s.netstack.ListenTCP(&net.TCPAddr{ |
||||
IP: ip, |
||||
Port: portNum, |
||||
}) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to listen on netstack: %w", err) |
||||
} |
||||
|
||||
// Create HTTP server with WebSocket handler
|
||||
mux := http.NewServeMux() |
||||
mux.HandleFunc("/", s.handleWebSocket) |
||||
|
||||
s.server = &http.Server{ |
||||
Handler: mux, |
||||
ReadTimeout: 30 * time.Second, |
||||
WriteTimeout: 30 * time.Second, |
||||
IdleTimeout: 120 * time.Second, |
||||
} |
||||
|
||||
s.wg.Add(1) |
||||
go func() { |
||||
defer s.wg.Done() |
||||
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { |
||||
log.E.F("bunker server error: %v", err) |
||||
} |
||||
}() |
||||
|
||||
log.I.F("NIP-46 bunker server started on %s (WireGuard only)", s.listenAddr) |
||||
return nil |
||||
} |
||||
|
||||
// Stop shuts down the bunker server.
|
||||
func (s *Server) Stop() error { |
||||
s.cancel() |
||||
|
||||
if s.server != nil { |
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
||||
defer cancel() |
||||
if err := s.server.Shutdown(ctx); chk.E(err) { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
s.wg.Wait() |
||||
log.I.F("NIP-46 bunker server stopped") |
||||
return nil |
||||
} |
||||
|
||||
// handleWebSocket handles WebSocket connections for NIP-46.
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { |
||||
conn, err := upgrader.Upgrade(w, r, nil) |
||||
if err != nil { |
||||
log.E.F("bunker websocket upgrade failed: %v", err) |
||||
return |
||||
} |
||||
|
||||
session := NewSession(s.ctx, conn, s.relaySigner, s.relayPubkey) |
||||
|
||||
// Register session
|
||||
s.sessionsMu.Lock() |
||||
s.sessions[session.ID] = session |
||||
s.sessionsMu.Unlock() |
||||
|
||||
// Handle session
|
||||
session.Handle() |
||||
|
||||
// Unregister session
|
||||
s.sessionsMu.Lock() |
||||
delete(s.sessions, session.ID) |
||||
s.sessionsMu.Unlock() |
||||
} |
||||
|
||||
// SessionCount returns the number of active sessions.
|
||||
func (s *Server) SessionCount() int { |
||||
s.sessionsMu.RLock() |
||||
defer s.sessionsMu.RUnlock() |
||||
return len(s.sessions) |
||||
} |
||||
|
||||
// RelayPubkeyHex returns the relay's public key as hex.
|
||||
func (s *Server) RelayPubkeyHex() string { |
||||
return fmt.Sprintf("%x", s.relayPubkey) |
||||
} |
||||
|
||||
// NIP46Request represents a NIP-46 request from a client.
|
||||
type NIP46Request struct { |
||||
ID string `json:"id"` |
||||
Method string `json:"method"` |
||||
Params json.RawMessage `json:"params"` |
||||
} |
||||
|
||||
// NIP46Response represents a NIP-46 response to a client.
|
||||
type NIP46Response struct { |
||||
ID string `json:"id"` |
||||
Result any `json:"result,omitempty"` |
||||
Error string `json:"error,omitempty"` |
||||
} |
||||
@ -0,0 +1,240 @@
@@ -0,0 +1,240 @@
|
||||
package bunker |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/gorilla/websocket" |
||||
"lukechampine.com/frand" |
||||
"lol.mleku.dev/log" |
||||
|
||||
"git.mleku.dev/mleku/nostr/encoders/event" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"git.mleku.dev/mleku/nostr/encoders/timestamp" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer" |
||||
) |
||||
|
||||
// NIP-46 method names
|
||||
const ( |
||||
MethodConnect = "connect" |
||||
MethodGetPublicKey = "get_public_key" |
||||
MethodSignEvent = "sign_event" |
||||
MethodNIP04Encrypt = "nip04_encrypt" |
||||
MethodNIP04Decrypt = "nip04_decrypt" |
||||
MethodNIP44Encrypt = "nip44_encrypt" |
||||
MethodNIP44Decrypt = "nip44_decrypt" |
||||
MethodPing = "ping" |
||||
) |
||||
|
||||
// Session represents a NIP-46 client session.
|
||||
type Session struct { |
||||
ID string |
||||
conn *websocket.Conn |
||||
ctx context.Context |
||||
cancel context.CancelFunc |
||||
relaySigner signer.I |
||||
relayPubkey []byte |
||||
authenticated bool |
||||
clientPubkey []byte // Client's pubkey after connect
|
||||
} |
||||
|
||||
// NewSession creates a new bunker session.
|
||||
func NewSession(parentCtx context.Context, conn *websocket.Conn, relaySigner signer.I, relayPubkey []byte) *Session { |
||||
ctx, cancel := context.WithCancel(parentCtx) |
||||
|
||||
// Generate random session ID
|
||||
idBytes := make([]byte, 16) |
||||
frand.Read(idBytes) |
||||
|
||||
return &Session{ |
||||
ID: hex.Enc(idBytes), |
||||
conn: conn, |
||||
ctx: ctx, |
||||
cancel: cancel, |
||||
relaySigner: relaySigner, |
||||
relayPubkey: relayPubkey, |
||||
} |
||||
} |
||||
|
||||
// Handle processes messages from the client.
|
||||
func (s *Session) Handle() { |
||||
defer s.conn.Close() |
||||
defer s.cancel() |
||||
|
||||
log.D.F("bunker session started: %s", s.ID[:8]) |
||||
|
||||
for { |
||||
select { |
||||
case <-s.ctx.Done(): |
||||
return |
||||
default: |
||||
} |
||||
|
||||
// Set read deadline
|
||||
s.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) |
||||
|
||||
// Read message
|
||||
_, msg, err := s.conn.ReadMessage() |
||||
if err != nil { |
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { |
||||
log.D.F("bunker session closed normally: %s", s.ID[:8]) |
||||
} else { |
||||
log.D.F("bunker session read error: %v", err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
// Parse request
|
||||
var req NIP46Request |
||||
if err := json.Unmarshal(msg, &req); err != nil { |
||||
s.sendError("", "invalid request format") |
||||
continue |
||||
} |
||||
|
||||
// Handle request
|
||||
resp := s.handleRequest(&req) |
||||
s.sendResponse(resp) |
||||
} |
||||
} |
||||
|
||||
// handleRequest processes a NIP-46 request.
|
||||
func (s *Session) handleRequest(req *NIP46Request) *NIP46Response { |
||||
switch req.Method { |
||||
case MethodConnect: |
||||
return s.handleConnect(req) |
||||
case MethodGetPublicKey: |
||||
return s.handleGetPublicKey(req) |
||||
case MethodSignEvent: |
||||
return s.handleSignEvent(req) |
||||
case MethodPing: |
||||
return s.handlePing(req) |
||||
case MethodNIP44Encrypt, MethodNIP44Decrypt, MethodNIP04Encrypt, MethodNIP04Decrypt: |
||||
// Encryption/decryption not supported in this bunker implementation
|
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Error: "encryption methods not supported", |
||||
} |
||||
default: |
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Error: fmt.Sprintf("unsupported method: %s", req.Method), |
||||
} |
||||
} |
||||
} |
||||
|
||||
// handleConnect handles the connect method.
|
||||
func (s *Session) handleConnect(req *NIP46Request) *NIP46Response { |
||||
// Parse params: [pubkey, secret?]
|
||||
var params []string |
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil { |
||||
return &NIP46Response{ID: req.ID, Error: "invalid params"} |
||||
} |
||||
|
||||
if len(params) < 1 { |
||||
return &NIP46Response{ID: req.ID, Error: "missing pubkey"} |
||||
} |
||||
|
||||
pubkeyHex := params[0] |
||||
clientPubkey, err := hex.Dec(pubkeyHex) |
||||
if err != nil || len(clientPubkey) != 32 { |
||||
return &NIP46Response{ID: req.ID, Error: "invalid pubkey"} |
||||
} |
||||
|
||||
s.clientPubkey = clientPubkey |
||||
s.authenticated = true |
||||
|
||||
log.I.F("bunker session authenticated: %s (client=%s...)", |
||||
s.ID[:8], pubkeyHex[:16]) |
||||
|
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Result: "ack", |
||||
} |
||||
} |
||||
|
||||
// handleGetPublicKey returns the relay's public key.
|
||||
func (s *Session) handleGetPublicKey(req *NIP46Request) *NIP46Response { |
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Result: hex.Enc(s.relayPubkey), |
||||
} |
||||
} |
||||
|
||||
// handleSignEvent signs an event with the relay's key.
|
||||
func (s *Session) handleSignEvent(req *NIP46Request) *NIP46Response { |
||||
if !s.authenticated { |
||||
return &NIP46Response{ID: req.ID, Error: "not authenticated"} |
||||
} |
||||
|
||||
// Parse event from params
|
||||
var params []json.RawMessage |
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil { |
||||
return &NIP46Response{ID: req.ID, Error: "invalid params"} |
||||
} |
||||
|
||||
if len(params) < 1 { |
||||
return &NIP46Response{ID: req.ID, Error: "missing event"} |
||||
} |
||||
|
||||
// Parse the event
|
||||
ev := &event.E{} |
||||
if err := json.Unmarshal(params[0], ev); err != nil { |
||||
return &NIP46Response{ID: req.ID, Error: "invalid event"} |
||||
} |
||||
|
||||
// Set pubkey to relay's pubkey
|
||||
copy(ev.Pubkey[:], s.relayPubkey) |
||||
|
||||
// Set created_at if not set
|
||||
if ev.CreatedAt == 0 { |
||||
ev.CreatedAt = timestamp.Now().V |
||||
} |
||||
|
||||
// Sign the event
|
||||
if err := ev.Sign(s.relaySigner); err != nil { |
||||
return &NIP46Response{ID: req.ID, Error: fmt.Sprintf("signing failed: %v", err)} |
||||
} |
||||
|
||||
// Return signed event as JSON
|
||||
signedJSON, err := json.Marshal(ev) |
||||
if err != nil { |
||||
return &NIP46Response{ID: req.ID, Error: "marshal failed"} |
||||
} |
||||
|
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Result: string(signedJSON), |
||||
} |
||||
} |
||||
|
||||
// handlePing responds to ping requests.
|
||||
func (s *Session) handlePing(req *NIP46Request) *NIP46Response { |
||||
return &NIP46Response{ |
||||
ID: req.ID, |
||||
Result: "pong", |
||||
} |
||||
} |
||||
|
||||
// sendResponse sends a response to the client.
|
||||
func (s *Session) sendResponse(resp *NIP46Response) { |
||||
data, err := json.Marshal(resp) |
||||
if err != nil { |
||||
log.E.F("bunker marshal error: %v", err) |
||||
return |
||||
} |
||||
|
||||
s.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) |
||||
if err := s.conn.WriteMessage(websocket.TextMessage, data); err != nil { |
||||
log.E.F("bunker write error: %v", err) |
||||
} |
||||
} |
||||
|
||||
// sendError sends an error response.
|
||||
func (s *Session) sendError(id, msg string) { |
||||
s.sendResponse(&NIP46Response{ |
||||
ID: id, |
||||
Error: msg, |
||||
}) |
||||
} |
||||
@ -0,0 +1,591 @@
@@ -0,0 +1,591 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package database |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/dgraph-io/badger/v4" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
|
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"next.orly.dev/pkg/wireguard" |
||||
) |
||||
|
||||
// Key prefixes for WireGuard data
|
||||
const ( |
||||
wgServerKeyPrefix = "wg:server:key" // Server's WireGuard private key
|
||||
wgSubnetSeedPrefix = "wg:subnet:seed" // Seed for deterministic subnet generation
|
||||
wgPeerPrefix = "wg:peer:" // Peer data by Nostr pubkey hex
|
||||
wgSequenceKey = "wg:seq" // Badger sequence key for subnet allocation
|
||||
wgRevokedPrefix = "wg:revoked:" // Revoked keypairs by Nostr pubkey hex
|
||||
wgAccessLogPrefix = "wg:accesslog:" // Access log for obsolete addresses
|
||||
) |
||||
|
||||
// WireGuardPeer stores WireGuard peer information in the database.
|
||||
type WireGuardPeer struct { |
||||
NostrPubkey []byte `json:"nostr_pubkey"` // User's Nostr pubkey (32 bytes)
|
||||
WGPrivateKey []byte `json:"wg_private_key"` // WireGuard private key (32 bytes)
|
||||
WGPublicKey []byte `json:"wg_public_key"` // WireGuard public key (32 bytes)
|
||||
Sequence uint32 `json:"sequence"` // Sequence number for subnet derivation
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
} |
||||
|
||||
// WireGuardRevokedKey stores a revoked/old WireGuard keypair for audit purposes.
|
||||
type WireGuardRevokedKey struct { |
||||
NostrPubkey []byte `json:"nostr_pubkey"` // User's Nostr pubkey (32 bytes)
|
||||
WGPublicKey []byte `json:"wg_public_key"` // Revoked WireGuard public key (32 bytes)
|
||||
Sequence uint32 `json:"sequence"` // Sequence number (subnet)
|
||||
CreatedAt int64 `json:"created_at"` // When the key was originally created
|
||||
RevokedAt int64 `json:"revoked_at"` // When the key was revoked
|
||||
AccessCount int `json:"access_count"` // Number of access attempts since revocation
|
||||
LastAccessAt int64 `json:"last_access_at"` // Last access attempt timestamp (0 if never)
|
||||
} |
||||
|
||||
// WireGuardAccessLog records an access attempt to an obsolete address.
|
||||
type WireGuardAccessLog struct { |
||||
NostrPubkey []byte `json:"nostr_pubkey"` // User's Nostr pubkey
|
||||
WGPublicKey []byte `json:"wg_public_key"` // The obsolete public key used
|
||||
Sequence uint32 `json:"sequence"` // Subnet sequence
|
||||
Timestamp int64 `json:"timestamp"` // When the access occurred
|
||||
RemoteAddr string `json:"remote_addr"` // Remote IP address
|
||||
} |
||||
|
||||
// ServerIP returns the derived server IP for this peer's subnet.
|
||||
func (p *WireGuardPeer) ServerIP(pool *wireguard.SubnetPool) string { |
||||
subnet := pool.SubnetForSequence(p.Sequence) |
||||
return subnet.ServerIP.String() |
||||
} |
||||
|
||||
// ClientIP returns the derived client IP for this peer's subnet.
|
||||
func (p *WireGuardPeer) ClientIP(pool *wireguard.SubnetPool) string { |
||||
subnet := pool.SubnetForSequence(p.Sequence) |
||||
return subnet.ClientIP.String() |
||||
} |
||||
|
||||
// GetWireGuardServerKey retrieves the WireGuard server private key.
|
||||
func (d *D) GetWireGuardServerKey() (key []byte, err error) { |
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(wgServerKeyPrefix)) |
||||
if errors.Is(err, badger.ErrKeyNotFound) { |
||||
return err |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return item.Value(func(val []byte) error { |
||||
key = make([]byte, len(val)) |
||||
copy(key, val) |
||||
return nil |
||||
}) |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// SetWireGuardServerKey stores the WireGuard server private key.
|
||||
func (d *D) SetWireGuardServerKey(key []byte) error { |
||||
if len(key) != 32 { |
||||
return fmt.Errorf("invalid key length: %d (expected 32)", len(key)) |
||||
} |
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set([]byte(wgServerKeyPrefix), key) |
||||
}) |
||||
} |
||||
|
||||
// GetOrCreateWireGuardServerKey retrieves or creates the WireGuard server key.
|
||||
func (d *D) GetOrCreateWireGuardServerKey() (key []byte, err error) { |
||||
// Try to get existing key
|
||||
if key, err = d.GetWireGuardServerKey(); err == nil && len(key) == 32 { |
||||
return key, nil |
||||
} |
||||
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { |
||||
return nil, err |
||||
} |
||||
|
||||
// Generate new keypair
|
||||
privateKey, publicKey, err := wireguard.GenerateKeyPair() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to generate WireGuard keypair: %w", err) |
||||
} |
||||
|
||||
// Store the private key
|
||||
if err = d.SetWireGuardServerKey(privateKey); chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
log.I.F("generated new WireGuard server key (pubkey=%s...)", hex.Enc(publicKey[:8])) |
||||
return privateKey, nil |
||||
} |
||||
|
||||
// GetSubnetSeed retrieves the subnet pool seed.
|
||||
func (d *D) GetSubnetSeed() (seed []byte, err error) { |
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(wgSubnetSeedPrefix)) |
||||
if errors.Is(err, badger.ErrKeyNotFound) { |
||||
return err |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return item.Value(func(val []byte) error { |
||||
seed = make([]byte, len(val)) |
||||
copy(seed, val) |
||||
return nil |
||||
}) |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// SetSubnetSeed stores the subnet pool seed.
|
||||
func (d *D) SetSubnetSeed(seed []byte) error { |
||||
if len(seed) != 32 { |
||||
return fmt.Errorf("invalid seed length: %d (expected 32)", len(seed)) |
||||
} |
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set([]byte(wgSubnetSeedPrefix), seed) |
||||
}) |
||||
} |
||||
|
||||
// GetOrCreateSubnetPool creates or restores a subnet pool from the database.
|
||||
func (d *D) GetOrCreateSubnetPool(baseNetwork string) (*wireguard.SubnetPool, error) { |
||||
// Try to get existing seed
|
||||
seed, err := d.GetSubnetSeed() |
||||
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { |
||||
return nil, err |
||||
} |
||||
|
||||
var pool *wireguard.SubnetPool |
||||
|
||||
if len(seed) == 32 { |
||||
// Restore pool with existing seed
|
||||
pool, err = wireguard.NewSubnetPoolWithSeed(baseNetwork, seed) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
log.D.F("restored subnet pool with existing seed") |
||||
} else { |
||||
// Create new pool with random seed
|
||||
pool, err = wireguard.NewSubnetPool(baseNetwork) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Store the new seed
|
||||
if err = d.SetSubnetSeed(pool.Seed()); err != nil { |
||||
return nil, fmt.Errorf("failed to store subnet seed: %w", err) |
||||
} |
||||
log.I.F("generated new subnet pool seed") |
||||
} |
||||
|
||||
// Restore existing allocations from database
|
||||
peers, err := d.GetAllWireGuardPeers() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to load existing peers: %w", err) |
||||
} |
||||
|
||||
for _, peer := range peers { |
||||
pool.RestoreAllocation(hex.Enc(peer.NostrPubkey), peer.Sequence) |
||||
} |
||||
|
||||
if len(peers) > 0 { |
||||
log.D.F("restored %d subnet allocations", len(peers)) |
||||
} |
||||
|
||||
return pool, nil |
||||
} |
||||
|
||||
// GetWireGuardPeer retrieves a WireGuard peer by Nostr pubkey.
|
||||
func (d *D) GetWireGuardPeer(nostrPubkey []byte) (peer *WireGuardPeer, err error) { |
||||
key := append([]byte(wgPeerPrefix), []byte(hex.Enc(nostrPubkey))...) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get(key) |
||||
if errors.Is(err, badger.ErrKeyNotFound) { |
||||
return err |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return item.Value(func(val []byte) error { |
||||
peer = &WireGuardPeer{} |
||||
return json.Unmarshal(val, peer) |
||||
}) |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// GetOrCreateWireGuardPeer retrieves or creates a WireGuard peer.
|
||||
// The pool is used for subnet derivation from the sequence number.
|
||||
func (d *D) GetOrCreateWireGuardPeer(nostrPubkey []byte, pool *wireguard.SubnetPool) (peer *WireGuardPeer, err error) { |
||||
// Try to get existing peer
|
||||
if peer, err = d.GetWireGuardPeer(nostrPubkey); err == nil { |
||||
return peer, nil |
||||
} |
||||
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { |
||||
return nil, err |
||||
} |
||||
|
||||
// Generate new WireGuard keypair
|
||||
privateKey, publicKey, err := wireguard.GenerateKeyPair() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to generate WireGuard keypair: %w", err) |
||||
} |
||||
|
||||
// Get next sequence number from Badger's sequence
|
||||
seq64, err := d.GetNextWGSequence() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to allocate sequence: %w", err) |
||||
} |
||||
seq := uint32(seq64) |
||||
|
||||
// Register allocation with pool for in-memory tracking
|
||||
pubkeyHex := hex.Enc(nostrPubkey) |
||||
pool.RestoreAllocation(pubkeyHex, seq) |
||||
|
||||
peer = &WireGuardPeer{ |
||||
NostrPubkey: nostrPubkey, |
||||
WGPrivateKey: privateKey, |
||||
WGPublicKey: publicKey, |
||||
Sequence: seq, |
||||
CreatedAt: time.Now().Unix(), |
||||
} |
||||
|
||||
// Store peer data
|
||||
if err = d.setWireGuardPeer(peer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
subnet := pool.SubnetForSequence(seq) |
||||
log.I.F("created WireGuard peer: nostr=%s... -> subnet %s/%s (seq=%d)", |
||||
hex.Enc(nostrPubkey[:8]), subnet.ServerIP, subnet.ClientIP, seq) |
||||
|
||||
return peer, nil |
||||
} |
||||
|
||||
// RegenerateWireGuardPeer generates a new keypair for an existing peer.
|
||||
// The sequence number (and thus subnet) is preserved.
|
||||
// The old keypair is archived for audit purposes.
|
||||
func (d *D) RegenerateWireGuardPeer(nostrPubkey []byte, pool *wireguard.SubnetPool) (peer *WireGuardPeer, err error) { |
||||
// Get existing peer to preserve sequence
|
||||
existing, err := d.GetWireGuardPeer(nostrPubkey) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Archive the old keypair for audit purposes
|
||||
if err = d.ArchiveRevokedKey(existing); err != nil { |
||||
log.W.F("failed to archive revoked key: %v", err) |
||||
// Continue anyway - this is audit logging, not critical
|
||||
} |
||||
|
||||
// Generate new WireGuard keypair
|
||||
privateKey, publicKey, err := wireguard.GenerateKeyPair() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to generate WireGuard keypair: %w", err) |
||||
} |
||||
|
||||
peer = &WireGuardPeer{ |
||||
NostrPubkey: nostrPubkey, |
||||
WGPrivateKey: privateKey, |
||||
WGPublicKey: publicKey, |
||||
Sequence: existing.Sequence, // Keep same sequence (same subnet)
|
||||
CreatedAt: time.Now().Unix(), |
||||
} |
||||
|
||||
// Store updated peer data
|
||||
if err = d.setWireGuardPeer(peer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
subnet := pool.SubnetForSequence(peer.Sequence) |
||||
log.I.F("regenerated WireGuard peer: nostr=%s... -> subnet %s/%s (old key archived)", |
||||
hex.Enc(nostrPubkey[:8]), subnet.ServerIP, subnet.ClientIP) |
||||
|
||||
return peer, nil |
||||
} |
||||
|
||||
// DeleteWireGuardPeer removes a WireGuard peer from the database.
|
||||
// Note: The sequence number is not recycled to prevent subnet reuse.
|
||||
func (d *D) DeleteWireGuardPeer(nostrPubkey []byte) error { |
||||
peerKey := append([]byte(wgPeerPrefix), []byte(hex.Enc(nostrPubkey))...) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
if err := txn.Delete(peerKey); err != nil && !errors.Is(err, badger.ErrKeyNotFound) { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
// GetAllWireGuardPeers returns all WireGuard peers.
|
||||
func (d *D) GetAllWireGuardPeers() (peers []*WireGuardPeer, err error) { |
||||
prefix := []byte(wgPeerPrefix) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
err := item.Value(func(val []byte) error { |
||||
peer := &WireGuardPeer{} |
||||
if err := json.Unmarshal(val, peer); err != nil { |
||||
return err |
||||
} |
||||
peers = append(peers, peer) |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// setWireGuardPeer stores a WireGuard peer in the database.
|
||||
func (d *D) setWireGuardPeer(peer *WireGuardPeer) error { |
||||
data, err := json.Marshal(peer) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal peer: %w", err) |
||||
} |
||||
|
||||
peerKey := append([]byte(wgPeerPrefix), []byte(hex.Enc(peer.NostrPubkey))...) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set(peerKey, data) |
||||
}) |
||||
} |
||||
|
||||
// GetNextWGSequence retrieves and increments the sequence counter using Badger's Sequence.
|
||||
func (d *D) GetNextWGSequence() (seq uint64, err error) { |
||||
// Get a sequence with bandwidth 1 (allocate 1 number at a time)
|
||||
badgerSeq, err := d.DB.GetSequence([]byte(wgSequenceKey), 1) |
||||
if err != nil { |
||||
return 0, fmt.Errorf("failed to get sequence: %w", err) |
||||
} |
||||
defer badgerSeq.Release() |
||||
|
||||
seq, err = badgerSeq.Next() |
||||
if err != nil { |
||||
return 0, fmt.Errorf("failed to get next sequence number: %w", err) |
||||
} |
||||
return seq, nil |
||||
} |
||||
|
||||
// ArchiveRevokedKey stores a revoked keypair for audit purposes.
|
||||
func (d *D) ArchiveRevokedKey(peer *WireGuardPeer) error { |
||||
revoked := &WireGuardRevokedKey{ |
||||
NostrPubkey: peer.NostrPubkey, |
||||
WGPublicKey: peer.WGPublicKey, |
||||
Sequence: peer.Sequence, |
||||
CreatedAt: peer.CreatedAt, |
||||
RevokedAt: time.Now().Unix(), |
||||
AccessCount: 0, |
||||
LastAccessAt: 0, |
||||
} |
||||
|
||||
data, err := json.Marshal(revoked) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal revoked key: %w", err) |
||||
} |
||||
|
||||
// Key: wg:revoked:<pubkey-hex>:<revoked-timestamp>
|
||||
keyStr := fmt.Sprintf("%s%s:%d", wgRevokedPrefix, hex.Enc(peer.NostrPubkey), revoked.RevokedAt) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set([]byte(keyStr), data) |
||||
}) |
||||
} |
||||
|
||||
// GetRevokedKeys returns all revoked keys for a user.
|
||||
func (d *D) GetRevokedKeys(nostrPubkey []byte) (keys []*WireGuardRevokedKey, err error) { |
||||
prefix := []byte(wgRevokedPrefix + hex.Enc(nostrPubkey) + ":") |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
err := item.Value(func(val []byte) error { |
||||
key := &WireGuardRevokedKey{} |
||||
if err := json.Unmarshal(val, key); err != nil { |
||||
return err |
||||
} |
||||
keys = append(keys, key) |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// GetAllRevokedKeys returns all revoked keys across all users (admin view).
|
||||
func (d *D) GetAllRevokedKeys() (keys []*WireGuardRevokedKey, err error) { |
||||
prefix := []byte(wgRevokedPrefix) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
err := item.Value(func(val []byte) error { |
||||
key := &WireGuardRevokedKey{} |
||||
if err := json.Unmarshal(val, key); err != nil { |
||||
return err |
||||
} |
||||
keys = append(keys, key) |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// LogObsoleteAccess records an access attempt to an obsolete WireGuard address.
|
||||
func (d *D) LogObsoleteAccess(nostrPubkey, wgPubkey []byte, sequence uint32, remoteAddr string) error { |
||||
now := time.Now().Unix() |
||||
|
||||
logEntry := &WireGuardAccessLog{ |
||||
NostrPubkey: nostrPubkey, |
||||
WGPublicKey: wgPubkey, |
||||
Sequence: sequence, |
||||
Timestamp: now, |
||||
RemoteAddr: remoteAddr, |
||||
} |
||||
|
||||
data, err := json.Marshal(logEntry) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal access log: %w", err) |
||||
} |
||||
|
||||
// Key: wg:accesslog:<pubkey-hex>:<timestamp>
|
||||
keyStr := fmt.Sprintf("%s%s:%d", wgAccessLogPrefix, hex.Enc(nostrPubkey), now) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set([]byte(keyStr), data) |
||||
}) |
||||
} |
||||
|
||||
// GetAccessLogs returns access logs for a user.
|
||||
func (d *D) GetAccessLogs(nostrPubkey []byte) (logs []*WireGuardAccessLog, err error) { |
||||
prefix := []byte(wgAccessLogPrefix + hex.Enc(nostrPubkey) + ":") |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
err := item.Value(func(val []byte) error { |
||||
logEntry := &WireGuardAccessLog{} |
||||
if err := json.Unmarshal(val, logEntry); err != nil { |
||||
return err |
||||
} |
||||
logs = append(logs, logEntry) |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// GetAllAccessLogs returns all access logs (admin view).
|
||||
func (d *D) GetAllAccessLogs() (logs []*WireGuardAccessLog, err error) { |
||||
prefix := []byte(wgAccessLogPrefix) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
err := item.Value(func(val []byte) error { |
||||
logEntry := &WireGuardAccessLog{} |
||||
if err := json.Unmarshal(val, logEntry); err != nil { |
||||
return err |
||||
} |
||||
logs = append(logs, logEntry) |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// IncrementRevokedKeyAccess updates the access count for a revoked key.
|
||||
func (d *D) IncrementRevokedKeyAccess(nostrPubkey, wgPubkey []byte) error { |
||||
// Find and update the matching revoked key
|
||||
prefix := []byte(wgRevokedPrefix + hex.Enc(nostrPubkey) + ":") |
||||
wgPubkeyHex := hex.Enc(wgPubkey) |
||||
now := time.Now().Unix() |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
key := item.KeyCopy(nil) |
||||
|
||||
err := item.Value(func(val []byte) error { |
||||
revoked := &WireGuardRevokedKey{} |
||||
if err := json.Unmarshal(val, revoked); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Check if this is the matching revoked key
|
||||
if hex.Enc(revoked.WGPublicKey) == wgPubkeyHex { |
||||
revoked.AccessCount++ |
||||
revoked.LastAccessAt = now |
||||
|
||||
data, err := json.Marshal(revoked) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return txn.Set(key, data) |
||||
} |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
package wireguard |
||||
|
||||
import "errors" |
||||
|
||||
var ( |
||||
// ErrInvalidKeyLength is returned when a key is not exactly 32 bytes.
|
||||
ErrInvalidKeyLength = errors.New("invalid key length: must be 32 bytes") |
||||
|
||||
// ErrServerNotRunning is returned when an operation requires a running server.
|
||||
ErrServerNotRunning = errors.New("wireguard server not running") |
||||
|
||||
// ErrEndpointRequired is returned when WireGuard is enabled but no endpoint is set.
|
||||
ErrEndpointRequired = errors.New("ORLY_WG_ENDPOINT is required when WireGuard is enabled") |
||||
|
||||
// ErrInvalidNetwork is returned when the network CIDR is invalid.
|
||||
ErrInvalidNetwork = errors.New("invalid network CIDR") |
||||
|
||||
// ErrPeerNotFound is returned when a peer lookup fails.
|
||||
ErrPeerNotFound = errors.New("peer not found") |
||||
|
||||
// ErrIPExhausted is returned when no more IPs are available in the network.
|
||||
ErrIPExhausted = errors.New("no more IP addresses available in network") |
||||
) |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
// Package wireguard provides an embedded WireGuard VPN server for secure
|
||||
// NIP-46 bunker access. It uses wireguard-go with gVisor netstack for
|
||||
// userspace networking (no root required).
|
||||
package wireguard |
||||
|
||||
import ( |
||||
"crypto/rand" |
||||
|
||||
"golang.org/x/crypto/curve25519" |
||||
) |
||||
|
||||
// GenerateKeyPair generates a new Curve25519 keypair for WireGuard.
|
||||
// Returns the private key and public key as 32-byte slices.
|
||||
func GenerateKeyPair() (privateKey, publicKey []byte, err error) { |
||||
privateKey = make([]byte, 32) |
||||
if _, err = rand.Read(privateKey); err != nil { |
||||
return nil, nil, err |
||||
} |
||||
|
||||
// Curve25519 clamping (required by WireGuard spec)
|
||||
privateKey[0] &= 248 |
||||
privateKey[31] &= 127 |
||||
privateKey[31] |= 64 |
||||
|
||||
// Derive public key from private key
|
||||
publicKey = make([]byte, 32) |
||||
curve25519.ScalarBaseMult((*[32]byte)(publicKey), (*[32]byte)(privateKey)) |
||||
|
||||
return privateKey, publicKey, nil |
||||
} |
||||
|
||||
// DerivePublicKey derives the public key from a private key.
|
||||
func DerivePublicKey(privateKey []byte) (publicKey []byte, err error) { |
||||
if len(privateKey) != 32 { |
||||
return nil, ErrInvalidKeyLength |
||||
} |
||||
|
||||
publicKey = make([]byte, 32) |
||||
curve25519.ScalarBaseMult((*[32]byte)(publicKey), (*[32]byte)(privateKey)) |
||||
|
||||
return publicKey, nil |
||||
} |
||||
@ -0,0 +1,281 @@
@@ -0,0 +1,281 @@
|
||||
package wireguard |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/base64" |
||||
"encoding/hex" |
||||
"fmt" |
||||
"net" |
||||
"net/netip" |
||||
"sync" |
||||
|
||||
"golang.zx2c4.com/wireguard/conn" |
||||
"golang.zx2c4.com/wireguard/device" |
||||
"golang.zx2c4.com/wireguard/tun" |
||||
"golang.zx2c4.com/wireguard/tun/netstack" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// Config holds the WireGuard server configuration.
|
||||
type Config struct { |
||||
Port int // UDP port for WireGuard (default 51820)
|
||||
Endpoint string // Public IP/domain for clients to connect to
|
||||
PrivateKey []byte // Server's 32-byte Curve25519 private key
|
||||
Network string // CIDR for internal network (e.g., "10.73.0.0/16")
|
||||
ServerIP string // Server's internal IP (e.g., "10.73.0.1")
|
||||
} |
||||
|
||||
// Peer represents a WireGuard peer (client).
|
||||
type Peer struct { |
||||
NostrPubkey []byte // User's Nostr pubkey (32 bytes)
|
||||
WGPublicKey []byte // WireGuard public key (32 bytes)
|
||||
AssignedIP string // Assigned internal IP
|
||||
} |
||||
|
||||
// Server manages the embedded WireGuard VPN server.
|
||||
type Server struct { |
||||
cfg *Config |
||||
device *device.Device |
||||
tun *netstack.Net |
||||
tunDev tun.Device |
||||
publicKey []byte |
||||
|
||||
peers map[string]*Peer // WG pubkey (base64) -> Peer
|
||||
peersMu sync.RWMutex |
||||
|
||||
ctx context.Context |
||||
cancel context.CancelFunc |
||||
running bool |
||||
mu sync.RWMutex |
||||
} |
||||
|
||||
// New creates a new WireGuard server with the given configuration.
|
||||
func New(cfg *Config) (*Server, error) { |
||||
if cfg.Endpoint == "" { |
||||
return nil, ErrEndpointRequired |
||||
} |
||||
|
||||
// Parse network CIDR to validate it
|
||||
_, _, err := net.ParseCIDR(cfg.Network) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidNetwork, err) |
||||
} |
||||
|
||||
// Default server IP if not set
|
||||
if cfg.ServerIP == "" { |
||||
cfg.ServerIP = "10.73.0.1" |
||||
} |
||||
|
||||
// Derive public key from private key
|
||||
publicKey, err := DerivePublicKey(cfg.PrivateKey) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to derive public key: %w", err) |
||||
} |
||||
|
||||
return &Server{ |
||||
cfg: cfg, |
||||
publicKey: publicKey, |
||||
peers: make(map[string]*Peer), |
||||
}, nil |
||||
} |
||||
|
||||
// Start initializes and starts the WireGuard server.
|
||||
func (s *Server) Start() error { |
||||
s.mu.Lock() |
||||
defer s.mu.Unlock() |
||||
|
||||
if s.running { |
||||
return nil |
||||
} |
||||
|
||||
s.ctx, s.cancel = context.WithCancel(context.Background()) |
||||
|
||||
// Parse server IP
|
||||
serverAddr, err := netip.ParseAddr(s.cfg.ServerIP) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid server IP: %w", err) |
||||
} |
||||
|
||||
// Create netstack TUN device (userspace, no root required)
|
||||
s.tunDev, s.tun, err = netstack.CreateNetTUN( |
||||
[]netip.Addr{serverAddr}, |
||||
[]netip.Addr{}, // No DNS servers
|
||||
1420, // MTU
|
||||
) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create netstack TUN: %w", err) |
||||
} |
||||
|
||||
// Create WireGuard device
|
||||
s.device = device.NewDevice( |
||||
s.tunDev, |
||||
conn.NewDefaultBind(), |
||||
device.NewLogger(device.LogLevelSilent, "wg"), |
||||
) |
||||
|
||||
// Configure device with server private key and listen port
|
||||
privateKeyHex := hex.EncodeToString(s.cfg.PrivateKey) |
||||
ipcConfig := fmt.Sprintf("private_key=%s\nlisten_port=%d\n", |
||||
privateKeyHex, |
||||
s.cfg.Port, |
||||
) |
||||
|
||||
if err = s.device.IpcSet(ipcConfig); err != nil { |
||||
s.device.Close() |
||||
return fmt.Errorf("failed to configure WireGuard device: %w", err) |
||||
} |
||||
|
||||
// Bring up the device
|
||||
if err = s.device.Up(); err != nil { |
||||
s.device.Close() |
||||
return fmt.Errorf("failed to bring up WireGuard device: %w", err) |
||||
} |
||||
|
||||
s.running = true |
||||
log.I.F("WireGuard server started on UDP port %d", s.cfg.Port) |
||||
log.I.F("WireGuard server public key: %s", base64.StdEncoding.EncodeToString(s.publicKey)) |
||||
log.I.F("WireGuard internal network: %s (server: %s)", s.cfg.Network, s.cfg.ServerIP) |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Stop shuts down the WireGuard server.
|
||||
func (s *Server) Stop() error { |
||||
s.mu.Lock() |
||||
defer s.mu.Unlock() |
||||
|
||||
if !s.running { |
||||
return nil |
||||
} |
||||
|
||||
if s.cancel != nil { |
||||
s.cancel() |
||||
} |
||||
|
||||
if s.device != nil { |
||||
s.device.Close() |
||||
} |
||||
|
||||
s.running = false |
||||
log.I.F("WireGuard server stopped") |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// IsRunning returns whether the server is currently running.
|
||||
func (s *Server) IsRunning() bool { |
||||
s.mu.RLock() |
||||
defer s.mu.RUnlock() |
||||
return s.running |
||||
} |
||||
|
||||
// ServerPublicKey returns the server's WireGuard public key.
|
||||
func (s *Server) ServerPublicKey() []byte { |
||||
return s.publicKey |
||||
} |
||||
|
||||
// Endpoint returns the configured endpoint address.
|
||||
func (s *Server) Endpoint() string { |
||||
return fmt.Sprintf("%s:%d", s.cfg.Endpoint, s.cfg.Port) |
||||
} |
||||
|
||||
// GetNetstack returns the netstack networking interface.
|
||||
// This is used by the bunker to listen on the WireGuard network.
|
||||
func (s *Server) GetNetstack() *netstack.Net { |
||||
s.mu.RLock() |
||||
defer s.mu.RUnlock() |
||||
return s.tun |
||||
} |
||||
|
||||
// ServerIP returns the server's internal IP address.
|
||||
func (s *Server) ServerIP() string { |
||||
return s.cfg.ServerIP |
||||
} |
||||
|
||||
// AddPeer adds a new peer to the WireGuard server.
|
||||
func (s *Server) AddPeer(nostrPubkey, wgPublicKey []byte, assignedIP string) error { |
||||
s.mu.RLock() |
||||
if !s.running { |
||||
s.mu.RUnlock() |
||||
return ErrServerNotRunning |
||||
} |
||||
s.mu.RUnlock() |
||||
|
||||
// Encode WG public key as hex for IPC
|
||||
wgPubkeyHex := hex.EncodeToString(wgPublicKey) |
||||
wgPubkeyBase64 := base64.StdEncoding.EncodeToString(wgPublicKey) |
||||
|
||||
// Configure peer in WireGuard device
|
||||
ipcConfig := fmt.Sprintf( |
||||
"public_key=%s\nallowed_ip=%s/32\n", |
||||
wgPubkeyHex, |
||||
assignedIP, |
||||
) |
||||
|
||||
if err := s.device.IpcSet(ipcConfig); err != nil { |
||||
return fmt.Errorf("failed to add peer: %w", err) |
||||
} |
||||
|
||||
// Track peer
|
||||
s.peersMu.Lock() |
||||
s.peers[wgPubkeyBase64] = &Peer{ |
||||
NostrPubkey: nostrPubkey, |
||||
WGPublicKey: wgPublicKey, |
||||
AssignedIP: assignedIP, |
||||
} |
||||
s.peersMu.Unlock() |
||||
|
||||
log.I.F("WireGuard peer added: %s -> %s", wgPubkeyBase64[:16]+"...", assignedIP) |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// RemovePeer removes a peer from the WireGuard server.
|
||||
func (s *Server) RemovePeer(wgPublicKey []byte) error { |
||||
s.mu.RLock() |
||||
if !s.running { |
||||
s.mu.RUnlock() |
||||
return ErrServerNotRunning |
||||
} |
||||
s.mu.RUnlock() |
||||
|
||||
wgPubkeyHex := hex.EncodeToString(wgPublicKey) |
||||
wgPubkeyBase64 := base64.StdEncoding.EncodeToString(wgPublicKey) |
||||
|
||||
// Remove peer from WireGuard device
|
||||
ipcConfig := fmt.Sprintf( |
||||
"public_key=%s\nremove=true\n", |
||||
wgPubkeyHex, |
||||
) |
||||
|
||||
if err := s.device.IpcSet(ipcConfig); err != nil { |
||||
return fmt.Errorf("failed to remove peer: %w", err) |
||||
} |
||||
|
||||
// Remove from tracking
|
||||
s.peersMu.Lock() |
||||
delete(s.peers, wgPubkeyBase64) |
||||
s.peersMu.Unlock() |
||||
|
||||
log.I.F("WireGuard peer removed: %s", wgPubkeyBase64[:16]+"...") |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// GetPeer returns a peer by their WireGuard public key.
|
||||
func (s *Server) GetPeer(wgPublicKey []byte) (*Peer, bool) { |
||||
wgPubkeyBase64 := base64.StdEncoding.EncodeToString(wgPublicKey) |
||||
|
||||
s.peersMu.RLock() |
||||
defer s.peersMu.RUnlock() |
||||
|
||||
peer, ok := s.peers[wgPubkeyBase64] |
||||
return peer, ok |
||||
} |
||||
|
||||
// PeerCount returns the number of active peers.
|
||||
func (s *Server) PeerCount() int { |
||||
s.peersMu.RLock() |
||||
defer s.peersMu.RUnlock() |
||||
return len(s.peers) |
||||
} |
||||
@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
|
||||
package wireguard |
||||
|
||||
import ( |
||||
"crypto/sha256" |
||||
"encoding/binary" |
||||
"fmt" |
||||
"net/netip" |
||||
"sync" |
||||
|
||||
"lukechampine.com/frand" |
||||
) |
||||
|
||||
// Subnet represents a /31 point-to-point subnet.
|
||||
type Subnet struct { |
||||
ServerIP netip.Addr // Even address (server side)
|
||||
ClientIP netip.Addr // Odd address (client side)
|
||||
} |
||||
|
||||
// SubnetPool manages deterministic /31 subnet generation from a seed.
|
||||
// Given the same seed and sequence number, the same subnet is always generated.
|
||||
type SubnetPool struct { |
||||
seed [32]byte // Random seed for deterministic generation
|
||||
basePrefix netip.Prefix // e.g., 10.0.0.0/8
|
||||
maxSeq uint32 // Current highest sequence number
|
||||
assigned map[string]uint32 // Client pubkey hex -> sequence number
|
||||
mu sync.RWMutex |
||||
} |
||||
|
||||
// NewSubnetPool creates a subnet pool with a new random seed.
|
||||
func NewSubnetPool(baseNetwork string) (*SubnetPool, error) { |
||||
prefix, err := netip.ParsePrefix(baseNetwork) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid base network: %w", err) |
||||
} |
||||
|
||||
var seed [32]byte |
||||
frand.Read(seed[:]) |
||||
|
||||
return &SubnetPool{ |
||||
seed: seed, |
||||
basePrefix: prefix, |
||||
maxSeq: 0, |
||||
assigned: make(map[string]uint32), |
||||
}, nil |
||||
} |
||||
|
||||
// NewSubnetPoolWithSeed creates a subnet pool with an existing seed.
|
||||
func NewSubnetPoolWithSeed(baseNetwork string, seed []byte) (*SubnetPool, error) { |
||||
prefix, err := netip.ParsePrefix(baseNetwork) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid base network: %w", err) |
||||
} |
||||
|
||||
if len(seed) != 32 { |
||||
return nil, fmt.Errorf("seed must be 32 bytes, got %d", len(seed)) |
||||
} |
||||
|
||||
pool := &SubnetPool{ |
||||
basePrefix: prefix, |
||||
maxSeq: 0, |
||||
assigned: make(map[string]uint32), |
||||
} |
||||
copy(pool.seed[:], seed) |
||||
|
||||
return pool, nil |
||||
} |
||||
|
||||
// Seed returns the pool's seed for persistence.
|
||||
func (p *SubnetPool) Seed() []byte { |
||||
return p.seed[:] |
||||
} |
||||
|
||||
// deriveSubnet deterministically generates a /31 subnet from seed + sequence.
|
||||
func (p *SubnetPool) deriveSubnet(seq uint32) Subnet { |
||||
// Hash seed + sequence to get deterministic randomness
|
||||
h := sha256.New() |
||||
h.Write(p.seed[:]) |
||||
binary.Write(h, binary.BigEndian, seq) |
||||
hash := h.Sum(nil) |
||||
|
||||
// Use first 4 bytes as offset within the prefix
|
||||
offset := binary.BigEndian.Uint32(hash[:4]) |
||||
|
||||
// Calculate available address space
|
||||
bits := p.basePrefix.Bits() |
||||
availableBits := uint32(32 - bits) |
||||
maxOffset := uint32(1) << availableBits |
||||
|
||||
// Make offset even (for /31 alignment) and within range
|
||||
offset = (offset % (maxOffset / 2)) * 2 |
||||
|
||||
// Calculate server IP (even) and client IP (odd)
|
||||
baseAddr := p.basePrefix.Addr() |
||||
baseBytes := baseAddr.As4() |
||||
baseVal := uint32(baseBytes[0])<<24 | uint32(baseBytes[1])<<16 | |
||||
uint32(baseBytes[2])<<8 | uint32(baseBytes[3]) |
||||
|
||||
serverVal := baseVal + offset |
||||
clientVal := serverVal + 1 |
||||
|
||||
serverBytes := [4]byte{ |
||||
byte(serverVal >> 24), byte(serverVal >> 16), |
||||
byte(serverVal >> 8), byte(serverVal), |
||||
} |
||||
clientBytes := [4]byte{ |
||||
byte(clientVal >> 24), byte(clientVal >> 16), |
||||
byte(clientVal >> 8), byte(clientVal), |
||||
} |
||||
|
||||
return Subnet{ |
||||
ServerIP: netip.AddrFrom4(serverBytes), |
||||
ClientIP: netip.AddrFrom4(clientBytes), |
||||
} |
||||
} |
||||
|
||||
// ServerIPs returns server-side IPs for sequences 0 to maxSeq (for netstack).
|
||||
func (p *SubnetPool) ServerIPs() []netip.Addr { |
||||
p.mu.RLock() |
||||
defer p.mu.RUnlock() |
||||
|
||||
if p.maxSeq == 0 { |
||||
return nil |
||||
} |
||||
|
||||
ips := make([]netip.Addr, p.maxSeq) |
||||
for seq := uint32(0); seq < p.maxSeq; seq++ { |
||||
subnet := p.deriveSubnet(seq) |
||||
ips[seq] = subnet.ServerIP |
||||
} |
||||
return ips |
||||
} |
||||
|
||||
// GetSubnet returns the subnet for a client, or nil if not assigned.
|
||||
func (p *SubnetPool) GetSubnet(clientPubkeyHex string) *Subnet { |
||||
p.mu.RLock() |
||||
defer p.mu.RUnlock() |
||||
|
||||
if seq, ok := p.assigned[clientPubkeyHex]; ok { |
||||
subnet := p.deriveSubnet(seq) |
||||
return &subnet |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// GetSequence returns the sequence number for a client, or -1 if not assigned.
|
||||
func (p *SubnetPool) GetSequence(clientPubkeyHex string) int { |
||||
p.mu.RLock() |
||||
defer p.mu.RUnlock() |
||||
|
||||
if seq, ok := p.assigned[clientPubkeyHex]; ok { |
||||
return int(seq) |
||||
} |
||||
return -1 |
||||
} |
||||
|
||||
// RestoreAllocation restores a previously saved allocation.
|
||||
func (p *SubnetPool) RestoreAllocation(clientPubkeyHex string, seq uint32) { |
||||
p.mu.Lock() |
||||
defer p.mu.Unlock() |
||||
|
||||
p.assigned[clientPubkeyHex] = seq |
||||
if seq >= p.maxSeq { |
||||
p.maxSeq = seq + 1 |
||||
} |
||||
} |
||||
|
||||
// MaxSequence returns the current max sequence number.
|
||||
func (p *SubnetPool) MaxSequence() uint32 { |
||||
p.mu.RLock() |
||||
defer p.mu.RUnlock() |
||||
return p.maxSeq |
||||
} |
||||
|
||||
// AllocatedCount returns the number of allocated subnets.
|
||||
func (p *SubnetPool) AllocatedCount() int { |
||||
p.mu.RLock() |
||||
defer p.mu.RUnlock() |
||||
return len(p.assigned) |
||||
} |
||||
|
||||
// SubnetForSequence returns the subnet for a given sequence number.
|
||||
func (p *SubnetPool) SubnetForSequence(seq uint32) Subnet { |
||||
return p.deriveSubnet(seq) |
||||
} |
||||
Loading…
Reference in new issue