Scert/backend/internal/bootstrap/server.go

236 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package bootstrap
import (
"context"
"crypto/sha256"
"crypto/tls"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"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/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
type ServerOptions struct {
Config config.Config
ConfigPath string
PublicIP string
URLPath string
Output io.Writer
FirewallMode string
}
// Run 只创建初始化所需组件;正式数据库、业务路由和后台任务由切换后的应用创建。
func Run(ctx context.Context, options ServerOptions) (bool, error) {
if options.Config.SetupReady {
return false, errors.New("已经初始化,不能启动临时初始化服务")
}
firewallManager, err := firewall.NewManagerWithMode(options.FirewallMode)
if err != nil {
return false, err
}
material, err := NewTemporaryTLSMaterial(options.PublicIP)
if err != nil {
return false, err
}
sessions, token, err := NewSessionManager()
if err != nil {
return false, err
}
defer sessions.RevokeAll()
ip, err := advertisedBootstrapIP(options.PublicIP, material.IPAddresses)
if err != nil {
return false, err
}
address, err := bootstrapURL(ip, options.Config.Server.Port, token)
if err != nil {
return false, err
}
store, err := secretstore.Open(options.Config.Paths.Config)
if err != nil {
return false, fmt.Errorf("打开初始化敏感信息存储失败:%w", err)
}
databases := NewDatabaseVerificationManager(options.Config, store)
dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store)
// 服务完全停止后再清理未领取的凭据,避免在途验证重新留下临时凭据。
defer databases.DiscardAllExcept()
defer dns.DiscardAll()
issuer := certificate.NewService(options.Config, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store))
issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
completer := NewCompleter(CompleteDependencies{
BaseConfig: options.Config, ConfigPath: options.ConfigPath, BootstrapURLPath: options.URLPath,
DatabaseSessions: databases, DNSSessions: dns, Credentials: store, Sessions: sessions,
Firewall: firewallManager, Certificate: issuer,
Warn: func(message string) {
if options.Output != nil {
fmt.Fprintln(options.Output, message)
}
},
})
router := NewRouter(sessions, databases, dns, completer)
handler, closeFiles := bootstrapHandler(router, options.Config)
defer closeFiles()
listener, err := net.Listen("tcp", net.JoinHostPort(options.Config.Server.Host, strconv.Itoa(options.Config.Server.Port)))
if err != nil {
return false, fmt.Errorf("监听初始化端口失败:%w", err)
}
defer listener.Close()
if err := WriteBootstrapURL(options.URLPath, address); err != nil {
return false, err
}
defer func() {
if err := os.Remove(options.URLPath); err != nil && !errors.Is(err, os.ErrNotExist) && options.Output != nil {
fmt.Fprintln(options.Output, "初始化地址文件清理失败,请检查运行目录权限。")
}
}()
if options.Output != nil {
writeBootstrapNotice(options.Output, options.Config.Server.Port, options.URLPath, material)
if strings.EqualFold(strings.TrimSpace(options.FirewallMode), firewall.ModeExternal) {
fmt.Fprintln(options.Output, "初始化防火墙采用 external 模式:不检查或修改主机规则及云安全组,请管理员预先放行所需端口。")
}
}
return serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed())
}
func writeBootstrapNotice(output io.Writer, port int, path string, material TLSMaterial) {
// 指纹是公开证书摘要;一次性令牌和私钥仍只能留在受保护文件或内存中。
fingerprint := sha256.Sum256(material.Certificate.Certificate[0])
fmt.Fprintf(output, "首次初始化 HTTPS 服务已启动,端口 %d。请读取 %s 获取一次性访问地址。\n", port, path)
fmt.Fprintf(output, "Bootstrap 临时证书 SHA-256 指纹:%X\n请通过可信运维通道与浏览器证书详情核对;指纹不一致时不要输入初始化令牌、密码或 RAM 凭据。\n", fingerprint)
}
func serveTemporaryHTTPS(ctx context.Context, listener net.Listener, material TLSMaterial, handler http.Handler, completed <-chan struct{}) (bool, error) {
requestContext, cancelRequests := context.WithCancel(context.Background())
defer cancelRequests()
var active sync.WaitGroup
server := &http.Server{
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
active.Add(1)
defer active.Done()
handler.ServeHTTP(writer, request)
}),
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 * 1024,
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{material.Certificate}},
BaseContext: func(net.Listener) context.Context { return requestContext },
}
errorsChannel := make(chan error, 1)
go func() { errorsChannel <- server.ServeTLS(listener, "", "") }()
success := false
var serveErr error
select {
case <-completed:
success = true
case <-ctx.Done():
cancelRequests()
case serveErr = <-errorsChannel:
cancelRequests()
}
shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelShutdown()
shutdownErr := server.Shutdown(shutdownContext)
if shutdownErr != nil {
cancelRequests()
_ = server.Close()
}
// Shutdown 已停止接收新请求;等待在途处理完成后才能清理其凭据。
active.Wait()
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return false, serveErr
}
if shutdownErr != nil {
return false, fmt.Errorf("关闭初始化服务失败:%w", shutdownErr)
}
return success, nil
}
func bootstrapHandler(router *Router, cfg config.Config) (http.Handler, func()) {
root, _ := os.OpenRoot(filepath.Join(cfg.Paths.Install, "web"))
closeFiles := func() {
if root != nil {
_ = root.Close()
}
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
setBootstrapSecurityHeaders(writer)
if request.URL.Path == "/healthz" || request.URL.Path == "/readyz" {
if !requireBootstrapMethod(writer, request, http.MethodGet) {
return
}
// 初始化期间只报告进程存活,正式应用接管之前始终拒绝就绪。
if request.URL.Path == "/readyz" {
writeBootstrapJSON(writer, http.StatusServiceUnavailable, map[string]string{
"status": "not_ready", "service": "scert", "reason": "setup_incomplete",
})
} else {
writeBootstrapJSON(writer, http.StatusOK, map[string]string{"status": "ok", "service": "scert"})
}
return
}
if request.URL.Path == "/api/v1/bootstrap/settings" {
if !requireBootstrapMethod(writer, request, http.MethodGet) || !router.authenticate(writer, request) {
return
}
writeBootstrapJSON(writer, http.StatusOK, map[string]any{
"mode": "bootstrap", "initialized": false, "server_port": cfg.Server.Port,
"web_enabled": true, "web_hostname": cfg.Web.Hostname, "web_public_ip": cfg.Web.PublicIP,
"https_mode": cfg.Server.HTTPSMode, "proxy_https_port": cfg.Server.ProxyHTTPSPort, "certificate_root": cfg.Paths.Certificate,
"dns_provider": dnsprovider.Aliyun, "acme_environment": cfg.ACME.Environment,
"propagation_timeout_secs": cfg.ACME.PropagationTimeoutSecs,
"propagation_polling_interval_secs": cfg.ACME.PropagationPollingIntervalSecs,
})
return
}
path := request.URL.Path
if strings.HasPrefix(path, "/api/") {
router.ServeHTTP(writer, request)
return
}
if request.Method != http.MethodGet && request.Method != http.MethodHead {
http.NotFound(writer, request)
return
}
name := strings.TrimPrefix(path, "/")
if path == "/" {
name = "index.html"
} else if path != "/Scert.png" && path != "/favicon.ico" && !strings.HasPrefix(path, "/assets/") {
http.NotFound(writer, request)
return
}
if !fs.ValidPath(name) {
writeBootstrapError(writer, http.StatusBadRequest, "资源路径无效")
return
}
if root == nil {
http.Error(writer, "初始化页面资源尚未安装,请检查 web 目录。", http.StatusServiceUnavailable)
return
}
// os.Root 限制符号链接逃逸,不暴露配置目录或任意业务文件。
file, err := root.Open(name)
if err != nil {
writeBootstrapError(writer, http.StatusNotFound, "资源不存在")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
writeBootstrapError(writer, http.StatusNotFound, "资源不存在")
return
}
http.ServeContent(writer, request, info.Name(), info.ModTime(), file)
}), closeFiles
}