feat(node): package Ubuntu desktop setup and trusted access

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 17:27:59 +03:00
parent 57f2537af3
commit d696842f5d
52 changed files with 4457 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
package node
import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
type AccessKey struct {
ID string `json:"id"`
User string `json:"user"`
Label string `json:"label"`
PublicKey string `json:"public_key"`
}
type AccessStore struct {
mu sync.Mutex
Path string
Users func() []string
}
var usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
// Only existing local administrative accounts are eligible. Never root,
// a supplied home directory, an arbitrary NSS principal, or a generated user.
func LocalAdmins(root string) []string {
group, _ := os.ReadFile(filepath.Join(root, "etc/group"))
admins := map[string]bool{}
for _, line := range strings.Split(string(group), "\n") {
p := strings.Split(line, ":")
if len(p) == 4 && p[0] == "sudo" {
for _, u := range strings.Split(p[3], ",") {
admins[u] = true
}
}
}
passwd, _ := os.ReadFile(filepath.Join(root, "etc/passwd"))
result := []string{}
for _, line := range strings.Split(string(passwd), "\n") {
p := strings.Split(line, ":")
if len(p) != 7 {
continue
}
uid, e := strconv.Atoi(p[2])
if e == nil && uid >= 1000 && uid < 65534 && admins[p[0]] && usernamePattern.MatchString(p[0]) && !strings.HasSuffix(p[6], "nologin") && !strings.HasSuffix(p[6], "false") {
result = append(result, p[0])
}
}
return result
}
func canonicalKey(key string) (string, string, error) {
parts := strings.Fields(strings.TrimSpace(key))
bad := errors.New("Нужен публичный ключ Ed25519, начинающийся с ssh-ed25519; приватный ключ вводить нельзя")
if len(parts) < 2 || parts[0] != "ssh-ed25519" || strings.ContainsAny(key, "\r\n") {
return "", "", bad
}
b, e := base64.StdEncoding.DecodeString(parts[1])
if e != nil || len(b) != 51 {
return "", "", bad
}
if binary.BigEndian.Uint32(b[:4]) != 11 || string(b[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(b[15:19]) != 32 {
return "", "", bad
}
h := sha256.Sum256(b)
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b), "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]), nil
}
func ReadAccess(path string) ([]AccessKey, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return []AccessKey{}, nil
}
if err != nil {
return nil, err
}
var keys []AccessKey
if err = json.Unmarshal(b, &keys); err != nil {
return nil, err
}
if len(keys) > 64 {
return nil, errors.New("too many access keys")
}
for _, k := range keys {
key, id, err := canonicalKey(k.PublicKey)
if err != nil || key != k.PublicKey || id != k.ID || !usernamePattern.MatchString(k.User) {
return nil, errors.New("invalid access store")
}
}
return keys, nil
}
func (a *AccessStore) List() ([]AccessKey, error) {
a.mu.Lock()
defer a.mu.Unlock()
return ReadAccess(a.Path)
}
func (a *AccessStore) allowed(user string) bool {
for _, u := range a.Users() {
if user == u {
return true
}
}
return false
}
func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
a.mu.Lock()
defer a.mu.Unlock()
keys, err := ReadAccess(a.Path)
if err != nil {
return errors.New("Хранилище SSH недоступно")
}
keys, err = fn(keys)
if err != nil {
return err
}
b, err := json.Marshal(keys)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(a.Path), ".ssh-keys-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), a.Path)
}
func (a *AccessStore) Add(user, label, key string) error {
if !a.allowed(user) {
return errors.New("Выберите существующую учётную запись администратора Ubuntu")
}
label = strings.TrimSpace(label)
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
return errors.New("Название ключа должно содержать от 1 до 64 символов")
}
key, id, err := canonicalKey(key)
if err != nil {
return err
}
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
for _, k := range keys {
if k.ID == id && k.User == user {
return keys, nil
}
}
if len(keys) >= 64 {
return nil, errors.New("Достигнут предел 64 ключа")
}
return append(keys, AccessKey{ID: id, User: user, Label: label, PublicKey: key}), nil
})
}
func (a *AccessStore) Remove(user, id string) error {
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
next := []AccessKey{}
for _, k := range keys {
if k.User != user || k.ID != id {
next = append(next, k)
}
}
return next, nil
})
}
func SSHReady() bool {
c, err := net.DialTimeout("tcp", "127.0.0.1:22", 400*time.Millisecond)
if err != nil {
return false
}
defer c.Close()
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
s := bufio.NewScanner(c)
return s.Scan() && strings.HasPrefix(s.Text(), "SSH-2.0-")
}
func (s *Server) accessRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
keys, err := s.Access.List()
if err != nil {
reply(w, 503, map[string]string{"error": "Хранилище SSH недоступно"})
return
}
reply(w, 200, map[string]any{"users": s.Access.Users(), "keys": keys, "ssh_ready": SSHReady()})
})
mux.HandleFunc("POST /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
Label string `json:"label"`
Key string `json:"key"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Add(b.User, b.Label, b.Key); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("DELETE /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
ID string `json:"id"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Remove(b.User, b.ID); err != nil {
reply(w, 503, map[string]string{"error": "Не удалось удалить ключ"})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
func AuthorizedKeys(path, user string) (string, error) {
a := &AccessStore{Path: path, Users: func() []string { return LocalAdmins("/") }}
if !a.allowed(user) {
return "", nil
}
keys, err := a.List()
if err != nil {
return "", err
}
var out strings.Builder
for _, k := range keys {
if k.User == user {
out.WriteString(`from="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,127.0.0.0/8,::1,fc00::/7,fe80::/10" ` + k.PublicKey + "\n")
}
}
return out.String(), nil
}
+107
View File
@@ -0,0 +1,107 @@
package node
import (
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
type Network struct {
Name string `json:"name"`
Up bool `json:"up"`
Addresses []string `json:"addresses"`
}
type USB struct {
Port string `json:"port"`
Vendor string `json:"vendor"`
ProductID string `json:"product_id"`
Product string `json:"product"`
Speed string `json:"speed_mbps"`
}
type Inventory struct {
CollectedAt string `json:"collected_at"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Architecture string `json:"architecture"`
CPUs int `json:"cpus"`
MemoryKiB *uint64 `json:"memory_kib"`
AvailableKiB *uint64 `json:"available_kib"`
Networks []Network `json:"networks"`
USB []USB `json:"usb"`
USBReadable bool `json:"usb_readable"`
Warnings []string `json:"warnings"`
}
// Host reads only local kernel/OS metadata. It never probes network devices,
// opens camera streams, reads device serials, or changes a network interface.
func Host(root string) Inventory {
read := func(p string) string {
b, _ := os.ReadFile(filepath.Join(root, p))
return strings.TrimSpace(string(b))
}
host, _ := os.Hostname()
v := Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname: host, OS: runtime.GOOS, Architecture: runtime.GOARCH, CPUs: runtime.NumCPU(), Networks: []Network{}, USB: []USB{}, Warnings: []string{}}
for _, line := range strings.Split(read("etc/os-release"), "\n") {
if x, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
v.OS = strings.Trim(x, "\"")
}
}
for _, line := range strings.Split(read("proc/meminfo"), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
n, e := strconv.ParseUint(fields[1], 10, 64)
if e != nil {
continue
}
if fields[0] == "MemTotal:" {
v.MemoryKiB = &n
}
if fields[0] == "MemAvailable:" {
v.AvailableKiB = &n
}
}
if v.MemoryKiB == nil {
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
}
interfaces, err := net.Interfaces()
if err != nil {
v.Warnings = append(v.Warnings, "Не удалось прочитать сетевые интерфейсы")
}
for _, it := range interfaces {
if it.Flags&net.FlagLoopback != 0 {
continue
}
n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}}
addresses, e := it.Addrs()
if e != nil {
v.Warnings = append(v.Warnings, "Адреса интерфейса "+it.Name+" недоступны")
}
for _, a := range addresses {
n.Addresses = append(n.Addresses, a.String())
}
sort.Strings(n.Addresses)
v.Networks = append(v.Networks, n)
}
sort.Slice(v.Networks, func(i, j int) bool { return v.Networks[i].Name < v.Networks[j].Name })
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
v.USBReadable = err == nil
if err != nil {
v.Warnings = append(v.Warnings, "Сведения об USB недоступны")
}
for _, e := range entries {
prefix := filepath.Join("sys/bus/usb/devices", e.Name())
vendor := read(filepath.Join(prefix, "idVendor"))
if vendor == "" {
continue
}
v.USB = append(v.USB, USB{Port: e.Name(), Vendor: vendor, ProductID: read(filepath.Join(prefix, "idProduct")), Product: read(filepath.Join(prefix, "product")), Speed: read(filepath.Join(prefix, "speed"))})
}
return v
}
+255
View File
@@ -0,0 +1,255 @@
package node
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
state, e := OpenStore(t.TempDir())
if e != nil {
t.Fatal(e)
}
return &Server{Store: state, Origin: "http://127.0.0.1:8780", Assets: fstest.MapFS{"index.html": {Data: []byte("test-only asset")}}, Inventory: func() Inventory {
return Inventory{Hostname: "private-host", Networks: []Network{{Name: "eth0", Addresses: []string{"192.168.10.4/24"}}}}
}}
}
func call(s *Server, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, s.Origin+path, strings.NewReader(body))
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func login(t *testing.T, s *Server) *http.Cookie {
t.Helper()
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
w := call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
return w.Result().Cookies()[0]
}
func TestIdentitySurvivesRenameAndReopen(t *testing.T) {
dir := t.TempDir()
s, e := OpenStore(dir)
if e != nil {
t.Fatal(e)
}
id, _ := s.Public()
if e = s.Rename("Борт 1"); e != nil {
t.Fatal(e)
}
s, e = OpenStore(dir)
if e != nil {
t.Fatal(e)
}
next, name := s.Public()
if next != id || name != "Борт 1" {
t.Fatal(next, name)
}
info, _ := os.Stat(filepath.Join(dir, "identity.json"))
if info.Mode().Perm() != 0600 {
t.Fatal(info.Mode())
}
if e = s.Rename("bad\nname"); e == nil {
t.Fatal("accepted control character")
}
}
func TestCorruptIdentityNeverReplaced(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "identity.json")
bad := []byte(`{"version":1,"private_key":"bad"}`)
os.WriteFile(p, bad, 0600)
if _, e := OpenStore(dir); e == nil {
t.Fatal("corrupt state accepted")
}
got, _ := os.ReadFile(p)
if !bytes.Equal(got, bad) {
t.Fatal("identity replaced")
}
}
func TestLoginOneUseConcurrentAndExpires(t *testing.T) {
s := newTestServer(t)
now := time.Now()
s.Now = func() time.Time { return now }
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
var wg sync.WaitGroup
codes := make(chan int, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); codes <- call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code }()
}
wg.Wait()
close(codes)
success := 0
for c := range codes {
if c == 200 {
success++
} else if c != 401 {
t.Fatal(c)
}
}
if success != 1 {
t.Fatal(success)
}
v = strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
now = now.Add(time.Minute)
if call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code != 401 {
t.Fatal("expired launch accepted")
}
c := login(t, s)
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode {
t.Fatal(c)
}
now = now.Add(8 * time.Hour)
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("expired session accepted")
}
}
func TestUnauthenticatedAndCrossSiteRequestsDenied(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, path := range []string{"/api/status", "/api/report"} {
if call(s, "GET", path, "", nil).Code != 401 {
t.Fatal(path)
}
}
for _, kind := range []string{"origin", "host", "metadata", "missing-origin"} {
r := httptest.NewRequest("PUT", s.Origin+"/api/name", strings.NewReader(`{"name":"attacker"}`))
r.AddCookie(c)
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
switch kind {
case "origin":
r.Header.Set("Origin", "https://evil.example")
case "host":
r.Host = "evil.example"
case "metadata":
r.Header.Set("Sec-Fetch-Site", "cross-site")
case "missing-origin":
r.Header.Del("Origin")
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(kind, w.Code)
}
}
_, name := s.Store.Public()
if name == "attacker" {
t.Fatal("cross-site state changed")
}
}
func TestReportDoesNotLeakPrivateState(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
w := call(s, "GET", "/api/report", "", c)
if w.Code != 200 {
t.Fatal(w.Code)
}
id, _ := s.Store.Public()
for _, secret := range []string{"private-host", "192.168.10.4", id, "private_key", c.Value} {
if strings.Contains(w.Body.String(), secret) {
t.Fatal("report leaked", secret)
}
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "attachment") {
t.Fatal("not downloadable")
}
}
func TestLogoutAndStrictJSON(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, body := range []string{`{"name":"x"} {}`, `{"name":"x","other":1}`} {
if call(s, "PUT", "/api/name", body, c).Code != 400 {
t.Fatal("accepted invalid document")
}
}
if call(s, "POST", "/api/logout", `{}`, c).Code != 200 {
t.Fatal("logout failed")
}
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("session survived logout")
}
}
func syntheticKey() string {
b := make([]byte, 51)
binary.BigEndian.PutUint32(b[:4], 11)
copy(b[4:15], "ssh-ed25519")
binary.BigEndian.PutUint32(b[15:19], 32)
for i := 19; i < len(b); i++ {
b[i] = byte(i)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b)
}
func TestSSHKeyEnrollmentRejectsCommandsAndRoot(t *testing.T) {
a := &AccessStore{Path: filepath.Join(t.TempDir(), "ssh-keys.json"), Users: func() []string { return []string{"operator"} }}
key := syntheticKey()
for _, bad := range []string{"command=\"sh\" " + key, key + "\n" + key, "-----BEGIN PRIVATE KEY-----", "ssh-ed25519 YQ=="} {
if e := a.Add("operator", "laptop", bad); e == nil {
t.Fatal("unsafe key accepted")
}
}
if e := a.Add("root", "laptop", key); e == nil {
t.Fatal("root accepted")
}
if e := a.Add("operator", "laptop", key+" private-comment"); e != nil {
t.Fatal(e)
}
if e := a.Add("operator", "laptop", key); e != nil {
t.Fatal(e)
}
keys, e := a.List()
if e != nil || len(keys) != 1 || keys[0].PublicKey != key {
t.Fatal(keys, e)
}
if e := a.Remove("operator", keys[0].ID); e != nil {
t.Fatal(e)
}
keys, _ = a.List()
if len(keys) != 0 {
t.Fatal("revocation failed")
}
}
func TestLinuxInventoryUsesActualMetadataWithoutSerial(t *testing.T) {
dir := t.TempDir()
for p, v := range map[string]string{"etc/os-release": "PRETTY_NAME=\"Synthetic Linux\"", "proc/meminfo": "MemTotal: 8388608 kB\nMemAvailable: 4000000 kB", "sys/bus/usb/devices/1-2/idVendor": "8086", "sys/bus/usb/devices/1-2/idProduct": "0b5c", "sys/bus/usb/devices/1-2/product": "Synthetic camera", "sys/bus/usb/devices/1-2/speed": "5000", "sys/bus/usb/devices/1-2/serial": "do-not-read"} {
target := filepath.Join(dir, p)
os.MkdirAll(filepath.Dir(target), 0700)
os.WriteFile(target, []byte(v), 0600)
}
v := Host(dir)
if v.OS != "Synthetic Linux" || *v.MemoryKiB != 8388608 || !v.USBReadable || len(v.USB) != 1 || v.USB[0].Speed != "5000" {
t.Fatal(v)
}
b, _ := json.Marshal(v)
if bytes.Contains(b, []byte("do-not-read")) {
t.Fatal("serial leaked")
}
}
+231
View File
@@ -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
}
+122
View File
@@ -0,0 +1,122 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
type State struct {
Version int `json:"version"`
PrivateKey []byte `json:"private_key"`
Name string `json:"name"`
}
type Store struct {
mu sync.Mutex
path string
state State
}
func OpenStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
s := &Store{path: filepath.Join(dir, "identity.json")}
b, err := os.ReadFile(s.path)
if err == nil {
if err = json.Unmarshal(b, &s.state); err != nil {
return nil, errors.New("invalid identity; recovery required")
}
if s.state.Version != 1 || len(s.state.PrivateKey) != ed25519.PrivateKeySize {
return nil, errors.New("unsupported identity; recovery required")
}
derived := ed25519.NewKeyFromSeed(s.state.PrivateKey[:ed25519.SeedSize])
if !equalKey(derived, s.state.PrivateKey) {
return nil, errors.New("corrupt identity; recovery required")
}
if info, e := os.Stat(s.path); e != nil || info.Mode().Perm()&0077 != 0 {
return nil, errors.New("identity permissions must be private")
}
return s, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
s.state = State{Version: 1, PrivateKey: key, Name: "Моя нода"}
if err := s.write(s.state); err != nil {
return nil, err
}
return s, nil
}
func equalKey(a, b []byte) bool { return string(a) == string(b) }
func (s *Store) write(state State) error {
b, err := json.Marshal(state)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(f.Name(), s.path); err != nil {
return err
}
d, err := os.Open(filepath.Dir(s.path))
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
func (s *Store) Public() (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
pub := ed25519.PrivateKey(s.state.PrivateKey).Public().(ed25519.PublicKey)
hash := sha256.Sum256(pub)
return "node_" + hex.EncodeToString(hash[:]), s.state.Name
}
func (s *Store) Rename(name string) error {
name = strings.TrimSpace(name)
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 64 || strings.ContainsFunc(name, unicode.IsControl) {
return errors.New("Название должно содержать от 1 до 64 символов без управляющих знаков")
}
s.mu.Lock()
defer s.mu.Unlock()
next := s.state
next.Name = name
if err := s.write(next); err != nil {
return errors.New("Не удалось сохранить название")
}
s.state = next
return nil
}
@@ -0,0 +1,76 @@
package node
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/netip"
"os"
"os/exec"
"time"
)
type TailscaleStatus struct {
Installed bool `json:"installed"`
State string `json:"state"`
Online bool `json:"online"`
Addresses []string `json:"addresses"`
}
type boundedProviderOutput struct{ bytes.Buffer }
func (b *boundedProviderOutput) Write(data []byte) (int, error) {
if b.Len()+len(data) > 1024*1024 {
return 0, errors.New("provider status too large")
}
return b.Buffer.Write(data)
}
func ReadTailscale() TailscaleStatus {
value := TailscaleStatus{State: "not_installed", Addresses: []string{}}
if _, err := os.Stat("/usr/bin/tailscale"); err != nil {
return value
}
value.Installed = true
value.State = "unavailable"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/tailscale", "status", "--json", "--peers=false")
// Do not request peer inventory or expose auth URLs, user identities, keys,
// provider diagnostics or profile objects in the product API.
var output boundedProviderOutput
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return value
}
return parseTailscale(output.Bytes())
}
func parseTailscale(data []byte) TailscaleStatus {
value := TailscaleStatus{Installed: true, State: "unavailable", Addresses: []string{}}
if len(data) > 1024*1024 {
return value
}
var raw struct {
BackendState string
TailscaleIPs []string
Self *struct{ Online bool }
}
if json.Unmarshal(data, &raw) != nil {
return value
}
switch raw.BackendState {
case "Running", "Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState":
value.State = raw.BackendState
default:
return value
}
value.Online = raw.BackendState == "Running" && raw.Self != nil && raw.Self.Online
for _, address := range raw.TailscaleIPs {
if ip, err := netip.ParseAddr(address); err == nil {
value.Addresses = append(value.Addresses, ip.String())
}
}
return value
}
@@ -0,0 +1,60 @@
package node
import (
"encoding/json"
"strings"
"testing"
)
func TestTailscaleDoesNotExposeProviderCredentialsOrPeers(t *testing.T) {
status := parseTailscale([]byte(`{"BackendState":"Running","TailscaleIPs":["100.64.0.10","invalid"],"Self":{"Online":true,"PublicKey":"synthetic-key"},"AuthURL":"https://login.tailscale.com/a/synthetic","User":{"1":{"LoginName":"synthetic@example.test"}},"Peer":{"synthetic":{"HostName":"another-computer"}}}`))
if !status.Online || status.State != "Running" || len(status.Addresses) != 1 {
t.Fatalf("wrong connection status: %+v", status)
}
encoded, _ := json.Marshal(status)
for _, forbidden := range []string{"synthetic", "AuthURL", "User", "Peer", "PublicKey"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider data leaked: %s", forbidden)
}
}
}
func TestTailscaleDoesNotClaimUnknownOrOfflineConnection(t *testing.T) {
for _, input := range []string{`{`, `null`, `{}`, `{"BackendState":"FutureState","Self":{"Online":true}}`} {
got := parseTailscale([]byte(input))
if got.Online || got.State != "unavailable" {
t.Fatalf("unknown state was accepted: %+v", got)
}
}
for _, state := range []string{"Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState"} {
got := parseTailscale([]byte(`{"BackendState":"` + state + `","Self":{"Online":true}}`))
if got.Online || got.State != state {
t.Fatalf("not connected: %+v", got)
}
}
if parseTailscale([]byte(`{"BackendState":"Running","Self":{"Online":false}}`)).Online {
t.Fatal("offline peer shown as connected")
}
}
func TestProviderOutputIsBounded(t *testing.T) {
var buffer boundedProviderOutput
if _, err := buffer.Write(make([]byte, 1024*1024+1)); err == nil || buffer.Len() != 0 {
t.Fatal("oversized provider output accepted")
}
}
func TestTailscaleStatusRequiresLocalLoginBeforeProbe(t *testing.T) {
s := newTestServer(t)
probes := 0
s.Tailscale = func() TailscaleStatus {
probes++
return TailscaleStatus{State: "not_installed", Addresses: []string{}}
}
if call(s, "GET", "/api/network/tailscale", "", nil).Code != 401 || probes != 0 {
t.Fatal("unauthenticated provider probe")
}
if call(s, "GET", "/api/network/tailscale", "", login(t, s)).Code != 200 || probes != 1 {
t.Fatal("authenticated status unavailable")
}
}