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..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 @' ... 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 }