2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 05:31:44 +08:00

feat: 实现登录验证码和安全入口错误页伪装功能 (#1206)

* Initial plan

* feat: 实现登录验证码和安全入口错误页伪装功能

- 后端:添加登录验证码功能(密码错误3次后触发)
- 后端:支持3种安全入口错误页伪装(418/nginx/close)
- 后端:添加验证码API和更新设置项
- 前端:登录页支持验证码输入和刷新
- 前端:设置页添加登录验证码和错误页伪装选项

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复代码审查问题

- hijack失败时回退到418错误页而非返回200
- 验证码输入去除空格

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 优化细节

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-10 05:21:09 +08:00
committed by GitHub
parent e9c0190a49
commit 01a228f3ad
17 changed files with 335 additions and 82 deletions

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/beevik/ntp v1.5.0
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/dchest/captcha v1.1.0
github.com/expr-lang/expr v1.17.7
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/httplog/v3 v3.3.0

2
go.sum
View File

@@ -55,6 +55,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=

View File

@@ -248,27 +248,29 @@ func (r *settingRepo) GetPanel() (*request.SettingPanel, error) {
}
return &request.SettingPanel{
Name: name,
Channel: channel,
Locale: r.conf.App.Locale,
Entrance: r.conf.HTTP.Entrance,
OfflineMode: offlineMode,
AutoUpdate: autoUpdate,
Lifetime: r.conf.Session.Lifetime,
IPHeader: r.conf.HTTP.IPHeader,
BindDomain: r.conf.HTTP.BindDomain,
BindIP: r.conf.HTTP.BindIP,
BindUA: r.conf.HTTP.BindUA,
WebsitePath: websitePath,
BackupPath: backupPath,
HiddenMenu: hiddenMenu,
CustomLogo: customLogo,
Port: r.conf.HTTP.Port,
HTTPS: r.conf.HTTP.TLS,
ACME: r.conf.HTTP.ACME,
PublicIP: publicIP,
Cert: crt,
Key: key,
Name: name,
Channel: channel,
Locale: r.conf.App.Locale,
Entrance: r.conf.HTTP.Entrance,
EntranceError: r.conf.HTTP.EntranceError,
LoginCaptcha: r.conf.HTTP.LoginCaptcha,
OfflineMode: offlineMode,
AutoUpdate: autoUpdate,
Lifetime: r.conf.Session.Lifetime,
IPHeader: r.conf.HTTP.IPHeader,
BindDomain: r.conf.HTTP.BindDomain,
BindIP: r.conf.HTTP.BindIP,
BindUA: r.conf.HTTP.BindUA,
WebsitePath: websitePath,
BackupPath: backupPath,
HiddenMenu: hiddenMenu,
CustomLogo: customLogo,
Port: r.conf.HTTP.Port,
HTTPS: r.conf.HTTP.TLS,
ACME: r.conf.HTTP.ACME,
PublicIP: publicIP,
Cert: crt,
Key: key,
}, nil
}
@@ -354,6 +356,8 @@ func (r *settingRepo) UpdatePanel(req *request.SettingPanel) (bool, error) {
conf.App.Locale = req.Locale
conf.HTTP.Port = req.Port
conf.HTTP.Entrance = req.Entrance
conf.HTTP.EntranceError = req.EntranceError
conf.HTTP.LoginCaptcha = req.LoginCaptcha
conf.HTTP.TLS = req.HTTPS
conf.HTTP.ACME = req.ACME
conf.HTTP.IPHeader = req.IPHeader

View File

@@ -12,6 +12,7 @@ import (
"github.com/libtnb/sessions"
"github.com/acepanel/panel/pkg/config"
"github.com/acepanel/panel/pkg/embed"
"github.com/acepanel/panel/pkg/punycode"
)
@@ -41,7 +42,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
}
}
if len(conf.HTTP.BindDomain) > 0 && !slices.Contains(conf.HTTP.BindDomain, host) {
Abort(w, http.StatusTeapot, t.Get("invalid request domain: %s", r.Host))
abortEntrance(w, r, conf, conf.App.Locale)
return
}
@@ -77,12 +78,12 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
}
}
if !allowed {
Abort(w, http.StatusTeapot, t.Get("invalid request ip: %s", ip))
abortEntrance(w, r, conf, conf.App.Locale)
return
}
}
if len(conf.HTTP.BindUA) > 0 && !slices.Contains(conf.HTTP.BindUA, r.UserAgent()) {
Abort(w, http.StatusTeapot, t.Get("invalid request user agent: %s", r.UserAgent()))
abortEntrance(w, r, conf, conf.App.Locale)
return
}
@@ -122,7 +123,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
if !conf.App.Debug &&
sess.Missing("verify_entrance") &&
r.URL.Path != "/robots.txt" {
Abort(w, http.StatusTeapot, t.Get("invalid access entrance"))
abortEntrance(w, r, conf, conf.App.Locale)
return
}
@@ -130,3 +131,43 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
})
}
}
func abortEntrance(w http.ResponseWriter, r *http.Request, conf *config.Config, locale string) {
errorType := conf.HTTP.EntranceError
switch errorType {
case "close":
hj, ok := w.(http.Hijacker)
if ok {
conn, _, err := hj.Hijack()
if err == nil {
_ = conn.Close()
return
}
}
// 如果无法 hijack则返回空响应
w.WriteHeader(http.StatusTeapot)
return
case "nginx":
content, err := embed.ErrorFS.ReadFile("error/nginx_404.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Server", "nginx")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(content)
return
default:
fileName := "error/418.html"
if locale == "zh_CN" {
fileName = "error/418_zh_CN.html"
}
content, _ := embed.ErrorFS.ReadFile(fileName)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write(content)
return
}
}

