fix(website): 修复 IP 作为域名的处理(尤其 IPv6)

- 抽取 IsIPv6/WrapIPv6/UnwrapIPv6 到 pkg/tools,复用于 acme panelSolver
- server_name 写入时为 IPv6 套方括号、读取时剥离,Domains 统一裸地址口径,证书 SAN 判定自然正确
- 前端打开网站链接时为 IPv6 套方括号,避免拼出非法 URL

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-07-03 01:21:04 +08:00
parent 93e013ec56
commit 65e2750102
5 changed files with 42 additions and 19 deletions
+4
View File
@@ -28,6 +28,7 @@ import (
"github.com/acepanel/panel/v3/pkg/punycode"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/tools"
"github.com/acepanel/panel/v3/pkg/types"
"github.com/acepanel/panel/v3/pkg/webserver"
webservertypes "github.com/acepanel/panel/v3/pkg/webserver/types"
@@ -147,6 +148,7 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
setting.Listens = vhost.Listen()
// 域名
domains := vhost.ServerName()
domains = lo.Map(domains, func(d string, _ int) string { return tools.UnwrapIPv6(d) })
domains, err = punycode.DecodeDomains(domains)
if err != nil {
return nil, err
@@ -322,6 +324,7 @@ func (r *websiteRepo) Create(ctx context.Context, req *request.WebsiteCreate) (*
if err != nil {
return nil, err
}
domains = lo.Map(domains, func(d string, _ int) string { return tools.WrapIPv6(d) })
if err = vhost.SetServerName(domains); err != nil {
return nil, err
}
@@ -604,6 +607,7 @@ func (r *websiteRepo) Update(ctx context.Context, req *request.WebsiteUpdate) er
if err != nil {
return err
}
domains = lo.Map(domains, func(d string, _ int) string { return tools.WrapIPv6(d) })
if err = vhost.SetServerName(domains); err != nil {
return err
}
+4 -16
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
@@ -30,6 +29,7 @@ import (
pkgos "github.com/acepanel/panel/v3/pkg/os"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/tools"
)
var panelSolverGlobal sync.Mutex
@@ -116,7 +116,7 @@ func (s *panelSolver) startServer() error {
}
func (s *panelSolver) writeNginxConfig() error {
hasIPv6 := lo.SomeBy(s.ip, s.isIPv6)
hasIPv6 := lo.SomeBy(s.ip, tools.IsIPv6)
var conf strings.Builder
conf.WriteString("server {\n listen 80;\n")
@@ -125,10 +125,7 @@ func (s *panelSolver) writeNginxConfig() error {
conf.WriteString(" listen [::]:80;\n")
}
names := lo.Map(s.ip, func(ip string, _ int) string {
if s.isIPv6(ip) {
return "[" + ip + "]"
}
return ip
return tools.WrapIPv6(ip)
})
_, _ = fmt.Fprintf(&conf, " server_name %s;\n", strings.Join(names, " "))
for path, token := range s.tokens {
@@ -166,10 +163,7 @@ func (s *panelSolver) writeApacheConfig() error {
var conf strings.Builder
addrs := lo.Map(s.ip, func(ip string, _ int) string {
if s.isIPv6(ip) {
return "[" + ip + "]:80"
}
return ip + ":80"
return tools.WrapIPv6(ip) + ":80"
})
_, _ = fmt.Fprintf(&conf, "<VirtualHost %s>\n", strings.Join(addrs, " "))
conf.WriteString(" ServerName acme-ip-validation\n")
@@ -236,12 +230,6 @@ func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error {
return nil
}
// isIPv6 判断 host 是否为 IPv6 地址
func (s *panelSolver) isIPv6(host string) bool {
addr, err := netip.ParseAddr(host)
return err == nil && !addr.Is4()
}
type httpSolver struct {
conf string
webServer string // "nginx" or "apache"
+26
View File
@@ -8,6 +8,7 @@ import (
"fmt"
stdnet "net"
"net/http"
"net/netip"
"slices"
"sort"
"strings"
@@ -313,3 +314,28 @@ func FormatBytes(size float64) string {
return fmt.Sprintf("%.2f %s", size, units[i])
}
// IsIPv6 判断 host 是否为 IPv6 地址(裸地址,不含方括号)
func IsIPv6(host string) bool {
addr, err := netip.ParseAddr(host)
return err == nil && !addr.Is4()
}
// WrapIPv6 为裸 IPv6 地址套上方括号(如 ::1 → [::1]),用于 nginx server_name、URL 等语境
// 非 IPv6 或已带方括号则原样返回
func WrapIPv6(host string) string {
if IsIPv6(host) {
return "[" + host + "]"
}
return host
}
// UnwrapIPv6 去除 IPv6 地址的方括号(如 [::1] → ::1),非此形式则原样返回
func UnwrapIPv6(host string) string {
if inner, ok := strings.CutPrefix(host, "["); ok {
if inner, ok = strings.CutSuffix(inner, "]"); ok && IsIPv6(inner) {
return inner
}
}
return host
}
+5
View File
@@ -56,3 +56,8 @@ export function generateRandomString(length: number) {
}
return result
}
/** 为裸 IPv6 地址套上方括号(如 ::1 → [::1]),用于拼接 URL;非 IPv6 或已带方括号则原样返回 */
export function wrapIPv6(host: string): string {
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
}
+3 -3
View File
@@ -17,7 +17,7 @@ import website from '@/api/panel/website'
import TheIcon from '@/components/custom/TheIcon.vue'
import ConfirmDialog from '@/components/system/ConfirmDialog.vue'
import { useFileStore } from '@/stores'
import { isNullOrUndef } from '@/utils'
import { isNullOrUndef, wrapIPv6 } from '@/utils'
const type = defineModel<string>('type', { type: String, required: true }) // 网站类型
const createModal = defineModel<boolean>('createModal', { type: Boolean, required: true }) // 创建网站
@@ -63,7 +63,7 @@ const columns: any = [
class: 'cursor-pointer hover:opacity-60 inline-flex',
onDblclick: () => {
const protocol = row.ssl ? 'https' : 'http'
window.open(`${protocol}://${row.domains[0]}`, '_blank')
window.open(`${protocol}://${wrapIPv6(row.domains[0])}`, '_blank')
},
},
[h(TheIcon, { icon: 'mdi:link-variant', size: 16 })],
@@ -76,7 +76,7 @@ const columns: any = [
default: () =>
row.domains.map((domain: string) => {
const protocol = row.ssl ? 'https' : 'http'
const url = `${protocol}://${domain}`
const url = `${protocol}://${wrapIPv6(domain)}`
return h(
NFlex,
{ align: 'center', size: 'small' },