266 lines
7.0 KiB
Go
266 lines
7.0 KiB
Go
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
|
|
}
|