Feature: per-instance public_host for .ovpn

When creating/editing an instance, add '公网地址' (public_host) field.
This is the domain/IP clients will use to reach the OpenVPN server.
When downloading a .ovpn, this field is used first, falling back to:
  1. ?host= query parameter (one-time override)
  2. Request Host header
  3. 'vpn.example.com' default

This eliminates the need to manually edit .ovpn files after download
when the server's public address differs from its internal IP.

Backend changes:
  model.Instance: add PublicHost string field
  service.GenerateOVPN: priority PublicHost > remoteHost > default
  service.UpdateInstance: PublicHost persisted (already via JSON tag)

Frontend changes:
  Instances.vue: new '公网地址' input in create/edit dialog
  Instances.vue: new column in list table (shows '未配置' when empty)
  Users.vue: hint text updated to '可选: 覆盖实例公网地址'

E2E verified:
  public_host='vpn.yunwei.blog'  → .ovpn has 'remote vpn.yunwei.blog 1194'
  public_host='' + host=X        → .ovpn has 'remote X 1194'
  public_host set + no host      → public_host wins
This commit is contained in:
cnbugs
2026-08-10 00:35:30 +08:00
parent 2b0f144abc
commit a5519c2f6c
4 changed files with 22 additions and 4 deletions
+1
View File
@@ -41,6 +41,7 @@ type Instance struct {
AccessMode AccessMode `json:"access_mode"` // open | whitelist AccessMode AccessMode `json:"access_mode"` // open | whitelist
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表 AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
PublicHost string `json:"public_host"` // 公网域名/IP,生成 .ovpn 时优先用它;空则用下载时前端传入的 host
Status string `json:"status"` // running/stopped/error Status string `json:"status"` // running/stopped/error
PID int `json:"pid"` PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+7 -2
View File
@@ -400,7 +400,8 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
return &u, nil return &u, nil
} }
// GenerateOVPN 下载/重新生成 .ovpnremoteHost 由前端传入 // GenerateOVPN 下载/重新生成 .ovpn。
// remoteHost 优先级:Instance.PublicHost > 传入的 remoteHost > "vpn.example.com"。
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) { func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
u, err := s.Store.GetUser(userID) u, err := s.Store.GetUser(userID)
if err != nil { if err != nil {
@@ -410,7 +411,11 @@ func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost) host := in.PublicHost
if host == "" {
host = remoteHost
}
return s.Ovm.GenerateClientOVPNFor(u, in, host)
} }
// RevokeUser 吊销用户:禁用 + 标记。 // RevokeUser 吊销用户:禁用 + 标记。
+13 -1
View File
@@ -12,6 +12,12 @@
<el-table-column prop="proto" label="协议" width="80" /> <el-table-column prop="proto" label="协议" width="80" />
<el-table-column prop="dev" label="设备" width="80" /> <el-table-column prop="dev" label="设备" width="80" />
<el-table-column prop="subnet" label="子网" /> <el-table-column prop="subnet" label="子网" />
<el-table-column prop="public_host" label="公网地址">
<template #default="{row}">
<span v-if="row.public_host" style="font-family:monospace">{{ row.public_host }}</span>
<span v-else class="muted">未配置</span>
</template>
</el-table-column>
<el-table-column label="访问控制" width="110"> <el-table-column label="访问控制" width="110">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small"> <el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
@@ -53,6 +59,12 @@
<el-select v-model="form.dev"><el-option label="tun" value="tun"/><el-option label="tap" value="tap"/></el-select> <el-select v-model="form.dev"><el-option label="tun" value="tun"/><el-option label="tap" value="tap"/></el-select>
</el-form-item> </el-form-item>
<el-form-item label="子网"><el-input v-model="form.subnet" placeholder="10.8.0.0/24"/></el-form-item> <el-form-item label="子网"><el-input v-model="form.subnet" placeholder="10.8.0.0/24"/></el-form-item>
<el-form-item label="公网地址">
<el-input v-model="form.public_host" placeholder="vpn.example.com 或 203.0.113.10" />
<div class="muted" style="margin-top:4px">
客户端连接时用的服务端地址<b>留空</b>则下载 .ovpn 时手动输入;<b>填写</b>则所有用户下载的 .ovpn 自动用此地址,无需修改
</div>
</el-form-item>
<el-form-item label="加密"> <el-form-item label="加密">
<el-select v-model="form.cipher"><el-option label="AES-256-GCM" value="AES-256-GCM"/><el-option label="AES-128-GCM" value="AES-128-GCM"/><el-option label="CHACHA20-POLY1305" value="CHACHA20-POLY1305"/></el-select> <el-select v-model="form.cipher"><el-option label="AES-256-GCM" value="AES-256-GCM"/><el-option label="AES-128-GCM" value="AES-128-GCM"/><el-option label="CHACHA20-POLY1305" value="CHACHA20-POLY1305"/></el-select>
</el-form-item> </el-form-item>
@@ -103,7 +115,7 @@ const router = useRouter()
async function load() { list.value = await Inst.list() } async function load() { list.value = await Inst.list() }
function openCreate() { function openCreate() {
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', access_mode:'open', allow_networks:[], id:'' }) Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', access_mode:'open', allow_networks:[], public_host:'', id:'' })
dlg.value = true dlg.value = true
} }
function openEdit(row) { function openEdit(row) {
+1 -1
View File
@@ -7,7 +7,7 @@
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" /> <el-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" />
</el-select> </el-select>
</el-col> </el-col>
<el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col> <el-col :span="6"><el-input v-model="host" placeholder="可选: 覆盖实例公网地址" /></el-col>
<el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col> <el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col>
<el-col :span="6" style="text-align:right"> <el-col :span="6" style="text-align:right">
<el-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button> <el-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button>