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}