354 lines
11 KiB
Go
354 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// consoleSessionTTL 是会话从创建起的最长存活时间,超时未在用即回收(含云端连接)。
|
|
// OCI 侧另有 24 小时强制断开兜底。
|
|
const consoleSessionTTL = 15 * time.Minute
|
|
|
|
// 网页控制台会话类型:serial 走 PTY 交互式会话,vnc 走 5900 端口转发。
|
|
const (
|
|
ConsoleTypeSerial = "serial"
|
|
ConsoleTypeVnc = "vnc"
|
|
)
|
|
|
|
// ConsoleSession 是一次网页控制台会话(串行 / VNC):私钥只存内存,服务重启即失效。
|
|
type ConsoleSession struct {
|
|
ID string
|
|
Type string
|
|
cfgID uint
|
|
instanceID string
|
|
region string
|
|
connectionID string // console connection OCID,同时是第一跳 SSH 的用户名
|
|
consoleHost string // instance-console.<region>.oci.oraclecloud.com
|
|
signer ssh.Signer
|
|
createdAt time.Time
|
|
|
|
mu sync.Mutex
|
|
inUse bool
|
|
}
|
|
|
|
// ConsoleService 管理网页控制台会话:创建 OCI 控制台连接、建立两跳 SSH 隧道。
|
|
type ConsoleService struct {
|
|
configs *OciConfigService
|
|
pollInterval time.Duration // 清理残留连接的轮询间隔,测试注入缩短
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*ConsoleSession
|
|
}
|
|
|
|
func NewConsoleService(configs *OciConfigService) *ConsoleService {
|
|
return &ConsoleService{
|
|
configs: configs,
|
|
pollInterval: 2 * time.Second,
|
|
sessions: map[string]*ConsoleSession{},
|
|
}
|
|
}
|
|
|
|
// CreateSession 生成一次性 RSA 密钥对并创建实例控制台连接(等待 ACTIVE)。
|
|
// OCI 限制每实例一个控制台连接,创建前先清理实例上的残留连接。
|
|
func (s *ConsoleService) CreateSession(ctx context.Context, cfgID uint, instanceID, region, typ string) (*ConsoleSession, error) {
|
|
if typ != ConsoleTypeSerial && typ != ConsoleTypeVnc {
|
|
return nil, fmt.Errorf("unsupported console session type %q (expect serial or vnc)", typ)
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate rsa key: %w", err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new ssh signer: %w", err)
|
|
}
|
|
if err := s.cleanupStaleConnections(ctx, cfgID, region, instanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
pubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
|
|
conn, err := s.configs.CreateConsoleConnection(ctx, cfgID, region, instanceID, pubKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conn.LifecycleState != "ACTIVE" {
|
|
return nil, fmt.Errorf("console connection not active yet (state %s), retry later", conn.LifecycleState)
|
|
}
|
|
host, err := parseConsoleHost(conn.VncConnectionString)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.storeSession(cfgID, instanceID, region, typ, conn.ID, host, signer), nil
|
|
}
|
|
|
|
// cleanupStaleConnections 删除实例上已有的控制台连接(每实例限一个),
|
|
// 并等待其真正消失;DELETED 是终态记录,仅需跳过。
|
|
func (s *ConsoleService) cleanupStaleConnections(ctx context.Context, cfgID uint, region, instanceID string) error {
|
|
conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID)
|
|
if err != nil {
|
|
return fmt.Errorf("list existing console connections: %w", err)
|
|
}
|
|
pending := false
|
|
for _, c := range conns {
|
|
switch c.LifecycleState {
|
|
case "DELETED":
|
|
continue
|
|
case "DELETING":
|
|
pending = true
|
|
default:
|
|
if err := s.configs.DeleteConsoleConnection(ctx, cfgID, region, c.ID); err != nil {
|
|
return fmt.Errorf("delete stale console connection: %w", err)
|
|
}
|
|
pending = true
|
|
}
|
|
}
|
|
if !pending {
|
|
return nil
|
|
}
|
|
return s.waitConnectionsGone(ctx, cfgID, region, instanceID)
|
|
}
|
|
|
|
// waitConnectionsGone 轮询等待实例上的控制台连接全部进入 DELETED,最长约 60 秒。
|
|
func (s *ConsoleService) waitConnectionsGone(ctx context.Context, cfgID uint, region, instanceID string) error {
|
|
for i := 0; i < 30; i++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("wait console connection cleanup: %w", ctx.Err())
|
|
case <-time.After(s.pollInterval):
|
|
}
|
|
conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID)
|
|
if err != nil {
|
|
return fmt.Errorf("poll console connection cleanup: %w", err)
|
|
}
|
|
gone := true
|
|
for _, c := range conns {
|
|
if c.LifecycleState != "DELETED" {
|
|
gone = false
|
|
break
|
|
}
|
|
}
|
|
if gone {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("cleanup previous console connection timed out, retry later")
|
|
}
|
|
|
|
func (s *ConsoleService) storeSession(cfgID uint, instanceID, region, typ, connID, host string, signer ssh.Signer) *ConsoleSession {
|
|
buf := make([]byte, 16)
|
|
_, _ = rand.Read(buf)
|
|
sess := &ConsoleSession{
|
|
ID: hex.EncodeToString(buf),
|
|
Type: typ,
|
|
cfgID: cfgID,
|
|
instanceID: instanceID,
|
|
region: region,
|
|
connectionID: connID,
|
|
consoleHost: host,
|
|
signer: signer,
|
|
createdAt: time.Now(),
|
|
}
|
|
s.mu.Lock()
|
|
s.sessions[sess.ID] = sess
|
|
s.mu.Unlock()
|
|
time.AfterFunc(consoleSessionTTL, func() { s.expire(sess.ID) })
|
|
return sess
|
|
}
|
|
|
|
// expire TTL 到期回收:正在使用的会话跳过(连接断开后自然停止,无续期)。
|
|
func (s *ConsoleService) expire(id string) {
|
|
s.mu.Lock()
|
|
sess, ok := s.sessions[id]
|
|
if ok {
|
|
sess.mu.Lock()
|
|
if sess.inUse {
|
|
ok = false
|
|
} else {
|
|
delete(s.sessions, id)
|
|
}
|
|
sess.mu.Unlock()
|
|
}
|
|
s.mu.Unlock()
|
|
if ok {
|
|
s.deleteCloudConnection(sess)
|
|
}
|
|
}
|
|
|
|
// Get 返回会话;不存在或已回收返回 nil。
|
|
func (s *ConsoleService) Get(id string) *ConsoleSession {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.sessions[id]
|
|
}
|
|
|
|
// CloseSession 主动结束会话并删除云端控制台连接。
|
|
func (s *ConsoleService) CloseSession(id string) {
|
|
s.mu.Lock()
|
|
sess, ok := s.sessions[id]
|
|
delete(s.sessions, id)
|
|
s.mu.Unlock()
|
|
if ok {
|
|
s.deleteCloudConnection(sess)
|
|
}
|
|
}
|
|
|
|
func (s *ConsoleService) deleteCloudConnection(sess *ConsoleSession) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
_ = s.configs.DeleteConsoleConnection(ctx, sess.cfgID, sess.region, sess.connectionID)
|
|
}
|
|
|
|
// MarkUse 标记会话进入 / 退出使用(使用中的会话不被 TTL 回收)。
|
|
func (sess *ConsoleSession) MarkUse(v bool) {
|
|
sess.mu.Lock()
|
|
sess.inUse = v
|
|
sess.mu.Unlock()
|
|
}
|
|
|
|
// consoleHostRe 从 OCI 下发的 VNC 连接串里取代理主机:
|
|
// ssh -o ProxyCommand='ssh -W %h:%p -p 443 <conn_ocid>@<host>' ...
|
|
var consoleHostRe = regexp.MustCompile(`-p\s+443\s+\S+@([A-Za-z0-9.-]+)`)
|
|
|
|
func parseConsoleHost(vncConnectionString string) (string, error) {
|
|
m := consoleHostRe.FindStringSubmatch(vncConnectionString)
|
|
if m == nil {
|
|
return "", fmt.Errorf("parse console host from vnc connection string failed")
|
|
}
|
|
return m[1], nil
|
|
}
|
|
|
|
// sshConfig 构造两跳共用的客户端配置;代理主机名来自 OCI API 响应,无公开指纹可校验。
|
|
func (sess *ConsoleSession) sshConfig(user string) *ssh.ClientConfig {
|
|
return &ssh.ClientConfig{
|
|
User: user,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(sess.signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 20 * time.Second,
|
|
}
|
|
}
|
|
|
|
// dialHops 建立两跳 SSH:第一跳到区域控制台代理(443,用户名为连接 OCID),
|
|
// 在其上开通道到实例 22 端口完成第二跳握手,返回第二跳客户端。
|
|
func (sess *ConsoleSession) dialHops() (hop1, hop2 *ssh.Client, err error) {
|
|
hop1, err = ssh.Dial("tcp", net.JoinHostPort(sess.consoleHost, "443"), sess.sshConfig(sess.connectionID))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("dial console proxy: %w", err)
|
|
}
|
|
pipe, err := hop1.Dial("tcp", net.JoinHostPort(sess.instanceID, "22"))
|
|
if err != nil {
|
|
hop1.Close()
|
|
return nil, nil, fmt.Errorf("open tunnel to instance console endpoint: %w", err)
|
|
}
|
|
conn, chans, reqs, err := ssh.NewClientConn(pipe, sess.instanceID+":22", sess.sshConfig(sess.instanceID))
|
|
if err != nil {
|
|
pipe.Close()
|
|
hop1.Close()
|
|
return nil, nil, fmt.Errorf("second hop ssh handshake: %w", err)
|
|
}
|
|
return hop1, ssh.NewClient(conn, chans, reqs), nil
|
|
}
|
|
|
|
// DialVNC 经第二跳转发到实例 5900 的 VNC 流。
|
|
func (sess *ConsoleSession) DialVNC(ctx context.Context) (net.Conn, error) {
|
|
hop1, hop2, err := sess.dialHops()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vnc, err := hop2.Dial("tcp", net.JoinHostPort(sess.instanceID, "5900"))
|
|
if err != nil {
|
|
hop2.Close()
|
|
hop1.Close()
|
|
return nil, fmt.Errorf("open vnc channel: %w", err)
|
|
}
|
|
return &chainedConn{Conn: vnc, closers: []func() error{hop2.Close, hop1.Close}}, nil
|
|
}
|
|
|
|
// SerialTerminal 是一路串行控制台终端:读端为串口输出,写端为键入;
|
|
// Resize 调整代理侧 PTY 尺寸(guest 内 tty 需另行 stty 同步)。
|
|
type SerialTerminal struct {
|
|
io.Reader
|
|
io.Writer
|
|
session *ssh.Session
|
|
closers []func() error
|
|
}
|
|
|
|
func (t *SerialTerminal) Resize(rows, cols int) error {
|
|
return t.session.WindowChange(rows, cols)
|
|
}
|
|
|
|
func (t *SerialTerminal) Close() error {
|
|
err := t.session.Close()
|
|
for _, fn := range t.closers {
|
|
_ = fn()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// OpenSerial 经第二跳打开交互式 PTY 会话,会话的输入输出即串口字节流。
|
|
func (sess *ConsoleSession) OpenSerial(ctx context.Context, rows, cols int) (*SerialTerminal, error) {
|
|
hop1, hop2, err := sess.dialHops()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
term, err := openSerialSession(hop2, rows, cols)
|
|
if err != nil {
|
|
hop2.Close()
|
|
hop1.Close()
|
|
return nil, err
|
|
}
|
|
term.closers = []func() error{hop2.Close, hop1.Close}
|
|
return term, nil
|
|
}
|
|
|
|
// openSerialSession 在第二跳上申请 PTY 并启动 shell(即串口流)。
|
|
func openSerialSession(hop2 *ssh.Client, rows, cols int) (*SerialTerminal, error) {
|
|
session, err := hop2.NewSession()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open serial session: %w", err)
|
|
}
|
|
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200}
|
|
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
|
session.Close()
|
|
return nil, fmt.Errorf("request pty: %w", err)
|
|
}
|
|
stdin, err := session.StdinPipe()
|
|
if err != nil {
|
|
session.Close()
|
|
return nil, fmt.Errorf("serial stdin pipe: %w", err)
|
|
}
|
|
stdout, err := session.StdoutPipe()
|
|
if err != nil {
|
|
session.Close()
|
|
return nil, fmt.Errorf("serial stdout pipe: %w", err)
|
|
}
|
|
if err := session.Shell(); err != nil {
|
|
session.Close()
|
|
return nil, fmt.Errorf("start serial shell: %w", err)
|
|
}
|
|
return &SerialTerminal{Reader: stdout, Writer: stdin, session: session}, nil
|
|
}
|
|
|
|
// chainedConn 关闭数据流时连带关闭底层 SSH 连接,避免隧道泄漏。
|
|
type chainedConn struct {
|
|
net.Conn
|
|
closers []func() error
|
|
}
|
|
|
|
func (c *chainedConn) Close() error {
|
|
err := c.Conn.Close()
|
|
for _, fn := range c.closers {
|
|
_ = fn()
|
|
}
|
|
return err
|
|
}
|