package api import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "net/http" "debtik/internal/aerotun" "debtik/internal/auth" "time" ) func (s *Server) handleAeroTUNHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, map[string]any{"states": s.vpnMgr.AeroTUN.HealthSummaries(), "events": s.vpnMgr.AeroTUN.HealthHistory(""), "service_cpu_percent": s.vpnMgr.AeroTUN.ProcessCPUPercent()}) } func (s *Server) handleAeroTUNHealthCheck(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 7*time.Second) defer cancel() result, err := s.vpnMgr.AeroTUN.CheckHealth(ctx, r.PathValue("id")) if err != nil { writeError(w, http.StatusUnprocessableEntity, err.Error()) return } w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, result) } type aeroIdentityDraft struct { private, public, session string expires time.Time } func (s *Server) handleCreateAeroIdentityDraft(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } private, public, err := aerotun.GenerateIdentity() if err != nil { http.Error(w, "failed to generate identity", http.StatusInternalServerError) return } b := make([]byte, 32) if _, err = rand.Read(b); err != nil { http.Error(w, "failed to generate identity token", http.StatusInternalServerError) return } token := hex.EncodeToString(b) session := getSessionFromContext(r.Context()).Token s.aeroDraftMu.Lock() if s.aeroDrafts == nil { s.aeroDrafts = make(map[string]aeroIdentityDraft) } now := time.Now() count := 0 for id, d := range s.aeroDrafts { if now.After(d.expires) { delete(s.aeroDrafts, id) } else if d.session == session { count++ } } if count >= 16 { s.aeroDraftMu.Unlock() http.Error(w, "too many pending identities; close an open form and try again", http.StatusTooManyRequests) return } s.aeroDrafts[token] = aeroIdentityDraft{private: private, public: public, session: session, expires: now.Add(2 * time.Hour)} s.aeroDraftMu.Unlock() w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"draft_id": token, "public_key": public}) } func (s *Server) handleDeleteAeroIdentityDraft(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } s.aeroDraftMu.Lock() if d, ok := s.aeroDrafts[r.PathValue("id")]; ok && d.session == getSessionFromContext(r.Context()).Token { delete(s.aeroDrafts, r.PathValue("id")) } s.aeroDraftMu.Unlock() w.WriteHeader(http.StatusNoContent) } func (s *Server) handleListAeroTUNTunnels(w http.ResponseWriter, r *http.Request) { tunnels := s.vpnMgr.AeroTUN.List() for i := range tunnels { tunnels[i] = tunnels[i].PublicView() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(tunnels) } func (s *Server) handleGetAeroTUNStats(w http.ResponseWriter, r *http.Request) { stats := s.vpnMgr.AeroTUN.GetSummaryStats() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(stats) } func requireAeroTUNAdmin(w http.ResponseWriter, r *http.Request) bool { sess := getSessionFromContext(r.Context()) if sess == nil || sess.Role != auth.RoleFull { http.Error(w, "AeroTUN administration requires full privileges", http.StatusForbidden) return false } return true } func (s *Server) handleGenerateAeroTUNKey(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } w.Header().Set("Cache-Control", "no-store") key, err := aerotun.GenerateSecureKey() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"key": key}) } func (s *Server) handleSaveAeroTUNTunnel(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } var req struct { aerotun.TunnelInstance IdentityDraftID string `json:"identity_draft_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } inst := req.TunnelInstance if req.IdentityDraftID != "" { if inst.ID != "" || inst.Protocol != 3 || inst.PrivateKey != "" { http.Error(w, "identity draft is only valid for a new v3 tunnel", http.StatusBadRequest) return } s.aeroDraftMu.Lock() defer s.aeroDraftMu.Unlock() d, ok := s.aeroDrafts[req.IdentityDraftID] if !ok || d.session != getSessionFromContext(r.Context()).Token || time.Now().After(d.expires) { http.Error(w, "identity draft expired or is unavailable; reopen the form to create a new key", http.StatusUnprocessableEntity) return } inst.PrivateKey, inst.PublicKey = d.private, d.public } saved, err := s.vpnMgr.AeroTUN.Save(inst) if req.IdentityDraftID != "" { if err == nil { delete(s.aeroDrafts, req.IdentityDraftID) } else if existing, ok := s.vpnMgr.AeroTUN.Get(saved.ID); ok && existing.PublicKey == inst.PublicKey { delete(s.aeroDrafts, req.IdentityDraftID) } } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(saved.PublicView()) } func (s *Server) handleDeleteAeroTUNTunnel(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } id := r.PathValue("id") if id == "" { http.Error(w, "missing tunnel ID", http.StatusBadRequest) return } if err := s.vpnMgr.AeroTUN.Delete(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) } func (s *Server) handleSetAeroTUNTunnelState(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } id := r.PathValue("id") if id == "" { http.Error(w, "missing tunnel ID", http.StatusBadRequest) return } var req struct { Disabled bool `json:"disabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } if err := s.vpnMgr.AeroTUN.ToggleState(id, req.Disabled); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) } func (s *Server) handleStartAeroTUNTunnel(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } id := r.PathValue("id") if id == "" { http.Error(w, "missing tunnel ID", http.StatusBadRequest) return } if err := s.vpnMgr.AeroTUN.StartTunnel(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) } func (s *Server) handleStopAeroTUNTunnel(w http.ResponseWriter, r *http.Request) { if !requireAeroTUNAdmin(w, r) { return } id := r.PathValue("id") if id == "" { http.Error(w, "missing tunnel ID", http.StatusBadRequest) return } if err := s.vpnMgr.AeroTUN.StopTunnel(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) }