001/* 002* This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software, please see the 018 * company website: https://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: https://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.letsencrypt; 029 030import org.opencms.json.JSONArray; 031import org.opencms.json.JSONObject; 032import org.opencms.letsencrypt.CmsLetsEncryptConfiguration.Mode; 033import org.opencms.main.CmsLog; 034import org.opencms.report.I_CmsReport; 035import org.opencms.site.CmsSSLMode; 036import org.opencms.site.CmsSite; 037import org.opencms.site.CmsSiteManagerImpl; 038import org.opencms.site.CmsSiteMatcher; 039import org.opencms.util.CmsStringUtil; 040 041import java.io.IOException; 042import java.io.InputStream; 043import java.net.InetAddress; 044import java.net.URI; 045import java.net.URISyntaxException; 046import java.net.UnknownHostException; 047import java.security.MessageDigest; 048import java.util.Collection; 049import java.util.Collections; 050import java.util.IdentityHashMap; 051import java.util.List; 052import java.util.Set; 053import java.util.stream.Collectors; 054 055import org.apache.commons.codec.binary.Hex; 056import org.apache.commons.logging.Log; 057 058import com.google.common.collect.ArrayListMultimap; 059import com.google.common.collect.Lists; 060import com.google.common.collect.Multimap; 061import com.google.common.collect.Sets; 062 063import de.malkusch.whoisServerList.publicSuffixList.PublicSuffixList; 064import de.malkusch.whoisServerList.publicSuffixList.PublicSuffixListFactory; 065 066/** 067 * Class which converts the OpenCms site configuration to a certificate configuration for the LetsEncrypt docker instance. 068 */ 069public class CmsSiteConfigToLetsEncryptConfigConverter { 070 071 /** 072 * Represents a grouping of domains into certificates. 073 */ 074 public static class DomainGrouping { 075 076 /** The list of domain sets. */ 077 private List<Set<String>> m_domainGroups = Lists.newArrayList(); 078 079 /** 080 * Adds a domain group.<p> 081 * 082 * @param domains the domain group 083 */ 084 public void addDomainSet(Set<String> domains) { 085 086 if (!domains.isEmpty()) { 087 088 m_domainGroups.add(domains.stream().map(domain -> domain.toLowerCase()).collect(Collectors.toSet())); 089 } 090 } 091 092 /** 093 * Generates the JSON configuration corresponding to the domain grouping.<p> 094 * 095 * @return the JSON configuration corresponding to the domain grouping 096 */ 097 public String generateCertJson() { 098 099 try { 100 JSONObject result = new JSONObject(); 101 for (Set<String> domainGroup : m_domainGroups) { 102 String key = computeName(domainGroup); 103 if (key != null) { 104 result.put(key, new JSONArray(domainGroup)); 105 } 106 } 107 return result.toString(); 108 } catch (Exception e) { 109 LOG.error(e.getLocalizedMessage(), e); 110 return null; 111 } 112 } 113 114 /** 115 * Checks all domains for resolvability and return the unresolvable ones. 116 * 117 * @return the set of unresolvable domains 118 */ 119 public Set<String> getUnresolvableDomains() { 120 121 Set<String> result = Sets.newHashSet(); 122 for (Set<String> domainGroup : m_domainGroups) { 123 for (String domain : domainGroup) { 124 try { 125 InetAddress.getByName(domain); 126 } catch (UnknownHostException e) { 127 result.add(domain); 128 } catch (SecurityException e) { 129 LOG.error(e.getLocalizedMessage(), e); 130 } 131 } 132 } 133 return result; 134 } 135 136 /** 137 * Checks if the domain grouping does not contain any domain groups. 138 * 139 * @return true if there are no domain groups 140 */ 141 public boolean isEmpty() { 142 143 return m_domainGroups.isEmpty(); 144 } 145 146 /** 147 * Deterministically generates a certificate name for a set of domains.<p> 148 * 149 * @param domains the domains 150 * @return the certificate name 151 */ 152 private String computeName(Set<String> domains) { 153 154 try { 155 List<String> domainList = Lists.newArrayList(domains); 156 Collections.sort(domainList); 157 String prefix = domainList.get(0); 158 MessageDigest md5 = MessageDigest.getInstance("MD5"); 159 for (String domain : domainList) { 160 md5.update(domain.getBytes("UTF-8")); 161 md5.update((byte)10); 162 } 163 164 return prefix + "-" + new String(Hex.encodeHex(md5.digest())); 165 } catch (Exception e) { 166 LOG.error(e.getLocalizedMessage(), e); 167 return null; 168 } 169 170 } 171 } 172 173 /** 174 * Represents the domain information for a single site.<p> 175 */ 176 public static class SiteDomainInfo { 177 178 /** The common root domain, or null if there is no common root domain. */ 179 private String m_commonRootDomain; 180 181 /** The set of domains for the site. */ 182 private Set<String> m_domains = Sets.newHashSet(); 183 184 /** True if an invalid port was used. */ 185 private boolean m_invalidPort; 186 187 /** 188 * Creates a new instance.<p> 189 * 190 * @param domains the set of domains 191 * @param commonRootDomain the common root domain 192 * @param invalidPort true if an invalid port was used 193 */ 194 public SiteDomainInfo(Set<String> domains, String commonRootDomain, boolean invalidPort) { 195 196 super(); 197 m_domains = domains; 198 m_commonRootDomain = commonRootDomain; 199 m_invalidPort = invalidPort; 200 } 201 202 /** 203 * Gets the common root domain.<p> 204 * 205 * @return the common root domain 206 */ 207 public String getCommonRootDomain() { 208 209 return m_commonRootDomain; 210 } 211 212 /** 213 * Gets the set of domains.<p> 214 * 215 * @return the set of domains 216 */ 217 public Set<String> getDomains() { 218 219 return m_domains; 220 } 221 222 /** 223 * True if an invalid port was used.<p> 224 * 225 * @return true if an invalid port was used 226 */ 227 public boolean hasInvalidPort() { 228 229 return m_invalidPort; 230 } 231 } 232 233 /** 234 * Timed cache for the public suffix list.<p> 235 */ 236 static class SuffixListCache { 237 238 /** The public suffix list. */ 239 private PublicSuffixList m_suffixList; 240 241 /** The time the list was last cached. */ 242 private long m_timestamp = -1; 243 244 /** 245 * Gets the public suffix list, loading it if hasn't been loaded before or the time since it was loaded was too long ago.<p> 246 * 247 * @return the public suffix list 248 */ 249 public synchronized PublicSuffixList getPublicSuffixList() { 250 251 long now = System.currentTimeMillis(); 252 if ((m_suffixList == null) || ((now - m_timestamp) > (1000 * 3600))) { 253 PublicSuffixListFactory factory = new PublicSuffixListFactory(); 254 try (InputStream stream = CmsSiteConfigToLetsEncryptConfigConverter.class.getResourceAsStream( 255 "public_suffix_list.dat")) { 256 m_suffixList = factory.build(stream); 257 m_timestamp = now; 258 } catch (IOException e) { 259 LOG.error(e.getLocalizedMessage(), e); 260 } 261 } 262 return m_suffixList; 263 264 } 265 } 266 267 /** The logger used for this class. */ 268 static final Log LOG = CmsLog.getLog(CmsSiteConfigToLetsEncryptConfigConverter.class); 269 270 /** Disables grouping. */ 271 public static final boolean GROUPING_DISABLED = true; 272 273 /** Lock to prevent two converters from running simultaneously. */ 274 private static Object LOCK = new Object(); 275 276 /** The cache for the public suffix list. */ 277 private static SuffixListCache SUFFIX_LIST_CACHE = new SuffixListCache(); 278 279 /** The configuration. */ 280 private CmsLetsEncryptConfiguration m_config; 281 282 /** The object to which the configuration is sent after it is generated. */ 283 private I_CmsLetsEncryptUpdater m_configUpdater; 284 285 /** 286 * Creates a new instance.<p> 287 * 288 * @param config the LetsEncrypt configuration 289 */ 290 public CmsSiteConfigToLetsEncryptConfigConverter(CmsLetsEncryptConfiguration config) { 291 292 m_config = config; 293 m_configUpdater = new CmsLetsEncryptUpdater(config); 294 } 295 296 /** 297 * Computes the domain information for a single site.<p> 298 * 299 * @param site the site 300 * @return the domain information for a site 301 */ 302 private static SiteDomainInfo getDomainInfo(CmsSite site) { 303 304 List<String> urls = Lists.newArrayList(); 305 for (CmsSiteMatcher matcher : site.getAllMatchers()) { 306 urls.add(matcher.getUrl()); 307 308 } 309 return getDomainInfo(urls); 310 } 311 312 /** 313 * Computes the SiteDomainInfo bean for a collection of URIs.<p> 314 * 315 * @param uris a collection of URIs 316 * @return the SiteDomainInfo bean for the URIs 317 */ 318 private static SiteDomainInfo getDomainInfo(Collection<String> uris) { 319 320 Set<String> rootDomains = Sets.newHashSet(); 321 Set<String> domains = Sets.newHashSet(); 322 boolean invalidPort = false; 323 324 for (String uriStr : uris) { 325 326 try { 327 URI uri = new URI(uriStr); 328 int port = uri.getPort(); 329 if (!((port == 80) || (port == 443) || (port == -1))) { 330 invalidPort = true; 331 } 332 String rootDomain = getDomainRoot(uri); 333 if (rootDomain == null) { 334 LOG.warn("Host is not under public suffix, skipping it: " + uri); 335 continue; 336 } 337 domains.add(uri.getHost()); 338 rootDomains.add(rootDomain); 339 } catch (URISyntaxException e) { 340 LOG.warn("getDomainInfo: invalid URI " + uriStr, e); 341 continue; 342 } 343 } 344 String rootDomain = (rootDomains.size() == 1 ? rootDomains.iterator().next() : null); 345 return new SiteDomainInfo(domains, rootDomain, invalidPort); 346 } 347 348 /** 349 * Calculates the domain root for a given uri.<p> 350 * 351 * @param uri an URI 352 * @return the domain root for the uri 353 */ 354 private static String getDomainRoot(URI uri) { 355 356 String host = uri.getHost(); 357 return SUFFIX_LIST_CACHE.getPublicSuffixList().getRegistrableDomain(host); 358 } 359 360 /** 361 * Gets the domains for a collection of SiteDomainInfo beans.<p> 362 * 363 * @param infos a collection of SiteDomainInfo beans 364 * @return the domains for the beans 365 */ 366 private static Set<String> getDomains(Collection<SiteDomainInfo> infos) { 367 368 Set<String> domains = Sets.newHashSet(); 369 for (SiteDomainInfo info : infos) { 370 for (String domain : info.getDomains()) { 371 domains.add(domain); 372 } 373 } 374 return domains; 375 } 376 377 /** 378 * Runs the certificate configuration update for the sites configured in a site manager.<p> 379 * 380 * @param report the report to write to 381 * @param siteManager the site manager instance 382 * 383 * @return true if the Letsencrypt update was successful 384 */ 385 public boolean run(I_CmsReport report, CmsSiteManagerImpl siteManager) { 386 387 synchronized (LOCK) { 388 // *not* using getAvailable sites here, because the result does not include sites with unpublished site folders if called with a CmsObject in the Online project 389 // Instead we use getSites() and avoid duplicates using an IdentityHashMap 390 IdentityHashMap<CmsSite, CmsSite> siteIdMap = new IdentityHashMap<CmsSite, CmsSite>(); 391 for (CmsSite site : siteManager.getSites().values()) { 392 if (site.getSSLMode() == CmsSSLMode.LETS_ENCRYPT) { 393 siteIdMap.put(site, site); 394 } 395 } 396 List<CmsSite> sites = Lists.newArrayList(siteIdMap.values()); 397 List<String> workplaceServers = siteManager.getWorkplaceServers(CmsSSLMode.LETS_ENCRYPT); 398 return run(report, sites, workplaceServers); 399 } 400 } 401 402 /** 403 * Computes the domain grouping for a set of sites and workplace URLs.<p> 404 * 405 * @param sites the sites 406 * @param workplaceUrls the workplace URLS 407 * @return the domain grouping 408 */ 409 private DomainGrouping computeDomainGrouping(Collection<CmsSite> sites, Collection<String> workplaceUrls) { 410 411 DomainGrouping result = new DomainGrouping(); 412 if (LOG.isInfoEnabled()) { 413 LOG.info("Computing domain grouping for sites..."); 414 List<String> servers = Lists.newArrayList(); 415 for (CmsSite site : sites) { 416 servers.add(site.getUrl()); 417 } 418 LOG.info("SITES = " + CmsStringUtil.listAsString(servers, ", ")); 419 } 420 421 Mode mode = m_config.getMode(); 422 boolean addWp = false; 423 boolean addSites = false; 424 if ((mode == Mode.all) || (mode == Mode.sites)) { 425 addSites = true; 426 } 427 if ((mode == Mode.all) || (mode == Mode.workplace)) { 428 addWp = true; 429 } 430 431 if (addWp) { 432 Set<String> workplaceDomains = Sets.newHashSet(); 433 for (String wpServer : workplaceUrls) { 434 try { 435 URI uri = new URI(wpServer); 436 workplaceDomains.add(uri.getHost()); 437 } catch (Exception e) { 438 LOG.error(e.getLocalizedMessage(), e); 439 } 440 } 441 result.addDomainSet(workplaceDomains); 442 } 443 444 if (addSites) { 445 Multimap<String, SiteDomainInfo> infosByRootDomain = ArrayListMultimap.create(); 446 447 List<SiteDomainInfo> ungroupedSites = Lists.newArrayList(); 448 for (CmsSite site : sites) { 449 SiteDomainInfo info = getDomainInfo(site); 450 if (info.hasInvalidPort()) { 451 LOG.warn("Invalid port occuring in site definition: " + site); 452 continue; 453 } 454 String root = info.getCommonRootDomain(); 455 if ((root == null) || GROUPING_DISABLED) { 456 ungroupedSites.add(info); 457 } else { 458 infosByRootDomain.put(root, info); 459 } 460 } 461 List<String> keysToRemove = Lists.newArrayList(); 462 463 for (String key : infosByRootDomain.keySet()) { 464 Collection<SiteDomainInfo> siteInfos = infosByRootDomain.get(key); 465 Set<String> domains = getDomains(siteInfos); 466 if (domains.size() > 100) { 467 LOG.info("Too many domains for root domain " + key + ", splitting them up by site instead."); 468 keysToRemove.add(key); 469 for (SiteDomainInfo info : siteInfos) { 470 ungroupedSites.add(info); 471 } 472 } 473 } 474 for (String key : keysToRemove) { 475 infosByRootDomain.removeAll(key); 476 } 477 for (SiteDomainInfo ungroupedSite : ungroupedSites) { 478 Set<String> domains = getDomains(Collections.singletonList(ungroupedSite)); 479 result.addDomainSet(domains); 480 LOG.info("DOMAINS (site config): " + domains); 481 } 482 for (String key : infosByRootDomain.keySet()) { 483 Set<String> domains = getDomains(infosByRootDomain.get(key)); 484 result.addDomainSet(domains); 485 LOG.info("DOMAINS (" + key + ")" + domains); 486 } 487 } 488 return result; 489 } 490 491 /** 492 * Runs the certificate configuration update for a given set of sites and workplace URLS.<p> 493 * 494 * @param report the report to write to 495 * @param sites the sites 496 * @param workplaceUrls the workplace URLS 497 * 498 * @return true if the Letsencrypt update was successful 499 */ 500 private boolean run(I_CmsReport report, Collection<CmsSite> sites, Collection<String> workplaceUrls) { 501 502 try { 503 DomainGrouping domainGrouping = computeDomainGrouping(sites, workplaceUrls); 504 if (domainGrouping.isEmpty()) { 505 report.println( 506 org.opencms.ui.apps.Messages.get().container( 507 org.opencms.ui.apps.Messages.RPT_LETSENCRYPT_NO_DOMAINS_0)); 508 return false; 509 } 510 String certConfig = domainGrouping.generateCertJson(); 511 if (!m_configUpdater.update(certConfig)) { 512 report.println( 513 org.opencms.ui.apps.Messages.get().container( 514 org.opencms.ui.apps.Messages.RPT_LETSENCRYPT_UPDATE_FAILED_0), 515 I_CmsReport.FORMAT_WARNING); 516 return false; 517 } 518 return true; 519 } catch (Exception e) { 520 report.println(e); 521 return false; 522 } 523 } 524 525}