You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
283 lines
8.1 KiB
283 lines
8.1 KiB
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)
|
|
}
|
|
|