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 }