增加了服务器防火墙功能

This commit is contained in:
MengMengCode
2026-06-12 01:35:04 +08:00
parent 6194b6e364
commit 875cd4716b
14 changed files with 711 additions and 22 deletions
+36
View File
@@ -176,6 +176,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
['GET', '/api/v1/containers/{id}/firewall', '获取防火墙设置'],
['PUT', '/api/v1/containers/{id}/firewall', '更新防火墙设置'],
['GET', '/api/v1/snapshots', '快照总览'],
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
@@ -817,6 +819,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
limit: 64,
},
'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/containers/{id}/firewall': {
enabled: true,
rules: [
{ id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
{ id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
{ id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
],
},
'PUT /api/v1/security/settings': { auto_shutdown: false },
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
'POST /api/v1/batch-create': {
@@ -1025,6 +1035,29 @@ const responseSamples: Record<string, unknown> = {
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
},
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
'GET /api/v1/containers/{id}/firewall': {
success: true,
data: {
enabled: true,
rules: [
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
],
},
},
'PUT /api/v1/containers/{id}/firewall': {
success: true,
message: 'Firewall updated',
data: {
enabled: true,
rules: [
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
],
},
},
'GET /api/v1/snapshots': { success: true, data: null },
'GET /api/v1/containers/{id}/snapshots': {
success: true,
@@ -1139,6 +1172,9 @@ function endpointNoteFor(key: string) {
if (key === 'POST /api/v1/batch-create') {
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
}
if (key === 'PUT /api/v1/containers/{id}/firewall') {
notes.push('启用防火墙后默认拒绝所有 TCP/UDP 入站和出站流量,仅放行 rules 中定义的规则。direction: in=入站, out=出站。action: ACCEPT=放行, DROP=拒绝。port 支持单端口(22)、多端口(80,443)、范围(8000-9000)。')
}
if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
}
+221 -16
View File
@@ -19,7 +19,6 @@ import {
Plus,
RefreshCw,
Save,
Settings,
Square,
TerminalSquare,
@@ -48,6 +47,7 @@ import {
TrafficInfo,
getEnabledImages,
PortMapping,
FirewallRule,
reinstallContainer,
resetSSHPassword,
restartContainer,
@@ -57,6 +57,7 @@ import {
SnapshotSchedule,
Template,
updateContainerExpiry,
updateFirewall,
updateSnapshotQuota,
updateSnapshotSchedule,
restoreContainerSnapshot,
@@ -163,6 +164,12 @@ export default function ContainerDetail() {
const [snapshotBusy, setSnapshotBusy] = useState('')
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const [showFirewall, setShowFirewall] = useState(false)
const [firewallEnabled, setFirewallEnabled] = useState(false)
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
const [firewallSaving, setFirewallSaving] = useState(false)
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
const fetchContainer = useCallback(async () => {
if (!containerIdentifier) return
@@ -410,6 +417,61 @@ export default function ContainerDetail() {
}
}
const openFirewall = () => {
if (!container) return
setFirewallEnabled(container.firewall_enabled || false)
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : [])
setShowFirewall(true)
}
const saveFirewall = async () => {
if (!container) return
setFirewallSaving(true)
try {
await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules })
fetchContainer()
} catch (err: any) {
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败')
} finally {
setFirewallSaving(false)
}
}
const addFirewallRule = () => {
setEditingFirewallRule({
id: '',
direction: 'in',
protocol: 'tcp',
port: '',
source_ip: '',
action: 'DROP',
description: '',
enabled: true,
})
setShowFirewallEditor(true)
}
const saveFirewallRule = (rule: FirewallRule) => {
if (rule.id) {
// Update existing
setFirewallRules(firewallRules.map(r => r.id === rule.id ? rule : r))
} else {
// Add new with temporary ID
const newRule = { ...rule, id: `tmp-${Date.now()}` }
setFirewallRules([...firewallRules, newRule])
}
setShowFirewallEditor(false)
setEditingFirewallRule(null)
}
const deleteFirewallRule = (ruleId: string) => {
setFirewallRules(firewallRules.filter(r => r.id !== ruleId))
}
const toggleFirewallRule = (ruleId: string) => {
setFirewallRules(firewallRules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r))
}
const openReinstall = async () => {
try {
const res = await getEnabledImages(container?.virtualization || 'lxc')
@@ -929,24 +991,24 @@ export default function ContainerDetail() {
</ActionButton>
)}
{!hasIndependentIPv4 && (
<>
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
IPv4 NAT
</ActionButton>
</>
{!hasIndependentIPv4 && hasNATQuota && (
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
IPv4 NAT
</ActionButton>
)}
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
<FirewallIcon className="w-3.5 h-3.5" />
</ActionButton>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
{!isSubUser && (
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
</ActionButton>
)}
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
</ActionButton>
{!isSubUser && (
<ActionButton disabled={!!taskStatus} onClick={() => handleAction('delete')}>
<Trash2 className="w-3.5 h-3.5" />
@@ -970,7 +1032,7 @@ export default function ContainerDetail() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel
title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
extra={!isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
@@ -1446,6 +1508,140 @@ export default function ContainerDetail() {
</Modal>
)}
{showFirewall && (
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
!isSubUser && (
<button onClick={addFirewallRule} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" />
</button>
)
}>
<div className="space-y-5">
{/* Global toggle */}
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-800"></div>
<div className="text-xs text-gray-500"></div>
</div>
<button
onClick={() => setFirewallEnabled(!firewallEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${firewallEnabled ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${firewallEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Rules table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium">/ IP</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
{!isSubUser && <th className="px-3 py-2 text-right font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{firewallRules.map((rule) => (
<tr key={rule.id} className={!rule.enabled ? 'opacity-50' : ''}>
<td className="px-3 py-2">
<button onClick={() => toggleFirewallRule(rule.id)} className={`inline-flex h-4 w-7 items-center rounded-full transition-colors ${rule.enabled ? 'bg-emerald-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
</button>
</td>
<td className="px-3 py-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
{rule.direction === 'in' ? '入站' : '出站'}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{rule.protocol.toUpperCase()}</td>
<td className="px-3 py-2 font-mono text-xs">{rule.port || '全部'}</td>
<td className="px-3 py-2 font-mono text-xs">{rule.source_ip || '任意'}</td>
<td className="px-3 py-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.action === 'ACCEPT' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
{rule.action === 'ACCEPT' ? '放行' : '拒绝'}
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-600 max-w-32 truncate">{rule.description || '-'}</td>
{!isSubUser && (
<td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1">
<button onClick={() => { setEditingFirewallRule({ ...rule }); setShowFirewallEditor(true) }} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
{firewallRules.length === 0 && (
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400"></td></tr>
)}
</tbody>
</table>
</div>
{/* Save button */}
{!isSubUser && (
<div className="flex justify-end">
<button onClick={saveFirewall} disabled={firewallSaving} className="inline-flex items-center gap-1.5 px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800 disabled:opacity-50">
<Save className="w-3.5 h-3.5" />
{firewallSaving ? '保存中...' : '保存'}
</button>
</div>
)}
</div>
</Modal>
)}
{showFirewallEditor && editingFirewallRule && (
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
<div className="space-y-4">
<Field label="方向">
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
<option value="in"> (Inbound)</option>
<option value="out"> (Outbound)</option>
</select>
</Field>
<Field label="协议">
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} className={inputClass}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="icmp">ICMP</option>
<option value="all"></option>
</select>
</Field>
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000">
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} />
</Field>
<Field label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24">
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} />
</Field>
<Field label="动作">
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
<option value="ACCEPT"> (ACCEPT)</option>
<option value="DROP"> (DROP)</option>
</select>
</Field>
<Field label="描述">
<input value={editingFirewallRule.description} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, description: e.target.value })} placeholder="规则描述" className={inputClass} />
</Field>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button onClick={() => saveFirewallRule(editingFirewallRule)} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800"></button>
</div>
</div>
</Modal>
)}
{showNat && !hasIndependentIPv4 && (
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
!isSubUser && canAddMapping && (
@@ -1680,6 +1876,14 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val
)
}
function FirewallIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M979.989543 469.308394H757.450516c4.519899-21.887511 7.247838-45.094992 7.247838-69.798441 0-137.428929-116.773391-270.417958-121.72528-276.001833a21.415521 21.415521 0 0 0-21.887511-6.319858 21.287524 21.287524 0 0 0-15.103663 16.98362l-12.583719 75.438315C571.854663 148.115571 533.535519 69.229333 467.241 5.910748A21.46352 21.46352 0 0 0 441.585573 2.982813a21.295524 21.295524 0 0 0-9.727782 23.935466c15.703649 58.366696-2.815937 152.996581-22.911488 226.978928-5.591875-35.7912-15.615651-66.214521-32.935264-76.414293a21.351523 21.351523 0 0 0-32.167282 18.399589c0 31.359299-15.999643 60.278653-34.519228 93.813904-24.703448 44.759-52.734822 95.525866-52.734822 167.972247 0 4.055909 0.599987 7.727827 0.767983 11.64774H41.346516A21.343523 21.343523 0 0 0 20.010993 490.651917v511.98856a21.343523 21.343523 0 0 0 21.335523 21.335524H979.989543a21.343523 21.343523 0 0 0 21.335524-21.335524v-511.98856A21.343523 21.343523 0 0 0 979.989543 469.308394z m-149.332663 42.663047v127.99714H660.380685c33.879243-29.183348 65.878528-72.702376 85.334093-127.99714h84.942102zM346.699693 310.255948c7.559831-13.599696 14.895667-26.919399 21.167527-40.399098 3.495922 28.543362 5.503877 64.510559 5.071887 100.26176a21.311524 21.311524 0 0 0 17.367612 21.199526 21.255525 21.255525 0 0 0 23.935465-13.351701c3.071931-8.191817 63.918572-169.980202 65.958527-293.241448 78.462247 104.493665 96.429845 228.906885 96.63784 230.354853a21.279525 21.279525 0 0 0 20.823535 18.431588c9.85578-0.255994 19.631561-7.383835 21.335523-17.791602L640.077138 189.29865c32.895265 46.422963 81.958169 129.277111 81.958169 210.219303 0 157.772475-113.837456 240.458627-153.212577 240.458628H455.241268c-19.023575-5.247883-155.940516-47.742933-155.940516-182.347926 0-61.486626 24.111461-105.133651 47.398941-147.372707zM659.996693 682.647627v127.99714H361.339366v-127.99714H659.996693zM190.67118 511.971441h72.750374c15.311658 60.974638 54.910773 101.717727 93.693907 127.99714H190.67118v-127.99714z m-127.99714 0H148.008133v127.99714H62.67404v-127.99714z m0 170.668186h255.99428v127.99714h-255.99428v-127.99714zM148.008133 981.296954H62.67404v-127.99714H148.008133v127.99714z m341.328373 0H190.67118v-127.99714h298.665326v127.99714z m341.320374 0H531.999553v-127.99714h298.657327v127.99714z m127.99714 0h-85.326093v-127.99714h85.326093v127.99714z m0-170.660187h-255.99428v-127.99714h255.99428v127.99714z m0-170.668186h-85.326093v-127.99714h85.326093v127.99714z" />
</svg>
)
}
function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) {
if (initializing) {
return (
@@ -2041,11 +2245,12 @@ function TableHead({ children }: { children: ReactNode }) {
return <th className="text-left px-3 py-2 text-xs font-medium text-gray-500">{children}</th>
}
function Field({ label, children }: { label: string; children: ReactNode }) {
function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
return (
<label className="block">
<span className="block text-xs font-medium text-gray-600 mb-1.5">{label}</span>
{children}
{hint && <span className="block text-[11px] text-gray-400 mt-1">{hint}</span>}
</label>
)
}
+2
View File
@@ -724,6 +724,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ssh_password: '',
port_mappings: [],
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
firewall_enabled: false,
firewall_rules: [],
snapshot_limit: cfg.snapshot_limit || 3,
created_at: '',
expires_at: cfg.expires_at,
+19
View File
@@ -45,6 +45,17 @@ export interface PortMapping {
description: string
}
export interface FirewallRule {
id: string
direction: 'in' | 'out'
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
port: string
source_ip: string
action: 'ACCEPT' | 'DROP'
description: string
enabled: boolean
}
export interface PublicIPv4Assignment {
address: string
interface?: string
@@ -88,6 +99,8 @@ export interface Container {
ssh_password: string
port_mappings: PortMapping[]
port_mapping_limit: number
firewall_enabled: boolean
firewall_rules: FirewallRule[]
snapshot_limit: number
created_at: string
expires_at: string
@@ -487,6 +500,12 @@ export const updatePortMapping = (id: ContainerIdentifier, index: number, data:
export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
export const getFirewall = (id: ContainerIdentifier) =>
api.get<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) =>
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
+32
View File
@@ -788,6 +788,37 @@ const exact: Record<string, string> = {
'50 / 页': '50 / page',
'全局快照列表,共': 'Global snapshot list, total',
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
'防火墙': 'Firewall',
'防火墙设置': 'Firewall Settings',
'独立 IPv4': 'Dedicated IPv4',
'添加规则': 'Add Rule',
'启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.',
'方向': 'Direction',
'来源/目标 IP': 'Source / Destination IP',
'动作': 'Action',
'入站': 'Inbound',
'出站': 'Outbound',
'任意': 'Any',
'放行': 'Allow',
'拒绝': 'Deny',
'暂无防火墙规则': 'No firewall rules',
'编辑规则': 'Edit Rule',
'入站 (Inbound)': 'Inbound',
'出站 (Outbound)': 'Outbound',
'留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
'如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000',
'来源 IP': 'Source IP',
'目标 IP': 'Destination IP',
'留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24',
'如: 192.168.1.0/24': 'e.g. 192.168.1.0/24',
'放行 (ACCEPT)': 'Allow (ACCEPT)',
'拒绝 (DROP)': 'Deny (DROP)',
'规则描述': 'Rule description',
'登录方式': 'SSH Auth Method',
'保留当前密码': 'Keep current password',
'生成新密码': 'Generate new password',
'自定义密码': 'Custom password',
'生成密码': 'Generate password',
}
const artifactPatterns: RegExp[] = [
@@ -853,6 +884,7 @@ const replacements: Array<[RegExp, string]> = [
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
[/(\d+)\s*个/g, '$1 items'],
[/(\d+)\s*条/g, '$1 records'],
[/1\s*核\b/g, '1 core'],
[/(\d+)\s*核/g, '$1 cores'],
[/(\d+)\s*线程/g, '$1 threads'],
[/已用/g, 'used'],