packagedashboardimport("context""embed""encoding/json""fmt""honeypot/internal/database""honeypot/internal/geodb""honeypot/internal/honeypot""honeypot/internal/types""honeypot/internal/utils""io/fs""net""net/http""os""path""regexp""strings""sync""time""golang.org/x/sync/errgroup")//go:embed frontend/distvarembeddedDistembed.FSvarspaRoutes=[]*regexp.Regexp{regexp.MustCompile("^/$"),regexp.MustCompile("^/login$"),regexp.MustCompile("^/events$"),regexp.MustCompile("^/charts$"),regexp.MustCompile("^/charts/map$"),regexp.MustCompile("^/charts/port$"),regexp.MustCompile("^/stats$"),regexp.MustCompile("^/ip/.+$"),regexp.MustCompile("^/port/.+$"),regexp.MustCompile("^/city/.+$"),regexp.MustCompile("^/country/.+$"),regexp.MustCompile("^/asn/.+$"),regexp.MustCompile("^/domain/.+$"),regexp.MustCompile("^/fqdn/.+$"),}// FrontendHandler serves the embedded SPA frontend.func(s*Service)FrontendHandler()http.Handler{sub,err:=fs.Sub(embeddedDist,"frontend/dist")iferr!=nil{returnhttp.HandlerFunc(func(whttp.ResponseWriter,_*http.Request){w.WriteHeader(http.StatusServiceUnavailable)_,_=w.Write([]byte("dashboard assets not found"))})}fileServer:=http.FileServer(http.FS(sub))returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){// serve index for SPA routesfor_,route:=rangespaRoutes{ifroute.MatchString(r.URL.Path){r.URL.Path="/"fileServer.ServeHTTP(w,r)return}}base:=path.Base(r.URL.Path)ifstrings.Contains(base,"."){fileServer.ServeHTTP(w,r)return}r.URL.Path="/"fileServer.ServeHTTP(w,r)})}// ListEvents handles GET /api/eventsfunc(s*Service)ListEvents(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Header().Set("Transfer-Encoding","chunked")flusher,_:=w.(http.Flusher)enc:=json.NewEncoder(w)meta,err:=s.database.QueryEventsMeta(r.URL.Query())iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}// ---- Start JSON object ----w.Write([]byte("{"))writeJSONField:=func(keystring,valueany){w.Write([]byte(`"`+key+`":`))enc.Encode(value)w.Write([]byte(","))}writeJSONField("query",meta.Query)writeJSONField("where_args",meta.WhereArgs)writeJSONField("total",meta.Total)writeJSONField("query_time",meta.QueryTime.String())// ---- Start events array ----w.Write([]byte(`"events":[`))first:=trueerr=s.database.StreamEvents(r.URL.Query(),func(eventdatabase.Event)error{if!first{w.Write([]byte(","))}first=falseiferr:=enc.Encode(event);err!=nil{returnerr}ifflusher!=nil{flusher.Flush()}returnnil})iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}// ---- Close array + object ----w.Write([]byte("]}"))}// GetStats handles GET /api/statsfunc(s*Service)GetStats(whttp.ResponseWriter,r*http.Request){stats,err:=s.database.GetDashboardStats()iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(stats);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetSystemStats handles GET /api/system-statsfunc(s*Service)GetSystemStats(whttp.ResponseWriter,r*http.Request){dbStats,err:=s.database.GetDatabaseStats()iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}res:=map[string]any{"database":dbStats,"ip_info_last_run":s.database.IPInfoLastRun,"score_cache_updated":s.scoreCache.LastUpdated,"geolite_asn_date":getFileModTime(s.asnDBFile),"geolite_city_date":getFileModTime(s.cityDBFile),"geolite_urls_set":s.asnDBURL!=""&&s.cityDBURL!="",}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(res);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}funcgetFileModTime(pathstring)time.Time{info,err:=os.Stat(path)iferr!=nil{returntime.Time{}}returninfo.ModTime()}// TriggerGeoDBUpdate handles POST /api/system/update-geodbfunc(s*Service)TriggerGeoDBUpdate(whttp.ResponseWriter,r*http.Request){ifr.Method!=http.MethodPost{http.Error(w,"Method not allowed",http.StatusMethodNotAllowed)return}ifs.asnDBURL==""||s.cityDBURL==""{http.Error(w,"GeoLite download URLs not configured",http.StatusBadRequest)return}// Download new filesg:=errgroup.Group{}g.Go(func()error{returngeodb.DownloadFile(s.asnDBURL,s.asnDBFile)})g.Go(func()error{returngeodb.DownloadFile(s.cityDBURL,s.cityDBFile)})iferr:=g.Wait();err!=nil{http.Error(w,fmt.Sprintf("failed to download updates: %v",err),http.StatusInternalServerError)return}// Reload GeoDBiferr:=s.geodb.Reload(s.asnDBFile,s.cityDBFile);err!=nil{http.Error(w,fmt.Sprintf("failed to reload GeoDB: %v",err),http.StatusInternalServerError)return}w.WriteHeader(http.StatusOK)}// ExportEvents handles GET /api/events/export/{format}func(s*Service)ExportEvents(whttp.ResponseWriter,r*http.Request){format:=r.PathValue("format")ifformat!="json"{http.Error(w,"invalid format",http.StatusBadRequest)return}w.Header().Set("Content-Type","application/json")w.Header().Set("Content-Disposition","attachment; filename=events.json")// Important: disable buffering in some proxiesw.Header().Set("Transfer-Encoding","chunked")enc:=json.NewEncoder(w)flusher,_:=w.(http.Flusher)// Start JSON arrayw.Write([]byte("["))first:=trueerr:=s.database.ExportEvents(r.URL.Query(),func(eventdatabase.Event)error{if!first{w.Write([]byte(","))}first=falseiferr:=enc.Encode(event);err!=nil{returnerr}ifflusher!=nil{flusher.Flush()}returnnil})iferr!=nil{http.Error(w,fmt.Sprintf("failed to export events: %v",err),http.StatusInternalServerError)return}// End JSON arrayw.Write([]byte("]"))}typeDNSLookupResponsestruct{IPsmap[string]string`json:"ips"`NotFound[]string`json:"not_found"`QueryTimestring`json:"query_time"`}// getOrFetchIPMetadataBulk retrieves IP metadata for multiple IPs, fetching and caching any that are missing or stale.func(s*Service)getOrFetchIPMetadataBulk(ctxcontext.Context,ips[]string)(map[string]*database.IPMetadata,error){ifs.database==nil{returnnil,fmt.Errorf("database not initialized")}uniqueIPs:=make([]string,0,len(ips))seenIP:=make(map[string]bool)for_,ip:=rangeips{ip=strings.TrimSpace(ip)ifip==""||seenIP[ip]{continue}seenIP[ip]=trueuniqueIPs=append(uniqueIPs,ip)}results,err:=s.database.GetIPMetadata(uniqueIPs)iferr!=nil{returnnil,err}missing:=[]string{}for_,ip:=rangeuniqueIPs{meta,ok:=results[ip]if!ok||time.Since(meta.LastUpdated)>24*time.Hour{missing=append(missing,ip)}}iflen(missing)==0{returnresults,nil}// Fetch missing/stale metadata concurrentlyvarwgsync.WaitGroupvarmusync.Mutexfor_,ip:=rangemissing{wg.Add(1)gofunc(ipstring){deferwg.Done()ifs.geodb==nil{return}fresh,err:=s.geodb.LookupMetadata(ctx,ip)iferr!=nil{return}dbMeta:=&database.IPMetadata{IP:fresh.IP,Country:fresh.CountryCode,ASN:fresh.ASN,ASNOrg:fresh.ASNOrg,City:fresh.City,Latitude:fresh.Latitude,Longitude:fresh.Longitude,FQDN:fresh.FQDN,Domain:fresh.Domain,}ipInt,err:=utils.IPToInt(ip)iferr==nil{dbMeta.IPInt=ipInt}iferr:=s.database.UpsertIPMetadata(dbMeta);err==nil{// Reload from DB to get the correct LastUpdatedm,_:=s.database.GetIPMetadata([]string{ip})ifmeta,ok:=m[ip];ok{mu.Lock()results[ip]=metamu.Unlock()}}}(ip)}wg.Wait()returnresults,nil}// GetIPInfo handles GET /api/ipinfofunc(s*Service)GetIPInfo(whttp.ResponseWriter,r*http.Request){startTime:=time.Now()ipQuery:=r.URL.Query().Get("ip")ifipQuery==""{http.Error(w,"ip required",http.StatusBadRequest)return}ips:=strings.Split(ipQuery,",")metas,err:=s.getOrFetchIPMetadataBulk(r.Context(),ips)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}results:=make(map[string]any)varnotFound[]stringfor_,ip:=rangeips{ip=strings.TrimSpace(ip)ifip==""{continue}ifmeta,ok:=metas[ip];ok{results[ip]=map[string]any{"asn":map[string]any{"autonomous_system_number":meta.ASN,"autonomous_system_organization":meta.ASNOrg,},"city":map[string]any{"name":meta.City,"country":meta.Country,},"country":map[string]any{"iso_code":meta.Country,},"location":map[string]any{"latitude":meta.Latitude,"longitude":meta.Longitude,},"fqdn":meta.FQDN,"domain":meta.Domain,}}else{notFound=append(notFound,ip)}}w.Header().Set("Content-Type","application/json; charset=utf-8")_=json.NewEncoder(w).Encode(map[string]any{"results":results,"not_found":notFound,"query_time":time.Since(startTime).String(),})}// GetPortStats handles GET /api/stats/portfunc(s*Service)GetPortStats(whttp.ResponseWriter,r*http.Request){portStr:=r.URL.Query().Get("port")ifportStr==""{http.Error(w,"port required",http.StatusBadRequest)return}varportintif_,err:=fmt.Sscanf(portStr,"%d",&port);err!=nil{http.Error(w,"invalid port",http.StatusBadRequest)return}topAddrs,err:=s.database.GetTopNFields("remote_addr","type = 'packetlogger' AND dst_port = ?",[]any{port},50)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}topEvents,err:=s.database.GetTopNFields("event","dst_port = ?",[]any{port},50)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}firstSeen,lastSeen,count,err:=s.database.GetPortStatsOverview(port)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")res:=map[string]any{"top_addrs":topAddrs,"top_events":topEvents,"total_events":count,"first_seen":firstSeen,"last_seen":lastSeen,}iferr:=json.NewEncoder(w).Encode(res);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetIpStats handles GET /api/stats/ipfunc(s*Service)GetIpStats(whttp.ResponseWriter,r*http.Request){ip:=r.URL.Query().Get("ip")ifip==""{http.Error(w,"ip required",http.StatusBadRequest)return}g,_:=errgroup.WithContext(r.Context())varfirstSeen,lastSeenstringvarcountintvartopPorts[]database.LabelCountvartopEvents[]database.LabelCountvarblocklist[]types.BlocklistEntryg.Go(func()error{varerrerrorfirstSeen,lastSeen,count,err=s.database.GetFirstLastSeenTotalForNet(ip)returnerr})varwherestringvarargs[]anyvarerrerrorifwhere,args,err=database.GetIpWhere(ip);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}g.Go(func()error{varerrerrortopPorts,err=s.database.GetTopNFields("dst_port","type = 'packetlogger' AND "+where,args,50)returnerr})g.Go(func()error{varerrerrortopEvents,err=s.database.GetTopNFields("event",where,args,50)returnerr})g.Go(func()error{varerrerrorblocklist,err=s.database.GetBlocklistForNet(ip,50)returnerr})iferr:=g.Wait();err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(map[string]any{"total_events":count,"first_seen":firstSeen,"last_seen":lastSeen,"top_ports":topPorts,"top_events":topEvents,"blocklist":blocklist,});err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetGeoStats handles GET /api/stats/geofunc(s*Service)GetGeoStats(whttp.ResponseWriter,r*http.Request){geoType:=r.URL.Query().Get("type")value:=r.URL.Query().Get("value")ifgeoType==""||value==""{http.Error(w,"type and value required",http.StatusBadRequest)return}stats,err:=s.database.GetGeoStats(geoType,value)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(map[string]any{"title":stats.Title,"metadata":stats.Metadata,"top_addrs":stats.RemoteAddrs,"top_events":stats.EventTypes,"top_ports":stats.Ports,"top_subdomains":stats.FQDNs,"total_events":stats.TotalEvents,"first_seen":stats.FirstSeen,"last_seen":stats.LastSeen,});err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetHoneypotStats handles GET /api/stats/honeypotfunc(s*Service)GetHoneypotStats(whttp.ResponseWriter,r*http.Request){eventType:=r.URL.Query().Get("event_type")ifeventType==""{http.Error(w,"event_type required",http.StatusBadRequest)return}stats,err:=s.database.GetHoneypotStats(eventType)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(stats);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetSubnetStats handles GET /api/stats/subnetfunc(s*Service)GetSubnetStats(whttp.ResponseWriter,r*http.Request){ipStr:=r.URL.Query().Get("ip")maskStr:=r.URL.Query().Get("mask")ifipStr==""||maskStr==""{http.Error(w,"ip and mask required",http.StatusBadRequest)return}ip:=net.ParseIP(ipStr)ifip==nil{http.Error(w,"invalid ip",http.StatusBadRequest)return}maskInt:=24fmt.Sscanf(maskStr,"%d",&maskInt)_,ipNet,err:=net.ParseCIDR(fmt.Sprintf("%s/%d",ipStr,maskInt))iferr!=nil{// Fallback for cases where ParseCIDR fails (e.g. invalid mask)http.Error(w,"invalid subnet configuration",http.StatusBadRequest)return}stats,err:=s.database.GetSubnetStats(ipNet.String())iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(stats);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetActivityOverTime handles GET /api/stats/activity-over-timefunc(s*Service)GetActivityOverTime(whttp.ResponseWriter,r*http.Request){activity,err:=s.database.GetActivityOverTime(r.URL.Query())iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(activity);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetActiveHoneypots handles GET /api/active-honeypotsfunc(s*Service)GetActiveHoneypots(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")honeypots:=[]map[string]any{}for_,hp:=ranges.honeypots{honeypots=append(honeypots,map[string]any{"name":string(hp.Name()),"label":hp.Label(),"ports":hp.Ports(),})}iferr:=json.NewEncoder(w).Encode(honeypots);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetBlocklistEntries handles GET /api/blocklist-entriesfunc(s*Service)GetBlocklistEntries(whttp.ResponseWriter,r*http.Request){updateScoreCache(s)blocklist,err:=s.database.GetBlockedAddresses()iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")iferr:=json.NewEncoder(w).Encode(blocklist);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)}}// GetBlockList handles GET /api/blocklist// this is a list of ip addresses or subnets that are in the scorelist// one ip or subnet per line for ingestion into OPNsensefunc(s*Service)GetBlockList(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","text/plain")updateScoreCache(s)blocklist,err:=s.database.GetBlockedAddresses()iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}for_,entry:=rangeblocklist{w.Write([]byte(entry.Address+"\n"))}}funcupdateScoreCache(s*Service){iftime.Since(s.scoreCache.LastUpdated)<2*time.Minute{return}scores:=honeypot.ScoreMap{}for_,hp:=ranges.honeypots{scores=honeypot.MergeScores(scores,hp.GetScores(s.database,"'3 HOURS'"))}scores=honeypot.MergeScores(scores,honeypot.GetAuthAttemptScores(s.database,"'3 HOURS'"))// only return scores > 300 pointsforip,score:=rangescores{ifscore.Score<300{delete(scores,ip)}}honeypot.UpdateBlocklist(s.database,scores)s.scoreCache.Scores=scoress.scoreCache.LastUpdated=time.Now()}