feat(node): package Ubuntu desktop setup and trusted access
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user