From c409dc5a021fb1abaa7a885a8653e173f27fc118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=AF=BB=E6=AC=A2?= Date: Mon, 7 Apr 2025 11:14:30 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E4=BC=98=E5=8C=96=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=86=E9=85=8D=E5=8F=AF=E7=94=A8IP?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=B9=B6=E4=BF=AE=E6=94=B9=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=A1=AE=E8=AE=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 80 +++++++ internal/docker/container.go | 390 +++++++++++++++++++++++++++++++++- internal/view/robot/show.html | 22 +- 3 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..d5143a9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + # 微信机器人管理系统 + wechat-robot: + build: + context: . + dockerfile: Dockerfile + container_name: wechat-robot + restart: unless-stopped + ports: + - "8080:8080" # 应用端口 + volumes: + - ./configs:/app/configs # 配置文件 + - ./data:/app/data # 数据文件 + - ./logs:/app/logs # 日志文件 + - /var/run/docker.sock:/var/run/docker.sock # Docker socket 用于容器管理 + # 添加 docker 组的 GID 到容器,999 是常见的 docker 组 GID,但在不同系统可能不同 + group_add: + - "999" # 请确认您系统上的 docker 组 GID + environment: + - APP_ENV=production + - TZ=Asia/Shanghai + - DOCKER_HOST=unix:///var/run/docker.sock + depends_on: + - postgres + - redis + networks: + - wechat-network + + # PostgreSQL 数据库 + postgres: + image: postgres:14-alpine + container_name: wechat-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password # 生产环境请修改此密码 + POSTGRES_DB: wechat_demo + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - wechat-network + + # Redis 服务 + redis: + image: redis:7-alpine + container_name: wechat-redis + restart: unless-stopped + command: redis-server --requirepass pGhQKwj7DE7FbFL1 # 与配置中的密码一致 + volumes: + - redis-data:/data + ports: + - "6379:6379" + networks: + - wechat-network + + # PGAdmin(可选)数据库管理工具 + pgadmin: + image: dpage/pgadmin4 + container_name: wechat-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: admin # 生产环境请修改此密码 + ports: + - "5050:80" + depends_on: + - postgres + networks: + - wechat-network + +volumes: + postgres-data: + redis-data: + +networks: + wechat-network: + driver: bridge \ No newline at end of file diff --git a/internal/docker/container.go b/internal/docker/container.go index a9055bc..d308a94 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net" "os" "strconv" "strings" @@ -36,6 +37,16 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string, // 检测是否在容器内运行 inContainer := isRunningInContainer() + // 如果在容器内运行,获取当前容器的网络设置 + var currentNetworkName string + if inContainer { + currentNetworkName = getCurrentContainerNetwork(ctx, cli) + if currentNetworkName != "" { + // 使用发现的网络替代配置中的网络 + cfg.Network = currentNetworkName + } + } + // 端口映射 - 将容器的9000端口映射到主机的端口 hostPort := nat.Port("9000/tcp") exposedPorts := nat.PortSet{ @@ -94,8 +105,31 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string, // 设置网络配置 networkingConfig := &network.NetworkingConfig{} if cfg.Network != "" { + // 首先检查网络类型,只在用户自定义网络上分配固定IP + isUserNetwork := false + if cfg.Network != "bridge" && cfg.Network != "host" && cfg.Network != "none" { + // 检查网络是否存在 + _, err := cli.NetworkInspect(ctx, cfg.Network, types.NetworkInspectOptions{}) + if err == nil { + isUserNetwork = true + } + } + endpointsConfig := make(map[string]*network.EndpointSettings) - endpointsConfig[cfg.Network] = &network.EndpointSettings{} + endpointSettings := &network.EndpointSettings{} + + // 只在用户自定义网络上尝试分配固定IP + if isUserNetwork { + // 自动为容器分配一个递增的IP地址 + nextIP, err := getNextAvailableIPInNetwork(ctx, cli, cfg.Network) + if err == nil && nextIP != "" { + endpointSettings.IPAMConfig = &network.EndpointIPAMConfig{ + IPv4Address: nextIP, + } + } + } + + endpointsConfig[cfg.Network] = endpointSettings networkingConfig.EndpointsConfig = endpointsConfig } @@ -116,11 +150,341 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string, return resp.ID, nil } +// getNextAvailableIPInNetwork 获取网络中下一个可用的IP地址 +func getNextAvailableIPInNetwork(ctx context.Context, cli *client.Client, networkName string) (string, error) { + // 获取网络信息 + networkResource, err := cli.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{}) + if err != nil { + return "", fmt.Errorf("无法检查网络: %w", err) + } + + // 确认网络类型是否为用户自定义网络,并获取子网信息 + if networkResource.IPAM.Config == nil || len(networkResource.IPAM.Config) == 0 { + return "", fmt.Errorf("网络没有IPAM配置") + } + + // 获取网络子网 + subnet := networkResource.IPAM.Config[0].Subnet + if subnet == "" { + return "", fmt.Errorf("网络子网未定义") + } + + // 解析子网 + _, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + return "", fmt.Errorf("无法解析子网CIDR: %w", err) + } + + // 获取网络中的所有容器 + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{All: true}) + if err != nil { + return "", fmt.Errorf("无法列出容器: %w", err) + } + + // 获取已使用的IP地址 + usedIPs := make(map[string]bool) + for _, c := range containers { + for name, network := range c.NetworkSettings.Networks { + if name == networkName && network.IPAddress != "" { + usedIPs[network.IPAddress] = true + } + } + } + + // 获取网络中的所有IP地址,查找最大IP + var maxIP net.IP + for ip := range usedIPs { + ipAddr := net.ParseIP(ip) + if ipAddr == nil { + continue + } + + // 确保IP在子网内 + if !ipNet.Contains(ipAddr) { + continue + } + + // 更新最大IP + if maxIP == nil || compareIPs(ipAddr, maxIP) > 0 { + maxIP = ipAddr + } + } + + // 如果网络中没有容器,使用网关地址的下一个IP + if maxIP == nil { + // 获取网关IP + gateway := networkResource.IPAM.Config[0].Gateway + if gateway == "" { + // 如果没有定义网关,使用子网的第一个可用IP + ip := ipNet.IP.To4() + if ip == nil { + return "", fmt.Errorf("无法获取IPv4地址") + } + + // 通常第一个可用IP是网关后的地址(x.x.x.1 + 1 = x.x.x.2) + ip[3]++ + maxIP = ip + } else { + gwIP := net.ParseIP(gateway) + if gwIP == nil { + return "", fmt.Errorf("无效的网关地址") + } + + // 使用网关IP的下一个地址 + maxIP = gwIP.To4() + if maxIP == nil { + return "", fmt.Errorf("无法获取IPv4网关地址") + } + maxIP[3]++ + } + } else { + // 如果有容器,则使用最大IP + 1 + maxIP = incrementIP(maxIP) + } + + // 确保新IP仍然在子网内 + if !ipNet.Contains(maxIP) { + return "", fmt.Errorf("无法分配下一个IP,已达到子网范围上限") + } + + return maxIP.String(), nil +} + +// compareIPs 比较两个IP地址的大小 +func compareIPs(a, b net.IP) int { + // 转为IPv4格式 + a4 := a.To4() + b4 := b.To4() + + if a4 == nil || b4 == nil { + // 如果任一IP不是IPv4,则无法比较 + return 0 + } + + // 逐字节比较 + for i := 0; i < 4; i++ { + if a4[i] < b4[i] { + return -1 + } else if a4[i] > b4[i] { + return 1 + } + } + return 0 +} + +// incrementIP 将IP地址递增1 +func incrementIP(ip net.IP) net.IP { + // 创建IP的副本 + newIP := make(net.IP, len(ip)) + copy(newIP, ip) + + // 确保使用IPv4格式 + ipv4 := newIP.To4() + if ipv4 == nil { + return newIP + } + + // 递增最后一个字节 + for i := 3; i >= 0; i-- { + ipv4[i]++ + if ipv4[i] > 0 { + // 如果没有溢出,退出循环 + break + } + // 如果溢出(变成0),继续递增上一个字节 + } + + return ipv4 +} + +// getNextAvailableIP 获取网络中下一个可用的IP地址 +func getNextAvailableIP(ctx context.Context, cli *client.Client, networkName string) (string, error) { + // 获取网络信息 + networkResource, err := cli.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{}) + if err != nil { + return "", fmt.Errorf("无法检查网络: %w", err) + } + + // 确认网络类型是否为bridge,并获取子网信息 + if networkResource.IPAM.Config == nil || len(networkResource.IPAM.Config) == 0 { + return "", fmt.Errorf("网络没有IPAM配置") + } + + // 获取网络子网 + subnet := networkResource.IPAM.Config[0].Subnet + if subnet == "" { + return "", fmt.Errorf("网络子网未定义") + } + + // 解析子网 + _, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + return "", fmt.Errorf("无法解析子网CIDR: %w", err) + } + + // 获取现有容器的IP地址 + existingIPs := make(map[string]bool) + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{All: true}) + if err != nil { + return "", fmt.Errorf("无法列出容器: %w", err) + } + + // 收集所有容器在这个网络中使用的IP + for _, c := range containers { + if c.NetworkSettings != nil { + for name, network := range c.NetworkSettings.Networks { + if name == networkName && network.IPAddress != "" { + existingIPs[network.IPAddress] = true + } + } + } + } + + // 确定最大IP地址 + var maxIP net.IP + for ipStr := range existingIPs { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + + if !ipNet.Contains(ip) { + continue // IP不在子网内 + } + + if maxIP == nil || compareIPs(ip, maxIP) > 0 { + maxIP = ip + } + } + + // 如果没有找到最大IP,使用子网的第一个可用IP(通常是x.x.x.2,因为x.x.x.1通常是网关) + if maxIP == nil { + firstIP := getFirstUsableIP(ipNet) + if firstIP != nil { + return firstIP.String(), nil + } + return "", fmt.Errorf("无法确定子网的第一个可用IP") + } + + // 递增最大IP地址 + nextIP := getNextIP(maxIP) + + // 确保新IP仍然在子网内 + if !ipNet.Contains(nextIP) { + return "", fmt.Errorf("无法分配下一个IP,已达到子网范围上限") + } + + return nextIP.String(), nil +} + +// getNextIP 获取下一个IP地址 +func getNextIP(ip net.IP) net.IP { + // 创建IP的副本 + nextIP := make(net.IP, len(ip)) + copy(nextIP, ip) + + // 确保使用IPv4格式 + nextIP = nextIP.To4() + if nextIP == nil { + return nil + } + + // 从最后一个字节开始递增 + for i := len(nextIP) - 1; i >= 0; i-- { + if nextIP[i] < 255 { + nextIP[i]++ + break + } + nextIP[i] = 0 // 进位 + } + + return nextIP +} + +// getFirstUsableIP 获取子网中的第一个可用IP +func getFirstUsableIP(ipNet *net.IPNet) net.IP { + // 子网的第一个地址通常是网络地址,不可用 + // 第二个地址通常是网关地址,也可能不可用 + // 因此我们从第三个地址开始(通常是x.x.x.2) + + // 获取子网的起始IP + ip := ipNet.IP.To4() + if ip == nil { + return nil + } + + // 创建副本 + firstIP := make(net.IP, len(ip)) + copy(firstIP, ip) + + // 递增到第三个地址 + thirdOctet := int(firstIP[2]) + fourthOctet := int(firstIP[3]) + 2 // 通常是x.x.x.2 + + // 处理进位 + if fourthOctet > 255 { + fourthOctet = fourthOctet % 256 + thirdOctet++ + if thirdOctet > 255 { + // 溢出处理(通常不会发生在正常子网中) + return nil + } + } + + firstIP[2] = byte(thirdOctet) + firstIP[3] = byte(fourthOctet) + + return firstIP +} + +// getCurrentContainerNetwork 获取当前容器所在的网络 +func getCurrentContainerNetwork(ctx context.Context, cli *client.Client) string { + // 获取当前容器的ID + hostname, err := os.Hostname() + if err != nil { + return "" + } + + // 查询容器信息 + containerInfo, err := cli.ContainerInspect(ctx, hostname) + if err != nil { + return "" + } + + // 遍历容器的网络配置,获取第一个非空网络名称 + for networkName := range containerInfo.NetworkSettings.Networks { + if networkName != "bridge" && networkName != "host" && networkName != "none" { + return networkName // 返回自定义网络名称 + } + } + + // 如果只有默认网络,则返回第一个网络名称 + for networkName := range containerInfo.NetworkSettings.Networks { + return networkName // 返回第一个找到的网络名称 + } + + return "" +} + // isRunningInContainer 检测当前程序是否在容器内运行 func isRunningInContainer() bool { // 检查环境变量 IS_DOCKER isDockEnv := os.Getenv("IS_DOCKER") - return isDockEnv == "true" || isDockEnv == "1" || isDockEnv == "yes" + if isDockEnv == "true" || isDockEnv == "1" || isDockEnv == "yes" { + return true + } + + // 通过检查cgroup文件判断 + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // 检查进程的cgroup信息 + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + return strings.Contains(string(data), "docker") + } + + return false } // findMaxPortForImage 查找指定镜像的容器已使用的最大端口号 @@ -286,7 +650,7 @@ func GetContainerIP(ctx context.Context, containerID string, cfg *config.DockerC // GetContainerHost 获取容器的访问地址(格式:ip:port) func GetContainerHost(ctx context.Context, containerID string, cfg *config.DockerConfig) (string, error) { - // 如果在Docker环境中运行,需要获取容器真实IP + // 如果在Docker环境中运行,需要获取容器真实IP或容器名 if isRunningInContainer() { cli := GetClient() @@ -295,16 +659,26 @@ func GetContainerHost(ctx context.Context, containerID string, cfg *config.Docke return "", fmt.Errorf("无法检查容器: %w", err) } - // 遍历网络配置找到IP地址 - for networkName, network := range inspect.NetworkSettings.Networks { + // 使用容器名作为主机名,用于内部网络通信 + containerName := inspect.Name + if containerName != "" { + // 移除前导斜杠 + if strings.HasPrefix(containerName, "/") { + containerName = containerName[1:] + } + // 使用容器名:9000作为内部访问地址 + return containerName + ":9000", nil + } + + // 备选方案:尝试使用容器IP + for _, network := range inspect.NetworkSettings.Networks { if network.IPAddress != "" { // 容器内部访问使用IP:9000格式 return network.IPAddress + ":9000", nil } - // 防止在没有获取到IP的情况下继续循环 - fmt.Printf("Network %s has no IP address\n", networkName) } - return "localhost:9000", fmt.Errorf("未找到容器IP地址") + + return "localhost:9000", fmt.Errorf("未找到容器名或IP地址") } else { // 如果不是在Docker环境中运行,需要获取端口映射 cli := GetClient() diff --git a/internal/view/robot/show.html b/internal/view/robot/show.html index a796832..0476cf1 100644 --- a/internal/view/robot/show.html +++ b/internal/view/robot/show.html @@ -19,7 +19,8 @@ 登录 - @@ -255,6 +256,25 @@ setInterval(loadStats, 5000); // 每5秒更新一次 }); + // 确认删除函数 + async function confirmDelete(id, name) { + if (typeof confirmDialog === 'function') { + const confirmed = await confirmDialog( + `确定要删除机器人"${name}"吗?此操作将永久删除容器及相关数据,无法恢复!`, + { type: 'danger', title: '删除机器人' } + ); + + if (confirmed) { + deleteRobot(id); + } + } else { + // 降级方案:使用原生confirm + if (confirm(`确定要删除机器人"${name}"吗?此操作不可恢复!`)) { + deleteRobot(id); + } + } + } + // 删除机器人 function deleteRobot(id) { fetch(`/admin/robots/${id}`, {