From abc7c0d93359fe54a9eac3506881103f4330a357 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 18:42:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AF=81=E4=B9=A6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=AD=BE=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 2 +- service/xboard/service.go | 145 ++++++++++++++++++++++++++++++++- service/xboard/service_test.go | 67 +++++++++++++++ 3 files changed, 209 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 5a08a386..11b9a199 100644 --- a/install.sh +++ b/install.sh @@ -89,7 +89,7 @@ build_sing_box() { # Build params from Makefile VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") # Reduced tags for safer build on smaller servers - TAGS="with_quic,with_utls,with_clash_api,with_gvisor" + TAGS="with_quic,with_utls,with_clash_api,with_gvisor,with_acme" echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}" echo -e "${YELLOW}Note: Using -p 1 to save memory and avoid silent crashes.${NC}" diff --git a/service/xboard/service.go b/service/xboard/service.go index 149b2684..40df7cd0 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -184,6 +184,9 @@ type XInnerConfig struct { StreamSettings json.RawMessage `json:"streamSettings"` UpMbps int `json:"up_mbps"` DownMbps int `json:"down_mbps"` + CertConfig *XCertConfig `json:"cert_config,omitempty"` + AutoTLS bool `json:"auto_tls,omitempty"` + Domain string `json:"domain,omitempty"` } type XMultiplexConfig struct { @@ -438,6 +441,94 @@ func applyCertConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConf } } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mergedCertConfig(inner XInnerConfig, config *XNodeConfig) *XCertConfig { + if inner.CertConfig != nil { + return inner.CertConfig + } + if config != nil { + return config.CertConfig + } + return nil +} + +func hasUsableServerTLS(tlsOptions option.InboundTLSOptions) bool { + return tlsOptions.CertificateProvider != nil || + tlsOptions.ACME != nil && len(tlsOptions.ACME.Domain) > 0 || + (tlsOptions.CertificatePath != "" && tlsOptions.KeyPath != "") || + (len(tlsOptions.Certificate) > 0 && len(tlsOptions.Key) > 0) +} + +func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool { + if !autoTLS && certConfig == nil { + return false + } + mode := "" + if certConfig != nil { + mode = strings.ToLower(strings.TrimSpace(certConfig.CertMode)) + } + if mode == "" && autoTLS { + mode = "http" + } + if mode != "http" && mode != "dns" { + return false + } + domain = strings.TrimSpace(domain) + if domain == "" { + return false + } + + acmeOptions := &option.InboundACMEOptions{ + Domain: badoption.Listable[string]{domain}, + DataDirectory: "acme", + DefaultServerName: domain, + DisableHTTPChallenge: true, + } + if certConfig != nil { + acmeOptions.Email = strings.TrimSpace(certConfig.Email) + } + if listenPort > 0 && listenPort != 443 { + acmeOptions.AlternativeTLSPort = uint16(listenPort) + } + if mode == "dns" && certConfig != nil { + dnsProvider := strings.ToLower(strings.TrimSpace(certConfig.DNSProvider)) + if dnsProvider == "" { + return false + } + acmeOptions.DisableHTTPChallenge = true + acmeOptions.DisableTLSALPNChallenge = true + dns01 := &option.ACMEDNS01ChallengeOptions{Provider: dnsProvider} + switch dnsProvider { + case C.DNSProviderCloudflare: + dns01.CloudflareOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["CF_API_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_API_TOKEN"]) + dns01.CloudflareOptions.ZoneToken = firstNonEmpty(certConfig.DNSEnv["CF_ZONE_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_ZONE_TOKEN"]) + case C.DNSProviderAliDNS: + dns01.AliDNSOptions.AccessKeyID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_ID"], certConfig.DNSEnv["ALI_ACCESS_KEY_ID"]) + dns01.AliDNSOptions.AccessKeySecret = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_SECRET"], certConfig.DNSEnv["ALI_ACCESS_KEY_SECRET"]) + dns01.AliDNSOptions.RegionID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_REGION_ID"], certConfig.DNSEnv["ALI_REGION_ID"]) + dns01.AliDNSOptions.SecurityToken = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_SECURITY_TOKEN"], certConfig.DNSEnv["ALI_SECURITY_TOKEN"]) + case C.DNSProviderACMEDNS: + dns01.ACMEDNSOptions.Username = certConfig.DNSEnv["ACMEDNS_USERNAME"] + dns01.ACMEDNSOptions.Password = certConfig.DNSEnv["ACMEDNS_PASSWORD"] + dns01.ACMEDNSOptions.Subdomain = certConfig.DNSEnv["ACMEDNS_SUBDOMAIN"] + dns01.ACMEDNSOptions.ServerURL = certConfig.DNSEnv["ACMEDNS_SERVER_URL"] + default: + return false + } + acmeOptions.DNS01Challenge = dns01 + } + tlsOptions.ACME = acmeOptions + return true +} + func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { tlsSettings := inner.TLSSettings if tlsSettings == nil { @@ -880,6 +971,15 @@ func (s *Service) setupNode() error { if len(inner.NetworkSettings_) == 0 { inner.NetworkSettings_ = config.NetworkSettings_ } + if inner.CertConfig == nil { + inner.CertConfig = config.CertConfig + } + if !inner.AutoTLS { + inner.AutoTLS = config.AutoTLS + } + if inner.Domain == "" { + inner.Domain = config.Domain + } // Resolve protocol protocol := inner.Protocol @@ -950,9 +1050,31 @@ func (s *Service) setupNode() error { if tlsSettings != nil && tlsSettings.ServerName != "" { s.vlessServerName = tlsSettings.ServerName } - hasCertificate := applyCertConfig(&tlsOptions, config.CertConfig) - if config.CertConfig != nil && !hasCertificate && config.CertConfig.CertMode != "" && config.CertConfig.CertMode != "none" { - s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", config.CertConfig.CertMode) + certConfig := mergedCertConfig(inner, config) + hasCertificate := applyCertConfig(&tlsOptions, certConfig) + configDomain := "" + configAutoTLS := false + if config != nil { + configDomain = config.Domain + configAutoTLS = config.AutoTLS + } + certDomain := "" + if certConfig != nil { + certDomain = certConfig.Domain + } + autoTLSDomain := firstNonEmpty(inner.Domain, configDomain, certDomain) + if autoTLSDomain == "" && tlsSettings != nil { + autoTLSDomain = tlsSettings.ServerName + } + hasACME := false + if !hasCertificate { + hasACME = applyACMEConfig(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port) + } + if certConfig != nil && !hasCertificate && !hasACME && certConfig.CertMode != "" && certConfig.CertMode != "none" { + s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode) + } + if hasACME { + s.logger.Info("Xboard ACME configured for domain ", autoTLSDomain) } switch securityType { @@ -1043,7 +1165,7 @@ func (s *Service) setupNode() error { } } - if securityType == 1 && !tlsOptions.Enabled { + if securityType == 1 && !tlsOptions.Enabled && !hasUsableServerTLS(tlsOptions) { s.logger.Warn("Xboard TLS enabled by panel but no usable certificate material found; inbound will run without local TLS") } @@ -1163,6 +1285,9 @@ func (s *Service) setupNode() error { inboundOptions = ssOptions case "trojan": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("trojan requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // Trojan supports ws/grpc transport like V2bX transport, err := getInboundTransport(networkType, networkSettings) if err != nil { @@ -1181,6 +1306,9 @@ func (s *Service) setupNode() error { } inboundOptions = opts case "tuic": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("tuic requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: TUIC always uses TLS with h3 ALPN tuicTLS := tlsOptions tuicTLS.Enabled = true @@ -1202,6 +1330,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl) case "hysteria": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("hysteria requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: Hysteria always uses TLS hyTLS := tlsOptions hyTLS.Enabled = true @@ -1218,6 +1349,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps) case "hysteria2": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("hysteria2 requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: Hysteria2 always uses TLS, optional obfs hy2TLS := tlsOptions hy2TLS.Enabled = true @@ -1249,6 +1383,9 @@ func (s *Service) setupNode() error { inboundOptions = opts s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth) case "anytls": + if !hasUsableServerTLS(tlsOptions) { + return fmt.Errorf("anytls requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") + } // V2bX: AnyTLS always uses TLS anyTLS := tlsOptions anyTLS.Enabled = true diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index 3372d320..9ae25d48 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" ) func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) { @@ -145,6 +146,72 @@ func TestExpandedNodeTagFallsBackToNodeID(t *testing.T) { } } +func TestApplyACMEConfigFromAutoTLS(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, nil, true, "example.com", 8443) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil { + t.Fatal("ACME options not configured") + } + if len(tlsOptions.ACME.Domain) != 1 || tlsOptions.ACME.Domain[0] != "example.com" { + t.Fatalf("ACME domains = %+v", tlsOptions.ACME.Domain) + } + if tlsOptions.ACME.AlternativeTLSPort != 8443 { + t.Fatalf("AlternativeTLSPort = %d, want 8443", tlsOptions.ACME.AlternativeTLSPort) + } + if !tlsOptions.ACME.DisableHTTPChallenge { + t.Fatal("DisableHTTPChallenge should be true for inline ACME") + } +} + +func TestApplyACMEConfigFromDNSCertMode(t *testing.T) { + var tlsOptions option.InboundTLSOptions + ok := applyACMEConfig(&tlsOptions, &XCertConfig{ + CertMode: "dns", + Domain: "example.com", + DNSProvider: "cloudflare", + DNSEnv: map[string]string{ + "CF_API_TOKEN": "token", + }, + }, false, "example.com", 443) + if !ok { + t.Fatal("applyACMEConfig() returned false") + } + if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil { + t.Fatal("DNS01Challenge not configured") + } + if tlsOptions.ACME.DNS01Challenge.Provider != "cloudflare" { + t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider) + } + if tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken != "token" { + t.Fatalf("Cloudflare API token = %q", tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken) + } + if !tlsOptions.ACME.DisableTLSALPNChallenge { + t.Fatal("DisableTLSALPNChallenge should be true for dns mode") + } +} + +func TestHasUsableServerTLS(t *testing.T) { + if hasUsableServerTLS(option.InboundTLSOptions{}) { + t.Fatal("empty TLS options should not be usable") + } + if !hasUsableServerTLS(option.InboundTLSOptions{ + CertificatePath: "cert.pem", + KeyPath: "key.pem", + }) { + t.Fatal("file certificate should be usable") + } + if !hasUsableServerTLS(option.InboundTLSOptions{ + ACME: &option.InboundACMEOptions{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }) { + t.Fatal("ACME certificate should be usable") + } +} + func TestBuildInboundMultiplex(t *testing.T) { config := &XMultiplexConfig{ Enabled: true,