Add WireGuard VPN with random /31 subnet isolation (v0.40.0)
Some checks failed
Go / build-and-release (push) Has been cancelled

- 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>
This commit is contained in:
2025-12-27 16:32:48 +02:00
parent 2aa5c16311
commit e84949140b
23 changed files with 3498 additions and 25 deletions

514
app/handle-wireguard.go Normal file
View File

@@ -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
}