Files
oci-portal/internal/service/webconsole.go
T
2026-07-17 12:19:21 +08:00

358 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 // 清理残留连接的轮询间隔,测试注入缩短
sessionTTL time.Duration // 会话回收检查周期,测试注入缩短
mu sync.Mutex
sessions map[string]*ConsoleSession
}
func NewConsoleService(configs *OciConfigService) *ConsoleService {
return &ConsoleService{
configs: configs,
pollInterval: 2 * time.Second,
sessionTTL: consoleSessionTTL,
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(s.sessionTTL, 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
time.AfterFunc(s.sessionTTL, func() { s.expire(id) })
} 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
}