feat(node): package Ubuntu desktop setup and trusted access
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
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.Access != nil {
|
||||
s.accessRoutes(mux)
|
||||
}
|
||||
mux.HandleFunc("POST /api/session", s.login)
|
||||
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")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; 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": "Повторно откройте приложение через меню Ubuntu"})
|
||||
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": "Сеанс завершён. Откройте приложение через меню Ubuntu"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user