package node import ( "crypto/rand" "encoding/base64" "encoding/json" "io" "io/fs" "net/http" "strings" "sync" "time" ) type Server struct { Monitor *Monitor Store *Store Pairing *Pairing Sensors *Sensors DeviceEnrollment *DeviceEnrollment Assets fs.FS Origin string Version string Inventory func() Inventory Access *AccessStore Tailscale func() TailscaleStatus Environment func() EnvironmentStatus Presentation *PresentationStore mu sync.Mutex logins map[string]time.Time sessions map[string]time.Time Now func() time.Time } func token() string { b := make([]byte, 32) if _, e := rand.Read(b); e != nil { panic(e) } return base64.RawURLEncoding.EncodeToString(b) } func (s *Server) now() time.Time { if s.Now != nil { return s.Now() } return time.Now() } func prune(m map[string]time.Time, now time.Time) { for k, v := range m { if !v.After(now) { delete(m, k) } } } // IssueLogin is reachable through the private Unix socket, never the web API. // OS authentication belongs to the fixed polkit launcher, not a web password. func (s *Server) IssueLogin() string { s.mu.Lock() defer s.mu.Unlock() if s.logins == nil { s.logins = make(map[string]time.Time) } prune(s.logins, s.now()) // Cap abandoned desktop launches; newest launches supersede the oldest. if len(s.logins) >= 16 { for k := range s.logins { delete(s.logins, k) break } } t := token() s.logins[t] = s.now().Add(time.Minute) return s.Origin + "/#login=" + t } func reply(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() s.localPreviewRoutes(mux) s.monitorRoutes(mux) if s.Presentation != nil { s.presentationRoutes(mux) } if s.Sensors != nil { s.Sensors.Routes(mux, s) } if s.DeviceEnrollment != nil { s.DeviceEnrollment.Routes(mux, s) } if s.Pairing != nil { s.Pairing.localRoutes(mux, s) } if s.Access != nil { s.accessRoutes(mux) } mux.HandleFunc("POST /api/session", s.login) mux.HandleFunc("GET /api/environment", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } read := s.Environment if read == nil { read = ReadEnvironment } reply(w, 200, read()) }) mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } probe := s.Tailscale if probe == nil { probe = ReadTailscale } reply(w, 200, probe()) }) mux.HandleFunc("POST /api/logout", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } c, _ := r.Cookie("mc_node") s.mu.Lock() delete(s.sessions, c.Value) s.mu.Unlock() http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode}) reply(w, 200, map[string]bool{"ok": true}) }) mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } id, name := s.Store.Public() reply(w, 200, map[string]any{"version": s.Version, "node_id": id, "name": name, "host": s.Inventory()}) }) mux.HandleFunc("PUT /api/name", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } var body struct { Name string `json:"name"` } if !decode(w, r, &body) { return } if err := s.Store.Rename(body.Name); err != nil { reply(w, 400, map[string]string{"error": err.Error()}) return } reply(w, 200, map[string]bool{"ok": true}) }) mux.HandleFunc("GET /api/report", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return } v := s.Inventory() // Export is deliberately redacted even though the authenticated UI shows LAN addresses. v.Hostname = "[redacted]" for i := range v.Networks { v.Networks[i].Addresses = []string{} } w.Header().Set("Content-Disposition", `attachment; filename="mission-core-node-report.json"`) reply(w, 200, map[string]any{"schema": "missioncore.node.inventory-report/v1", "version": s.Version, "host": v}) }) mux.Handle("GET /", http.FileServerFS(s.Assets)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") ancestor, scripts := "'none'", "'self'" if r.URL.Path == "/rerun-runtime.html" { ancestor, scripts = "'self'", "'self' 'wasm-unsafe-eval'" } w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src "+scripts+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self'; frame-src 'self'; worker-src 'self' blob:; frame-ancestors "+ancestor+"; base-uri 'none'; form-action 'self'") if "http://"+r.Host != s.Origin { http.Error(w, "Invalid host", http.StatusForbidden) return } if origin := r.Header.Get("Origin"); origin != "" && origin != s.Origin { http.Error(w, "Invalid origin", http.StatusForbidden) return } if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" { http.Error(w, "Cross-site request denied", http.StatusForbidden) return } if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("Origin") != s.Origin { http.Error(w, "Origin required", http.StatusForbidden) return } mux.ServeHTTP(w, r) }) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { if r.Header.Get("Content-Type") != "application/json" { reply(w, 415, map[string]string{"error": "Ожидался JSON"}) return false } d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) d.DisallowUnknownFields() if err := d.Decode(v); err != nil { reply(w, 400, map[string]string{"error": "Некорректный запрос"}) return false } if err := d.Decode(new(any)); err != io.EOF { reply(w, 400, map[string]string{"error": "Некорректный запрос"}) return false } return true } func (s *Server) login(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if !decode(w, r, &body) { return } s.mu.Lock() defer s.mu.Unlock() prune(s.logins, s.now()) _, ok := s.logins[body.Token] delete(s.logins, body.Token) if !ok { reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"}) return } if s.sessions == nil { s.sessions = make(map[string]time.Time) } prune(s.sessions, s.now()) if len(s.sessions) >= 32 { for k := range s.sessions { delete(s.sessions, k) break } } t := token() s.sessions[t] = s.now().Add(8 * time.Hour) // Loopback HTTP is intentionally local-only; never expose this cookie on LAN. http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: t, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 28800}) reply(w, 200, map[string]bool{"ok": true}) } func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool { c, err := r.Cookie("mc_node") if err != nil || strings.TrimSpace(c.Value) == "" { reply(w, 401, map[string]string{"error": "Откройте Mission Core Node через меню приложений"}) return false } s.mu.Lock() defer s.mu.Unlock() prune(s.sessions, s.now()) if _, ok := s.sessions[c.Value]; !ok { reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню приложений"}) return false } return true }