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
|
||||
}
|
||||
Reference in New Issue
Block a user