diff --git a/pkg/webserver/nginx/proxy.go b/pkg/webserver/nginx/proxy.go
index 742b766e..658dbfe5 100644
--- a/pkg/webserver/nginx/proxy.go
+++ b/pkg/webserver/nginx/proxy.go
@@ -76,6 +76,7 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
proxy := &types.Proxy{
Location: strings.TrimSpace(matches[1]),
+ Resolver: []string{},
Replaces: make(map[string]string),
}
@@ -240,8 +241,10 @@ func generateProxyConfig(proxy types.Proxy) string {
sb.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
// SNI 配置
- if proxy.SNI != "" {
+ if strings.HasPrefix(proxy.Pass, "https") {
sb.WriteString(" proxy_ssl_server_name on;\n")
+ }
+ if proxy.SNI != "" {
sb.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", proxy.SNI))
}
diff --git a/pkg/webserver/types/proxy.go b/pkg/webserver/types/proxy.go
index 0b0cda98..126596b1 100644
--- a/pkg/webserver/types/proxy.go
+++ b/pkg/webserver/types/proxy.go
@@ -17,7 +17,9 @@ type Proxy struct {
// Upstream 上游服务器配置
type Upstream struct {
- Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["server1"] = "weight=5 resolve"
- Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "ip_hash"
- Keepalive int `form:"keepalive" json:"keepalive"` // 保持连接数,如: 32
+ Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["server1"] = "weight=5 resolve"
+ Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "ip_hash"
+ Keepalive int `form:"keepalive" json:"keepalive"` // 保持连接数,如: 32
+ Resolver []string `form:"resolver" json:"resolver"` // 自定义 DNS 解析器配置,如: ["8.8.8.8", "ipv6=off"]
+ ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间,如: 5 * time.Second
}
diff --git a/web/package.json b/web/package.json
index 09d11837..387606ad 100644
--- a/web/package.json
+++ b/web/package.json
@@ -53,7 +53,8 @@
"vue": "^3.5.22",
"vue-echarts": "^8.0.1",
"vue-router": "^4.6.3",
- "vue3-gettext": "4.0.0-beta.1"
+ "vue3-gettext": "4.0.0-beta.1",
+ "vuedraggable": "^4.1.0"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index f9cccc6b..a29607a2 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
vue3-gettext:
specifier: 4.0.0-beta.1
version: 4.0.0-beta.1(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@5.9.3))
+ vuedraggable:
+ specifier: ^4.1.0
+ version: 4.1.0(vue@3.5.26(typescript@5.9.3))
devDependencies:
'@iconify-json/mdi':
specifier: ^1.2.3
@@ -2951,6 +2954,9 @@ packages:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
+ sortablejs@1.14.0:
+ resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3373,6 +3379,11 @@ packages:
typescript:
optional: true
+ vuedraggable@4.1.0:
+ resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+ peerDependencies:
+ vue: ^3.0.1
+
vueuc@0.4.65:
resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==}
peerDependencies:
@@ -6564,6 +6575,8 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
+ sortablejs@1.14.0: {}
+
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -7063,6 +7076,11 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ vuedraggable@4.1.0(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ sortablejs: 1.14.0
+ vue: 3.5.26(typescript@5.9.3)
+
vueuc@0.4.65(vue@3.5.26(typescript@5.9.3)):
dependencies:
'@css-render/vue3-ssr': 0.15.14(vue@3.5.26(typescript@5.9.3))
diff --git a/web/src/views/toolbox/SshView.vue b/web/src/views/toolbox/SshView.vue
index fac4b888..1505ce95 100644
--- a/web/src/views/toolbox/SshView.vue
+++ b/web/src/views/toolbox/SshView.vue
@@ -285,7 +285,7 @@ onMounted(() => {
{{
$gettext(
- 'It is recommended to use a complex password. Save after modification. Refresh will clear the password field.'
+ 'It is recommended to use a complex password. Refresh will clear the password field.'
)
}}
diff --git a/web/src/views/website/EditView.vue b/web/src/views/website/EditView.vue
index e6cbd0ef..2563490d 100644
--- a/web/src/views/website/EditView.vue
+++ b/web/src/views/website/EditView.vue
@@ -6,6 +6,7 @@ defineOptions({
import type { MessageReactive } from 'naive-ui'
import { NButton } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
+import draggable from 'vuedraggable'
import cert from '@/api/panel/cert'
import home from '@/api/panel/home'
@@ -179,6 +180,218 @@ const toggleArg = (args: string[], arg: string, checked: boolean) => {
const hasArg = (args: string[], arg: string) => {
return args.includes(arg)
}
+
+// ========== Upstreams 相关 ==========
+const upstreamAlgoOptions = [
+ { label: $gettext('Round Robin (default)'), value: '' },
+ { label: 'least_conn', value: 'least_conn' },
+ { label: 'ip_hash', value: 'ip_hash' },
+ { label: 'hash', value: 'hash' },
+ { label: 'random', value: 'random' }
+]
+
+// 添加新的上游
+const addUpstream = () => {
+ const name = `backend_${Date.now()}`
+ if (!setting.value.upstreams) {
+ setting.value.upstreams = {}
+ }
+ setting.value.upstreams[name] = {
+ servers: {},
+ algo: '',
+ keepalive: 32
+ }
+}
+
+// 删除上游
+const removeUpstream = (name: string) => {
+ if (setting.value.upstreams) {
+ delete setting.value.upstreams[name]
+ }
+}
+
+// 重命名上游
+const renameUpstream = (oldName: string, newName: string) => {
+ if (!setting.value.upstreams || oldName === newName) return
+ if (setting.value.upstreams[newName]) {
+ window.$message.error($gettext('Upstream name already exists'))
+ return
+ }
+ const upstream = setting.value.upstreams[oldName]
+ delete setting.value.upstreams[oldName]
+ setting.value.upstreams[newName] = upstream
+}
+
+// 为上游添加服务器
+const addServerToUpstream = (upstreamName: string) => {
+ if (!setting.value.upstreams[upstreamName].servers) {
+ setting.value.upstreams[upstreamName].servers = {}
+ }
+ setting.value.upstreams[upstreamName].servers[
+ `127.0.0.1:${8080 + Object.keys(setting.value.upstreams[upstreamName].servers).length}`
+ ] = ''
+}
+
+// ========== Proxies 相关 ==========
+// Location 匹配类型选项
+const locationMatchTypes = [
+ { label: $gettext('Exact Match (=)'), value: '=' },
+ { label: $gettext('Priority Prefix Match (^~)'), value: '^~' },
+ { label: $gettext('Prefix Match'), value: '' },
+ { label: $gettext('Case-sensitive Regex (~)'), value: '~' },
+ { label: $gettext('Case-insensitive Regex (~*)'), value: '~*' }
+]
+
+// 解析 location 字符串,返回匹配类型和表达式
+const parseLocation = (location: string): { type: string; expression: string } => {
+ if (!location) return { type: '', expression: '/' }
+
+ // 精确匹配 =
+ if (location.startsWith('= ')) {
+ return { type: '=', expression: location.slice(2) }
+ }
+ // 优先前缀匹配 ^~
+ if (location.startsWith('^~ ')) {
+ return { type: '^~', expression: location.slice(3) }
+ }
+ // 不区分大小写正则 ~*
+ if (location.startsWith('~* ')) {
+ return { type: '~*', expression: location.slice(3) }
+ }
+ // 区分大小写正则 ~
+ if (location.startsWith('~ ')) {
+ return { type: '~', expression: location.slice(2) }
+ }
+ // 普通前缀匹配
+ return { type: '', expression: location }
+}
+
+// 组合 location 字符串
+const buildLocation = (type: string, expression: string): string => {
+ if (!expression) expression = '/'
+ if (type === '') return expression
+ return `${type} ${expression}`
+}
+
+// 从 URL 提取主机名
+const extractHostFromUrl = (url: string): string => {
+ try {
+ const urlObj = new URL(url)
+ return urlObj.host
+ } catch {
+ // 如果不是有效的 URL,尝试简单提取
+ const match = url.match(/^https?:\/\/([^/]+)/)
+ return match ? match[1] : ''
+ }
+}
+
+// 添加新的代理
+const addProxy = () => {
+ if (!setting.value.proxies) {
+ setting.value.proxies = []
+ }
+ setting.value.proxies.push({
+ location: '/',
+ pass: 'http://127.0.0.1:8080',
+ host: '$host',
+ sni: '',
+ cache: false,
+ buffering: true,
+ resolver: [],
+ resolver_timeout: 5 * 1000000000, // 5秒,以纳秒为单位
+ replaces: {}
+ })
+}
+
+// 删除代理
+const removeProxy = (index: number) => {
+ if (setting.value.proxies) {
+ setting.value.proxies.splice(index, 1)
+ }
+}
+
+// 处理 Proxy Pass 变化,自动更新 Host
+const handleProxyPassChange = (proxy: any, value: string) => {
+ proxy.pass = value
+ // 如果 host 是 $host 或为空,则自动提取
+ if (!proxy.host || proxy.host === '$host') {
+ const extracted = extractHostFromUrl(value)
+ // 只有当提取到的不是 IP 地址时才设置
+ if (
+ extracted &&
+ !/^\d+\.\d+\.\d+\.\d+(:\d+)?$/.test(extracted) &&
+ !/^localhost(:\d+)?$/i.test(extracted)
+ ) {
+ proxy.host = extracted
+ } else {
+ proxy.host = '$host'
+ }
+ }
+}
+
+// 更新 Location 匹配类型
+const updateLocationType = (proxy: any, type: string) => {
+ const parsed = parseLocation(proxy.location)
+ proxy.location = buildLocation(type, parsed.expression)
+}
+
+// 更新 Location 表达式
+const updateLocationExpression = (proxy: any, expression: string) => {
+ const parsed = parseLocation(proxy.location)
+ proxy.location = buildLocation(parsed.type, expression)
+}
+
+// ========== 时间单位相关 ==========
+// Go time.Duration 在 JSON 中以纳秒表示
+const NANOSECOND = 1
+const SECOND = 1000000000 * NANOSECOND
+const MINUTE = 60 * SECOND
+const HOUR = 60 * MINUTE
+
+// 时间单位选项
+const timeUnitOptions = [
+ { label: $gettext('Seconds'), value: 's' },
+ { label: $gettext('Minutes'), value: 'm' },
+ { label: $gettext('Hours'), value: 'h' }
+]
+
+// 从纳秒解析为 {value, unit} 格式
+const parseDuration = (ns: number): { value: number; unit: string } => {
+ if (!ns || ns <= 0) return { value: 5, unit: 's' }
+
+ if (ns >= HOUR && ns % HOUR === 0) {
+ return { value: ns / HOUR, unit: 'h' }
+ }
+ if (ns >= MINUTE && ns % MINUTE === 0) {
+ return { value: ns / MINUTE, unit: 'm' }
+ }
+ return { value: Math.floor(ns / SECOND), unit: 's' }
+}
+
+// 将 {value, unit} 转换为纳秒
+const buildDuration = (value: number, unit: string): number => {
+ if (!value || value <= 0) value = 5
+ switch (unit) {
+ case 'h':
+ return value * HOUR
+ case 'm':
+ return value * MINUTE
+ default:
+ return value * SECOND
+ }
+}
+
+// 更新超时时间值
+const updateTimeoutValue = (proxy: any, value: number) => {
+ const parsed = parseDuration(proxy.resolver_timeout)
+ proxy.resolver_timeout = buildDuration(value, parsed.unit)
+}
+
+// 更新超时时间单位
+const updateTimeoutUnit = (proxy: any, unit: string) => {
+ const parsed = parseDuration(proxy.resolver_timeout)
+ proxy.resolver_timeout = buildDuration(parsed.value, unit)
+}
@@ -261,6 +474,266 @@ const hasArg = (args: string[], arg: string) => {
+
+
+
+ {{
+ $gettext(
+ 'Upstreams define backend server groups for load balancing. You can reference an upstream in proxy settings using "http://upstream_name".'
+ )
+ }}
+
+
+
+
+
+ renameUpstream(String(name), (e.target as HTMLInputElement).value)
+ "
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const newAddr = (e.target as HTMLInputElement).value
+ const oldAddr = String(address)
+ if (newAddr && newAddr !== oldAddr) {
+ upstream.servers[newAddr] = upstream.servers[oldAddr]
+ delete upstream.servers[oldAddr]
+ }
+ }
+ "
+ />
+ (upstream.servers[String(address)] = v)"
+ />
+
+ {{ $gettext('Remove') }}
+
+
+
+ {{ $gettext('Add Server') }}
+
+
+
+
+
+
+
+ {{ $gettext('No upstreams configured') }}
+
+
+
+ {{ $gettext('Add Upstream') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $gettext('Rule') }} #{{ index + 1 }}
+ {{ proxy.location }}
+
+ {{ proxy.pass }}
+
+
+
+
+
+ updateLocationType(proxy, v)"
+ />
+
+
+ updateLocationExpression(proxy, v)"
+ />
+
+
+ handleProxyPassChange(proxy, v)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateTimeoutValue(proxy, v ?? 5)"
+ />
+ updateTimeoutUnit(proxy, v)"
+ />
+
+
+
+ {{ $gettext('Response Content Replacement') }}
+
+
+ {
+ const newFrom = (e.target as HTMLInputElement).value
+ const oldFrom = String(fromValue)
+ if (newFrom && newFrom !== oldFrom) {
+ proxy.replaces[newFrom] = proxy.replaces[oldFrom]
+ delete proxy.replaces[oldFrom]
+ }
+ }
+ "
+ />
+ =>
+ (proxy.replaces[String(fromValue)] = v)"
+ />
+
+ {{ $gettext('Remove') }}
+
+
+ {
+ if (!proxy.replaces) proxy.replaces = {}
+ proxy.replaces[`/old_${Object.keys(proxy.replaces).length}`] = '/new'
+ }
+ "
+ >
+ {{ $gettext('Add Replacement Rule') }}
+
+
+
+
+
+
+
+
+
+ {{ $gettext('No proxy rules configured') }}
+
+
+
+
+ {{ $gettext('Add Proxy Rule') }}
+
+
+