feat: configure HTTP/HTTPS public entry and managed certificates

This commit is contained in:
Codex committed 2026-10-26 14:06:41 +08:00
1 parent 35596cda9d
commit 88ea6b7854
21 files changed
+1309 -149

No files matched your search

+97 -9
View File
@@ -40,9 +40,13 @@ func Stage() string {
if err != nil {
return "none"
}
if strings.Contains(string(data), "# Add only after the certificate exists.") {
body := string(data)
if strings.Contains(body, "# Add only after the certificate exists.") {
return "https"
}
if strings.Contains(body, "# Plain HTTP public entry") || strings.Contains(body, "server_name ") {
return "http"
}
return "http"
}
@@ -59,23 +63,46 @@ func AuthRouteReady(port int) (bool, bool) {
// RefreshAuth updates only the managed site after validation; a bad config is
// rolled back before returning. It never provisions a certificate or domain.
func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error {
status := tlsservice.Status()
scheme := status.Scheme
if scheme == "" {
scheme = "https"
}
return RefreshAuthEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: scheme, Port: status.Port}, routes, authPort)
}
func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int) error {
if Running() {
return errors.New("公网入口操作正在进行")
}
if err := checkSites(); err != nil {
scheme := strings.ToLower(strings.TrimSpace(opts.Scheme))
if scheme == "" {
scheme = "https"
}
opts.Scheme = scheme
if scheme == "http" {
if err := checkSitesHTTP(); err != nil {
return err
}
} else if err := checkSites(); err != nil {
return err
}
if Stage() != "https" {
stage := Stage()
if scheme == "https" && stage != "https" {
return errors.New("请先启用 HTTPS 站点")
}
if scheme == "http" && stage == "none" {
return errors.New("请先启用公网入口站点")
}
previous, err := os.ReadFile(sitePath)
if err != nil {
return err
}
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
if !strings.Contains(string(previous), "server_name "+domain+";") {
return errors.New("现有站点域名与配置不匹配")
}
httpSite, httpsSite, err := nginxconfig.RenderWithAuth(domain, routes, authPort)
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort)
if err != nil {
return err
}
@@ -137,6 +164,33 @@ func writeSite(content string) error {
return os.Rename(f.Name(), sitePath)
}
func checkSitesHTTP() error {
if os.Geteuid() != 0 {
return errors.New("配置入口需要 root 权限")
}
state := nginxsetup.Inspect()
if !state.Nginx {
return errors.New("请先安装 Nginx")
}
entries, err := os.ReadDir(siteDir)
if err != nil {
return err
}
for _, e := range entries {
if e.Name() != "default" && e.Name() != "opsx-vpn.conf" {
return fmt.Errorf("发现其他启用站点 %s,请先由管理员检查冲突", e.Name())
}
}
if entries, err := os.ReadDir("/etc/nginx/conf.d"); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".conf") {
return fmt.Errorf("发现其他 Nginx 配置 %s,请先由管理员检查冲突", e.Name())
}
}
}
return nil
}
func checkSites() error {
if os.Geteuid() != 0 {
return errors.New("配置入口需要 root 权限")
@@ -179,21 +233,39 @@ func checkSites() error {
}
func certificateExists(domain string) bool {
path := filepath.Join("/etc/letsencrypt/live", domain, "fullchain.pem")
_, err := os.Stat(path)
fullchain, _, _ := tlsservice.CertificateFiles(domain)
_, err := os.Stat(fullchain)
return err == nil
}
// Run performs a reversible HTTP bootstrap, certificate issuance, then HTTPS
// activation. On issuance errors the HTTP challenge site remains available for retry.
func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
return RunEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, emit)
}
// RunEntry activates the public entry for the given scheme/port. HTTP skips ACME.
func RunEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, emit func(string)) error {
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
scheme := strings.ToLower(strings.TrimSpace(opts.Scheme))
if scheme == "" {
scheme = "https"
}
opts.Domain, opts.Scheme = domain, scheme
if !tlsservice.ValidDomain(domain) {
return errors.New("请先保存有效域名")
}
if err := checkSites(); err != nil {
if !tlsservice.ValidScheme(scheme) {
return errors.New("协议仅支持 http 或 https")
}
if scheme == "http" {
if err := checkSitesHTTP(); err != nil {
return err
}
} else if err := checkSites(); err != nil {
return err
}
httpSite, httpsSite, err := nginxconfig.Render(domain, routes)
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, 0)
if err != nil {
return err
}
@@ -209,7 +281,16 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
return errors.New("已有其他域名的入口配置,请人工迁移证书与站点")
}
if readErr != nil {
for _, port := range []string{"80", "443"} {
ports := []string{fmt.Sprintf("%d", tlsservice.EffectivePort(scheme, opts.Port))}
if scheme == "https" && tlsservice.EffectivePort(scheme, opts.Port) != 80 {
ports = append([]string{"80"}, ports...)
}
seen := map[string]bool{}
for _, port := range ports {
if seen[port] {
continue
}
seen[port] = true
listener, e := net.Listen("tcp", "0.0.0.0:"+port)
if e != nil {
return fmt.Errorf("端口 %s 已占用,请先检查监听服务: %w", port, e)
@@ -251,6 +332,10 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
rollback()
return err
}
if scheme == "http" {
emit("HTTP 公网入口已启用(未申请证书)。\n")
return nil
}
emit("HTTP-01 验证站点已启用,开始签发证书。\n")
if !certificateExists(domain) {
if err := command(emit, 4*time.Minute, "certbot", "certonly", "--webroot", "-w", nginxsetup.Webroot,
@@ -286,6 +371,9 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
if err := os.WriteFile(hookPath, []byte(hook), 0o755); err != nil {
return err
}
if _, _, source := tlsservice.CertificateFiles(domain); source == tlsservice.CertSourceHTTP01 {
_ = tlsservice.MarkCertSource(tlsservice.DataDir(), tlsservice.CertSourceHTTP01)
}
emit("HTTPS 入口已启用;Certbot 续签成功后将自动重载 Nginx。\n")
return nil
}