Compare commits

..

2 Commits

Author SHA1 Message Date
MengMengCode 9a826add87 release: v1.1.11 2026-06-09 22:48:20 +08:00
MengMengCode 63611dc932 Support webSSH Origin Allowlist 2026-06-09 22:48:09 +08:00
12 changed files with 389 additions and 91 deletions
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"clicd/internal/config"
)
type webSSHOriginSettingsRequest struct {
Origins []string `json:"origins"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
}
type webSSHOriginSettingsResponse struct {
Origins []string `json:"origins"`
CurrentOrigin string `json:"current_origin,omitempty"`
}
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
case http.MethodPut:
updateWebSSHOriginSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
var req webSSHOriginSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
origins := req.Origins
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
origins = req.WebSSHAllowedOrigins
}
normalized, err := config.NormalizeAllowedOrigins(origins)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
config.AppConfig.WebSSHAllowedOrigins = normalized
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
return
}
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
}
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
origins := config.AppConfig.WebSSHAllowedOrigins
if origins == nil {
origins = []string{}
}
return webSSHOriginSettingsResponse{
Origins: origins,
CurrentOrigin: requestOrigin(r),
}
}
func requestOrigin(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return ""
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
}
return scheme + "://" + host
}
+3 -17
View File
@@ -1,10 +1,9 @@
package api
import (
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil {
return false
}
originHost := strings.ToLower(stripPort(originURL.Host))
requestHost := strings.ToLower(stripPort(r.Host))
return originHost != "" && originHost == requestHost
return config.IsOriginAllowed(origin, r.Host)
},
}
func stripPort(host string) string {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
+26 -17
View File
@@ -363,6 +363,7 @@ type ClicdConfig struct {
Snapshots []Snapshot `json:"snapshots"`
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
Language string `json:"language"`
SSL SSLConfig `json:"ssl"`
@@ -480,23 +481,24 @@ func InitConfig() (*ClicdConfig, error) {
}
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
}
if err := SaveConfig(); err != nil {
@@ -555,6 +557,13 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
changed = true
}
if AppConfig.WebSSHAllowedOrigins == nil {
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
changed = true
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"fmt"
"net"
"net/url"
"strings"
)
// NormalizeAllowedOrigin accepts a browser Origin value such as
// https://www.example.com and returns a canonical form for exact matching.
func NormalizeAllowedOrigin(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
u, err := url.Parse(value)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
}
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
}
host := normalizeOriginHostPort(u.Host, scheme)
if host == "" {
return "", fmt.Errorf("Origin host is required: %s", value)
}
return scheme + "://" + host, nil
}
func NormalizeAllowedOrigins(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
origin, err := NormalizeAllowedOrigin(value)
if err != nil {
return nil, err
}
if origin == "" || seen[origin] {
continue
}
seen[origin] = true
result = append(result, origin)
}
return result, nil
}
func IsOriginAllowed(origin string, requestHost string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
return true
}
if isSameRequestOrigin(origin, requestHost) {
return true
}
normalized, err := NormalizeAllowedOrigin(origin)
if err != nil {
return false
}
if AppConfig == nil {
return false
}
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
allowed, err := NormalizeAllowedOrigin(allowed)
if err == nil && normalized == allowed {
return true
}
}
return false
}
func isSameRequestOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHostOnly(u.Hostname())
host := normalizeHostOnly(requestHost)
if originHost == "" || host == "" {
return false
}
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeOriginHostPort(raw string, scheme string) string {
host := raw
port := ""
if h, p, err := net.SplitHostPort(raw); err == nil {
host = h
port = p
}
host = normalizeHostOnly(host)
if host == "" {
return ""
}
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
port = ""
}
if port != "" {
return net.JoinHostPort(host, port)
}
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
return "[" + host + "]"
}
return host
}
func normalizeHostOnly(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if h, _, err := net.SplitHostPort(raw); err == nil {
raw = h
}
raw = strings.Trim(raw, "[]")
if ip := net.ParseIP(raw); ip != nil {
return strings.ToLower(ip.String())
}
return strings.TrimSuffix(strings.ToLower(raw), ".")
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
+5
View File
@@ -426,6 +426,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
}
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -524,6 +527,7 @@ func saveMeta(tx *sql.Tx) error {
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -540,6 +544,7 @@ func saveMeta(tx *sql.Tx) error {
"ssl_certificates": string(sslCertificatesJSON),
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
+4 -32
View File
@@ -4,9 +4,7 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/api"
@@ -19,7 +17,7 @@ var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -28,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -40,34 +38,6 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
func isAllowedOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHost(u.Host)
host := normalizeHost(requestHost)
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeHost(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
@@ -78,6 +48,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -148,6 +119,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.10"
Version = "1.1.11"
Repo = "MengMengCode/CLICD"
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.10",
"version": "1.1.11",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.10</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.11</p>
</div>
</div>
)
+114 -22
View File
@@ -1,13 +1,16 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -33,6 +36,9 @@ export default function Settings() {
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const fetchLogs = useCallback(async () => {
try {
@@ -60,12 +66,25 @@ export default function Settings() {
}
}, [])
const fetchWebSSHOrigins = useCallback(async () => {
try {
const res = await getWebSSHOriginSettings()
const data = res.data.data
if (!data) return
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL])
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -101,6 +120,25 @@ export default function Settings() {
}
}
const handleSaveWebSSHOrigins = async () => {
setSavingWebSSHOrigins(true)
try {
const origins = webSSHOriginsText.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
const res = await updateWebSSHOriginSettings(origins)
const data = res.data.data
if (data) {
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
}
dialog.alert('完成', 'Origin 白名单已保存')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'Origin 白名单保存失败')
} finally {
setSavingWebSSHOrigins(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
@@ -159,26 +197,37 @@ export default function Settings() {
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="space-y-6">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
@@ -232,6 +281,49 @@ interface SSLCardProps {
onSave: () => void
}
interface WebSSHOriginCardProps {
settings: WebSSHOriginSettings | null
originsText: string
saving: boolean
onOriginsTextChange: (value: string) => void
onRefresh: () => void
onSave: () => void
}
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-gray-500"> Origin</label>
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
)
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
+11
View File
@@ -393,6 +393,17 @@ export const getSSLSettings = () =>
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
export interface WebSSHOriginSettings {
origins: string[]
current_origin?: string
}
export const getWebSSHOriginSettings = () =>
api.get<APIResponse<WebSSHOriginSettings>>('/webssh-origins')
export const updateWebSSHOriginSettings = (origins: string[]) =>
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
+7
View File
@@ -397,6 +397,13 @@ const exact: Record<string, string> = {
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
'保存中...': 'Saving...',
'保存 SSL 设置': 'Save SSL Settings',
'WebSSH Origin 白名单': 'WebSSH Origin Allowlist',
'允许的 Origin': 'Allowed Origins',
'当前面板来源:': 'Current panel origin:',
'默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。': 'The current panel origin and local loopback origins are allowed by default. Add one full Origin per line.',
'保存 Origin 白名单': 'Save Origin Allowlist',
'Origin 白名单已保存': 'Origin allowlist saved',
'Origin 白名单保存失败': 'Failed to save Origin allowlist',
'登录日志': 'Login Logs',
'首页': 'First',
'上一页': 'Previous',