Files
NODEDC_MISSION_CORE/apps/node-agent/internal/node/server.go
T

263 lines
7.6 KiB
Go

package node
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"sync"
"time"
)
type Server struct {
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()
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
}