Skip to content

Commit 4169f85

Browse files
committed
feat: SSH代理支持公钥认证(入站密钥免密登录)
- proxy.go: ServerConfig 添加 PublicKeyCallback - resolver.go: 新增 ResolveContainerByPublicKey 方法 通过 host short ID + 客户端公钥匹配 DB 中的入站密钥 - resolverRepo 接口新增 ListSSHKeysByUserAndPurpose - 公钥认证和密码认证并存,客户端可以用任一方式登录 Made-with: Cursor
1 parent 4ce4b20 commit 4169f85

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

internal/sshproxy/proxy.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ import (
1818
"golang.org/x/crypto/ssh"
1919
)
2020

21-
// ContainerResolver maps an SSH login (host_short_id + entry_password)
22-
// to the target container's SSH address and credentials.
2321
type ContainerResolver interface {
2422
ResolveContainer(ctx context.Context, hostShortID, password string) (ContainerTarget, error)
23+
ResolveContainerByPublicKey(ctx context.Context, hostShortID string, clientKey ssh.PublicKey) (ContainerTarget, error)
2524
}
2625

2726
type Server struct {
@@ -114,7 +113,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
114113

115114
target, err := s.resolver.ResolveContainer(authCtx, conn.User(), string(password))
116115
if err != nil {
117-
s.logger.Debug("SSH auth failed", "user", conn.User(), "remote", conn.RemoteAddr(), "reason", err)
116+
s.logger.Debug("SSH password auth failed", "user", conn.User(), "remote", conn.RemoteAddr(), "reason", err)
117+
return nil, fmt.Errorf("auth failed")
118+
}
119+
return &ssh.Permissions{
120+
Extensions: map[string]string{
121+
"target_addr": target.Addr,
122+
"target_user": target.User,
123+
"target_password": target.Password,
124+
},
125+
}, nil
126+
},
127+
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
128+
authCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
129+
defer cancel()
130+
131+
target, err := s.resolver.ResolveContainerByPublicKey(authCtx, conn.User(), key)
132+
if err != nil {
133+
s.logger.Debug("SSH pubkey auth failed", "user", conn.User(), "remote", conn.RemoteAddr(), "reason", err)
118134
return nil, fmt.Errorf("auth failed")
119135
}
120136
return &ssh.Permissions{

internal/sshproxy/resolver.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,22 @@ import (
77
"os/exec"
88
"strings"
99

10+
gossh "golang.org/x/crypto/ssh"
11+
1012
"github.com/zanel1u/cloud-cli-proxy/internal/store/repository"
1113
)
1214

1315
type resolverRepo interface {
1416
GetHostByShortID(ctx context.Context, shortID string) (repository.HostSSHAuth, error)
17+
ListSSHKeysByUserAndPurpose(ctx context.Context, userID, purpose string) ([]repository.SSHKey, error)
1518
}
1619

17-
// ContainerTarget is the SSH endpoint and credentials for dialing the user container.
1820
type ContainerTarget struct {
1921
Addr string
2022
User string
2123
Password string
2224
}
2325

24-
// RepoResolver implements ContainerResolver using the database repository.
25-
// It validates the host's entry_password, checks that the owning user is
26-
// active and the host is running, then resolves the container's SSH address.
2726
type RepoResolver struct {
2827
repo resolverRepo
2928
}
@@ -42,10 +41,43 @@ func (r *RepoResolver) ResolveContainer(ctx context.Context, hostShortID, passwo
4241
return ContainerTarget{}, fmt.Errorf("invalid credentials")
4342
}
4443

44+
return r.resolveTarget(ctx, auth)
45+
}
46+
47+
func (r *RepoResolver) ResolveContainerByPublicKey(ctx context.Context, hostShortID string, clientKey gossh.PublicKey) (ContainerTarget, error) {
48+
auth, err := r.repo.GetHostByShortID(ctx, hostShortID)
49+
if err != nil {
50+
return ContainerTarget{}, fmt.Errorf("host not found")
51+
}
52+
53+
inboundKeys, err := r.repo.ListSSHKeysByUserAndPurpose(ctx, auth.UserID, "inbound")
54+
if err != nil || len(inboundKeys) == 0 {
55+
return ContainerTarget{}, fmt.Errorf("no inbound keys configured")
56+
}
57+
58+
clientKeyData := clientKey.Marshal()
59+
matched := false
60+
for _, k := range inboundKeys {
61+
parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(k.PublicKey))
62+
if err != nil {
63+
continue
64+
}
65+
if clientKey.Type() == parsed.Type() && subtle.ConstantTimeCompare(clientKeyData, parsed.Marshal()) == 1 {
66+
matched = true
67+
break
68+
}
69+
}
70+
if !matched {
71+
return ContainerTarget{}, fmt.Errorf("public key not authorized")
72+
}
73+
74+
return r.resolveTarget(ctx, auth)
75+
}
76+
77+
func (r *RepoResolver) resolveTarget(ctx context.Context, auth repository.HostSSHAuth) (ContainerTarget, error) {
4578
if auth.UserStatus != "active" {
4679
return ContainerTarget{}, fmt.Errorf("user suspended")
4780
}
48-
4981
if auth.HostStatus != "running" {
5082
return ContainerTarget{}, fmt.Errorf("host not running (status: %s)", auth.HostStatus)
5183
}

0 commit comments

Comments
 (0)