feat(tls): manage ACME domain and add desktop client settings
This commit is contained in:
1 parent
003f755b37
commit
00f6c876cf
85 files changed
+7276
-1
No files matched your search
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { checkServer, getSystemReadiness, type SystemReadiness } from './platform'
|
||||
import './system.css'
|
||||
|
||||
type Page = 'overview' | 'routes' | 'settings'
|
||||
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
|
||||
const nodes = [
|
||||
{ name: '自动选择', region: '推荐线路', ms: 74 },
|
||||
{ name: '香港 · 中心节点', region: '亚太地区', ms: 75 },
|
||||
{ name: '新加坡 · 备用节点', region: '亚太地区', ms: 91 },
|
||||
{ name: '法兰克福 · 节点', region: '欧洲地区', ms: 313 },
|
||||
{ name: '洛杉矶 · 节点', region: '美洲地区', ms: 314 },
|
||||
]
|
||||
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'chevron'; size?: number }) {
|
||||
const paths = {
|
||||
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
|
||||
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c-5 5-5 13 0 18M12 3c5 5 5 13 0 18" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M19 5l-2 2M7 17l-2 2" /></>,
|
||||
power: <><path d="M12 2v10" /><path d="M6.1 5.5a9 9 0 1 0 11.8 0" /></>,
|
||||
shield: <><path d="m12 2 8 3v6c0 5.4-3.5 8.8-8 11-4.5-2.2-8-5.6-8-11V5z" /><path d="m9 12 2 2 4-4" /></>,
|
||||
server: <><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01" /></>,
|
||||
check: <path d="m5 12 5 5 9-10" />,
|
||||
close: <path d="M5 5 19 19M19 5 5 19" />,
|
||||
chevron: <path d="m7 10 5 5 5-5" />,
|
||||
}
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>('overview')
|
||||
const [dialog, setDialog] = useState(false)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [state, setState] = useState<CheckState>('idle')
|
||||
const [error, setError] = useState('')
|
||||
const [checkedAddress, setCheckedAddress] = useState('')
|
||||
const [responseMs, setResponseMs] = useState<number | null>(null)
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 'settings') return
|
||||
let active = true
|
||||
getSystemReadiness().then(result => {
|
||||
if (active) { setReadiness(result); setReadinessError('') }
|
||||
}).catch(() => {
|
||||
if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [page])
|
||||
|
||||
async function handleCheck(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setState('checking')
|
||||
setError('')
|
||||
setResponseMs(null)
|
||||
const start = performance.now()
|
||||
try {
|
||||
const health = await checkServer(address)
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
} catch (cause) {
|
||||
setState('error')
|
||||
setError(String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
function saveAddress() {
|
||||
try {
|
||||
const url = new URL(address.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error('请输入 HTTP 或 HTTPS 服务端根地址,不包含路径、账号或参数。')
|
||||
}
|
||||
const root = url.href.replace(/\/$/, '')
|
||||
localStorage.setItem('opsx_server_address', root)
|
||||
setAddress(root)
|
||||
setError('')
|
||||
} catch (cause) {
|
||||
setError(cause instanceof TypeError ? '请输入完整的 HTTP 或 HTTPS 服务端地址。' : String(cause))
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar" aria-label="主导航">
|
||||
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
|
||||
<nav className="navigation">
|
||||
{([['overview', 'home', '概览'], ['routes', 'globe', '线路'], ['settings', 'settings', '设置']] as const).map(([id, icon, label]) =>
|
||||
<button key={id} className={`nav-button ${page === id ? 'active' : ''}`} onClick={() => setPage(id)} aria-current={page === id ? 'page' : undefined}><Icon name={icon} /><span>{label}</span></button>)}
|
||||
</nav>
|
||||
<div className="sidebar-bottom">OX <small>/ VPN</small></div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● 界面预览 · 0.1</span></header>
|
||||
{page === 'overview' && <main className="page">
|
||||
<div className="section-intro"><div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>从这里查看接入状态,准备好后即可连接企业网络。</p></div><span className="status-tag">● 尚未连接</span></div>
|
||||
<div className="overview-grid">
|
||||
<section className="connection-card" aria-label="VPN 连接状态">
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>企业网络</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
|
||||
<div className="connect-visual"><div className="orbit outer" /><div className="orbit inner" /><div className="power-display" aria-label="VPN 拨号待接入"><Icon name="power" size={42} /></div></div>
|
||||
<div className="connection-message"><strong>尚未连接</strong><span>登录与设备授权接口接入后,即可启用 VPN 拨号</span></div>
|
||||
<button className="node-selector" onClick={() => setDialog(true)}><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>当前预览线路</small><strong>{nodes[selected].name}</strong></span><span className="demo-chip">演示数据</span><Icon name="chevron" size={18} /></button>
|
||||
</section>
|
||||
<div className="right-column">
|
||||
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{checkedAddress || address || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
|
||||
<section className="info-card progress-card"><div className="info-heading"><span className="icon-tile green"><Icon name="shield" size={20} /></span><div><h2>接入进度</h2><p>桌面端能力逐步开放</p></div></div>
|
||||
{([['✓', '服务可达性检测', '已接入现有健康接口', '已就绪'], ['2', '用户登录与设备授权', '等待客户端专用接口', '待接入'], ['3', '原生 WireGuard 连接', '等待多平台隧道适配', '待接入']] as const).map(([num, title, detail, status], index) => <div className="milestone" key={num}><span className={`milestone-mark ${index === 0 ? 'done' : ''}`}>{num}</span><span><strong>{title}</strong><small>{detail}</small></span><em>{status}</em></div>)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<p className="footnote">预览线路与延时仅展示交互样式,不代表实际节点;当前没有建立 VPN 隧道。</p>
|
||||
</main>}
|
||||
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">了解线路选择界面。真实节点列表和延时需要服务端开放客户端接口。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><span className="demo-chip">演示数据</span><h2>{nodes[selected].name}</h2><p>当前选择只用于界面预览,不会建立 VPN 连接。</p></div><button className="secondary-action" onClick={() => setDialog(true)}>查看预览线路 →</button></section></main>}
|
||||
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>保存后下次打开客户端仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;健康检查会请求 /api/health。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
|
||||
</div>
|
||||
{dialog && <div className="modal-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) setDialog(false) }}><section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" onKeyDown={event => { if (event.key === 'Escape') setDialog(false) }}><div className="dialog-header"><div><span className="overline">ROUTE PREVIEW</span><h2 id="dialog-title">选择服务器节点</h2><p>线路名称与延时均为演示数据,尚未接入真实节点。</p></div><button className="icon-button" onClick={() => setDialog(false)} aria-label="关闭节点选择"><Icon name="close" size={20} /></button></div><div className="dialog-labels"><span>预览线路</span><span>示例延时</span></div><div className="node-list">{nodes.map((node, index) => <button className={`node-row ${selected === index ? 'selected' : ''}`} key={node.name} onClick={() => setSelected(index)}><span className="radio-mark">{selected === index ? '✓' : ''}</span><span className="node-icon"><Icon name={index === 0 ? 'globe' : 'server'} size={18} /></span><span className="node-name"><strong>{node.name}</strong><small>{node.region}</small></span><span className={`latency ${node.ms < 150 ? 'fast' : 'slow'}`}><span className="signal-bars"><i /><i /><i /></span>{node.ms} ms</span></button>)}</div><div className="dialog-footer"><span>仅预览界面,不会发起连接</span><button className="primary-action" onClick={() => setDialog(false)}>完成选择</button></div></section></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './style.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type ServerHealth = { address: string; reachable: boolean }
|
||||
export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean }
|
||||
|
||||
// All desktop-to-native calls pass through this boundary. The WebView never
|
||||
// handles a management token or a WireGuard private key.
|
||||
export function checkServer(baseUrl: string): Promise<ServerHealth> {
|
||||
return invoke<ServerHealth>('check_server', { baseUrl })
|
||||
}
|
||||
|
||||
export function getSystemReadiness(): Promise<SystemReadiness> {
|
||||
return invoke<SystemReadiness>('get_system_readiness')
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#192b45;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:680px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #a6c6ff;outline-offset:2px}.app-shell{display:flex;min-height:100vh}.sidebar{width:92px;flex:none;display:flex;flex-direction:column;align-items:center;background:linear-gradient(172deg,#3476f7,#245cdd 76%,#2859d0);color:#fff}.brand-symbol{width:46px;height:46px;margin:27px 0 50px;position:relative}.brand-symbol span{position:absolute;inset:5px;border:4px solid #fff;border-radius:12px;transform:rotate(-32deg)}.brand-symbol i{position:absolute;inset:15px;border-radius:50%;background:#fff}.navigation{width:100%;display:grid;gap:8px}.nav-button{width:100%;height:78px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:0;background:transparent;color:#d6e5ff;font-size:12px}.nav-button:hover,.nav-button.active{background:#174bbd;color:#fff}.nav-button.active{box-shadow:inset 3px 0 0 #fff}.sidebar-bottom{margin:auto 0 28px;font-weight:800;font-size:15px;letter-spacing:-.06em}.sidebar-bottom small{font-size:9px;font-weight:500;opacity:.7}.workspace{flex:1;min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid #ecf0f6;display:flex;align-items:center;justify-content:space-between;padding:0 43px}.topbar-brand{display:flex;align-items:center;gap:5px;font-size:15px;font-weight:700;color:#516077;white-space:nowrap}.topbar-brand strong{color:#326ef3}.brand-dot{width:10px;height:10px;background:#3575f5;border-radius:3px;transform:rotate(-30deg);margin-right:7px}.divider{height:16px;width:1px;background:#d9e0ec;margin:0 10px}.preview-pill{border:1px solid #dae7ff;border-radius:100px;background:#f3f7ff;color:#4268ad;font-size:11px;padding:7px 11px}.page{max-width:1160px;margin:0 auto;padding:38px 44px 50px}.section-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:29px}.overline{color:#6483bc;letter-spacing:.18em;font-size:10px;font-weight:800}h1{font-size:29px;letter-spacing:-.04em;margin:9px 0 8px}h2{margin:0;font-size:17px;letter-spacing:-.02em}.section-intro p,.subpage-intro{color:#8b98aa;font-size:13px;margin:0}.status-tag{padding:9px 14px;border:1px solid #e4e9f1;background:white;border-radius:100px;color:#6c7c94;font-size:12px;white-space:nowrap}.overview-grid{display:grid;grid-template-columns:minmax(350px,1.06fr) minmax(340px,.94fr);gap:20px}.connection-card,.info-card{background:white;border:1px solid #e8edf5;border-radius:18px;box-shadow:0 8px 32px rgba(29,72,137,.035)}.connection-card{padding:26px 27px 22px;min-height:494px;display:flex;flex-direction:column;overflow:hidden}.card-top{display:flex;justify-content:space-between;align-items:start;gap:12px}.kicker{font-size:11px;color:#7e90ac}.card-top h2{font-size:20px;margin-top:6px}.protocol{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:750;color:#4a7de8;background:#f2f6ff;padding:8px;border-radius:7px;letter-spacing:.04em}.connect-visual{position:relative;width:218px;height:218px;margin:23px auto 0;display:grid;place-items:center}.orbit{position:absolute;border-radius:50%;border:1px solid #e8f0ff}.orbit.outer{inset:0;background:repeating-conic-gradient(from -2deg,#d5e4ff 0 1deg,transparent 1deg 5deg);mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%);-webkit-mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%)}.orbit.inner{inset:19px;border:1px solid #e2ecff}.power-display{display:grid;place-items:center;width:136px;height:136px;border-radius:50%;background:linear-gradient(145deg,#5090ff,#2864ea);color:white;box-shadow:0 16px 30px #397af149,inset 0 1px 2px #ffffff66}.connection-message{text-align:center;display:grid;gap:7px;margin:0 0 25px}.connection-message strong{font-size:16px}.connection-message span{color:#8898ae;font-size:12px}.node-selector{display:flex;align-items:center;width:100%;gap:11px;text-align:left;border:1px solid #e6edf8;background:#fafcff;padding:11px 12px;border-radius:11px;color:#536785;margin-top:auto}.node-selector:hover{border-color:#92b5fc;background:#f5f9ff}.node-icon{width:35px;height:35px;border-radius:10px;background:#ecf3ff;color:#3577f4;display:grid;place-items:center;flex:none}.node-selector-copy{display:grid;gap:3px;flex:1}.node-selector-copy small{color:#93a1b4;font-size:10px}.node-selector-copy strong{font-size:12px;color:#304461}.demo-chip{font-size:10px;color:#6383b7;background:#eaf2ff;padding:5px 7px;border-radius:5px;white-space:nowrap}.right-column{display:grid;gap:18px;grid-template-rows:auto 1fr}.info-card{padding:24px}.info-heading{display:flex;align-items:center;gap:12px;margin-bottom:25px}.info-heading p,.route-preview p{font-size:11px;color:#98a5b7;margin:5px 0 0}.icon-tile{width:39px;height:39px;border-radius:11px;display:grid;place-iteLine truncated
|
||||
@@ -0,0 +1,13 @@
|
||||
.system-card { margin-top: 20px; }
|
||||
.system-card .info-heading { margin-bottom: 15px; }
|
||||
.system-grid { display: grid; grid-template-columns: 140px 1fr; gap: 14px; padding: 16px 0; border-top: 1px solid #edf1f7; font-size: 12px; }
|
||||
.system-grid span { color: #8292a8; }
|
||||
.system-grid strong { font-weight: 600; color: #354969; }
|
||||
.server-address { overflow-wrap: anywhere; color: #415574; font-size: 13px; margin: 0 0 11px; }
|
||||
.service-state { display: inline-block; padding: 6px 9px; border-radius: 6px; background: #f2f5fa; color: #71819a; font-size: 11px; }
|
||||
.service-state.reachable { background: #ebf8f2; color: #168861; }
|
||||
.service-state.error { background: #fff1f0; color: #b34444; }
|
||||
.system-card .input-row .save-button { background: #edf4ff; color: #3476f5; }
|
||||
.system-card .input-row .save-button:hover { background: #dbe9ff; }
|
||||
.input-row .save-button { background: #edf4ff; color: #3476f5; }
|
||||
.http-warning { background: #fff7e6; color: #a0691b; border: 1px solid #fae5be; border-radius: 8px; padding: 10px; font-size: 12px; line-height: 1.6; }
|
||||
Reference in new issue
Block a user