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