🎨 优化容器网络配置,支持自动分配可用IP地址并修改删除按钮确认逻辑
All checks were successful
BuildImage / build-image (push) Successful in 4m0s

This commit is contained in:
李寻欢 2025-04-07 11:14:30 +08:00
parent 37a628b5c3
commit c409dc5a02
3 changed files with 483 additions and 9 deletions

80
docker-compose.yaml Normal file
View File

@ -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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -36,6 +37,16 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string,
// 检测是否在容器内运行 // 检测是否在容器内运行
inContainer := isRunningInContainer() inContainer := isRunningInContainer()
// 如果在容器内运行,获取当前容器的网络设置
var currentNetworkName string
if inContainer {
currentNetworkName = getCurrentContainerNetwork(ctx, cli)
if currentNetworkName != "" {
// 使用发现的网络替代配置中的网络
cfg.Network = currentNetworkName
}
}
// 端口映射 - 将容器的9000端口映射到主机的端口 // 端口映射 - 将容器的9000端口映射到主机的端口
hostPort := nat.Port("9000/tcp") hostPort := nat.Port("9000/tcp")
exposedPorts := nat.PortSet{ exposedPorts := nat.PortSet{
@ -94,8 +105,31 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string,
// 设置网络配置 // 设置网络配置
networkingConfig := &network.NetworkingConfig{} networkingConfig := &network.NetworkingConfig{}
if cfg.Network != "" { 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 := 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 networkingConfig.EndpointsConfig = endpointsConfig
} }
@ -116,11 +150,341 @@ func CreateContainer(ctx context.Context, cfg *config.DockerConfig, name string,
return resp.ID, nil 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 检测当前程序是否在容器内运行 // isRunningInContainer 检测当前程序是否在容器内运行
func isRunningInContainer() bool { func isRunningInContainer() bool {
// 检查环境变量 IS_DOCKER // 检查环境变量 IS_DOCKER
isDockEnv := os.Getenv("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 查找指定镜像的容器已使用的最大端口号 // findMaxPortForImage 查找指定镜像的容器已使用的最大端口号
@ -286,7 +650,7 @@ func GetContainerIP(ctx context.Context, containerID string, cfg *config.DockerC
// GetContainerHost 获取容器的访问地址格式ip:port // GetContainerHost 获取容器的访问地址格式ip:port
func GetContainerHost(ctx context.Context, containerID string, cfg *config.DockerConfig) (string, error) { func GetContainerHost(ctx context.Context, containerID string, cfg *config.DockerConfig) (string, error) {
// 如果在Docker环境中运行需要获取容器真实IP // 如果在Docker环境中运行需要获取容器真实IP或容器名
if isRunningInContainer() { if isRunningInContainer() {
cli := GetClient() cli := GetClient()
@ -295,16 +659,26 @@ func GetContainerHost(ctx context.Context, containerID string, cfg *config.Docke
return "", fmt.Errorf("无法检查容器: %w", err) 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 != "" { if network.IPAddress != "" {
// 容器内部访问使用IP:9000格式 // 容器内部访问使用IP:9000格式
return network.IPAddress + ":9000", nil 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 { } else {
// 如果不是在Docker环境中运行需要获取端口映射 // 如果不是在Docker环境中运行需要获取端口映射
cli := GetClient() cli := GetClient()

View File

@ -19,7 +19,8 @@
<a href="/admin/robots/{{.Robot.ID}}/login" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors mr-2"> <a href="/admin/robots/{{.Robot.ID}}/login" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors mr-2">
<i class="fas fa-qrcode mr-2"></i> 登录 <i class="fas fa-qrcode mr-2"></i> 登录
</a> </a>
<button data-confirm="确定要删除此机器人吗?" data-confirm-type="danger" onclick="deleteRobot({{.Robot.ID}})" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors"> <!-- 修改删除按钮使用confirmDialog而非直接调用deleteRobot函数 -->
<button onclick="confirmDelete({{.Robot.ID}}, '{{.Robot.Nickname}}')" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
<i class="fas fa-trash mr-2"></i> 删除 <i class="fas fa-trash mr-2"></i> 删除
</button> </button>
</div> </div>
@ -255,6 +256,25 @@
setInterval(loadStats, 5000); // 每5秒更新一次 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) { function deleteRobot(id) {
fetch(`/admin/robots/${id}`, { fetch(`/admin/robots/${id}`, {