Files

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("Выберите существующую учётную запись администратора системы")
}
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
}