feat(M2): SSH 密钥管理 — 公钥上传/重命名/吊销、authorized_keys 原子同步与吊销即时失效

- KeyService:crypto/ssh 解析校验(单行/类型/长度/去重指纹,拒 ssh-dss 与 RSA<2048),
  Create/Rename/Revoke/List,变更后以 DB 状态全量重写 authorized_keys(同步失败回滚)
- system 层:SyncAuthorizedKeys 完善 —— sudo 模式经白名单命令(mkdir/chown/chmod/install)
  落位并修正属主(sshd StrictModes),direct 模式 root 时同样修正属主;dry-run 计划日志
- API:GET/POST /me/keys、PATCH/DELETE /me/keys/:id(user 会话)、GET /users/:id/keys(admin),
  密钥操作带审计;deploy/sudoers.example 补充密钥同步白名单
- 版本 0.3.0-m2;测试:service 单元(校验/生命周期/回滚/权限)、system 直写落盘、
  API 全流程集成;容器 E2E 32 项 PASS(真实 useradd/authorized_keys/吊销即时失效/禁用清空/删除回收)
This commit is contained in:
2026-08-29 23:55:39 +08:00
parent 630d240dc0
commit a5f501dba4
15 changed files with 998 additions and 59 deletions
+219
View File
@@ -0,0 +1,219 @@
package service
import (
"context"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
"ws_usernode/internal/config"
"ws_usernode/internal/model"
"ws_usernode/internal/system"
)
// 密钥服务错误。
var (
ErrKeyNotFound = errors.New("service: 密钥不存在")
ErrKeyDuplicate = errors.New("service: 该公钥已存在")
ErrKeyInvalid = errors.New("service: 公钥格式不合法")
ErrUserNotActive = errors.New("service: 用户未处于可用状态,无法添加密钥")
)
// maxPublicKeyLen 公钥输入上限(正常公钥约 100~700 字节,防止异常大输入)。
const maxPublicKeyLen = 8192
// KeyService SSH 公钥管理。密钥仅用户自行上传(管理员不代签,PLAN §2.3);
// 每次变更后以 DB 状态全量重写 authorized_keyssystem 层原子写 + 并发锁),
// 吊销密钥即从文件移除、立即失效(PLAN F3)。
type KeyService struct {
db *gorm.DB
sys system.Manager
cfg *config.Config
}
// NewKeyService 创建密钥服务。
func NewKeyService(db *gorm.DB, sys system.Manager, cfg *config.Config) *KeyService {
return &KeyService{db: db, sys: sys, cfg: cfg}
}
// user 查询外部用户记录。
func (s *KeyService) user(ctx context.Context, id uint) (*model.User, error) {
var u model.User
if err := s.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &u, nil
}
// ListByUser 返回用户全部密钥(最新在前)。用户不存在时报 ErrUserNotFound。
func (s *KeyService) ListByUser(ctx context.Context, userID uint) ([]model.SSHKey, error) {
if _, err := s.user(ctx, userID); err != nil {
return nil, err
}
var keys []model.SSHKey
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&keys).Error; err != nil {
return nil, err
}
return keys, nil
}
// Create 上传公钥:校验(类型/长度/重复)→ 落 DB → 全量同步 authorized_keys。
// 同步失败时回滚 DB 记录,保证两侧一致(同 user.Create 模式)。
func (s *KeyService) Create(ctx context.Context, userID uint, name, publicKey string, createdBy uint) (*model.SSHKey, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, fmt.Errorf("%w: 密钥名称需为 1~64 字符", ErrKeyInvalid)
}
keyType, fingerprint, body, err := parsePublicKey(publicKey)
if err != nil {
return nil, err
}
u, err := s.user(ctx, userID)
if err != nil {
return nil, err
}
if u.Status != model.UserStatusActive {
return nil, ErrUserNotActive
}
// 同用户下已存在该公钥(active)→ 拒绝重复;已吊销的密钥允许重新上传
var n int64
if err := s.db.WithContext(ctx).Model(&model.SSHKey{}).
Where("user_id = ? AND fingerprint = ? AND status = ?", userID, fingerprint, model.StatusActive).
Count(&n).Error; err != nil {
return nil, err
}
if n > 0 {
return nil, ErrKeyDuplicate
}
k := &model.SSHKey{
UserID: userID,
Name: name,
KeyType: keyType,
PublicKey: body,
Fingerprint: fingerprint,
Status: model.StatusActive,
Source: "user_uploaded",
CreatedBy: createdBy,
}
if err := s.db.WithContext(ctx).Create(k).Error; err != nil {
return nil, err
}
if err := s.syncUserKeys(ctx, u); err != nil {
_ = s.db.WithContext(ctx).Delete(k).Error
return nil, err
}
return k, nil
}
// Rename 重命名密钥(仅元数据,不影响 authorized_keys)。
func (s *KeyService) Rename(ctx context.Context, keyID, userID uint, name string) (*model.SSHKey, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, fmt.Errorf("%w: 密钥名称需为 1~64 字符", ErrKeyInvalid)
}
k, err := s.owned(ctx, keyID, userID)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Model(&model.SSHKey{}).Where("id = ?", keyID).Update("name", name).Error; err != nil {
return nil, err
}
k.Name = name
return k, nil
}
// Revoke 吊销密钥(软删除,保留记录供审计)。先以剩余有效密钥(排除本次
// 吊销的密钥)重写 authorized_keys(吊销立即失效),成功后再落 DB;同步失败
// 则中止,文件与 DB 保持一致(密钥仍为 active)。已吊销时幂等。
func (s *KeyService) Revoke(ctx context.Context, keyID, userID uint) (*model.SSHKey, error) {
k, err := s.owned(ctx, keyID, userID)
if err != nil {
return nil, err
}
if k.Status == model.StatusRevoked {
return k, nil // 幂等
}
u, err := s.user(ctx, userID)
if err != nil {
return nil, err
}
// 剩余有效密钥(不含本次吊销的),先同步文件再落 DB(同 Disable 的 fail-closed 模式)
keys, err := activeUserKeys(s.db, ctx, u.ID, k.ID)
if err != nil {
return nil, err
}
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil {
return nil, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.SSHKey{}).Where("id = ?", keyID).
Updates(map[string]any{"status": model.StatusRevoked, "revoked_at": &now}).Error; err != nil {
return nil, err
}
k.Status = model.StatusRevoked
k.RevokedAt = &now
return k, nil
}
// owned 返回属于 userID 的密钥;跨用户访问视为不存在,不泄露存在性。
func (s *KeyService) owned(ctx context.Context, keyID, userID uint) (*model.SSHKey, error) {
var k model.SSHKey
if err := s.db.WithContext(ctx).First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrKeyNotFound
}
return nil, err
}
return &k, nil
}
// syncUserKeys 以 DB 当前 active 密钥全量重写 authorized_keys。
func (s *KeyService) syncUserKeys(ctx context.Context, u *model.User) error {
keys, err := activeUserKeys(s.db, ctx, u.ID, 0)
if err != nil {
return err
}
return s.sys.SyncAuthorizedKeys(ctx, u.Username, keys)
}
// parsePublicKey 校验并解析 OpenSSH 公钥行,返回类型 / SHA256 指纹 / base64 主体。
// 仅接受标准单行 "类型 base64 [注释]";拒绝 ssh-dss(弱算法)与 <2048 位 RSA。
func parsePublicKey(input string) (keyType, fingerprint, body string, err error) {
if len(input) > maxPublicKeyLen {
return "", "", "", fmt.Errorf("%w: 公钥内容过长", ErrKeyInvalid)
}
line := strings.TrimSpace(input)
if line == "" || strings.ContainsAny(line, "\r\n") {
return "", "", "", fmt.Errorf("%w: 公钥必须为单行", ErrKeyInvalid)
}
pub, _, options, rest, perr := ssh.ParseAuthorizedKey([]byte(line))
if perr != nil {
return "", "", "", fmt.Errorf("%w: %v", ErrKeyInvalid, perr)
}
if len(options) > 0 || len(rest) > 0 {
return "", "", "", fmt.Errorf("%w: 仅支持标准公钥行,不能带选项或多余内容", ErrKeyInvalid)
}
keyType = pub.Type()
switch keyType {
case "ssh-dss":
return "", "", "", fmt.Errorf("%w: 不支持 ssh-dss 密钥", ErrKeyInvalid)
case "ssh-rsa":
if cp, ok := pub.(ssh.CryptoPublicKey); ok {
if rsaPub, ok := cp.CryptoPublicKey().(*rsa.PublicKey); ok && rsaPub.N.BitLen() < 2048 {
return "", "", "", fmt.Errorf("%w: RSA 密钥长度至少 2048 位", ErrKeyInvalid)
}
}
}
fingerprint = ssh.FingerprintSHA256(pub)
body = base64.StdEncoding.EncodeToString(pub.Marshal())
return keyType, fingerprint, body, nil
}