package console import ( "fmt" "net" "strings" "time" "yunyan/pb" "github.com/gin-gonic/gin" "golang.org/x/crypto/ssh" ) // ============================ 部署环境域 (environment) ============================ // // 环境 = 一个部署目标/区域(上海/日本/新加坡…),一行存在 console 主库(postgres.GetSys())。 // 本期只做「CRUD + 连通性测试」;远程部署执行留待下一期(字段已备齐,见 model_environment.go)。 // // 密钥字段一律 AES-256-GCM 加密落库(复用 api_svcconfig.go 的 svcEncrypt/svcDecrypt), // 返回前端前脱敏为 envEncMask;更新/测试时前端回传占位符则保留/解密旧密文。 const envEncMask = "[encrypted]" // envSecretPtrs 节点里所有需要加密存储/脱敏返回的密钥字段指针(顺序与 fillEnvSecretsForTest 一致)。 func envSecretPtrs(m *AppEnvironment) []*string { return []*string{&m.SshPrivateKey, &m.RegistryPass, &m.RedisPwd} } // maskEnvSecrets 返回前端前脱敏:非空密文 → envEncMask,绝不明文下发。 func maskEnvSecrets(m *AppEnvironment) { for _, p := range envSecretPtrs(m) { if *p != "" { *p = envEncMask } } } // encryptEnvSecrets 新增时把密钥字段就地加密(占位/空 → 空串,新增无旧密文可保留)。 func (this *serverComp) encryptEnvSecrets(m *AppEnvironment) error { key := this.options.EncryptKey for _, p := range envSecretPtrs(m) { if *p == "" || *p == envEncMask { *p = "" continue } ct, err := svcEncrypt(key, *p) if err != nil { return err } *p = ct } return nil } // mergeEnvSecrets 更新时逐个密钥字段:前端传占位/空 → 保留 old 的旧密文;否则用新明文加密覆盖。 func (this *serverComp) mergeEnvSecrets(in, old *AppEnvironment) error { key := this.options.EncryptKey inPtrs, oldPtrs := envSecretPtrs(in), envSecretPtrs(old) for i := range inPtrs { if *inPtrs[i] == "" || *inPtrs[i] == envEncMask { *inPtrs[i] = *oldPtrs[i] continue } ct, err := svcEncrypt(key, *inPtrs[i]) if err != nil { return err } *inPtrs[i] = ct } return nil } // fillEnvSecretsForTest 测试既有环境时,前端传占位/空的密钥字段回填为解密后的旧明文,供拨测使用。 func (this *serverComp) fillEnvSecretsForTest(req *AppEnvironment) { if req.Id == 0 { return } old, err := this.module.model.getEnv(req.Id) if err != nil { return } key := this.options.EncryptKey inPtrs, oldPtrs := envSecretPtrs(req), envSecretPtrs(old) for i := range inPtrs { if *inPtrs[i] == "" || *inPtrs[i] == envEncMask { if plain, e := svcDecrypt(key, *oldPtrs[i]); e == nil { *inPtrs[i] = plain } } } } // ============================== 环境 CRUD ============================== func (this *serverComp) envList(c *gin.Context) { models, err := this.module.model.listEnvs() if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } for _, m := range models { maskEnvSecrets(m) // 密钥不下发前端 } writeOK(c, models) } func (this *serverComp) envAdd(c *gin.Context) { var model AppEnvironment if err := c.ShouldBindJSON(&model); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if strings.TrimSpace(model.Name) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "名称必填") return } if model.SshPort == 0 { model.SshPort = 22 } if err := this.encryptEnvSecrets(&model); err != nil { writeErr(c, pb.ErrorCode_SystemError, "加密失败: "+err.Error()) return } model.Id = 0 now := time.Now().Unix() model.Createtime = now model.Updatetime = now if err := this.module.model.addEnv(&model); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } maskEnvSecrets(&model) writeOK(c, &model) } func (this *serverComp) envUpdate(c *gin.Context) { var in AppEnvironment if err := c.ShouldBindJSON(&in); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if in.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } old, err := this.module.model.getEnv(in.Id) if err != nil { writeErr(c, pb.ErrorCode_DBError, "环境不存在: "+err.Error()) return } if in.SshPort == 0 { in.SshPort = 22 } if err := this.mergeEnvSecrets(&in, old); err != nil { writeErr(c, pb.ErrorCode_SystemError, "加密失败: "+err.Error()) return } in.Createtime = old.Createtime // 建时间不可改 in.Updatetime = time.Now().Unix() if err := this.module.model.saveEnv(&in); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } maskEnvSecrets(&in) writeOK(c, &in) } func (this *serverComp) envDel(c *gin.Context) { var req struct { Id uint32 `json:"id"` } if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } // 引用完整性:环境下仍有应用关联则拒删,避免应用悬空。 apps, err := this.module.model.listApps() if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } for _, a := range apps { if a.EnvId == req.Id { writeErr(c, pb.ErrorCode_ReqParameterError, "该环境下仍有应用("+a.Name+")关联,请先解绑或删除应用") return } } if err := this.module.model.delEnv(req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, gin.H{"id": req.Id}) } // envTest 环境连通性自检:SSH 握手 + 各基础设施拨测,逐项返回。 // SSH 通即 ok=true(视为可部署);基础设施项仅展示、不硬阻断保存。 func (this *serverComp) envTest(c *gin.Context) { var req AppEnvironment _ = c.ShouldBindJSON(&req) this.fillEnvSecretsForTest(&req) // 编辑既有节点时回填解密后的旧密钥 sshOk, sshMsg := testSSHReachable(req.SshHost, req.SshPort, req.SshUser, req.SshPrivateKey) etcdOk, etcdMsg := testTCPReachable(req.EtcdServers) redisOk, redisMsg := testTCPReachable(req.RedisAddr) natsOk, natsMsg := testTCPReachable(req.NatsUrl) writeOK(c, gin.H{ "ok": sshOk, "ssh_ok": sshOk, "ssh_msg": sshMsg, "etcd_ok": etcdOk, "etcd_msg": etcdMsg, "redis_ok": redisOk, "redis_msg": redisMsg, "nats_ok": natsOk, "nats_msg": natsMsg, }) } // ============================== 拨测工具 ============================== // testSSHReachable 用私钥拨测部署目标:仅验证 host/port/user/私钥能完成握手(本期不执行任何命令)。 func testSSHReachable(host string, port int, user, privateKeyPEM string) (bool, string) { host = strings.TrimSpace(host) if host == "" { return false, "SSH host 为空" } if port == 0 { port = 22 } if strings.TrimSpace(privateKeyPEM) == "" { return false, "SSH 私钥为空" } signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { return false, "私钥解析失败: " + err.Error() } cfg := &ssh.ClientConfig{ User: user, Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网部署目标,本期仅拨测不校验 host key Timeout: 5 * time.Second, } cli, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg) if err != nil { return false, "SSH 连接失败: " + err.Error() } _ = cli.Close() return true, "SSH 握手成功" } // testTCPReachable 通用 TCP 拨测(etcd/redis/nats):取地址串里第一个 host:port,5s 超时。 func testTCPReachable(addr string) (bool, string) { hp := firstHostPort(addr) if hp == "" { return false, "地址为空" } conn, err := net.DialTimeout("tcp", hp, 5*time.Second) if err != nil { return false, "不可达: " + err.Error() } _ = conn.Close() return true, "可达" } // firstHostPort 从可能含 scheme / 逗号分隔多地址 / 路径的串里取第一个 host:port。 // // nats://a:4222 → a:4222 ; "e1:2379,e2:2379" → e1:2379 ; redis:6379 → redis:6379 func firstHostPort(addr string) string { addr = strings.TrimSpace(addr) if addr == "" { return "" } if i := strings.Index(addr, "://"); i >= 0 { addr = addr[i+3:] } if i := strings.IndexAny(addr, ",;"); i >= 0 { addr = addr[:i] } if i := strings.IndexAny(addr, "/?"); i >= 0 { addr = addr[:i] } return strings.TrimSpace(addr) }