@@ -273,6 +273,8 @@ func (s SiteService) CreateSite(ctx context.Context, opts CreateSiteOptions) err
273273 vhostDefinition := filepath .Join (vhostDir , "vhost.conf" )
274274 serverConfigPath := filepath .Join (s .lswsRoot , "conf" , "httpd_config.conf" )
275275 siteURL := wordPressBaseURL (opts .Domain , opts .WithLE )
276+ mapDomains := mappedListenerDomains (opts .Domain )
277+ isTopLevelDomain := isTopLevelSiteDomain (opts .Domain )
276278 var wpAccess * wpAdminAccess
277279
278280 s .console .Section ("Create site" )
@@ -311,7 +313,7 @@ func (s SiteService) CreateSite(ctx context.Context, opts CreateSiteOptions) err
311313 s .console .Bullet ("write " + vhostConfig )
312314 s .console .Bullet ("write " + vhostDefinition )
313315 s .console .Bullet ("append virtualhost block into " + serverConfigPath )
314- s .console .Bullet ("insert listener map in " + serverConfigPath )
316+ s .console .Bullet ("insert listener map in " + serverConfigPath + " for hosts: " + strings . Join ( mapDomains , ", " ) )
315317 s .console .Bullet ("align ownership to OpenLiteSpeed server user/group for " + siteRoot + " and " + vhostDir )
316318 if opts .OWASPEnabled != nil {
317319 s .console .Bullet ("set virtual-host OWASP mod_security: " + enabledLabel (* opts .OWASPEnabled ))
@@ -336,7 +338,14 @@ func (s SiteService) CreateSite(ctx context.Context, opts CreateSiteOptions) err
336338 s .console .Bullet ("write starter index.php into " + docRoot )
337339 }
338340 if opts .WithLE {
339- s .console .Bullet ("perform domain reachability precheck for Let's Encrypt" )
341+ if isTopLevelDomain {
342+ s .console .Bullet ("perform domain reachability precheck for " + opts .Domain )
343+ s .console .Bullet ("perform optional www reachability precheck for www." + opts .Domain )
344+ s .console .Bullet ("if www precheck passes, issue Let's Encrypt cert for both domains" )
345+ s .console .Bullet ("if www precheck fails, issue Let's Encrypt cert for primary domain only" )
346+ } else {
347+ s .console .Bullet ("skip domain reachability precheck for subdomain LE issuance" )
348+ }
340349 s .console .Bullet ("ensure certbot is installed" )
341350 s .console .Bullet ("issue Let's Encrypt certificate via certbot webroot challenge" )
342351 s .console .Bullet ("write cert/key into vhost SSL config" )
@@ -430,20 +439,33 @@ func (s SiteService) CreateSite(ctx context.Context, opts CreateSiteOptions) err
430439 }
431440
432441 if opts .WithLE {
433- ok , detail := precheckLEDomainReachability (opts .Domain )
434- if ! ok {
435- return apperr .New (apperr .CodeValidation , "Let's Encrypt precheck failed: " + detail )
442+ certDomains := []string {opts .Domain }
443+ if isTopLevelDomain {
444+ ok , detail := precheckLEDomainReachability (opts .Domain )
445+ if ! ok {
446+ return apperr .New (apperr .CodeValidation , "Let's Encrypt precheck failed for " + opts .Domain + ": " + detail )
447+ }
448+ s .console .Success ("Let's Encrypt precheck passed for " + opts .Domain + ": " + detail )
449+
450+ wwwDomain := "www." + opts .Domain
451+ okWWW , detailWWW := precheckLEDomainReachability (wwwDomain )
452+ if okWWW {
453+ certDomains = append (certDomains , wwwDomain )
454+ s .console .Success ("Let's Encrypt precheck passed for " + wwwDomain + ": " + detailWWW )
455+ } else {
456+ s .console .Warn ("Let's Encrypt precheck failed for " + wwwDomain + "; issuing certificate for primary domain only: " + detailWWW )
457+ }
436458 }
437- s .console .Success ("Let's Encrypt precheck passed: domain is reachable over HTTP" )
438459
439- certFile , keyFile , err := s .issueLetsEncryptCertificate (ctx , info , opts . Domain , docRoot )
460+ certFile , keyFile , err := s .issueLetsEncryptCertificate (ctx , info , docRoot , certDomains ... )
440461 if err != nil {
441462 return err
442463 }
443464 if err := applyVHostSSLCertificate (vhostConfig , certFile , keyFile ); err != nil {
444465 return err
445466 }
446467 s .console .Success ("Let's Encrypt certificate issued" )
468+ s .console .Bullet ("Certificate domains: " + strings .Join (certDomains , ", " ))
447469 s .console .Bullet ("Certificate: " + certFile )
448470 s .console .Bullet ("Private key: " + keyFile )
449471
@@ -1082,6 +1104,13 @@ func (s SiteService) ensureDomainDoesNotExist(domain, vhostDir, serverConfigPath
10821104 return apperr .New (apperr .CodeValidation , fmt .Sprintf ("domain %s is already mapped in %s" , domain , serverConfigPath ))
10831105 }
10841106
1107+ if isTopLevelSiteDomain (domain ) {
1108+ wwwDomain := "www." + domain
1109+ if hasDomainMapLine (cfg , wwwDomain ) {
1110+ return apperr .New (apperr .CodeValidation , fmt .Sprintf ("domain %s requires alias %s but alias is already mapped in %s" , domain , wwwDomain , serverConfigPath ))
1111+ }
1112+ }
1113+
10851114 return nil
10861115}
10871116
@@ -1096,17 +1125,18 @@ func (s SiteService) registerDomainInServerConfig(domain, siteRoot, vhostConfigP
10961125 return apperr .New (apperr .CodeValidation , fmt .Sprintf ("domain %s already exists in %s" , domain , serverConfigPath ))
10971126 }
10981127
1128+ mapDomains := mappedListenerDomains (domain )
10991129 updated := strings .TrimRight (cfg , "\n " ) + "\n \n " + buildVHostDefinition (domain , siteRoot , vhostConfigPath ) + "\n "
11001130 lines := strings .Split (updated , "\n " )
11011131 httpListenerName := chooseExistingListenerName (lines , []string {"HTTP" , "Default" }, "HTTP" )
11021132 httpsListenerName := chooseExistingListenerName (lines , []string {"HTTPS" , "SSL" }, "HTTPS" )
11031133
1104- updated , mappedHTTP := ensureDomainMappedInNamedListener (updated , httpListenerName , domain )
1105- updated , mappedHTTPS := ensureDomainMappedInNamedListener (updated , httpsListenerName , domain )
1134+ updated , mappedHTTP := ensureDomainMappedInNamedListener (updated , httpListenerName , domain , mapDomains )
1135+ updated , mappedHTTPS := ensureDomainMappedInNamedListener (updated , httpsListenerName , domain , mapDomains )
11061136
11071137 mappedFallback := false
11081138 if ! mappedHTTP && ! mappedHTTPS {
1109- updated , mappedFallback , err = ensureDomainMappedInFirstListener (updated , domain )
1139+ updated , mappedFallback , err = ensureDomainMappedInFirstListener (updated , domain , mapDomains )
11101140 if err != nil {
11111141 return err
11121142 }
@@ -1118,13 +1148,13 @@ func (s SiteService) registerDomainInServerConfig(domain, siteRoot, vhostConfigP
11181148
11191149 s .console .Bullet ("Registered virtual host in " + serverConfigPath )
11201150 if mappedHTTP {
1121- s .console .Bullet ("Mapped domain in HTTP listener (" + httpListenerName + "): " + domain )
1151+ s .console .Bullet ("Mapped domain in HTTP listener (" + httpListenerName + "): " + strings . Join ( mapDomains , ", " ) )
11221152 }
11231153 if mappedHTTPS {
1124- s .console .Bullet ("Mapped domain in HTTPS listener (" + httpsListenerName + "): " + domain )
1154+ s .console .Bullet ("Mapped domain in HTTPS listener (" + httpsListenerName + "): " + strings . Join ( mapDomains , ", " ) )
11251155 }
11261156 if mappedFallback {
1127- s .console .Bullet ("Mapped domain in first listener: " + domain )
1157+ s .console .Bullet ("Mapped domain in first listener: " + strings . Join ( mapDomains , ", " ) )
11281158 }
11291159 return nil
11301160}
@@ -1140,7 +1170,11 @@ func (s SiteService) removeDomainFromServerConfig(domain, serverConfigPath strin
11401170 if err != nil {
11411171 return err
11421172 }
1143- updated , removedMaps := removeDomainMappings (updated , domain )
1173+ removeDomains := []string {domain }
1174+ if isTopLevelSiteDomain (domain ) {
1175+ removeDomains = append (removeDomains , "www." + domain )
1176+ }
1177+ updated , removedMaps := removeDomainMappings (updated , removeDomains ... )
11441178 if ! removedVHost && ! removedMaps {
11451179 s .console .Warn ("No matching virtualhost/map entries found for " + domain )
11461180 return nil
@@ -1154,40 +1188,44 @@ func (s SiteService) removeDomainFromServerConfig(domain, serverConfigPath strin
11541188 return nil
11551189}
11561190
1157- func ensureDomainMappedInNamedListener (cfg , listenerName , domain string ) (string , bool ) {
1191+ func ensureDomainMappedInNamedListener (cfg , listenerName , domain string , mapDomains [] string ) (string , bool ) {
11581192 lines := strings .Split (cfg , "\n " )
11591193 start , end := findListenerBlockByName (lines , listenerName )
11601194 if start < 0 || end < 0 {
11611195 return cfg , false
11621196 }
11631197
11641198 for i := start ; i <= end ; i ++ {
1165- if mapLineContainsDomain (strings .TrimSpace (lines [i ]), domain ) {
1166- return cfg , false
1199+ for _ , mapDomain := range mapDomains {
1200+ if mapLineContainsDomain (strings .TrimSpace (lines [i ]), mapDomain ) {
1201+ return cfg , false
1202+ }
11671203 }
11681204 }
11691205
11701206 indent := detectMapIndent (lines [start : end + 1 ])
1171- mapLine := fmt .Sprintf ("%smap %s %s" , indent , domain , domain )
1207+ mapLine := fmt .Sprintf ("%smap %s %s" , indent , domain , strings . Join ( mapDomains , ", " ) )
11721208 lines = append (lines [:end ], append ([]string {mapLine }, lines [end :]... )... )
11731209 return strings .Join (lines , "\n " ), true
11741210}
11751211
1176- func ensureDomainMappedInFirstListener (cfg , domain string ) (string , bool , error ) {
1212+ func ensureDomainMappedInFirstListener (cfg , domain string , mapDomains [] string ) (string , bool , error ) {
11771213 lines := strings .Split (cfg , "\n " )
11781214 start , end := findFirstListenerBlock (lines )
11791215 if start < 0 || end < 0 {
11801216 return "" , false , apperr .New (apperr .CodeConfig , "no listener block found in OpenLiteSpeed server config; cannot auto-map domain" )
11811217 }
11821218
11831219 for i := start ; i <= end ; i ++ {
1184- if mapLineContainsDomain (strings .TrimSpace (lines [i ]), domain ) {
1185- return cfg , false , nil
1220+ for _ , mapDomain := range mapDomains {
1221+ if mapLineContainsDomain (strings .TrimSpace (lines [i ]), mapDomain ) {
1222+ return cfg , false , nil
1223+ }
11861224 }
11871225 }
11881226
11891227 indent := detectMapIndent (lines [start : end + 1 ])
1190- mapLine := fmt .Sprintf ("%smap %s %s" , indent , domain , domain )
1228+ mapLine := fmt .Sprintf ("%smap %s %s" , indent , domain , strings . Join ( mapDomains , ", " ) )
11911229 lines = append (lines [:end ], append ([]string {mapLine }, lines [end :]... )... )
11921230 return strings .Join (lines , "\n " ), true , nil
11931231}
@@ -1300,7 +1338,19 @@ func findVirtualHostBlockByName(lines []string, domain string) (int, int) {
13001338 return - 1 , - 1
13011339}
13021340
1303- func removeDomainMappings (cfg , domain string ) (string , bool ) {
1341+ func removeDomainMappings (cfg string , domains ... string ) (string , bool ) {
1342+ removeSet := map [string ]struct {}{}
1343+ for _ , domain := range domains {
1344+ normalized := strings .TrimSpace (strings .ToLower (domain ))
1345+ if normalized == "" {
1346+ continue
1347+ }
1348+ removeSet [normalized ] = struct {}{}
1349+ }
1350+ if len (removeSet ) == 0 {
1351+ return cfg , false
1352+ }
1353+
13041354 lines := strings .Split (cfg , "\n " )
13051355 changed := false
13061356 for i , line := range lines {
@@ -1321,7 +1371,7 @@ func removeDomainMappings(cfg, domain string) (string, bool) {
13211371 if host == "" {
13221372 continue
13231373 }
1324- if strings .EqualFold (host , domain ) {
1374+ if _ , ok := removeSet [ strings .ToLower (host )]; ok {
13251375 removed = true
13261376 continue
13271377 }
@@ -1340,7 +1390,7 @@ func removeDomainMappings(cfg, domain string) (string, bool) {
13401390 if idx := strings .Index (line , "map" ); idx > 0 {
13411391 indent = line [:idx ]
13421392 }
1343- lines [i ] = fmt .Sprintf ("%smap %s %s" , indent , mapName , strings .Join (keptHosts , "," ))
1393+ lines [i ] = fmt .Sprintf ("%smap %s %s" , indent , mapName , strings .Join (keptHosts , ", " ))
13441394 }
13451395 if ! changed {
13461396 return cfg , false
@@ -2097,23 +2147,30 @@ func (s SiteService) ensureCertbotAvailable(ctx context.Context, info platform.I
20972147 return nil
20982148}
20992149
2100- func (s SiteService ) issueLetsEncryptCertificate (ctx context.Context , info platform.Info , domain , webRoot string ) (string , string , error ) {
2150+ func (s SiteService ) issueLetsEncryptCertificate (ctx context.Context , info platform.Info , webRoot string , domains ... string ) (string , string , error ) {
21012151 if err := s .ensureCertbotAvailable (ctx , info ); err != nil {
21022152 return "" , "" , err
21032153 }
21042154
2105- res , err := s .runner .Run (
2106- ctx ,
2107- "certbot" ,
2155+ certDomains := uniqueNormalizedDomains (domains )
2156+ if len (certDomains ) == 0 {
2157+ return "" , "" , apperr .New (apperr .CodeValidation , "at least one domain is required for Let's Encrypt issuance" )
2158+ }
2159+
2160+ certbotArgs := []string {
21082161 "certonly" ,
21092162 "--non-interactive" ,
21102163 "--agree-tos" ,
21112164 "--register-unsafely-without-email" ,
21122165 "--keep-until-expiring" ,
21132166 "--webroot" ,
21142167 "-w" , webRoot ,
2115- "-d" , domain ,
2116- )
2168+ }
2169+ for _ , domain := range certDomains {
2170+ certbotArgs = append (certbotArgs , "-d" , domain )
2171+ }
2172+
2173+ res , err := s .runner .Run (ctx , "certbot" , certbotArgs ... )
21172174 if err != nil {
21182175 detail := strings .TrimSpace (strings .Join ([]string {res .Stdout , res .Stderr }, "\n " ))
21192176 if detail != "" {
@@ -2122,7 +2179,8 @@ func (s SiteService) issueLetsEncryptCertificate(ctx context.Context, info platf
21222179 return "" , "" , apperr .Wrap (apperr .CodeCommand , "certbot certificate issuance failed" , err )
21232180 }
21242181
2125- certFile , keyFile := letsEncryptCertPaths (domain )
2182+ primaryDomain := certDomains [0 ]
2183+ certFile , keyFile := letsEncryptCertPaths (primaryDomain )
21262184 if ! fileExists (certFile ) || ! fileExists (keyFile ) {
21272185 return "" , "" , apperr .New (
21282186 apperr .CodeCommand ,
@@ -2643,6 +2701,59 @@ func wordPressBaseURL(domain string, secure bool) string {
26432701 return scheme + "://" + strings .TrimSpace (strings .ToLower (domain ))
26442702}
26452703
2704+ func isTopLevelSiteDomain (domain string ) bool {
2705+ d := strings .TrimSpace (strings .ToLower (domain ))
2706+ if d == "" {
2707+ return false
2708+ }
2709+ labels := strings .Split (d , "." )
2710+ if len (labels ) < 2 {
2711+ return false
2712+ }
2713+ if len (labels ) == 2 {
2714+ return true
2715+ }
2716+ if len (labels ) == 3 {
2717+ sld := labels [len (labels )- 2 ]
2718+ tld := labels [len (labels )- 1 ]
2719+ if len (tld ) == 2 {
2720+ switch sld {
2721+ case "ac" , "co" , "com" , "edu" , "gov" , "mil" , "net" , "org" :
2722+ return true
2723+ }
2724+ }
2725+ }
2726+ return false
2727+ }
2728+
2729+ func mappedListenerDomains (domain string ) []string {
2730+ d := strings .TrimSpace (strings .ToLower (domain ))
2731+ if d == "" {
2732+ return nil
2733+ }
2734+ if isTopLevelSiteDomain (d ) {
2735+ return []string {"www." + d , d }
2736+ }
2737+ return []string {d }
2738+ }
2739+
2740+ func uniqueNormalizedDomains (domains []string ) []string {
2741+ out := make ([]string , 0 , len (domains ))
2742+ seen := map [string ]struct {}{}
2743+ for _ , domain := range domains {
2744+ normalized := strings .TrimSpace (strings .ToLower (domain ))
2745+ if normalized == "" {
2746+ continue
2747+ }
2748+ if _ , ok := seen [normalized ]; ok {
2749+ continue
2750+ }
2751+ seen [normalized ] = struct {}{}
2752+ out = append (out , normalized )
2753+ }
2754+ return out
2755+ }
2756+
26462757func (s SiteService ) createWordPressDatabase (ctx context.Context , dbName , dbUser , dbPassword string ) error {
26472758 sql := fmt .Sprintf (
26482759 "CREATE DATABASE IF NOT EXISTS `%s`; CREATE USER IF NOT EXISTS '%s'@'localhost' IDENTIFIED BY '%s'; GRANT ALL PRIVILEGES ON `%s`.* TO '%s'@'localhost'; FLUSH PRIVILEGES;" ,
0 commit comments