继续复审前保存初始化安全校验与链路测试检查点
This commit is contained in:
parent
199d727ecc
commit
c6bfcfd9ae
52
backend/internal/bootstrap/origin_test.go
Normal file
52
backend/internal/bootstrap/origin_test.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootstrapRejectsCrossOriginBeforeTokenExchange(t *testing.T) {
|
||||||
|
for _, origin := range []string{"https://attacker.example", "http://127.0.0.1:8443", "null", "https://127.0.0.1:8443/path"} {
|
||||||
|
t.Run(origin, func(t *testing.T) {
|
||||||
|
sessions, token, err := NewSessionManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router := NewRouter(sessions, nil, nil, nil)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "https://127.0.0.1:8443/api/v1/bootstrap/session", strings.NewReader(`{"token":"`+token+`"}`))
|
||||||
|
request.Header.Set("Origin", origin)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("跨源写入未拒绝:%d", response.Code)
|
||||||
|
}
|
||||||
|
if _, err := sessions.Exchange(token, "127.0.0.1"); err != nil {
|
||||||
|
t.Fatalf("被拒绝的请求消耗了令牌:%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapSameOriginPolicy(t *testing.T) {
|
||||||
|
for _, item := range []struct {
|
||||||
|
origin string
|
||||||
|
site string
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"https://127.0.0.1:8443", "same-origin", true},
|
||||||
|
{"", "", true},
|
||||||
|
{"", "cross-site", false},
|
||||||
|
{"https://127.0.0.1:8443", "same-site", false},
|
||||||
|
{"https://user@127.0.0.1:8443", "", false},
|
||||||
|
} {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "https://127.0.0.1:8443/", nil)
|
||||||
|
request.Header.Set("Origin", item.origin)
|
||||||
|
request.Header.Set("Sec-Fetch-Site", item.site)
|
||||||
|
request.Header.Set("X-Forwarded-Host", "attacker.example")
|
||||||
|
if bootstrapSameOrigin(request) != item.allowed {
|
||||||
|
t.Fatalf("来源策略不符:%+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -70,6 +71,10 @@ func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Reques
|
|||||||
writeBootstrapError(writer, http.StatusBadRequest, "请求无效")
|
writeBootstrapError(writer, http.StatusBadRequest, "请求无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if request.Method != http.MethodGet && request.Method != http.MethodHead && !bootstrapSameOrigin(request) {
|
||||||
|
writeBootstrapError(writer, http.StatusForbidden, "初始化写入请求必须来自当前管理页面")
|
||||||
|
return
|
||||||
|
}
|
||||||
switch request.URL.Path {
|
switch request.URL.Path {
|
||||||
case "/api/v1/bootstrap/status":
|
case "/api/v1/bootstrap/status":
|
||||||
if !requireBootstrapMethod(writer, request, http.MethodGet) {
|
if !requireBootstrapMethod(writer, request, http.MethodGet) {
|
||||||
@ -366,6 +371,21 @@ func requireBootstrapMethod(writer http.ResponseWriter, request *http.Request, m
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不信任转发头;同站子域也不能向初始化服务提交写入请求。
|
||||||
|
func bootstrapSameOrigin(request *http.Request) bool {
|
||||||
|
if site := request.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
origin := request.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(origin)
|
||||||
|
return err == nil && parsed.Scheme == "https" && parsed.User == nil &&
|
||||||
|
parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == "" &&
|
||||||
|
strings.EqualFold(parsed.Host, request.Host)
|
||||||
|
}
|
||||||
|
|
||||||
func setBootstrapSecurityHeaders(writer http.ResponseWriter) {
|
func setBootstrapSecurityHeaders(writer http.ResponseWriter) {
|
||||||
writer.Header().Set("Cache-Control", "no-store")
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
|||||||
261
backend/internal/bootstrap/server_lifecycle_test.go
Normal file
261
backend/internal/bootstrap/server_lifecycle_test.go
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/app"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||||
|
fixture := newCompletionFixture(t, "authorized.example.test")
|
||||||
|
store, err := secretstore.Open(fixture.base.Paths.Config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
databases := NewDatabaseVerificationManager(fixture.base, store)
|
||||||
|
dns := setup.NewDNSVerificationManager(completionDNSTester{}, store)
|
||||||
|
sessions, token, err := NewSessionManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
completer := NewCompleter(CompleteDependencies{
|
||||||
|
BaseConfig: fixture.base, ConfigPath: fixture.configPath,
|
||||||
|
DatabaseSessions: databases, DNSSessions: dns, Credentials: store,
|
||||||
|
Sessions: sessions, Firewall: fixture.firewall, Certificate: fixture.issuer,
|
||||||
|
})
|
||||||
|
router := NewRouter(sessions, databases, dns, completer)
|
||||||
|
handler, closeFiles := bootstrapHandler(router, fixture.base)
|
||||||
|
defer closeFiles()
|
||||||
|
material, err := NewTemporaryTLSMaterial("127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := listener.Addr().String()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
ok, err := serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed())
|
||||||
|
if err == nil && !ok {
|
||||||
|
err = errors.New("初始化未完成")
|
||||||
|
}
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(material.Leaf)
|
||||||
|
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: transport, Jar: jar, Timeout: 10 * time.Second}
|
||||||
|
origin := "https://" + address
|
||||||
|
post := func(path string, input any, output any) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodPost, origin+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Origin", origin)
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
data, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil || response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("初始化步骤 %s 失败:状态 %d,读取错误 %v", path, response.StatusCode, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var authenticated map[string]any
|
||||||
|
post("/api/v1/bootstrap/session", map[string]string{"token": token}, &authenticated)
|
||||||
|
var databaseResult, dnsResult struct {
|
||||||
|
Token string `json:"verification_token"`
|
||||||
|
}
|
||||||
|
post("/api/v1/bootstrap/database/test", DatabaseInput{Driver: "sqlite"}, &databaseResult)
|
||||||
|
post("/api/v1/bootstrap/dns/test", bootstrapDNSTestRequest{
|
||||||
|
Provider: "aliyun", TestDomain: "authorized.example.test",
|
||||||
|
Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"},
|
||||||
|
}, &dnsResult)
|
||||||
|
input := fixture.request("")
|
||||||
|
input.WebPublicIP = "203.0.113.20"
|
||||||
|
input.ServerPort = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
input.DatabaseVerificationToken = databaseResult.Token
|
||||||
|
input.DNSVerificationToken = dnsResult.Token
|
||||||
|
var completed CompleteResult
|
||||||
|
post("/api/v1/bootstrap/complete", input, &completed)
|
||||||
|
if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strings.Split(address, ":")[1])+"/" {
|
||||||
|
t.Fatal("初始化未返回正确管理地址")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("初始化服务未退出")
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(origin)
|
||||||
|
if len(jar.Cookies(parsed)) != 0 {
|
||||||
|
t.Fatal("完成响应未清除初始化会话")
|
||||||
|
}
|
||||||
|
// 清理未领取凭据不能删除已提交配置持有的凭据。
|
||||||
|
databases.DiscardAllExcept()
|
||||||
|
dns.DiscardAll()
|
||||||
|
stored, err := config.Load(fixture.configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored.Server.Host = "127.0.0.1"
|
||||||
|
application, err := app.NewWithConfigPath(ctx, stored, fixture.configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applicationResult := make(chan error, 1)
|
||||||
|
go func() { applicationResult <- application.HTTP.ListenAndServe() }()
|
||||||
|
// 正式 IP 证书与临时证书不同,只信任本次生成的正式证书。
|
||||||
|
certificatePEM, err := os.ReadFile(stored.Server.TLSCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
formalRoots := x509.NewCertPool()
|
||||||
|
if !formalRoots.AppendCertsFromPEM(certificatePEM) {
|
||||||
|
t.Fatal("正式管理证书无法解析")
|
||||||
|
}
|
||||||
|
// 仅连接回环套接字,证书身份仍按配置中的文档示例公网 IP 完整校验。
|
||||||
|
formalTransport := &http.Transport{TLSClientConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12, RootCAs: formalRoots, ServerName: input.WebPublicIP,
|
||||||
|
}}
|
||||||
|
defer formalTransport.CloseIdleConnections()
|
||||||
|
formalClient := &http.Client{Transport: formalTransport, Timeout: time.Second}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
response, err := formalClient.Get(origin + "/healthz")
|
||||||
|
if err == nil {
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("正式服务健康检查失败")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("正式 HTTPS 未就绪:%v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
defer application.Close()
|
||||||
|
for path, expected := range map[string]int{
|
||||||
|
"/api/v1/bootstrap/status": http.StatusNotFound,
|
||||||
|
"/api/v1/bootstrap/settings": http.StatusNotFound,
|
||||||
|
"/api/v1/auth/session": http.StatusUnauthorized,
|
||||||
|
} {
|
||||||
|
response, err := formalClient.Get(origin + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != expected {
|
||||||
|
t.Fatalf("切换后 %s 状态错误:%d", path, response.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := application.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-applicationResult:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
t.Fatalf("正式服务关闭错误:%v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("正式服务关闭超时")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *testing.T) {
|
||||||
|
fixture := newCompletionFixture(t, "authorized.example.test")
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixture.base.Server.Host = "127.0.0.1"
|
||||||
|
fixture.base.Server.Port = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
listener.Close()
|
||||||
|
var output bytes.Buffer
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
ok, err := Run(ctx, ServerOptions{
|
||||||
|
Config: fixture.base, ConfigPath: fixture.configPath,
|
||||||
|
PublicIP: "127.0.0.1", URLPath: fixture.urlPath, Output: &output,
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
err = errors.New("取消操作误报初始化完成")
|
||||||
|
}
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var address []byte
|
||||||
|
for {
|
||||||
|
address, err = os.ReadFile(fixture.urlPath)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("初始化地址文件没有生成")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("取消后初始化未退出")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(string(address)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsed.Fragment == "" || strings.Contains(output.String(), parsed.Fragment) {
|
||||||
|
t.Fatal("一次性令牌缺失或泄露到日志")
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.urlPath, fixture.configPath, filepath.Join(fixture.base.Paths.Data, "scert.db")} {
|
||||||
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("取消后不应保留初始化地址或提前产生正式文件:%s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -204,18 +204,24 @@ func WriteBootstrapURL(path, value string) error {
|
|||||||
return fmt.Errorf("检查 Bootstrap 地址文件失败:%w", err)
|
return fmt.Errorf("检查 Bootstrap 地址文件失败:%w", err)
|
||||||
}
|
}
|
||||||
temporary, err := os.CreateTemp(directory, ".bootstrap-url-")
|
temporary, err := os.CreateTemp(directory, ".bootstrap-url-")
|
||||||
if err != nil { return fmt.Errorf("创建 Bootstrap 临时地址文件失败:%w", err) }
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 Bootstrap 临时地址文件失败:%w", err)
|
||||||
|
}
|
||||||
temporaryPath := temporary.Name()
|
temporaryPath := temporary.Name()
|
||||||
defer os.Remove(temporaryPath)
|
defer os.Remove(temporaryPath)
|
||||||
defer temporary.Close()
|
defer temporary.Close()
|
||||||
if _, err := temporary.WriteString(value+"\n"); err != nil {
|
if _, err := temporary.WriteString(value + "\n"); err != nil {
|
||||||
return fmt.Errorf("写入 Bootstrap 临时地址文件失败:%w", err)
|
return fmt.Errorf("写入 Bootstrap 临时地址文件失败:%w", err)
|
||||||
}
|
}
|
||||||
if err := temporary.Chmod(0o600); err != nil {
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
return fmt.Errorf("设置 Bootstrap 临时地址文件权限失败:%w", err)
|
return fmt.Errorf("设置 Bootstrap 临时地址文件权限失败:%w", err)
|
||||||
}
|
}
|
||||||
if err := temporary.Sync(); err != nil { return fmt.Errorf("同步 Bootstrap 地址文件失败:%w", err) }
|
if err := temporary.Sync(); err != nil {
|
||||||
if err := temporary.Close(); err != nil { return fmt.Errorf("关闭 Bootstrap 地址文件失败:%w", err) }
|
return fmt.Errorf("同步 Bootstrap 地址文件失败:%w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return fmt.Errorf("关闭 Bootstrap 地址文件失败:%w", err)
|
||||||
|
}
|
||||||
if err := os.Rename(temporaryPath, path); err != nil {
|
if err := os.Rename(temporaryPath, path); err != nil {
|
||||||
_ = os.Remove(temporaryPath)
|
_ = os.Remove(temporaryPath)
|
||||||
return fmt.Errorf("替换 Bootstrap 地址文件失败:%w", err)
|
return fmt.Errorf("替换 Bootstrap 地址文件失败:%w", err)
|
||||||
|
|||||||
@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/app"
|
"git.pcux.cn/xuans/Scert/backend/internal/app"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
|
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
|
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
|
||||||
@ -20,9 +20,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
core *app.App
|
core *app.App
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
initial http.Handler
|
initial http.Handler
|
||||||
initialDNS *setup.DNSVerificationManager
|
initialDNS *setup.DNSVerificationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +54,10 @@ func Open(configPath, baseDir string) (*Runtime, error) {
|
|||||||
if !cfg.SetupReady {
|
if !cfg.SetupReady {
|
||||||
// 桌面端保留本机向导,但不再为初始化预先创建正式数据库和后台任务。
|
// 桌面端保留本机向导,但不再为初始化预先创建正式数据库和后台任务。
|
||||||
store, err := secretstore.Open(cfg.Paths.Config)
|
store, err := secretstore.Open(cfg.Paths.Config)
|
||||||
if err != nil { cancel(); return nil, err }
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store)
|
dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store)
|
||||||
issuer := certificate.NewService(cfg, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store))
|
issuer := certificate.NewService(cfg, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store))
|
||||||
issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
|
issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
|
||||||
@ -84,7 +87,9 @@ func Open(configPath, baseDir string) (*Runtime, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runtime) Handler() http.Handler {
|
func (r *Runtime) Handler() http.Handler {
|
||||||
if r != nil && r.initial != nil { return r.initial }
|
if r != nil && r.initial != nil {
|
||||||
|
return r.initial
|
||||||
|
}
|
||||||
if r == nil || r.core == nil || r.core.HTTP == nil {
|
if r == nil || r.core == nil || r.core.HTTP == nil {
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable)
|
http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable)
|
||||||
@ -100,7 +105,9 @@ func (r *Runtime) Close() error {
|
|||||||
if r.cancel != nil {
|
if r.cancel != nil {
|
||||||
r.cancel()
|
r.cancel()
|
||||||
}
|
}
|
||||||
if r.initialDNS != nil { r.initialDNS.DiscardAll() }
|
if r.initialDNS != nil {
|
||||||
|
r.initialDNS.DiscardAll()
|
||||||
|
}
|
||||||
if r.core != nil {
|
if r.core != nil {
|
||||||
return r.core.Close()
|
return r.core.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,12 @@ func TestOpenCreatesUninitializedRuntime(t *testing.T) {
|
|||||||
t.Fatalf("打开桌面核心服务失败:%v", err)
|
t.Fatalf("打开桌面核心服务失败:%v", err)
|
||||||
}
|
}
|
||||||
defer runtime.Close()
|
defer runtime.Close()
|
||||||
if runtime.core != nil { t.Fatal("桌面首次初始化不应创建正式应用") }
|
if runtime.core != nil {
|
||||||
if _, err := os.Stat(defaultConfig(baseDir).Database.DSN); !os.IsNotExist(err) { t.Fatalf("桌面初始化提前创建数据库:%v", err) }
|
t.Fatal("桌面首次初始化不应创建正式应用")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(defaultConfig(baseDir).Database.DSN); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("桌面初始化提前创建数据库:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/v1/setup/status", nil)
|
request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/v1/setup/status", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test src/views/SetupWizard/SetupWizard.test.js src/views/Dashboard/Dashboard.test.js",
|
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@ -71,7 +71,13 @@ export function useAppShell() {
|
|||||||
|
|
||||||
function handleInitialized(result) {
|
function handleInitialized(result) {
|
||||||
if (result?.completed && result?.management_url) {
|
if (result?.completed && result?.management_url) {
|
||||||
const destination = new URL(result.management_url);
|
let destination;
|
||||||
|
try {
|
||||||
|
destination = new URL(result.management_url);
|
||||||
|
} catch {
|
||||||
|
error.value = "正式管理地址无效,请检查服务器配置。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (destination.protocol !== "https:" || destination.username || destination.password) {
|
if (destination.protocol !== "https:" || destination.username || destination.password) {
|
||||||
error.value = "正式管理地址无效,请检查服务器配置。";
|
error.value = "正式管理地址无效,请检查服务器配置。";
|
||||||
return;
|
return;
|
||||||
|
|||||||
60
frontend/src/App.test.js
Normal file
60
frontend/src/App.test.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { useAppShell } from "./App.js";
|
||||||
|
import { mountComposable } from "./test-support/mount.js";
|
||||||
|
|
||||||
|
test("初始化令牌兑换前清除地址片段,后续请求只用同源会话", async (t) => {
|
||||||
|
const calls = [];
|
||||||
|
const { state } = await mountComposable(t, () => {
|
||||||
|
window.location.hash = "#token=test-bootstrap-token";
|
||||||
|
window.history.replaceState = (_, __, url) => calls.push({ replace: url });
|
||||||
|
return useAppShell();
|
||||||
|
}, async (path, options) => {
|
||||||
|
calls.push({ path, options });
|
||||||
|
if (path.endsWith("/status")) return Response.json({ mode: "bootstrap" });
|
||||||
|
if (path.endsWith("/session")) return Response.json({ authenticated: true });
|
||||||
|
return Response.json({ mode: "bootstrap", initialized: false });
|
||||||
|
});
|
||||||
|
assert.equal(state.error.value, "");
|
||||||
|
assert.equal(state.setupState.value.mode, "bootstrap");
|
||||||
|
assert.equal(calls[1].replace, "/");
|
||||||
|
assert.equal(calls[2].path, "/api/v1/bootstrap/session");
|
||||||
|
assert.equal(calls[2].options.credentials, "same-origin");
|
||||||
|
assert.deepEqual(JSON.parse(calls[2].options.body), { token: "test-bootstrap-token" });
|
||||||
|
assert.equal(calls[3].path, "/api/v1/bootstrap/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("没有令牌和有效会话时不暴露初始化表单", async (t) => {
|
||||||
|
const { state } = await mountComposable(t, useAppShell, async (path) => path.endsWith("/status")
|
||||||
|
? Response.json({ mode: "bootstrap" }) : new Response(null, { status: 401 }));
|
||||||
|
assert.equal(state.setupState.value, null);
|
||||||
|
assert.match(state.error.value, /完整一次性地址/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("正式服务回退状态接口并检查登录态", async (t) => {
|
||||||
|
const calls = [];
|
||||||
|
const { state } = await mountComposable(t, useAppShell, async (path) => {
|
||||||
|
calls.push(path);
|
||||||
|
if (path.includes("bootstrap")) return new Response(null, { status: 404 });
|
||||||
|
if (path.includes("setup")) return Response.json({ initialized: true });
|
||||||
|
return new Response(null, { status: 401 });
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, ["/api/v1/bootstrap/status", "/api/v1/setup/status", "/api/v1/auth/session"]);
|
||||||
|
assert.equal(state.authenticated.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完成跳转拒绝畸形地址、非 HTTPS 和带密码地址", async (t) => {
|
||||||
|
const { state } = await mountComposable(t, () => {
|
||||||
|
window.location.pathname = "/login";
|
||||||
|
return useAppShell();
|
||||||
|
}, () => { throw new Error("不应发送请求"); });
|
||||||
|
for (const address of ["invalid", "http://example.test", "https://owner:password@example.test"]) {
|
||||||
|
assert.doesNotThrow(() => state.handleInitialized({ completed: true, management_url: address }));
|
||||||
|
assert.match(state.error.value, /正式管理地址无效/);
|
||||||
|
}
|
||||||
|
let destination;
|
||||||
|
window.setTimeout = (callback) => callback();
|
||||||
|
window.location.assign = (url) => { destination = url; };
|
||||||
|
state.handleInitialized({ completed: true, management_url: "https://127.0.0.1:8443/" });
|
||||||
|
assert.equal(destination, "https://127.0.0.1:8443/");
|
||||||
|
});
|
||||||
@ -43,7 +43,10 @@ export async function mountComposable(t, factory, fetcher) {
|
|||||||
clearInterval(intervalID) {
|
clearInterval(intervalID) {
|
||||||
intervals.delete(intervalID);
|
intervals.delete(intervalID);
|
||||||
},
|
},
|
||||||
location: { assign() {} },
|
location: { pathname: "/", search: "", hash: "", assign() {} },
|
||||||
|
history: { replaceState() {} },
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
},
|
},
|
||||||
document: { body: { style: { overflow: "" } } },
|
document: { body: { style: { overflow: "" } } },
|
||||||
})) {
|
})) {
|
||||||
|
|||||||
24
frontend/src/views/SetupWizard/BootstrapPayload.test.js
Normal file
24
frontend/src/views/SetupWizard/BootstrapPayload.test.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { bootstrapPayload } from "./BootstrapPayload.js";
|
||||||
|
|
||||||
|
test("初始化只提交服务端验证令牌,通知字段保持兼容且不带凭据引用", () => {
|
||||||
|
const payload = bootstrapPayload({
|
||||||
|
admin_username: "owner", dns_verification_token: "test-dns-token",
|
||||||
|
dns_credential_ref: "secret://untrusted/dns", certificate_root: "/untrusted",
|
||||||
|
email_http_enabled: true, email_http_credential_ref: "secret://untrusted/mail",
|
||||||
|
email_http_credentials: { token: "test-notification-token" },
|
||||||
|
email_smtp_password: "test-notification-password", email_retry_count: 0,
|
||||||
|
recipients: ["owner@example.test"],
|
||||||
|
}, "test-database-token", "staging");
|
||||||
|
assert.equal(payload.database_verification_token, "test-database-token");
|
||||||
|
assert.equal(payload.dns_verification_token, "test-dns-token");
|
||||||
|
assert.equal(payload.acme_environment, "staging");
|
||||||
|
assert.equal("certificate_root" in payload, false);
|
||||||
|
assert.equal("dns_credential_ref" in payload, false);
|
||||||
|
assert.equal("http_credential_ref" in payload.notification, false);
|
||||||
|
assert.deepEqual(payload.notification.http_credentials, { token: "test-notification-token" });
|
||||||
|
assert.equal(payload.notification.smtp_password, "test-notification-password");
|
||||||
|
assert.equal(payload.notification.retry_count, 0);
|
||||||
|
assert.deepEqual(payload.notification.recipients, ["owner@example.test"]);
|
||||||
|
});
|
||||||
88
frontend/src/views/SetupWizard/DatabaseSetup.css
Normal file
88
frontend/src/views/SetupWizard/DatabaseSetup.css
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
.database-setup {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e4ebf5;
|
||||||
|
color: #32405a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-section-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d5deeb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #172033;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-test-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid #315faa;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #315faa;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid #315faa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="status"] {
|
||||||
|
color: #26754f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.setup-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,15 @@
|
|||||||
import { computed, onUnmounted, ref, watch } from "vue";
|
import { computed, onUnmounted, ref, watch } from "vue";
|
||||||
|
|
||||||
export function useDatabaseSetup(clock) {
|
export function useDatabaseSetup(clock) {
|
||||||
const databaseForm = ref({ driver: "sqlite", host: "", port: 3306, database: "scert", username: "", password: "", root_certificate: "" });
|
const databaseForm = ref({
|
||||||
|
driver: "sqlite",
|
||||||
|
host: "",
|
||||||
|
port: 3306,
|
||||||
|
database: "scert",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
root_certificate: "",
|
||||||
|
});
|
||||||
const databaseTesting = ref(false);
|
const databaseTesting = ref(false);
|
||||||
const databaseError = ref("");
|
const databaseError = ref("");
|
||||||
const databaseVerification = ref(null);
|
const databaseVerification = ref(null);
|
||||||
@ -38,7 +46,8 @@ export function useDatabaseSetup(clock) {
|
|||||||
});
|
});
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (current !== version) return;
|
if (current !== version) return;
|
||||||
if (!response.ok || !result.verified || !result.verification_token) {
|
if (!response.ok || result.verified !== true || !result.verification_token ||
|
||||||
|
result.driver !== payload.driver || !(new Date(result.expires_at).getTime() > Date.now())) {
|
||||||
throw new Error(result.error || "数据库连接和写权限验证失败。");
|
throw new Error(result.error || "数据库连接和写权限验证失败。");
|
||||||
}
|
}
|
||||||
databaseVerification.value = result;
|
databaseVerification.value = result;
|
||||||
|
|||||||
96
frontend/src/views/SetupWizard/DatabaseSetup.test.js
Normal file
96
frontend/src/views/SetupWizard/DatabaseSetup.test.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useDatabaseSetup } from "./DatabaseSetup.js";
|
||||||
|
import { deferred, mountComposable } from "../../test-support/mount.js";
|
||||||
|
|
||||||
|
function verified(driver = "sqlite", token = "test-database-token") {
|
||||||
|
return Response.json({
|
||||||
|
driver,
|
||||||
|
verified: true,
|
||||||
|
verification_token: token,
|
||||||
|
expires_at: new Date(Date.now() + 60000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const driver of ["sqlite", "mysql", "postgres"]) {
|
||||||
|
test(`数据库 ${driver} 使用结构化参数与严格 TLS`, async (t) => {
|
||||||
|
const requests = [];
|
||||||
|
const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), async (path, options) => {
|
||||||
|
requests.push({ path, payload: JSON.parse(options.body), credentials: options.credentials });
|
||||||
|
return verified(driver);
|
||||||
|
});
|
||||||
|
state.databaseForm.value.driver = driver;
|
||||||
|
Object.assign(state.databaseForm.value, {
|
||||||
|
host: "db.example.test", username: "test-user", password: "test-password",
|
||||||
|
});
|
||||||
|
await state.testDatabase();
|
||||||
|
assert.equal(state.databaseVerified.value, true);
|
||||||
|
assert.equal(requests[0].path, "/api/v1/bootstrap/database/test");
|
||||||
|
assert.equal(requests[0].credentials, "same-origin");
|
||||||
|
if (driver === "sqlite") {
|
||||||
|
assert.deepEqual(requests[0].payload, { driver: "sqlite" });
|
||||||
|
} else {
|
||||||
|
assert.equal(requests[0].payload.port, driver === "mysql" ? 3306 : 5432);
|
||||||
|
assert.equal(requests[0].payload.tls_mode, driver === "mysql" ? "true" : "verify-full");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("数据库输入变化立即作废令牌并忽略旧响应", async (t) => {
|
||||||
|
const pending = deferred();
|
||||||
|
const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => pending.promise);
|
||||||
|
const task = state.testDatabase();
|
||||||
|
state.databaseForm.value.password = "changed-test-password";
|
||||||
|
pending.resolve(verified());
|
||||||
|
await task;
|
||||||
|
assert.equal(state.databaseVerified.value, false);
|
||||||
|
assert.equal(state.databaseVerification.value, null);
|
||||||
|
assert.equal(state.databaseTesting.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("数据库旧失败不覆盖新成功,卸载后不接受在途结果", async (t) => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const third = deferred();
|
||||||
|
const pending = [first, second, third];
|
||||||
|
const { state, unmount } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => pending.shift().promise);
|
||||||
|
const oldTask = state.testDatabase();
|
||||||
|
const newTask = state.testDatabase();
|
||||||
|
second.resolve(verified());
|
||||||
|
await newTask;
|
||||||
|
first.reject(new Error("旧请求失败"));
|
||||||
|
await oldTask;
|
||||||
|
assert.equal(state.databaseVerified.value, true);
|
||||||
|
assert.equal(state.databaseError.value, "");
|
||||||
|
const closingTask = state.testDatabase();
|
||||||
|
unmount();
|
||||||
|
third.resolve(verified());
|
||||||
|
await closingTask;
|
||||||
|
assert.equal(state.databaseVerification.value, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("数据库令牌到期后不可继续提交", async (t) => {
|
||||||
|
const clock = ref(Date.now());
|
||||||
|
const { state, advanceTime } = await mountComposable(t, () => useDatabaseSetup(clock), () => verified());
|
||||||
|
await state.testDatabase();
|
||||||
|
assert.equal(state.databaseVerified.value, true);
|
||||||
|
advanceTime(60001);
|
||||||
|
clock.value = Date.now();
|
||||||
|
assert.equal(state.databaseVerified.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("拒绝数据库类型不匹配、非法有效期与畸形响应", async (t) => {
|
||||||
|
const responses = [
|
||||||
|
() => verified("mysql"),
|
||||||
|
() => Response.json({ verified: true, driver: "sqlite", verification_token: "test", expires_at: "invalid" }),
|
||||||
|
() => Response.json({ verified: "true", driver: "sqlite", verification_token: "test" }),
|
||||||
|
() => new Response("not-json", { status: 502 }),
|
||||||
|
];
|
||||||
|
const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => responses.shift()());
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
await state.testDatabase();
|
||||||
|
assert.equal(state.databaseVerified.value, false);
|
||||||
|
assert.notEqual(state.databaseError.value, "");
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -24,11 +24,26 @@ const emit = defineEmits(["test"]);
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="form.driver !== 'sqlite'">
|
<template v-if="form.driver !== 'sqlite'">
|
||||||
<label><span>数据库主机</span><input v-model.trim="form.host" autocomplete="off" placeholder="db.example.com" /></label>
|
<label>
|
||||||
<label><span>数据库端口</span><input v-model.number="form.port" type="number" min="1" max="65535" /></label>
|
<span>数据库主机</span>
|
||||||
<label><span>数据库名称</span><input v-model.trim="form.database" autocomplete="off" /></label>
|
<input v-model.trim="form.host" autocomplete="off" placeholder="db.example.com" />
|
||||||
<label><span>数据库用户名</span><input v-model.trim="form.username" autocomplete="off" /></label>
|
</label>
|
||||||
<label><span>数据库密码</span><input v-model="form.password" type="password" autocomplete="new-password" /></label>
|
<label>
|
||||||
|
<span>数据库端口</span>
|
||||||
|
<input v-model.number="form.port" type="number" min="1" max="65535" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>数据库名称</span>
|
||||||
|
<input v-model.trim="form.database" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>数据库用户名</span>
|
||||||
|
<input v-model.trim="form.username" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>数据库密码</span>
|
||||||
|
<input v-model="form.password" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
<label v-if="form.driver === 'postgres'">
|
<label v-if="form.driver === 'postgres'">
|
||||||
<span>根证书路径(服务器绝对路径,可选)</span>
|
<span>根证书路径(服务器绝对路径,可选)</span>
|
||||||
<input v-model.trim="form.root_certificate" placeholder="使用系统信任库时留空" />
|
<input v-model.trim="form.root_certificate" placeholder="使用系统信任库时留空" />
|
||||||
@ -45,3 +60,5 @@ const emit = defineEmits(["test"]);
|
|||||||
<p v-if="error" class="inline-error" role="alert">{{ error }}</p>
|
<p v-if="error" class="inline-error" role="alert">{{ error }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./DatabaseSetup.css"></style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user