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}