package web import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "strconv" "sync" "time" "git.t-juice.club/torjus/apiary" "git.t-juice.club/torjus/apiary/config" "git.t-juice.club/torjus/apiary/honeypot/ssh" "git.t-juice.club/torjus/apiary/honeypot/ssh/store" "git.t-juice.club/torjus/apiary/models" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" "golang.org/x/crypto/acme/autocert" ) const streamKeepAliveDuration = 30 * time.Second type Server struct { http.Server httpRedirectServer http.Server cfg config.FrontendConfig honeypotServer *ssh.HoneypotServer store store.LoginAttemptStore ServerLogger *zap.SugaredLogger AccessLogger *zap.SugaredLogger attemptListenersLock sync.RWMutex attemptListeners map[string]chan models.LoginAttempt streamContext context.Context } func NewServer(cfg config.FrontendConfig, hs *ssh.HoneypotServer, store store.LoginAttemptStore) *Server { s := &Server{ ServerLogger: zap.NewNop().Sugar(), AccessLogger: zap.NewNop().Sugar(), store: store, cfg: cfg, } if cfg.Autocert.Enable { certManager := autocert.Manager{ Prompt: autocert.AcceptTOS, HostPolicy: autocert.HostWhitelist(cfg.Autocert.Domains...), Email: cfg.Autocert.Email, } if cfg.Autocert.CacheDir != "" { certManager.Cache = autocert.DirCache(cfg.Autocert.CacheDir) } tlsConfig := certManager.TLSConfig() tlsConfig.MinVersion = tls.VersionTLS12 s.TLSConfig = tlsConfig s.RegisterOnShutdown(func() { timeoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() s.httpRedirectServer.Shutdown(timeoutCtx) }) s.Addr = ":443" if cfg.Autocert.RedirectHTTP { s.httpRedirectServer.Addr = ":80" s.httpRedirectServer.Handler = certManager.HTTPHandler(nil) } } else { s.Addr = cfg.ListenAddr } r := chi.NewRouter() // Setup middleware r.Use(middleware.RealIP) r.Use(middleware.RequestID) r.Use(s.LoggingMiddleware) r.Use(NewMetricsMiddleware()) r.Use(middleware.SetHeader("Server", fmt.Sprintf("apiary/%s", apiary.FullVersion()))) r.Handle("/metrics", promhttp.Handler()) r.Route("/", func(r chi.Router) { r.Get("/*", s.IndexHandler("web/vue-frontend/dist")) r.Get("/stream", s.HandlerAttemptStream) r.Route("/api", func(r chi.Router) { r.Get("/stats", s.HandlerStats) r.Get("/stream", s.HandlerAttemptStream) r.Get("/query", s.HandlerQuery) r.Get("/health", s.HandlerHealth) }) }) s.Handler = r s.honeypotServer = hs s.attemptListeners = make(map[string]chan models.LoginAttempt) streamCtx, streamCancel := context.WithCancel(context.Background()) s.streamContext = streamCtx s.RegisterOnShutdown(func() { streamCancel() }) hs.AddLoginCallback(func(l models.LoginAttempt) { s.attemptListenersLock.RLock() defer s.attemptListenersLock.RUnlock() for i := range s.attemptListeners { s.attemptListeners[i] <- l } }) return s } func (s *Server) StartServe() error { if s.cfg.Autocert.Enable { if s.cfg.Autocert.RedirectHTTP { s.ServerLogger.Debug("Starting HTTP redirect server") go func() { if err := s.httpRedirectServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.ServerLogger.Warnw("HTTP redirect server returned error", "error", err) } }() } return s.ListenAndServeTLS("", "") } else { return s.ListenAndServe() } } func (s *Server) addAttemptListener() (string, chan models.LoginAttempt) { ch := make(chan models.LoginAttempt) s.attemptListenersLock.Lock() defer s.attemptListenersLock.Unlock() id := uuid.Must(uuid.NewRandom()) s.attemptListeners[id.String()] = ch return id.String(), ch } func (s *Server) closeAttemptListener(id string) { s.attemptListenersLock.Lock() defer s.attemptListenersLock.Unlock() ch := s.attemptListeners[id] close(ch) delete(s.attemptListeners, id) } func (s *Server) HandlerAttemptStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") id, ch := s.addAttemptListener() defer s.closeAttemptListener(id) w.WriteHeader(http.StatusOK) flusher := w.(http.Flusher) ticker := time.NewTicker(streamKeepAliveDuration) defer ticker.Stop() for { select { case l := <-ch: data, err := json.Marshal(l) if err != nil { return } _, err = io.WriteString(w, fmt.Sprintf("data: %s\n\n", string(data))) if err != nil { s.ServerLogger.Warnw("Error writing event", "error", err) } flusher.Flush() ticker.Reset(streamKeepAliveDuration) case <-s.streamContext.Done(): return case <-r.Context().Done(): return case <-ticker.C: if _, err := io.WriteString(w, ": keep-alive\n\n"); err != nil { s.ServerLogger.Warnw("Error writing event", "error", err) } flusher.Flush() } } } func (s *Server) HandlerStats(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") statType := store.LoginStats(r.URL.Query().Get("type")) if statType == store.LoginStatsUndefined { statType = store.LoginStatsPasswords } var limit int limitString := r.URL.Query().Get("limit") limit, err := strconv.Atoi(limitString) if err != nil { limit = 0 } stats, err := s.store.Stats(statType, limit) if err != nil { s.ServerLogger.Warnw("Error fetching stats", "error", err) s.WriteAPIError(w, r, http.StatusInternalServerError, "Error fetching stats") return } encoder := json.NewEncoder(w) if err := encoder.Encode(stats); err != nil { s.ServerLogger.Debugf("Error encoding or writing response", "remote_ip", r.RemoteAddr, "error", err) } } func (s *Server) HandlerQuery(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") queryType := r.URL.Query().Get("type") query := r.URL.Query().Get("query") if query == "" { s.WriteAPIError(w, r, http.StatusBadRequest, "Invalid query or query type") return } results := []models.LoginAttempt{} if queryType == "" { uq := store.AttemptQuery{ QueryType: store.AttemptQueryType(store.AttemptQueryTypePassword), Query: query, } pq := store.AttemptQuery{ QueryType: store.AttemptQueryType(store.AttemptQueryTypeUsername), Query: query, } userResults, err := s.store.Query(uq) if err != nil { s.WriteAPIError(w, r, http.StatusInternalServerError, "Unable to perform query") s.ServerLogger.Warnw("Error performing query", "error", err) return } passwordResults, err := s.store.Query(pq) if err != nil { s.WriteAPIError(w, r, http.StatusInternalServerError, "Unable to perform query") s.ServerLogger.Warnw("Error performing query", "error", err) return } results = append(results, userResults...) results = append(results, passwordResults...) } else { aq := store.AttemptQuery{ QueryType: store.AttemptQueryType(queryType), Query: query, } queryResults, err := s.store.Query(aq) if err != nil { s.WriteAPIError(w, r, http.StatusInternalServerError, "Unable to perform query") s.ServerLogger.Warnw("Error performing query", "error", err) return } results = append(results, queryResults...) } encoder := json.NewEncoder(w) if err := encoder.Encode(&results); err != nil { s.ServerLogger.Warnw("Error writing query results", "error", err) } } func (s *Server) HandlerHealth(w http.ResponseWriter, r *http.Request) { err := s.store.IsHealthy() if err != nil { s.WriteAPIError(w, r, http.StatusInternalServerError, fmt.Sprintf("Health error: %s", err)) return } w.Write([]byte(`{}`)) } type APIErrorResponse struct { Error string `json:"error"` } func (s *Server) WriteAPIError(w http.ResponseWriter, r *http.Request, status int, message string) { encoder := json.NewEncoder(w) apiErr := APIErrorResponse{Error: message} w.WriteHeader(status) if err := encoder.Encode(&apiErr); err != nil { s.ServerLogger.Debugf("Error encoding or writing error response", "remote_ip", r.RemoteAddr, "error", err) } }