View File

@@ -23,6 +23,7 @@ func MustLogin(t *gotext.Locale, conf *config.Config, session *sessions.Manager,
// 白名单
whiteList := []string{
"/api/user/key",
"/api/user/captcha",
"/api/user/login",
"/api/user/logout",
"/api/user/is_login",

View File

@@ -3,28 +3,30 @@ package request
import "net/http"
type SettingPanel struct {
Name string `json:"name" validate:"required"`
Channel string `json:"channel" validate:"required|in:stable,beta"`
Locale string `json:"locale" validate:"required"`
Entrance string `json:"entrance" validate:"required"`
OfflineMode bool `json:"offline_mode"`
AutoUpdate bool `json:"auto_update"`
TwoFA bool `json:"two_fa"`
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
IPHeader string `json:"ip_header"`
BindDomain []string `json:"bind_domain"`
BindIP []string `json:"bind_ip"`
BindUA []string `json:"bind_ua"`
WebsitePath string `json:"website_path" validate:"required"`
BackupPath string `json:"backup_path" validate:"required"`
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
Port uint `json:"port" validate:"required|min:1|max:65535"`
HTTPS bool `json:"https"`
ACME bool `json:"acme"`
PublicIP []string `json:"public_ip"`
Cert string `json:"cert" validate:"required"`
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required"`
Channel string `json:"channel" validate:"required|in:stable,beta"`
Locale string `json:"locale" validate:"required"`
Entrance string `json:"entrance" validate:"required"`
EntranceError string `json:"entrance_error" validate:"in:418,nginx,close"` // 安全入口错误页伪装类型
LoginCaptcha bool `json:"login_captcha"` // 登录验证码
OfflineMode bool `json:"offline_mode"`
AutoUpdate bool `json:"auto_update"`
TwoFA bool `json:"two_fa"`
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
IPHeader string `json:"ip_header"`
BindDomain []string `json:"bind_domain"`
BindIP []string `json:"bind_ip"`
BindUA []string `json:"bind_ua"`
WebsitePath string `json:"website_path" validate:"required"`
BackupPath string `json:"backup_path" validate:"required"`
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
Port uint `json:"port" validate:"required|min:1|max:65535"`
HTTPS bool `json:"https"`
ACME bool `json:"acme"`
PublicIP []string `json:"public_ip"`
Cert string `json:"cert" validate:"required"`
Key string `json:"key" validate:"required"`
}
func (r *SettingPanel) Rules(_ *http.Request) map[string]string {

View File

@@ -5,10 +5,11 @@ type UserID struct {
}
type UserLogin struct {
Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP
Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP
SafeLogin bool `json:"safe_login"`
PassCode string `json:"pass_code"`
Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP
Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP
SafeLogin bool `json:"safe_login"`
PassCode string `json:"pass_code"` // 2FA
CaptchaCode string `json:"captcha_code"` // 验证码
}
type UserIsTwoFA struct {

View File

@@ -136,6 +136,7 @@ func (route *Http) Register(r *chi.Mux) {
r.Route("/api", func(r chi.Router) {
r.Route("/user", func(r chi.Router) {
r.Get("/key", route.user.GetKey)
r.Get("/captcha", route.user.GetCaptcha)
r.With(middleware.Throttle(route.conf.HTTP.IPHeader, 5, time.Minute)).Post("/login", route.user.Login)
r.Post("/logout", route.user.Logout)
r.Get("/is_login", route.user.IsLogin)

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/dchest/captcha"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/sessions"
@@ -25,6 +26,9 @@ import (
"github.com/acepanel/panel/pkg/rsacrypto"
)
// 登录失败次数阈值,超过此次数需要验证码
const loginFailThreshold = 3
type UserService struct {
t *gotext.Locale
conf *config.Config
@@ -65,6 +69,37 @@ func (s *UserService) GetKey(w http.ResponseWriter, r *http.Request) {
Success(w, pk)
}
// GetCaptcha 获取登录验证码
func (s *UserService) GetCaptcha(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
failCount := cast.ToInt(sess.Get("login_fail_count"))
if !s.conf.HTTP.LoginCaptcha || failCount < loginFailThreshold {
Success(w, chix.M{
"required": false,
})
return
}
captchaID := captcha.NewLen(4)
sess.Put("captcha_id", captchaID)
var buf bytes.Buffer
if err := captcha.WriteImage(&buf, captchaID, 150, 50); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"required": true,
"image": base64.StdEncoding.EncodeToString(buf.Bytes()),
})
}
func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
@@ -78,6 +113,16 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
return
}
failCount := cast.ToInt(sess.Get("login_fail_count"))
if s.conf.HTTP.LoginCaptcha && failCount >= loginFailThreshold {
captchaID, ok := sess.Get("captcha_id").(string)
if !ok || captchaID == "" || !captcha.VerifyString(captchaID, req.CaptchaCode) {
Error(w, http.StatusForbidden, s.t.Get("invalid captcha code"))
return
}
sess.Forget("captcha_id")
}
key, ok := sess.Get("key").(rsa.PrivateKey)
if !ok {
Error(w, http.StatusForbidden, s.t.Get("invalid key, please refresh the page"))
@@ -88,6 +133,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
decryptedPassword, _ := rsacrypto.DecryptData(&key, req.Password)
user, err := s.userRepo.CheckPassword(string(decryptedUsername), string(decryptedPassword))
if err != nil {
sess.Put("login_fail_count", failCount+1)
Error(w, http.StatusForbidden, "%v", err)
return
}
@@ -128,6 +174,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
sess.Put("user_id", user.ID)
sess.Put("refresh_at", time.Now().Unix())
sess.Forget("key")
sess.Forget("login_fail_count")
Success(w, nil)
}

View File

@@ -10,3 +10,6 @@ var WebsiteFS embed.FS
//go:embed all:locales/*
var LocalesFS embed.FS
//go:embed all:error/*
var ErrorFS embed.FS

17
pkg/embed/error/418.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>418 I'm a teapot</title>
<style>body{background:#f2f3f5;margin:0;padding:20px;font-family:system-ui,sans-serif}.container{max-width:600px;margin:3em auto;background:#fff;padding:40px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,.05)}h1{font-size:3em;font-weight:600;margin:0 0 30px;color:#1a1a1a}p{color:#5a5a5a;line-height:1.6}a{text-decoration:none;color:#333;font-weight:600}</style>
</head>
<body>
<div class="container">
<h1>418 I'm a teapot</h1>
<p>You are accessing AcePanel through the wrong entrance.</p>
<p>If you have forgotten the entrance, please run <code>acepanel entrance off</code> to disable it.</p>
<p><em>Powered by <a target="_blank" href="https://acepanel.net">AcePanel</a></em></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>418 I'm a teapot</title>
<style>body{background:#f2f3f5;margin:0;padding:20px;font-family:system-ui,sans-serif}.container{max-width:600px;margin:3em auto;background:#fff;padding:40px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,.05)}h1{font-size:3em;font-weight:600;margin:0 0 30px;color:#1a1a1a}p{color:#5a5a5a;line-height:1.6}a{text-decoration:none;color:#333;font-weight:600}</style>
</head>
<body>
<div class="container">
<h1>418 I'm a teapot</h1>
<p>您正在使用错误的入口访问 AcePanel</p>
<p>如果您忘记了入口请运行 <code>acepanel entrance off</code> 将其关闭</p>
<p><em> <a target="_blank" href="https://acepanel.net">AcePanel</a> 强力驱动</em></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

View File

@@ -3,13 +3,22 @@ import { http } from '@/utils'
export default {
// 公钥
key: () => http.Get('/user/key'),
// 获取验证码
captcha: () => http.Get('/user/captcha'),
// 登录
login: (username: string, password: string, pass_code: string, safe_login: boolean) =>
login: (
username: string,
password: string,
pass_code: string,
safe_login: boolean,
captcha_code: string
) =>
http.Post('/user/login', {
username,
password,
pass_code,
safe_login
safe_login,
captcha_code
}),
// 登出
logout: () => http.Post('/user/logout'),

View File

@@ -20,13 +20,15 @@ interface LoginInfo {
password: string
safe_login: boolean
pass_code: string
captcha_code: string
}
const loginInfo = ref<LoginInfo>({
username: '',
password: '',
safe_login: true,
pass_code: ''
pass_code: '',
captcha_code: ''
})
const localLoginInfo = getLocal('loginInfo') as LoginInfo
@@ -41,14 +43,42 @@ const logining = ref<boolean>(false)
const isRemember = useStorage('isRemember', false)
const showTwoFA = ref(false)
// 验证码相关
const captchaRequired = ref(false)
const captchaImage = ref('')
const logo = computed(() => themeStore.logo || logoImg)
// 刷新验证码
const refreshCaptcha = () => {
useRequest(user.captcha())
.onSuccess(({ data }) => {
captchaRequired.value = Boolean(data.required)
captchaImage.value = data.image || ''
loginInfo.value.captcha_code = ''
})
.onError(() => {
captchaRequired.value = false
captchaImage.value = ''
})
}
// 初始加载验证码
onMounted(() => {
refreshCaptcha()
})
async function handleLogin() {
const { username, password, pass_code, safe_login } = loginInfo.value
const { username, password, pass_code, safe_login, captcha_code } = loginInfo.value
if (!username || !password) {
window.$message.warning($gettext('Please enter username and password'))
return
}
const trimmedCaptcha = captcha_code?.trim() || ''
if (captchaRequired.value && !trimmedCaptcha) {
window.$message.warning($gettext('Please enter captcha code'))
return
}
if (!key) {
window.$message.warning(
$gettext('Failed to get encryption public key, please refresh the page and try again')
@@ -60,29 +90,35 @@ async function handleLogin() {
rsaEncrypt(username, String(unref(key))),
rsaEncrypt(password, String(unref(key))),
pass_code,
safe_login
safe_login,
trimmedCaptcha
)
).onSuccess(async () => {
logining.value = true
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
if (isRemember.value) {
setLocal('loginInfo', { username, password })
} else {
removeLocal('loginInfo')
}
)
.onSuccess(async () => {
logining.value = true
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
if (isRemember.value) {
setLocal('loginInfo', { username, password })
} else {
removeLocal('loginInfo')
}
await addDynamicRoutes()
useRequest(user.info()).onSuccess(({ data }) => {
userStore.set(data as any)
await addDynamicRoutes()
useRequest(user.info()).onSuccess(({ data }) => {
userStore.set(data as any)
})
if (query.redirect) {
const path = query.redirect as string
Reflect.deleteProperty(query, 'redirect')
await router.push({ path, query })
} else {
await router.push('/')
}
})
.onError(() => {
// 登录失败后刷新验证码状态
refreshCaptcha()
})
if (query.redirect) {
const path = query.redirect as string
Reflect.deleteProperty(query, 'redirect')
await router.push({ path, query })
} else {
await router.push('/')
}
})
logining.value = false
}
@@ -155,6 +191,26 @@ watch(isLogin, async () => {
@keydown.enter="handleLogin"
/>
</div>
<div v-if="captchaRequired" mt-30>
<n-flex align="center">
<n-input
v-model:value="loginInfo.captcha_code"
:maxlength="4"
class="text-16 pl-10 h-50 items-center"
style="flex: 1"
:placeholder="$gettext('Captcha Code')"
type="text"
@keydown.enter="handleLogin"
/>
<n-image
:src="'data:image/png;base64,' + captchaImage"
preview-disabled
class="cursor-pointer h-50"
style="border-radius: 4px"
@click="refreshCaptcha"
/>
</n-flex>
</div>
<div mt-20>
<n-flex>

View File

@@ -26,6 +26,8 @@ const { data: model } = useRequest(setting.list, {
locale: 'en',
port: 8888,
entrance: '',
entrance_error: '418',
login_captcha: false,
offline_mode: false,
two_fa: false,
lifetime: 0,

View File

@@ -29,12 +29,6 @@ const httpsMode = computed({
}
}
})
const httpsModeOptions = computed(() => [
{ label: $gettext('Disabled'), value: 'off' },
{ label: $gettext('ACME (Auto)'), value: 'acme' },
{ label: $gettext('Custom Certificate'), value: 'custom' }
])
</script>
<template>
@@ -86,6 +80,50 @@ const httpsModeOptions = computed(() => [
</template>
<n-input v-model:value="model.entrance" />
</n-form-item>
<n-form-item>
<template #label>
<n-tooltip>
<template #trigger>
<div class="flex items-center">
{{ $gettext('Entrance Error Page') }}
<the-icon :size="16" icon="mdi:help-circle-outline" class="ml-1" />
</div>
</template>
{{
$gettext(
'Set the error page to display when accessing with wrong entrance. 418 shows teapot page, Nginx 404 shows nginx style 404 page, Close Connection will close the connection immediately'
)
}}
</n-tooltip>
</template>
<n-select
v-model:value="model.entrance_error"
:options="[
{ label: $gettext(`418 I'm a teapot`), value: '418' },
{ label: $gettext('Nginx 404'), value: 'nginx' },
{ label: $gettext('Close Connection'), value: 'close' }
]"
:placeholder="$gettext(`418 I'm a teapot`)"
/>
</n-form-item>
<n-form-item>
<template #label>
<n-tooltip>
<template #trigger>
<div class="flex items-center">
{{ $gettext('Login Captcha') }}
<the-icon :size="16" icon="mdi:help-circle-outline" class="ml-1" />
</div>
</template>
{{
$gettext(
'When enabled, a captcha will be required after 3 failed login attempts to prevent brute force attacks'
)
}}
</n-tooltip>
</template>
<n-switch v-model:value="model.login_captcha" />
</n-form-item>
<n-form-item>
<template #label>
<n-tooltip>
@@ -220,7 +258,11 @@ const httpsModeOptions = computed(() => [
</template>
<n-radio-group v-model:value="httpsMode">
<n-radio-button
v-for="option in httpsModeOptions"
v-for="option in [
{ label: $gettext('Disabled'), value: 'off' },
{ label: $gettext('ACME (Auto)'), value: 'acme' },
{ label: $gettext('Custom Certificate'), value: 'custom' }
]"
:key="option.value"
:value="option.value"
:label="option.label"