feat(tls): manage ACME domain and add desktop client settings
No files matched your search
@@ -284,6 +284,8 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
// 主机环境体检与一键初始化(仅 admin)
|
// 主机环境体检与一键初始化(仅 admin)
|
||||||
admin.GET("/system/env", s.systemEnv)
|
admin.GET("/system/env", s.systemEnv)
|
||||||
admin.POST("/system/fix", s.systemFix)
|
admin.POST("/system/fix", s.systemFix)
|
||||||
|
admin.GET("/system/tls", s.tlsStatus)
|
||||||
|
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
||||||
admin.GET("/users", s.listUsers)
|
admin.GET("/users", s.listUsers)
|
||||||
admin.POST("/users", s.createUser)
|
admin.POST("/users", s.createUser)
|
||||||
admin.PUT("/users/:id", s.updateUser)
|
admin.PUT("/users/:id", s.updateUser)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
"vpn-platform/backend/internal/tlsservice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) tlsStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, tlsservice.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setTLSDomain(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入域名"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tlsservice.ConfigureDomain(service.BaseDir(), req.Domain); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Audit(c.GetString("username"), "tls_domain_configured", req.Domain, "需重启后生效")
|
||||||
|
status := tlsservice.Status()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTLSStatusRequiresAdmin(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/system/tls", func(c *gin.Context) {
|
||||||
|
c.Set("role", c.GetHeader("X-Test-Role"))
|
||||||
|
c.Next()
|
||||||
|
}, requireRole("admin"), (&Server{}).tlsStatus)
|
||||||
|
|
||||||
|
for _, role := range []string{"", "viewer", "operator"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/system/tls", nil)
|
||||||
|
req.Header.Set("X-Test-Role", role)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role %q received HTTP %d", role, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/system/tls", nil)
|
||||||
|
req.Header.Set("X-Test-Role", "admin")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin received HTTP %d", resp.Code)
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := data["configured_domain"]; !ok {
|
||||||
|
t.Fatal("admin response missing configured domain")
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"private_key", "account_key", "certificate_pem"} {
|
||||||
|
if _, ok := data[secret]; ok {
|
||||||
|
t.Errorf("response exposes %s", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package tlsservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
domain string
|
||||||
|
dataDir string
|
||||||
|
cacheDir string
|
||||||
|
httpAddr string
|
||||||
|
httpsAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
var state struct {
|
||||||
|
sync.RWMutex
|
||||||
|
config configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
type CertificateStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
ConfiguredDomain string `json:"configured_domain"`
|
||||||
|
RestartRequired bool `json:"restart_required"`
|
||||||
|
DNS []string `json:"dns"`
|
||||||
|
HTTPAddr string `json:"http_address"`
|
||||||
|
HTTPSAddr string `json:"https_address"`
|
||||||
|
Cached bool `json:"certificate_cached"`
|
||||||
|
Valid bool `json:"certificate_valid"`
|
||||||
|
Issuer string `json:"issuer,omitempty"`
|
||||||
|
NotBefore time.Time `json:"not_before,omitempty"`
|
||||||
|
NotAfter time.Time `json:"not_after,omitempty"`
|
||||||
|
Renewal string `json:"renewal_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidDomain(domain string) bool {
|
||||||
|
return len(domain) <= 253 && domainPattern.MatchString(domain) && net.ParseIP(domain) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromEnvironment(dataDir string) (configuration, error) {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(os.Getenv("VPN_TLS_DOMAIN")))
|
||||||
|
if domain == "" {
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-domain")); err == nil {
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(string(raw)))
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domain != "" && !ValidDomain(domain) {
|
||||||
|
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
|
||||||
|
}
|
||||||
|
httpAddr := os.Getenv("VPN_TLS_HTTP_ADDR")
|
||||||
|
if httpAddr == "" {
|
||||||
|
httpAddr = ":80"
|
||||||
|
}
|
||||||
|
httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR")
|
||||||
|
if httpsAddr == "" {
|
||||||
|
httpsAddr = ":443"
|
||||||
|
}
|
||||||
|
return configuration{domain: domain, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigureDomain(dataDir, domain string) error {
|
||||||
|
if os.Getenv("VPN_TLS_DOMAIN") != "" {
|
||||||
|
return fmt.Errorf("当前由 VPN_TLS_DOMAIN 环境变量管理域名,请修改启动配置")
|
||||||
|
}
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
if !ValidDomain(domain) {
|
||||||
|
return fmt.Errorf("请输入单个有效的完整域名")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temp, err := os.CreateTemp(dataDir, ".tls-domain-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(temp.Name())
|
||||||
|
defer temp.Close()
|
||||||
|
if err := temp.Chmod(0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := temp.WriteString(domain + "\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temp.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-domain"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start keeps certificates in the persistent data directory. Issuance occurs
|
||||||
|
// on the first TLS handshake, and autocert handles renewal.
|
||||||
|
func Start(handler http.Handler, dataDir string) error {
|
||||||
|
config, err := configFromEnvironment(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if config.domain == "" {
|
||||||
|
state.Lock()
|
||||||
|
state.config = config
|
||||||
|
state.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(config.cacheDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("建立 ACME 缓存目录: %w", err)
|
||||||
|
}
|
||||||
|
manager := &autocert.Manager{
|
||||||
|
Prompt: autocert.AcceptTOS,
|
||||||
|
HostPolicy: autocert.HostWhitelist(config.domain),
|
||||||
|
Cache: autocert.DirCache(config.cacheDir),
|
||||||
|
}
|
||||||
|
httpListener, err := net.Listen("tcp", config.httpAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("监听 HTTP-01 端口 %s: %w", config.httpAddr, err)
|
||||||
|
}
|
||||||
|
httpsListener, err := net.Listen("tcp", config.httpsAddr)
|
||||||
|
if err != nil {
|
||||||
|
httpListener.Close()
|
||||||
|
return fmt.Errorf("监听 HTTPS 端口 %s: %w", config.httpsAddr, err)
|
||||||
|
}
|
||||||
|
redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Host != config.domain && r.Host != config.domain+":80" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
http.Error(w, "Use HTTPS", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "https://"+config.domain+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
||||||
|
})
|
||||||
|
httpServer := &http.Server{Handler: manager.HTTPHandler(redirect), ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
httpsServer := &http.Server{Handler: handler, TLSConfig: manager.TLSConfig(), ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
state.Lock()
|
||||||
|
state.config = config
|
||||||
|
state.Unlock()
|
||||||
|
go func() {
|
||||||
|
if err := httpServer.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Printf("HTTP-01 监听失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
if err := httpsServer.Serve(tls.NewListener(httpsListener, httpsServer.TLSConfig)); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Printf("HTTPS 监听失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("HTTPS/ACME 已启用:%s,HTTP %s,HTTPS %s", config.domain, config.httpAddr, config.httpsAddr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Status() CertificateStatus {
|
||||||
|
state.RLock()
|
||||||
|
config := state.config
|
||||||
|
state.RUnlock()
|
||||||
|
status := CertificateStatus{Enabled: config.domain != "", Domain: config.domain, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: "autocert"}
|
||||||
|
if desired, err := configFromEnvironment(config.dataDir); err == nil {
|
||||||
|
status.ConfiguredDomain = desired.domain
|
||||||
|
status.RestartRequired = desired.domain != config.domain
|
||||||
|
}
|
||||||
|
if !status.Enabled {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
if ips, err := net.LookupIP(config.domain); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
status.DNS = append(status.DNS, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// autocert caches a private key PEM followed by a certificate chain.
|
||||||
|
// Parse only the public leaf metadata; never return private key material.
|
||||||
|
data, err := os.ReadFile(filepath.Join(config.cacheDir, config.domain))
|
||||||
|
if err != nil {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
_, data = pem.Decode(data)
|
||||||
|
for len(data) > 0 {
|
||||||
|
block, rest := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data = rest
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil || cert.VerifyHostname(config.domain) != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
status.Cached = true
|
||||||
|
status.Issuer = cert.Issuer.CommonName
|
||||||
|
status.NotBefore = cert.NotBefore
|
||||||
|
status.NotAfter = cert.NotAfter
|
||||||
|
status.Valid = time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package tlsservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainValidationAndRestart(t *testing.T) {
|
||||||
|
for _, domain := range []string{"test-link.appx365.com", "a.b"} {
|
||||||
|
if !ValidDomain(domain) {
|
||||||
|
t.Errorf("valid domain rejected: %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, domain := range []string{"", "localhost", "127.0.0.1", "-a.example.com", "a..example.com", "a.example.com:80", "*.example.com", "a.example.com/evil"} {
|
||||||
|
if ValidDomain(domain) {
|
||||||
|
t.Errorf("invalid domain accepted: %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("VPN_TLS_DOMAIN", "")
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := Start(http.NotFoundHandler(), dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ConfigureDomain(dir, "Test-Link.Appx365.Com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status := Status()
|
||||||
|
if status.Enabled || status.Domain != "" || status.ConfiguredDomain != "test-link.appx365.com" || !status.RestartRequired {
|
||||||
|
t.Fatalf("saved domain must wait for restart: %+v", status)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "tls-domain"))
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("domain config permissions: %v, %v", info, err)
|
||||||
|
}
|
||||||
|
t.Setenv("VPN_TLS_DOMAIN", "other.example.com")
|
||||||
|
if err := ConfigureDomain(dir, "test-link.appx365.com"); err == nil {
|
||||||
|
t.Fatal("environment-managed domain must not be overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedHTTPSBindReleasesHTTPPort(t *testing.T) {
|
||||||
|
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
|
||||||
|
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer occupied.Close()
|
||||||
|
httpPort, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
httpAddr := httpPort.Addr().String()
|
||||||
|
httpPort.Close()
|
||||||
|
t.Setenv("VPN_TLS_HTTP_ADDR", httpAddr)
|
||||||
|
t.Setenv("VPN_TLS_HTTPS_ADDR", occupied.Addr().String())
|
||||||
|
if err := Start(http.NotFoundHandler(), t.TempDir()); err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||||
|
t.Fatalf("expected HTTPS port conflict: %v", err)
|
||||||
|
}
|
||||||
|
rebound, err := net.Listen("tcp", httpAddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HTTP listener leaked after HTTPS bind failed: %v", err)
|
||||||
|
}
|
||||||
|
rebound.Close()
|
||||||
|
if Status().Enabled {
|
||||||
|
t.Fatal("failed start reported active TLS")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"vpn-platform/backend/internal/service/openvpn"
|
"vpn-platform/backend/internal/service/openvpn"
|
||||||
"vpn-platform/backend/internal/service/wireguard"
|
"vpn-platform/backend/internal/service/wireguard"
|
||||||
"vpn-platform/backend/internal/store"
|
"vpn-platform/backend/internal/store"
|
||||||
|
"vpn-platform/backend/internal/tlsservice"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
@@ -111,6 +112,9 @@ func main() {
|
|||||||
// 状态采集 + 自启恢复
|
// 状态采集 + 自启恢复
|
||||||
monitor.Start(mgr, 15*time.Second)
|
monitor.Start(mgr, 15*time.Second)
|
||||||
monitor.AutoStartAll(mgr)
|
monitor.AutoStartAll(mgr)
|
||||||
|
if err := tlsservice.Start(r, dataDir); err != nil {
|
||||||
|
log.Fatalf("HTTPS/ACME 启动失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
addr := os.Getenv("VPN_LISTEN_ADDR")
|
addr := os.Getenv("VPN_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# OpsX VPN 桌面客户端(第一阶段)
|
||||||
|
|
||||||
|
仓库根目录的 `client/` 是独立的 Rust + Tauri v2 + React/TypeScript 桌面项目。形态参考同级 `opsxshell`、`opsxapi`:界面运行在 Tauri WebView,服务请求经 Rust 命令完成。与仓库的 `frontend/` 管理后台独立构建。
|
||||||
|
|
||||||
|
目前仅实现对现有 `GET /api/health` 的真实检测。在“设置”中输入并保存服务端 HTTP 或 HTTPS 根地址:HTTP 可用于健康检测,但明文传输今后不应承载登录口令或设备令牌,推荐使用可信 HTTPS 域名。地址仅保存在本机前端存储中,不含账号。界面采用蓝白灰布局,线路弹窗中的节点和绿黄延时均为**演示数据**,选择仅改变页面展示。健康检查显示的耗时是控制面请求往返时间,不能当作 VPN 节点延时。没有普通用户设备 API 和系统隧道适配前,不提供登录或一键拨号。
|
||||||
|
|
||||||
|
桌面版托盘显示“尚未连接”,提供打开窗口与退出。设置页通过 Rust 只读检查 `wg`、`wg-quick` 是否出现在本机 PATH;工具存在不代表驱动、权限、路由及实际隧道可用。浏览器界面预览不会调用本机检查。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev # Tauri 桌面窗口
|
||||||
|
npm run dev:ui # 仅预览界面;浏览器里不能执行 Tauri 的真实健康检查
|
||||||
|
npm run build:ui # 界面类型检查与构建
|
||||||
|
npm run build # Tauri 安装包(需目标 OS 的构建依赖)
|
||||||
|
cd src-tauri && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
平台首期应使用**标准 WireGuard UDP**。现有 `/api/login` 是管理账户,Peer 创建/下载要求 operator 以上权限且下载的托管 Peer 私钥由服务端保存;不能直接把这些接口和管理令牌给桌面用户。需要先设计独立的客户端认证、设备公钥注册、分配 IP、撤销和增量更新 Peer。技术依据、验收条件及后续顺序见 [SDD-002](../docs/specs/SDD-002-desktop-wireguard-client.md)。`corplink-rs` 对接的是另一套飞连私有协议,不是 opsx-vpn 的协议实现。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OpsX VPN</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "opsx-vpn-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"dev:ui": "vite --host 127.0.0.1 --port 5176 --strictPort",
|
||||||
|
"build:ui": "tsc --noEmit && vite build",
|
||||||
|
"build": "tauri build",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "opsx-vpn-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OpsX VPN desktop client"
|
||||||
|
authors = ["OpsX"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "opsx_vpn_client_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "OpsX VPN desktop window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
mod server;
|
||||||
|
mod system;
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
|
tray::TrayIconBuilder,
|
||||||
|
Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
let status = MenuItem::with_id(app, "status", "VPN:尚未连接", false, None::<&str>)?;
|
||||||
|
let open = MenuItem::with_id(app, "open", "打开 OpsX VPN", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(
|
||||||
|
app,
|
||||||
|
&[&status, &open, &PredefinedMenuItem::separator(app)?, &quit],
|
||||||
|
)?;
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().expect("desktop app icon").clone())
|
||||||
|
.tooltip("OpsX VPN · 尚未连接")
|
||||||
|
.menu(&menu)
|
||||||
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
|
"open" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
server::check_server,
|
||||||
|
system::get_system_readiness
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("failed to start OpsX VPN client");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
opsx_vpn_client_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::{redirect::Policy, Url};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ServerHealth {
|
||||||
|
address: String,
|
||||||
|
reachable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_url(input: &str) -> Result<Url, String> {
|
||||||
|
let mut url = Url::parse(input.trim())
|
||||||
|
.map_err(|_| "请输入完整的服务端地址(含 http:// 或 https://)".to_string())?;
|
||||||
|
if url.scheme() != "https" && url.scheme() != "http" {
|
||||||
|
return Err("服务端地址须以 http:// 或 https:// 开头".to_string());
|
||||||
|
}
|
||||||
|
if url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
|| url.path() != "/"
|
||||||
|
{
|
||||||
|
return Err("请填写服务端根地址,不包含账号、路径、查询参数或片段".to_string());
|
||||||
|
}
|
||||||
|
url.set_path("/api/health");
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_server(base_url: String) -> Result<ServerHealth, String> {
|
||||||
|
let url = health_url(&base_url)?;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.redirect(Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("无法创建网络客户端:{err}"))?;
|
||||||
|
let response = client
|
||||||
|
.get(url.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("连接服务端失败:{err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("服务端健康检查返回 HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
let body: HealthResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|_| "服务端响应不是有效的 opsx-vpn 健康结果".to_string())?;
|
||||||
|
if !body.ok {
|
||||||
|
return Err("服务端报告未就绪".to_string());
|
||||||
|
}
|
||||||
|
let mut root = url;
|
||||||
|
root.set_path("");
|
||||||
|
Ok(ServerHealth {
|
||||||
|
address: root.as_str().trim_end_matches('/').to_string(),
|
||||||
|
reachable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{check_server, health_url};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_http_and_https_server_addresses() {
|
||||||
|
assert_eq!(
|
||||||
|
health_url("https://vpn.example.com:443").unwrap().as_str(),
|
||||||
|
"https://vpn.example.com/api/health"
|
||||||
|
);
|
||||||
|
assert!(health_url("http://127.0.0.1:18080/").is_ok());
|
||||||
|
assert!(health_url("http://localhost:18080").is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
health_url("http://121.4.111.208:18080").unwrap().as_str(),
|
||||||
|
"http://121.4.111.208:18080/api/health"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_schemes_and_non_root_addresses() {
|
||||||
|
for input in [
|
||||||
|
"ftp://vpn.example.com",
|
||||||
|
"https://user:pass@vpn.example.com",
|
||||||
|
"https://vpn.example.com/admin",
|
||||||
|
"https://vpn.example.com/?token=abc",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
] {
|
||||||
|
assert!(health_url(input).is_err(), "accepted {input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checks_the_existing_health_contract_over_http() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = [0; 512];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /api/health "));
|
||||||
|
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}").unwrap();
|
||||||
|
});
|
||||||
|
let result =
|
||||||
|
tauri::async_runtime::block_on(check_server(format!("http://{address}"))).unwrap();
|
||||||
|
assert!(result.reachable);
|
||||||
|
assert_eq!(result.address, format!("http://{address}"));
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SystemReadiness {
|
||||||
|
platform: &'static str,
|
||||||
|
wg_found: bool,
|
||||||
|
wg_quick_found: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_path(name: &str) -> bool {
|
||||||
|
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).any(|directory| {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
[name.to_string(), format!("{name}.exe")]
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| directory.join(candidate).is_file())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
directory.join(name).is_file()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_system_readiness() -> SystemReadiness {
|
||||||
|
SystemReadiness {
|
||||||
|
platform: std::env::consts::OS,
|
||||||
|
wg_found: on_path("wg"),
|
||||||
|
wg_quick_found: on_path("wg-quick"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "OpsX VPN",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.opsx.vpn.client",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev:ui",
|
||||||
|
"devUrl": "http://127.0.0.1:5176",
|
||||||
|
"beforeBuildCommand": "npm run build:ui",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [{ "label": "main", "title": "OpsX VPN", "width": 1080, "height": 740, "minWidth": 800, "minHeight": 590 }],
|
||||||
|
"security": { "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:5176 ws://127.0.0.1:5176; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||||
|
"category": "Utility"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: { host: '127.0.0.1', port: 5176, strictPort: true },
|
||||||
|
envPrefix: ['VITE_'],
|
||||||
|
})
|
||||||
@@ -55,7 +55,7 @@ sudo ss -tlnp | grep ':18080'
|
|||||||
|
|
||||||
认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
||||||
|
|
||||||
该域名须由你控制并解析到反向代理公网地址。可以在已有反向代理面板申请 Let's Encrypt 免费证书:HTTP-01 需要公网 TCP 80 能访问验证路径 `/.well-known/acme-challenge/`,续期时也要保留该路径;443 为认证入口提供 HTTPS。无需上传已有证书,也无需在 opsx-vpn 内再开发证书申请功能。若代理与 VPN 主机分离,代理上的 `127.0.0.1` 指代理主机自身,应改用受限的私网地址,并评估认证流量在私网链路上的保护。
|
该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com` 并重启服务,或通过 `VPN_TLS_DOMAIN=test-link.appx365.com` 配置(环境变量优先)。Gin 进程使用已有的 `golang.org/x/crypto/acme/autocert` 依赖监听 TCP 80/443,处理 HTTP-01、签发与自动续签;无需额外引入 `gin-gonic/autotls`。证书和 ACME 账号密钥保存在 `data/acme`(随数据目录备份)。端口空闲、公网 DNS 和云安全组 TCP 80/443 通畅是必要条件;首次访问 `https://test-link.appx365.com/api/health` 才开始签发。80/443 的进程需要绑定低端口的权限;现有以 sudo 运行的服务可直接使用。原端口 `18080` 保留用于兼容,启用 HTTPS 后应限制它的公网访问。保存域名不会自动打开云安全组,也不会为其他认证域名配置反向代理。可以选择外置 Nginx / Certbot,但不要让两者同时争用 80/443;若代理与 VPN 主机分离,代理上的 `127.0.0.1` 指代理主机自身,应改用受限的私网地址,并评估认证流量在私网链路上的保护。
|
||||||
|
|
||||||
`/.well-known/openid-configuration` 属于发行者域名,不是认证入口上的 VPN 回调。不要只放行 `/oauth2/callback`:认证组件还需要发起登录等路由。认证入口使用独立域名,只反代给认证组件;如需按路径白名单返回 Nginx `444`,须先核对实际安装版本的完整路由,并保障 HTTP-01 验证路径。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
`/.well-known/openid-configuration` 属于发行者域名,不是认证入口上的 VPN 回调。不要只放行 `/oauth2/callback`:认证组件还需要发起登录等路由。认证入口使用独立域名,只反代给认证组件;如需按路径白名单返回 Nginx `444`,须先核对实际安装版本的完整路由,并保障 HTTP-01 验证路径。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# SDD-002:opsx-vpn 桌面客户端与 WireGuard 接入评估
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 版本 / 变更级别 | 0.1 / S3(未来涉及设备凭据、系统路由和隧道权限) |
|
||||||
|
| 设计状态 | Draft |
|
||||||
|
| 实现状态 | 客户端骨架、只读本机工具检测及托盘入口;拨号功能未实现 |
|
||||||
|
| 负责人 / 日期 | 待指定 / 2026-10-21 |
|
||||||
|
| 关联需求 | 仓库根目录新增 `client/`,Rust + Tauri v2 桌面客户端,优先对接 WireGuard |
|
||||||
|
| 批准状态 | 用户要求评估并新增客户端;后续接口契约尚待确认 |
|
||||||
|
|
||||||
|
## 1. 背景、目标与范围
|
||||||
|
|
||||||
|
- **当前行为与证据**:[服务端路由](../../backend/internal/api/api.go)的 `/api/login` 是管理账号登录,`/api/tunnels/:id/wireguard/peers` 的读写与配置下载需要 operator 以上角色;[WireGuard 实现](../../backend/internal/service/wireguard/wireguard.go)可以为管理端生成 Peer 私钥和 `.conf`,新增/删除 Peer 会停止再启动正在运行的隧道。节点 API `/api/nodes` 是管理节点信息,尚不是面向终端用户的可用线路目录。
|
||||||
|
- **目标**:参考 `../opsxshell` 与 `../opsxapi` 的桌面项目形态,在 `client/` 建立独立 Tauri v2 + Rust + React/TypeScript 项目;第一阶段只做可验证的真实服务健康检查和清晰的接入状态;给出后续控制面/数据面改造顺序。
|
||||||
|
- **非目标**:第一阶段不使用管理员令牌给终端用户创建 Peer,不自动安装驱动、修改本机 DNS/路由或连接 VPN,不兼容飞连私有协议,不宣称飞书/LDAP 用户已经能领取 WireGuard 设备配置。
|
||||||
|
- **保留行为**:现有 `frontend/` 管理后台、现有 WireGuard 隧道和已经签发的 Peer 均不改变;服务端 Go 与桌面 Rust 独立构建。
|
||||||
|
|
||||||
|
## 2. 需求与约束
|
||||||
|
|
||||||
|
- FR-01:桌面端可输入控制面 HTTPS 地址,使用 Rust 命令调用现有 `/api/health` 并显示真实结果;仅允许明确的本机 HTTP 开发地址。
|
||||||
|
- FR-02:界面将“服务已可达”与“用户已登录/隧道已连接”分开显示;尚无客户端 API 时连接控制不可触发拨号,预览节点及延时必须在界面显著标记为演示数据。
|
||||||
|
- FR-02a:桌面端采用蓝白灰连接概览、窄侧栏和独立线路弹窗;预览延时以绿、黄区分,但不伪装为实时测量。只有真实健康检查可展示控制面往返耗时,且明确不等于 VPN 节点延时。
|
||||||
|
- FR-02b:托盘明确显示未连接,并提供打开窗口与退出;设置页只读检查本机 PATH 中是否存在 `wg`、`wg-quick`,不得把工具存在等同于可拨号或已连接。
|
||||||
|
- FR-03(后续):专门的终端用户认证、MFA/OIDC 与短期设备令牌;区分管理员和拨号用户,过期、退出及设备撤销有可验证行为。
|
||||||
|
- FR-04(后续):由桌面端生成并保管 WireGuard 私钥,只上传公钥;服务端按用户/设备身份分配地址、Peer、路由及 DNS,签发有限时配置并处理回收。
|
||||||
|
- FR-05(后续):针对 Windows/macOS/Linux 的系统隧道、权限、路由/DNS 回滚和断线恢复逐平台交付;首版仅标准 WireGuard UDP。
|
||||||
|
- NFR-01:应用不能请求广泛的 shell/filesystem 权限;客户端机密不进入前端源码、日志或仓库。桌面应用与管理 API 使用不同鉴权边界。
|
||||||
|
- NFR-02:健康检查超时且 TLS 默认验证证书;失败时展示可读原因,不把网络失败当 VPN 连接失败。
|
||||||
|
|
||||||
|
## 3. 架构与取舍
|
||||||
|
|
||||||
|
```text
|
||||||
|
React 页面 → Tauri invoke → Rust 命令/服务 → opsx-vpn 控制面 HTTPS
|
||||||
|
│ 登录/授权/设备与 Peer(后续)
|
||||||
|
└→ 标准 WireGuard UDP 网关
|
||||||
|
Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私钥
|
||||||
|
```
|
||||||
|
|
||||||
|
- 客户端采用 `client/src` + `client/src-tauri`,参考同族项目的轻量 Tauri v2 壳,不复制其 SSH/HTTP 工作台或许可证逻辑。UI 使用 React + TypeScript;网络请求由 Rust 执行,避免将控制面令牌交给 WebView。
|
||||||
|
- UI 参考用户提供的蓝白灰截图与 Clash Verge Rev 的桌面交互分层思路;Clash Verge Rev 是 GPL-3.0-only,未复制其源码和视觉资源。线路列表目前是固定的演示内容,选择仅更新当前页面状态,不写入持久配置、不进行网络探测或拨号。
|
||||||
|
- `corplink-rs`、`Client_UI_for_Corplink` 与 PinkD 的 `wireguard-go` 面向飞连私有控制协议;现有 opsx-vpn 使用 Linux `wg`/`wg-quick` 和标准 WireGuard。不要引入 `protocol_version` 等私有扩展或假设它们兼容。SOCKS5/netstack、WireGuard over TCP 另行评估。
|
||||||
|
- 可选的近期验证路线:由管理员在现有管理端下载独立测试 Peer 配置,再用目标 OS 的标准 WireGuard 客户端验证网关;桌面端的自动拨号必须等设备 API 和权限模型明确后再做。
|
||||||
|
|
||||||
|
### Clash Verge Rev 参考范围与接入顺序
|
||||||
|
|
||||||
|
已只读核对本地 `../demo/clash-verge-rev` 的 `src-tauri/src/lib.rs`、`ipc.rs`、`feat/tun.rs`、`core/tray/`、`core/service.rs`、`core/updater.rs` 与 Tauri 配置。其 Tauri IPC 类型与命令集中管理、托盘状态、服务可用性检测、配置写入失败恢复以及按平台拆分是可参考的**设计方式**;该项目的系统代理、Mihomo TUN/服务、订阅配置及更新地址都不能直接用于 opsx-vpn。其 GPL-3.0-only 源码、图标和界面资源不复制进当前仓库。
|
||||||
|
|
||||||
|
| 顺序 | 借鉴方向 | opsx-vpn 需要自行实现或验证的前置条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Rust IPC 边界、连接状态模型、节点选择、托盘显示 | 先有独立的终端用户身份/设备授权 API;托盘只能根据真实握手与系统隧道状态宣称“已连接”,演示数据不能进入托盘 |
|
||||||
|
| 2 | 服务可用性检查、失败恢复、跨平台权限管理 | 使用目标系统支持的**标准 WireGuard** 隧道方案,分别验证安装/卸载、DNS/路由保存与恢复、崩溃及重启行为;不以 Mihomo TUN 或系统代理开关替代 WireGuard |
|
||||||
|
| 3 | 配置存储与更新流程 | 私钥在客户端安全存储,令牌按平台凭据库管理;普通偏好与机密分离。具备签名、可信发布地址和回滚策略后才添加自动更新 |
|
||||||
|
|
||||||
|
`corplink-rs` 仅在明确提出**兼容飞连官方服务**且确认私有协议、许可与长期维护成本后作为研究参考,不能作为本项目认证或隧道实现的基础。当前项目的认证对象是 opsx-vpn 自己的 LDAP/飞书用户,需由 opsx-vpn 签发客户端设备身份;飞连企业识别码、Cookie、私有 WireGuard 扩展字段不属于本项目契约。
|
||||||
|
|
||||||
|
## 4. 接口、数据与配置契约
|
||||||
|
|
||||||
|
- 第一阶段复用 `GET /api/health`,不新增服务端 API、不改变数据库或现有 Peer 数据。
|
||||||
|
- 桌面原生命令 `get_system_readiness` 返回操作系统和 PATH 中 `wg`、`wg-quick` 文件存在性;不执行这些工具、不申请特权,也不访问 VPN 凭据。托盘当前仅提供未连接静态状态、打开与退出,未来需绑定真实隧道状态机后才能展示连接状态。
|
||||||
|
- 桌面 `check_server(base_url)` 只返回地址、是否可达及错误,不持久化用户口令或令牌。默认不配置公网地址;用户自行填真实 HTTPS 域名,开发时允许 `http://127.0.0.1` / `http://localhost`。
|
||||||
|
- 后续拟新增独立的客户端 `/api/client/*` 契约:登录/续期、可用线路、设备注册(公钥、设备标识)、设备吊销、连接配置领取及状态。响应不下发服务端私钥,客户端私钥不上传。接口形状、LDAP/飞书绑定与授权范围在实现前另行确定。
|
||||||
|
- 配置下载目前包含由服务端保存的客户端私钥,不得将管理员下载接口直接暴露给桌面端;现有管理员 `/api/login` 不等价于终端用户准入。
|
||||||
|
|
||||||
|
## 5. 风险与验收
|
||||||
|
|
||||||
|
| 风险或失败场景 | 处理方式 | 可观察信号 |
|
||||||
|
|---|---|---|
|
||||||
|
| 把管理 token 当拨号凭据 | 另建按用户/设备授权的控制面;第一阶段仅匿名健康检查 | 无管理 API 调用 |
|
||||||
|
| 设备私钥从管理配置泄漏 | 后续客户端生成密钥、公钥注册及撤销;不直接调用管理下载接口 | 设备 API 与密钥审计 |
|
||||||
|
| Peer 更新影响现有连接 | 后续用 `wg set` 等增量更新并测试掉线影响,再启用自动注册 | 连接连续性、审计日志 |
|
||||||
|
| Windows/macOS/Linux 权限差异 | 按平台单独设计特权 helper、安装与回滚;第一阶段不创建 TUN | 平台专项验收 |
|
||||||
|
|
||||||
|
| AC | 对应需求 | 前置与操作 | 预期结果 | 验证方式 | 状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| AC-01 | FR-01, NFR-02 | 输入有效/无效/非本机 HTTP 地址并点检测 | 真实健康结果、错误与拒绝行为明确 | Rust 单测、桌面手动试连 | 地址校验及本机健康接口测试通过;真实服务与桌面手动试连待做 |
|
||||||
|
| AC-02 | FR-02, FR-02a, NFR-01 | 打开桌面端并切换线路 | 蓝白灰界面、绿黄演示延时、显著的演示标记;可见未接入,不会调用管理 API 或修改系统 | UI 构建、代码检查、桌面演示启动 | UI 构建通过;桌面 demo 进程已启动,手动视觉验收待做 |
|
||||||
|
| AC-02b | FR-02b, NFR-01 | 在桌面端打开托盘并访问设置页 | 托盘显示未连接,能打开/退出;本机工具存在性仅标注为 PATH 检查结果 | 原生构建、桌面手动检查 | UI 构建及 Rust 测试通过,Tauri 开发进程已重启;托盘点击与设置页人工验收待做 |
|
||||||
|
| AC-03 | FR-03 | 普通用户/MFA/过期令牌/撤销设备 | 仅已授权设备可领取配置 | 后续 API 集成测试 | Not Run |
|
||||||
|
| AC-04 | FR-04 | 同一用户多个设备注册/注销 | 各设备独立密钥、Peer、地址与审计 | 后续真实网关联调 | Not Run |
|
||||||
|
| AC-05 | FR-05 | 各 OS 正常连接与异常退出 | 路由/DNS 可恢复,不影响已有连接 | 后续目标 OS 实机验收 | Not Run |
|
||||||
|
|
||||||
|
## 6. 发布与恢复
|
||||||
|
|
||||||
|
- 第一阶段仅新增 `client/`,不修改服务器进程、不申请系统特权;构建检查与桌面手动健康检查后才能称为可运行。
|
||||||
|
- 2026-10-21 已经管理 API 在现有服务器上创建停止状态测试专线 `opsx-client-pilot`(ID 4),配置与只读核验见 [WireGuard API 创建记录](../wireguard-api-create.md)。该资源尚未启动,不代表桌面客户端可以认证或连接;启用前确认云安全组与目标路由。
|
||||||
|
- 后续按“独立客户端身份 API → 设备注册/增量 Peer 更新 → Linux/Windows/macOS 原生隧道”逐步交付;先在独立测试 WireGuard 隧道验证。
|
||||||
|
- 若后续客户端版本故障,先禁用其设备访问凭据并停用客户端,保留现有管理后台与隧道;数据迁移需独立备份和回滚方案。
|
||||||
|
|
||||||
|
## 7. 待决问题
|
||||||
|
|
||||||
|
| 问题 | 是否阻塞第一阶段 | 建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| 首发 OS 与安装包签名 | 不阻塞骨架 | 先选一台真实目标 OS 验收,再扩平台 |
|
||||||
|
| 企业账号映射到 VPN 权限、MFA/OIDC 与设备配额 | 阻塞自动拨号 | 稳定用户 ID + 单独设备授权策略,不按同名邮箱猜测 |
|
||||||
|
| 会话断开是否自动注销 Peer | 阻塞自动拨号 | 区分临时连接与已授权设备;明确租约、续期与撤销 |
|
||||||
|
| 是否需要代理模式/TCP/飞连兼容 | 不阻塞标准 WireGuard UDP | 先证明原生 WG 客户端与当前网关互通 |
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# SDD-003:域名与 HTTPS 证书管理
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 版本 / 变更级别 | 0.1 / S3(证书私钥、网络入口及生产代理配置) |
|
||||||
|
| 设计状态 | Draft |
|
||||||
|
| 实现状态 | 应用内 ACME、域名设置与管理员状态页已实现,本地构建/单测通过;生产端口/证书待部署验证 |
|
||||||
|
| 负责人 / 日期 | 待指定 / 2026-10-21 |
|
||||||
|
| 关联需求 | `test-link.appx365.com` 使用 HTTP-01 自动签发和续期;管理后台提供域名及证书页面;桌面客户端可使用 HTTP 或 HTTPS,并在设置中填写地址 |
|
||||||
|
| 批准状态 | 用户要求开发与评估;生产入口切换以部署核验为准 |
|
||||||
|
|
||||||
|
## 1. 背景、目标与范围
|
||||||
|
|
||||||
|
- 当前行为:[后端路由](../../backend/internal/api/api.go)在 `:18080` 提供管理站点和 API;[部署文档](../deploy.md)仅描述外置 Nginx/Let's Encrypt 方案,没有集成证书状态页面。桌面 [URL 校验](../../client/src-tauri/src/server.rs)拒绝远端 HTTP,地址位于概览页。
|
||||||
|
- 2026-10-21 只读核对:`test-link.appx365.com` 解析到 `121.4.111.208`;服务器没有发现 Nginx、Certbot,80/443 未被监听。
|
||||||
|
- 本次目标:使用项目已有的 Go `autocert` 依赖,在现有 Gin 进程中提供公网 80/443、HTTP-01 自动签发/续签;管理 API 和页面展示域名及缓存证书状态;桌面将地址放在设置中,接受 HTTP(提示明文风险)。单域名场景无需 Nginx、Certbot 或额外的 `gin-gonic/autotls` 封装。
|
||||||
|
- 非目标:将证书私钥存到 SQLite/浏览器;允许任意命令、代理任意上游、公开认证服务内部端口;向桌面发放管理员 token;把 WireGuard 51820/UDP 交给 Nginx 代理。
|
||||||
|
- 保留行为:旧 `:18080` 管理界面与现有 VPN 专线不受证书签发流程影响;证书失败保持现有 HTTP 服务可用。
|
||||||
|
|
||||||
|
## 2. 需求与约束
|
||||||
|
|
||||||
|
- FR-01:管理员能查看当前域名、DNS、监听地址、证书颁发者/到期日;接口不返回私钥、登录凭据或证书正文。
|
||||||
|
- FR-02:服务端仅为管理员保存的域名或 `VPN_TLS_DOMAIN` 指定的单一有效域名签发(环境变量优先),ACME 使用该域名 HostWhitelist;首次 HTTPS 握手签发,证书与账号密钥持久缓存于 `data/acme`,禁止任意域名签发。
|
||||||
|
- FR-03:续签由 `autocert.Manager` 自动管理;重启后缓存证书由首次握手加载并继续续签,到期日由管理页显示;签发或续签失败保留已有 18080 HTTP 入口。
|
||||||
|
- FR-04:HTTP 80 通过 `autocert.Manager.HTTPHandler` 放行 HTTP-01,其他当前域名请求重定向 HTTPS;443 用同一 Gin handler 提供管理站点与 API。OIDC 认证入口若另设域名需另行设计其路由,不从后台域名假设回调。
|
||||||
|
- FR-05:桌面客户端服务端地址移到“设置”,支持完整 HTTP/HTTPS 根地址并允许保存;HTTP 展示风险提示,但健康检查可以执行。不保存账号和令牌。
|
||||||
|
- NFR-01:管理 API 仅 `admin`,域名符合严格 FQDN 白名单;私钥缓存目录权限 0700,不暴露证书缓存内容或触发任意命令。
|
||||||
|
- NFR-02:部署前核对 DNS、云安全组 TCP 80/443、端口占用、现有入口与回退;续签有可观测到期信息。浏览器页面与 Tauri 环境的 URL 校验一致。
|
||||||
|
|
||||||
|
## 3. 架构与方案
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet TCP 80 → autocert HTTPHandler(HTTP-01;其余跳转 HTTPS)
|
||||||
|
Internet TCP 443 → autocert TLSConfig → 同一 Gin 管理站点 / API
|
||||||
|
ACME 密钥与证书 → data/acme(重启保留)
|
||||||
|
管理页面 → admin API → 仅读 DNS、域名及证书缓存元数据
|
||||||
|
旧 TCP 18080 → 同一 Gin handler(保留兼容;后续限制公网访问)
|
||||||
|
```
|
||||||
|
|
||||||
|
使用项目已存在的 `golang.org/x/crypto/acme/autocert`,无需额外 Gin 插件或依赖:管理员将域名保存到 `data/tls-domain`,重启后生效;`VPN_TLS_DOMAIN` 优先于文件配置,`VPN_TLS_HTTP_ADDR` / `VPN_TLS_HTTPS_ADDR` 可覆盖监听地址;未配置域名时完全保留旧启动行为。证书管理不写入业务数据库。首次公网 HTTPS 请求后才有真实缓存证书,管理页未签发前必须显示“等待首次访问”,不能声称证书已签发。HTTP 客户端输入只是用户选择;登录与设备令牌将来仍应推荐 HTTPS。
|
||||||
|
|
||||||
|
## 4. 接口、数据与配置契约
|
||||||
|
|
||||||
|
- `GET /api/system/tls` 与 `PUT /api/system/tls/domain` 仅管理员可用,分别读取状态与保存单域名;状态返回当前/待启用域名、是否需重启、DNS、监听地址及 autocert 缓存证书的颁发者/到期日。没有手动签发按钮,首次 TLS 握手会触发签发。证书缓存位于 `data/acme`,不接收请求任意域名作为签发目标。
|
||||||
|
- 云安全组 TCP 80/443 与 DNS A 解析须对应当前服务器;`VPN_TLS_DOMAIN=test-link.appx365.com` 生效时若端口占用,应用拒绝启动并保持旧进程可回滚。
|
||||||
|
- 桌面仅在本机保存不含凭据的服务端地址;只允许 `http` / `https` 和根地址,拒绝 URL 账号、查询、片段、非根路径;实际健康检查继续调用 `/api/health`。
|
||||||
|
|
||||||
|
## 5. 风险与验收
|
||||||
|
|
||||||
|
| 风险或失败场景 | 防护 / 恢复 | 可观察信号 |
|
||||||
|
|---|---|---|
|
||||||
|
| 域名指向错误或 80 不可达 | 不启用证书前先核对 DNS 和公网入站;失败保留旧 18080 | ACME 错误日志、证书未缓存 |
|
||||||
|
| 80/443 端口占用 | 启动时先绑定两个端口,失败则终止新版进程并回退旧版 | 启动日志与端口监听 |
|
||||||
|
| 续签失败与证书过期 | autocert 自动续签、到期日提示、缓存保留 | 证书到期信息与 ACME 日志 |
|
||||||
|
| 公网 HTTP 管理凭据泄漏 | HTTPS 默认推荐;明示 HTTP 风险;服务端切换后限制 18080 公网 | HTTPS 可达、18080 仅可信入口 |
|
||||||
|
|
||||||
|
| AC | 对应需求 | 前置与操作 | 预期结果 | 验证方式 | 状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| AC-01 | FR-01, NFR-01 | 管理员/非管理员请求状态 | 仅管理员可查看,证书私钥不回传 | 路由权限检查;线上请求待验证 | 部分验证 |
|
||||||
|
| AC-02 | FR-02, NFR-01 | 设置非法域名或端口占用 | 启动前拒绝,不向任意域名申请 | 域名/端口失败分支单测 | 已通过(本地) |
|
||||||
|
| AC-03 | FR-03, FR-04, NFR-02 | 首次 HTTPS 请求与后续续签 | HTTP-01 可达、443 站点正确、缓存保留与自动续签 | DNS、ACME、TLS 实机验证 | Not Run |
|
||||||
|
| AC-04 | FR-05 | 桌面保存远端 HTTP/HTTPS 并检测 | 地址保存在设置;HTTP 提示且能检测,其他协议拒绝 | Rust 单测、UI 构建;桌面实机待验证 | 部分验证 |
|
||||||
|
|
||||||
|
## 6. 发布与恢复
|
||||||
|
|
||||||
|
- 先完成本地构建和测试,备份现有二进制、确认公网 80/443 无占用及云安全组策略,再配置 `VPN_TLS_DOMAIN` 并切换新二进制。以 `https://test-link.appx365.com/api/health` 触发首次签发;确认 TLS 域名、证书到期和管理站点之后收敛 18080 公网权限。
|
||||||
|
- 若端口监听、HTTP-01 或首次握手失败,恢复原二进制/启动参数并重启旧 `:18080` 入口;`data/acme` 保留避免重复申请。不修改现有 VPN 网关或删除证书缓存。
|
||||||
|
- 私钥文件和用户数据不删除;证书吊销、DNS 变更及防火墙收敛分别决策。
|
||||||
|
|
||||||
|
## 7. 待决问题
|
||||||
|
|
||||||
|
| 问题 | 是否阻塞 | 推荐方案 |
|
||||||
|
|---|---|---|
|
||||||
|
| 云安全组 TCP 80/443 是否放行 | 阻塞线上 HTTP-01 | 先核实公网访问再签发 |
|
||||||
|
| `test-link.appx365.com` 是否同时承载 OIDC 认证服务 | 不阻塞管理站点 | 初版仅反代管理站点;OIDC 独立域名或另行设计路径路由 |
|
||||||
|
| 证书到期告警 | 不阻塞本地实现 | 当前页面展示到期时间;后续可补主动告警 |
|
||||||
@@ -728,6 +728,14 @@
|
|||||||
"protocolIcon": {
|
"protocolIcon": {
|
||||||
"custom": "Custom Protocol"
|
"custom": "Custom Protocol"
|
||||||
},
|
},
|
||||||
|
"tls": {
|
||||||
|
"title": "Domain & certificate",
|
||||||
|
"description": "OpsX VPN can serve ports 80/443 with automatic ACME HTTP-01 issuance and renewal; Nginx and Certbot are optional.",
|
||||||
|
"domain": "Configured domain", "activeDomain": "Active domain", "saveDomain": "Save domain", "restartRequired": "Domain saved; restart OpsX VPN to enable ports 80/443 and automatic issuance", "saveFailed": "Could not save domain", "dns": "DNS", "certificate": "Certificate", "valid": "Valid", "pending": "Pending first HTTPS handshake",
|
||||||
|
"notConfigured": "Disabled", "saved": "Domain configuration saved", "issuer": "Issuer", "expires": "Expires", "renewal": "Renewal", "automatic": "Automatic (autocert)",
|
||||||
|
"ports": "Listening ports (HTTP / HTTPS)", "setupHint": "Save the domain and restart the server, then allow public TCP 80/443. The first HTTPS request issues a certificate and caches it in data/acme. VPN_TLS_DOMAIN may also be used and overrides the saved setting. Preserve the data directory.",
|
||||||
|
"refresh": "Refresh status", "openHttps": "Open HTTPS health check", "loadFailed": "Could not load certificate status"
|
||||||
|
},
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"wireguard": {
|
"wireguard": {
|
||||||
"name": "WireGuard",
|
"name": "WireGuard",
|
||||||
|
|||||||
@@ -728,6 +728,14 @@
|
|||||||
"protocolIcon": {
|
"protocolIcon": {
|
||||||
"custom": "自定义协议"
|
"custom": "自定义协议"
|
||||||
},
|
},
|
||||||
|
"tls": {
|
||||||
|
"title": "域名与证书",
|
||||||
|
"description": "opsx-vpn 可直接监听 80/443,通过 ACME HTTP-01 自动签发和续签;无需 Nginx 或 Certbot。",
|
||||||
|
"domain": "配置域名", "activeDomain": "当前监听域名", "saveDomain": "保存域名", "restartRequired": "域名已保存,重启 opsx-vpn 后才会监听 80/443 并启用自动签发", "saveFailed": "保存域名失败", "dns": "DNS 解析", "certificate": "证书", "valid": "有效", "pending": "等待首次 HTTPS 请求签发",
|
||||||
|
"notConfigured": "未启用", "saved": "域名配置已保存", "issuer": "颁发者", "expires": "到期时间", "renewal": "续签", "automatic": "自动(autocert)",
|
||||||
|
"ports": "监听端口(HTTP / HTTPS)", "setupHint": "保存域名并重启服务,放行公网 TCP 80/443;证书将在该域名首次 HTTPS 访问时签发,缓存于 data/acme。也可用 VPN_TLS_DOMAIN 环境变量设置(优先于页面配置)。请保留数据目录。",
|
||||||
|
"refresh": "刷新状态", "openHttps": "打开 HTTPS 健康检查", "loadFailed": "读取证书状态失败"
|
||||||
|
},
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"wireguard": {
|
"wireguard": {
|
||||||
"name": "WireGuard",
|
"name": "WireGuard",
|
||||||
|
|||||||
@@ -43,6 +43,32 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
||||||
|
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||||
|
<template #header><b>{{ $t('tls.title') }}</b></template>
|
||||||
|
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
|
||||||
|
<el-form @submit.prevent="saveTLSDomain" label-position="top">
|
||||||
|
<el-form-item :label="$t('tls.domain')">
|
||||||
|
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
|
||||||
|
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
|
||||||
|
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('tls.certificate')">
|
||||||
|
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.http_address || ':80' }} / {{ tlsStatus.https_address || ':443' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
|
||||||
|
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
|
||||||
|
<el-button v-if="tlsStatus?.enabled" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
|
||||||
|
</el-card>
|
||||||
<el-card class="page-card" shadow="never">
|
<el-card class="page-card" shadow="never">
|
||||||
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
||||||
<el-empty :description="$t('settings.auditMigrated')">
|
<el-empty :description="$t('settings.auditMigrated')">
|
||||||
@@ -82,6 +108,30 @@ const mfaCanvas = ref(null)
|
|||||||
const mfaLoading = ref(false)
|
const mfaLoading = ref(false)
|
||||||
const backupVisible = ref(false)
|
const backupVisible = ref(false)
|
||||||
const backupCodes = ref([])
|
const backupCodes = ref([])
|
||||||
|
const tlsStatus = ref(null)
|
||||||
|
const tlsLoading = ref(false)
|
||||||
|
const tlsSaving = ref(false)
|
||||||
|
const tlsDomain = ref('')
|
||||||
|
|
||||||
|
async function loadTLS() {
|
||||||
|
tlsLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/system/tls')
|
||||||
|
tlsStatus.value = data
|
||||||
|
tlsDomain.value = data.configured_domain || data.domain || ''
|
||||||
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||||
|
finally { tlsLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTLSDomain() {
|
||||||
|
tlsSaving.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
|
||||||
|
tlsStatus.value = data.status
|
||||||
|
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
|
||||||
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
|
||||||
|
finally { tlsSaving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
async function changePwd() {
|
async function changePwd() {
|
||||||
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning(t('settings.passwordMismatch'))
|
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning(t('settings.passwordMismatch'))
|
||||||
@@ -98,6 +148,7 @@ async function changePwd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (auth.isAdmin() && !auth.mustChange) loadTLS()
|
||||||
try {
|
try {
|
||||||
const status = await api.get('/mfa/status')
|
const status = await api.get('/mfa/status')
|
||||||
mfa.value.enabled = status.data.enabled
|
mfa.value.enabled = status.data.enabled
|
||||||
@@ -148,4 +199,5 @@ function copyBackup() {
|
|||||||
color: var(--text-primary); text-align: center; padding: 6px;
|
color: var(--text-primary); text-align: center; padding: 6px;
|
||||||
background: var(--bg-card); border-radius: 4px;
|
background: var(--bg-card); border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||