266 lines
7.7 KiB
Go
266 lines
7.7 KiB
Go
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
|
|
}
|