You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
514 lines
16 KiB
514 lines
16 KiB
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 |
|
}
|
|
|