Explorar o código

version check client service

Jason Rivard %!s(int64=3) %!d(string=hai) anos
pai
achega
ba6fd8c86d

+ 2 - 2
server/src/main/java/password/pwm/AppAttribute.java

@@ -37,8 +37,8 @@ public enum AppAttribute
     HTTPS_SELF_CERT( "https.selfCert" ),
     CONFIG_LOGIN_HISTORY( "config.loginHistory" ),
     LOCALDB_LOGGER_STORAGE_FORMAT( "localdb.logger.storage.format" ),
-
-    TELEMETRY_LAST_PUBLISH_TIMESTAMP( "telemetry.lastPublish.timestamp" );
+    TELEMETRY_LAST_PUBLISH_TIMESTAMP( "telemetry.lastPublish.timestamp" ),
+    VERSION_CHECK_CACHE( "versionCheckInfoCache" ),;
 
     private final String key;
 

+ 3 - 0
server/src/main/java/password/pwm/AppProperty.java

@@ -377,6 +377,9 @@ public enum AppProperty
 
     /** Regular expression to be used for matching URLs to be shortened by the URL Shortening Service Class. */
     URL_SHORTNER_URL_REGEX                          ( "urlshortener.url.regex" ),
+    VERSION_CHECK_URL                               ( "versionCheck.url" ),
+    VERSION_CHECK_CHECK_INTERVAL_SECONDS            ( "versionCheck.checkIntervalSeconds" ),
+    VERSION_CHECK_CHECK_INTERVAL_ERROR_SECONDS      ( "versionCheck.checkIntervalErrorSeconds" ),
     WORDLIST_BUILTIN_PATH                           ( "wordlist.builtin.path" ),
     WORDLIST_CHAR_LENGTH_MAX                        ( "wordlist.maxCharLength" ),
     WORDLIST_CHAR_LENGTH_MIN                        ( "wordlist.minCharLength" ),

+ 2 - 3
server/src/main/java/password/pwm/PwmApplication.java

@@ -70,7 +70,6 @@ import password.pwm.util.logging.PwmLogManager;
 import password.pwm.util.logging.PwmLogger;
 
 import java.io.File;
-import java.io.Serializable;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -446,7 +445,7 @@ public class PwmApplication
     }
 
 
-    public <T extends Serializable> Optional<T> readAppAttribute( final AppAttribute appAttribute, final Class<T> returnClass )
+    public <T> Optional<T> readAppAttribute( final AppAttribute appAttribute, final Class<T> returnClass )
     {
         final LocalDB localDB = getLocalDB();
 
@@ -561,7 +560,7 @@ public class PwmApplication
     }
 
 
-    public void writeAppAttribute( final AppAttribute appAttribute, final Serializable value )
+    public void writeAppAttribute( final AppAttribute appAttribute, final Object value )
     {
         final LocalDB localDB = getLocalDB();
 

+ 2 - 0
server/src/main/java/password/pwm/config/PwmSetting.java

@@ -99,6 +99,8 @@ public enum PwmSetting
     // telemetry
     PUBLISH_STATS_ENABLE(
             "pwm.publishStats.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.TELEMETRY ),
+    VERSION_CHECK_ENABLE(
+            "pwm.versionCheck.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.TELEMETRY ),
     PUBLISH_STATS_SITE_DESCRIPTION(
             "pwm.publishStats.siteDescription", PwmSettingSyntax.STRING, PwmSettingCategory.TELEMETRY ),
 

+ 2 - 0
server/src/main/java/password/pwm/health/HealthMessage.java

@@ -110,6 +110,8 @@ public enum HealthMessage
     ServiceClosed_AppReadOnly( HealthStatus.CAUTION, HealthTopic.Application ),
     ServiceError( HealthStatus.WARN, HealthTopic.Application ),
     SMS_SendFailure( HealthStatus.WARN, HealthTopic.SMS ),
+    Version_OutOfDate( HealthStatus.WARN, HealthTopic.Platform ),
+    Version_Unreachable( HealthStatus.CAUTION, HealthTopic.Platform ),
     Wordlist_AutoImportFailure( HealthStatus.WARN, HealthTopic.Configuration ),
     Wordlist_ImportInProgress( HealthStatus.CAUTION, HealthTopic.Application ),;
 

+ 2 - 0
server/src/main/java/password/pwm/svc/PwmServiceEnum.java

@@ -31,6 +31,7 @@ import password.pwm.svc.node.NodeService;
 import password.pwm.svc.pwnotify.PwNotifyService;
 import password.pwm.svc.sms.SmsQueueService;
 import password.pwm.svc.stats.StatisticsService;
+import password.pwm.svc.version.VersionCheckService;
 import password.pwm.svc.wordlist.SeedlistService;
 import password.pwm.svc.wordlist.SharedHistoryService;
 import password.pwm.svc.wordlist.WordlistService;
@@ -64,6 +65,7 @@ public enum PwmServiceEnum
     SessionTrackService( password.pwm.svc.sessiontrack.SessionTrackService.class, PwmSettingScope.SYSTEM ),
     SessionStateSvc( password.pwm.http.state.SessionStateService.class, PwmSettingScope.SYSTEM ),
     TelemetryService( password.pwm.svc.telemetry.TelemetryService.class, PwmSettingScope.SYSTEM ),
+    VersionCheckService( VersionCheckService.class, PwmSettingScope.SYSTEM ),
     NodeService( NodeService.class, PwmSettingScope.SYSTEM ),
 
     DomainSecureService( password.pwm.svc.secure.DomainSecureService.class, PwmSettingScope.DOMAIN, Flag.StartDuringRuntimeInstance ),

+ 8 - 2
server/src/main/java/password/pwm/svc/httpclient/PwmHttpClientResponse.java

@@ -23,9 +23,10 @@ package password.pwm.svc.httpclient;
 import lombok.Builder;
 import lombok.Value;
 import password.pwm.PwmApplication;
+import password.pwm.data.ImmutableByteArray;
 import password.pwm.http.HttpContentType;
 import password.pwm.http.HttpEntityDataType;
-import password.pwm.data.ImmutableByteArray;
+import password.pwm.util.java.StringUtil;
 
 import java.io.Serializable;
 import java.util.Map;
@@ -49,10 +50,15 @@ public class PwmHttpClientResponse implements Serializable, PwmHttpClientMessage
 
     String toDebugString( final PwmApplication pwmApplication, final PwmHttpClientConfiguration pwmHttpClientConfiguration )
     {
-        final String topLine = "HTTP response status " + statusCode + " " + statusPhrase;
+        final String topLine = "HTTP response status " + getStatusLine();
         return PwmHttpClientMessage.entityToDebugString( topLine, pwmApplication, pwmHttpClientConfiguration, this );
     }
 
+    public String getStatusLine()
+    {
+        return statusCode + ( StringUtil.isEmpty( statusPhrase ) ? "" : " " + statusPhrase );
+    }
+
     long size()
     {
         long size = PwmHttpClientMessage.sizeImpl( this );

+ 33 - 26
server/src/main/java/password/pwm/svc/telemetry/TelemetryService.java

@@ -67,6 +67,7 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.TreeMap;
 import java.util.stream.Collectors;
 
@@ -231,6 +232,11 @@ public class TelemetryService extends AbstractPwmService implements PwmService
     @Override
     public ServiceInfoBean serviceInfo( )
     {
+        if ( status() != STATUS.OPEN )
+        {
+            return null;
+        }
+
         final Map<String, String> debugMap = new LinkedHashMap<>();
         debugMap.put( "lastPublishTime", StringUtil.toIsoDate( lastPublishTime ) );
         if ( lastError != null )
@@ -259,31 +265,7 @@ public class TelemetryService extends AbstractPwmService implements PwmService
                 .sorted()
                 .collect( Collectors.toUnmodifiableList() );
 
-        String ldapVendorName = null;
-
-        domainConfigLoop:
-        for ( final PwmDomain pwmDomain : getPwmApplication().domains().values() )
-        {
-            for ( final LdapProfile ldapProfile : pwmDomain.getConfig().getLdapProfiles().values() )
-            {
-                try
-                {
-                    final DirectoryVendor directoryVendor = ldapProfile.getProxyChaiProvider( getSessionLabel(), pwmDomain ).getDirectoryVendor();
-                    final PwmLdapVendor pwmLdapVendor = PwmLdapVendor.fromChaiVendor( directoryVendor );
-                    if ( pwmLdapVendor != null )
-                    {
-                        ldapVendorName = pwmLdapVendor.name();
-                        break domainConfigLoop;
-
-                    }
-                }
-                catch ( final Exception e )
-                {
-                    LOGGER.trace( getSessionLabel(), () -> "unable to read ldap vendor type for stats publication: " + e.getMessage() );
-                }
-            }
-        }
-
+        final Optional<String> ldapVendorName = determineLdapVendorName();
 
         final Map<String, String> aboutStrings = new TreeMap<>();
         {
@@ -306,13 +288,38 @@ public class TelemetryService extends AbstractPwmService implements PwmService
                 .siteDescription( config.readSettingAsString( PwmSetting.PUBLISH_STATS_SITE_DESCRIPTION ) )
                 .versionBuild( PwmConstants.BUILD_NUMBER )
                 .versionVersion( PwmConstants.BUILD_VERSION )
-                .ldapVendorName( ldapVendorName )
+                .ldapVendorName( ldapVendorName.orElse( null ) )
                 .statistics( statData )
                 .configuredSettings( configuredSettings )
                 .about( aboutStrings )
                 .build();
     }
 
+    private Optional<String> determineLdapVendorName()
+    {
+        for ( final PwmDomain pwmDomain : getPwmApplication().domains().values() )
+        {
+            for ( final LdapProfile ldapProfile : pwmDomain.getConfig().getLdapProfiles().values() )
+            {
+                try
+                {
+                    final DirectoryVendor directoryVendor = ldapProfile.getProxyChaiProvider( getSessionLabel(), pwmDomain ).getDirectoryVendor();
+                    final PwmLdapVendor pwmLdapVendor = PwmLdapVendor.fromChaiVendor( directoryVendor );
+                    if ( pwmLdapVendor != null )
+                    {
+                        return Optional.of( pwmLdapVendor.name() );
+                    }
+                }
+                catch ( final Exception e )
+                {
+                    LOGGER.trace( getSessionLabel(), () -> "unable to read ldap vendor type for stats publication: " + e.getMessage() );
+                }
+            }
+        }
+
+        return Optional.empty();
+    }
+
     private static String makeId( final PwmApplication pwmApplication ) throws PwmUnrecoverableException
     {
         final String separator = "-";

+ 374 - 0
server/src/main/java/password/pwm/svc/version/VersionCheckService.java

@@ -0,0 +1,374 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2021 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.version;
+
+import lombok.Builder;
+import lombok.Value;
+import password.pwm.AppAttribute;
+import password.pwm.AppProperty;
+import password.pwm.PwmApplication;
+import password.pwm.PwmConstants;
+import password.pwm.bean.DomainID;
+import password.pwm.bean.SessionLabel;
+import password.pwm.bean.VersionNumber;
+import password.pwm.bean.pub.PublishVersionBean;
+import password.pwm.config.AppConfig;
+import password.pwm.config.PwmSetting;
+import password.pwm.error.ErrorInformation;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmException;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.health.HealthMessage;
+import password.pwm.health.HealthRecord;
+import password.pwm.http.HttpContentType;
+import password.pwm.http.HttpHeader;
+import password.pwm.svc.AbstractPwmService;
+import password.pwm.svc.PwmService;
+import password.pwm.svc.httpclient.PwmHttpClient;
+import password.pwm.svc.httpclient.PwmHttpClientRequest;
+import password.pwm.svc.httpclient.PwmHttpClientResponse;
+import password.pwm.util.i18n.LocaleHelper;
+import password.pwm.util.java.CollectionUtil;
+import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.StringUtil;
+import password.pwm.util.java.TimeDuration;
+import password.pwm.util.json.JsonFactory;
+import password.pwm.util.localdb.LocalDB;
+import password.pwm.util.logging.PwmLogger;
+import password.pwm.ws.server.RestResultBean;
+
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+public class VersionCheckService extends AbstractPwmService
+{
+    private static final PwmLogger LOGGER = PwmLogger.forClass( VersionCheckService.class );
+
+    private PwmApplication pwmApplication;
+    private VersionCheckSettings settings;
+
+    private VersionNumber runningVersion;
+    private CacheHolder cacheHolder;
+    private Instant nextScheduledCheck;
+
+    private enum DebugKey
+    {
+        runningVersion,
+        currentVersion,
+        outdatedVersionFlag,
+        lastCheckTime,
+        nextCheckTime,
+        lastError
+    }
+
+    @Override
+    protected STATUS postAbstractInit( final PwmApplication pwmApplication, final DomainID domainID )
+            throws PwmException
+    {
+        this.pwmApplication = Objects.requireNonNull( pwmApplication );
+        this.settings = VersionCheckSettings.fromConfig( pwmApplication.getConfig() );
+        initRunningVersion();
+
+        if ( enabled() )
+        {
+            cacheHolder = new CacheHolder( pwmApplication );
+
+            setStatus( STATUS.OPEN );
+
+            scheduleNextCheck();
+
+            return STATUS.OPEN;
+        }
+
+        return STATUS.CLOSED;
+    }
+
+    @Override
+    protected void shutdownImpl()
+    {
+    }
+
+    private Map<DebugKey, String> debugMap()
+    {
+        if ( status() != STATUS.OPEN )
+        {
+            return Collections.emptyMap();
+        }
+
+        final String notApplicable = LocaleHelper.valueNotApplicable( PwmConstants.DEFAULT_LOCALE );
+        final VersionCheckInfoCache localCache = cacheHolder.getVersionCheckInfoCache();
+
+        final Map<DebugKey, String> debugKeyMap = new EnumMap<>( DebugKey.class );
+        debugKeyMap.put( DebugKey.runningVersion, runningVersion == null ? notApplicable : runningVersion.prettyVersionString() );
+        debugKeyMap.put( DebugKey.currentVersion, localCache.getCurrentVersion() == null ? notApplicable : localCache.getCurrentVersion().prettyVersionString() );
+        debugKeyMap.put( DebugKey.outdatedVersionFlag, LocaleHelper.valueBoolean( PwmConstants.DEFAULT_LOCALE, isOutdated() ) );
+        debugKeyMap.put( DebugKey.lastError, localCache.getLastError() == null ? notApplicable : localCache.getLastError().toDebugStr() );
+        debugKeyMap.put( DebugKey.lastCheckTime, localCache.getLastCheckTimestamp() == null ? notApplicable : StringUtil.toIsoDate( localCache.getLastCheckTimestamp() ) );
+        debugKeyMap.put( DebugKey.nextCheckTime, nextScheduledCheck == null
+                ? notApplicable
+                : StringUtil.toIsoDate( nextScheduledCheck ) + " (" + TimeDuration.compactFromCurrent( nextScheduledCheck ) + ")" );
+
+        return Collections.unmodifiableMap( debugKeyMap );
+    }
+
+    @Override
+    public ServiceInfoBean serviceInfo()
+    {
+        return ServiceInfoBean.builder()
+                .debugProperties( CollectionUtil.enumMapToStringMap( debugMap() ) )
+                .build();
+    }
+
+    private void scheduleNextCheck()
+    {
+        if ( status() != PwmService.STATUS.OPEN )
+        {
+            return;
+        }
+
+        final VersionCheckInfoCache localCache = cacheHolder.getVersionCheckInfoCache();
+
+        final TimeDuration idealDurationUntilNextCheck = localCache.getLastError() != null && localCache.getCurrentVersion() == null
+                ? settings.getCheckIntervalError()
+                : settings.getCheckInterval();
+
+        this.nextScheduledCheck = localCache.getLastCheckTimestamp() == null
+                ? Instant.now().plus( 10, ChronoUnit.SECONDS )
+                : localCache.getLastCheckTimestamp().plus( idealDurationUntilNextCheck.asDuration() );
+
+        final TimeDuration delayUntilNextExecution = TimeDuration.fromCurrent( this.nextScheduledCheck );
+
+        getExecutorService().schedule( this::doPeriodicCheck, delayUntilNextExecution.asMillis(), TimeUnit.MILLISECONDS );
+
+        LOGGER.trace( getSessionLabel(), () -> "scheduled next check execution at " + StringUtil.toIsoDate( nextScheduledCheck )
+                + " in " + delayUntilNextExecution.asCompactString() );
+    }
+
+    private void doPeriodicCheck()
+    {
+        if ( status() != PwmService.STATUS.OPEN )
+        {
+            return;
+        }
+
+        cacheHolder.setVersionCheckInfoCache( executeFetch( pwmApplication, getSessionLabel(), settings ) );
+
+        scheduleNextCheck();
+    }
+
+    private static VersionCheckInfoCache executeFetch(
+            final PwmApplication pwmApplication,
+            final SessionLabel sessionLabel,
+            final VersionCheckSettings settings
+    )
+    {
+        final Instant startTime = Instant.now();
+        try
+        {
+            final PwmHttpClient pwmHttpClient = pwmApplication.getHttpClientService().getPwmHttpClient( sessionLabel );
+            final PwmHttpClientRequest request = PwmHttpClientRequest.builder()
+                    .url( settings.getUrl() )
+                    .header( HttpHeader.Accept.getHttpName(), HttpContentType.json.getHeaderValueWithEncoding() )
+                    .build();
+
+            LOGGER.trace( sessionLabel, () -> "sending cloud version request to: " + settings.getUrl() );
+            final PwmHttpClientResponse response = pwmHttpClient.makeRequest( request );
+
+            if ( response.getStatusCode() == 200 )
+            {
+                final Type restResultBeanType = JsonFactory.get().newParameterizedType( RestResultBean.class, PublishVersionBean.class );
+                final String body = response.getBody();
+                final RestResultBean<PublishVersionBean> restResultBean = JsonFactory.get().deserialize( body, restResultBeanType );
+                final PublishVersionBean publishVersionBean = restResultBean.getData();
+
+                final VersionNumber currentVersion = publishVersionBean.getVersions().get( PublishVersionBean.VersionKey.current );
+
+                LOGGER.trace( sessionLabel, () -> "successfully fetched current version information from cloud service: "
+                        + currentVersion, TimeDuration.fromCurrent( startTime ) );
+
+                return VersionCheckInfoCache.builder()
+                        .currentVersion( currentVersion )
+                        .lastCheckTimestamp( Instant.now() )
+                        .build();
+            }
+            else
+            {
+                LOGGER.debug( sessionLabel, () -> "error reading cloud current version information: " + response );
+                final String msg = "error reading cloud current version information: " + response.getStatusLine();
+                throw PwmUnrecoverableException.newException( PwmError.ERROR_UNREACHABLE_CLOUD_SERVICE, msg );
+            }
+        }
+        catch ( final Exception e )
+        {
+            final ErrorInformation errorInformation;
+
+            if ( e instanceof PwmException )
+            {
+                errorInformation = ( ( PwmUnrecoverableException ) e ).getErrorInformation();
+            }
+            else
+            {
+                final String errorMsg = "error reading current version from cloud service: " + e.getMessage();
+                errorInformation = new ErrorInformation( PwmError.ERROR_UNREACHABLE_CLOUD_SERVICE, errorMsg );
+            }
+
+            LOGGER.debug( sessionLabel, () -> "error fetching current version from cloud: "
+                    + e.getMessage(), TimeDuration.fromCurrent( startTime ) );
+
+            return VersionCheckInfoCache.builder()
+                    .lastError( errorInformation )
+                    .lastCheckTimestamp( Instant.now() )
+                    .build();
+        }
+    }
+
+    @Override
+    protected List<HealthRecord> serviceHealthCheck()
+    {
+        if ( status() != PwmService.STATUS.OPEN )
+        {
+            return Collections.emptyList();
+        }
+
+        final VersionCheckInfoCache localCache = cacheHolder.getVersionCheckInfoCache();
+
+        if ( isOutdated() )
+        {
+            return Collections.singletonList( HealthRecord.forMessage(
+                    DomainID.systemId(),
+                    HealthMessage.Version_OutOfDate,
+                    PwmConstants.PWM_APP_NAME,
+                    localCache.getCurrentVersion().prettyVersionString() ) );
+        }
+
+        if ( localCache.getLastError() != null )
+        {
+            return Collections.singletonList( HealthRecord.forMessage(
+                    DomainID.systemId(),
+                    HealthMessage.Version_Unreachable,
+                    localCache.getLastError().toDebugStr() ) );
+        }
+
+        return Collections.emptyList();
+    }
+
+    private boolean isOutdated()
+    {
+        if ( status() != PwmService.STATUS.OPEN )
+        {
+            return false;
+        }
+
+        final VersionCheckInfoCache localCache = cacheHolder.getVersionCheckInfoCache();
+        if ( runningVersion == null || localCache.getCurrentVersion() == null )
+        {
+            return false;
+        }
+
+        final int comparisonInt = runningVersion.compareTo( localCache.getCurrentVersion() );
+        return comparisonInt < 0;
+    }
+
+    @Value
+    @Builder
+    private static class VersionCheckInfoCache
+    {
+        private final Instant lastCheckTimestamp;
+        private final ErrorInformation lastError;
+        private final VersionNumber currentVersion;
+    }
+
+    @Value
+    @Builder
+    private static class VersionCheckSettings
+    {
+        private static final int DEFAULT_INTERVAL_SECONDS = 3801;
+
+        private final String url;
+        private final TimeDuration checkInterval;
+        private final TimeDuration checkIntervalError;
+
+        static VersionCheckSettings fromConfig( final AppConfig appConfig )
+        {
+            final int checkSeconds = JavaHelper.silentParseInt( appConfig.readAppProperty( AppProperty.VERSION_CHECK_CHECK_INTERVAL_SECONDS ), DEFAULT_INTERVAL_SECONDS );
+            final int checkSecondsError = JavaHelper.silentParseInt(
+                    appConfig.readAppProperty( AppProperty.VERSION_CHECK_CHECK_INTERVAL_ERROR_SECONDS ), DEFAULT_INTERVAL_SECONDS );
+
+            return  VersionCheckSettings.builder()
+                    .url( appConfig.readAppProperty( AppProperty.VERSION_CHECK_URL ) )
+                    .checkInterval( TimeDuration.of( checkSeconds, TimeDuration.Unit.SECONDS ) )
+                    .checkIntervalError( TimeDuration.of( checkSecondsError, TimeDuration.Unit.SECONDS ) )
+                    .build();
+        }
+    }
+
+    private void initRunningVersion()
+    {
+        try
+        {
+            this.runningVersion = VersionNumber.parse( PwmConstants.BUILD_VERSION );
+        }
+        catch ( final Exception e )
+        {
+            LOGGER.error( getSessionLabel(), () -> "error parsing internal running version number: " + e.getMessage() );
+        }
+    }
+
+    private boolean enabled()
+    {
+        return pwmApplication.getLocalDB() != null
+                && runningVersion != null
+                && pwmApplication.getLocalDB().status() == LocalDB.Status.OPEN
+                && !pwmApplication.getPwmEnvironment().isInternalRuntimeInstance()
+                && pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.VERSION_CHECK_ENABLE );
+    }
+
+    private static class CacheHolder
+    {
+        private final PwmApplication pwmApplication;
+        private VersionCheckInfoCache versionCheckInfoCache;
+
+        CacheHolder( final PwmApplication pwmApplication )
+        {
+            this.pwmApplication = pwmApplication;
+            this.versionCheckInfoCache = pwmApplication.readAppAttribute( AppAttribute.VERSION_CHECK_CACHE, VersionCheckInfoCache.class )
+                    .orElse( VersionCheckInfoCache.builder().build() );
+        }
+
+        public VersionCheckInfoCache getVersionCheckInfoCache()
+        {
+            return versionCheckInfoCache == null ? VersionCheckInfoCache.builder().build() : versionCheckInfoCache;
+        }
+
+        public void setVersionCheckInfoCache( final VersionCheckInfoCache versionCheckInfoCache )
+        {
+            this.versionCheckInfoCache = versionCheckInfoCache;
+            pwmApplication.writeAppAttribute( AppAttribute.VERSION_CHECK_CACHE, versionCheckInfoCache );
+        }
+    }
+}

+ 6 - 0
server/src/main/java/password/pwm/util/json/GsonJsonServiceProvider.java

@@ -60,6 +60,12 @@ class GsonJsonServiceProvider implements JsonProvider
         return getGson().fromJson( jsonString, classOfT );
     }
 
+    @Override
+    public <T> T deserialize( final String jsonString, final Type type )
+    {
+        return getGson().fromJson( jsonString, type );
+    }
+
     @Override
     public <V> List<V> deserializeList( final String jsonString, final Class<V> classOfV )
     {

+ 28 - 0
server/src/main/java/password/pwm/util/json/JsonProvider.java

@@ -20,6 +20,7 @@
 
 package password.pwm.util.json;
 
+import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.util.Collection;
 import java.util.List;
@@ -34,6 +35,8 @@ public interface JsonProvider
 
     <T> T deserialize( String jsonString, Class<T> classOfT );
 
+    <T> T deserialize( String jsonString, Type type );
+
     <V> List<V> deserializeList( String jsonString, Class<V> classOfV );
 
     <K, V> Map<K, V> deserializeMap( String jsonString, Class<K> classOfK, Class<V> classOfV );
@@ -61,4 +64,29 @@ public interface JsonProvider
         final String json = serialize( srcObject, classOfT );
         return deserialize( json, classOfT );
     }
+
+    default Type newParameterizedType( final Type rawType, final Type... parameterizedTypes )
+    {
+        return new ParameterizedType()
+        {
+            @Override
+            public Type[] getActualTypeArguments()
+            {
+                return parameterizedTypes;
+            }
+
+            @Override
+            public Type getRawType()
+            {
+                return rawType;
+            }
+
+            @Override
+            public Type getOwnerType()
+            {
+                return null;
+            }
+        };
+    }
+
 }

+ 6 - 0
server/src/main/java/password/pwm/util/json/MoshiJsonServiceProvider.java

@@ -88,6 +88,12 @@ class MoshiJsonServiceProvider implements JsonProvider
         return deserializeImpl( jsonString, classOfT );
     }
 
+    @Override
+    public <T> T deserialize( final String jsonString, final Type type )
+    {
+        return deserializeImpl( jsonString, type );
+    }
+
     @Override
     public <T> String serialize( final T srcObject, final Class<T> classOfT, final Type type, final Flag... flags )
     {

+ 3 - 0
server/src/main/resources/password/pwm/AppProperty.properties

@@ -350,6 +350,9 @@ token.storage.maxKeyLength=100
 rest.server.forgottenPW.token.display=%LABEL%  %MESSAGE%  %VALUE%
 rest.server.forgottenPW.ruleDelimiter=<br/>
 urlshortener.url.regex=(https?://([^:@]+(:[^@]+)?@)?([a-zA-Z0-9.]+|d{1,3}.d{1,3}.d{1,3}.d{1,3}|[[0-9a-fA-F:]+])(:d{1,5})?/*[a-zA-Z0-9/\%_.]*?*[a-zA-Z0-9/\%_.=&#]*)
+versionCheck.url=https://www.pwm-project.org/pwm-data-service/version
+versionCheck.checkIntervalSeconds=82803
+versionCheck.checkIntervalErrorSeconds=303
 wordlist.builtin.path=/WEB-INF/wordlist.zip
 wordlist.maxCharLength=64
 wordlist.minCharLength=2

+ 5 - 0
server/src/main/resources/password/pwm/config/PwmSetting.xml

@@ -104,6 +104,11 @@
         <example>https://www.example.com/example</example>
         <regex>^(http|https)://.+$</regex>
     </setting>
+    <setting key="pwm.versionCheck.enable" level="0" required="true">
+        <default>
+            <value>true</value>
+        </default>
+    </setting>
     <setting hidden="false" key="pwm.publishStats.enable" level="1" required="true">
         <default>
             <value>false</value>

+ 2 - 0
server/src/main/resources/password/pwm/i18n/Health.properties

@@ -99,6 +99,8 @@ HealthMessage_ServiceClosed_LocalDBUnavail=unable to start %1% service, LocalDB
 HealthMessage_ServiceClosed_AppReadOnly=unable to start %1% service, application is in read-only mode
 HealthMessage_ServiceError=Error operating service %1% service: %2%
 HealthMessage_SMS_SendFailure=Error while sending SMS messages: %1%
+HealthMessage_Version_OutOfDate=This version of %1% is out of date.  The current released version is %2%.
+HealthMessage_Version_Unreachable=Unable to check current version: %1%
 HealthMessage_Wordlist_AutoImportFailure=Configured word list (%1%) failed to import due to error: %2% at timestamp %3%
 HealthMessage_Wordlist_ImportInProgress=Wordlist import is in progress.  Application performance may be degraded during import.  %1%
 HealthStatus_WARN=WARN

+ 3 - 1
server/src/main/resources/password/pwm/i18n/PwmSetting.properties

@@ -662,7 +662,7 @@ Setting_Description_peopleSearch.searchBase=Specify the LDAP search bases for th
 Setting_Description_peopleSearch.searchFilter=Specify the LDAP search filter the People Search module uses to query the directory.  Substitute <i>%USERNAME%</i> for user-supplied user names. If blank, @PwmAppName@ auto-generates the search filter based on the values in the setting <a data-gotoSettingLink\="peopleSearch.searchAttributes">@PwmSettingReference\:peopleSearch.searchAttributes@</a>.\n        <br/><br>\n        Example\: <code>(&(objectClass\=Person)(|(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*)(mail\=*%USERNAME%*)(telephoneNumber\=*%USERNAME%*)))</code>\n\n
 Setting_Description_peopleSearch.useProxy=Enable this option to use the LDAP proxy account to perform searches. For proper security in most environments, do <b>not</b> enable this setting.
 Setting_Description_pwmAdmin.queryMatch=Specify the permissions @PwmAppName@ uses to determine if it grants a user administrator rights.
-Setting_Description_pwmAdmin.allowSkipForcedActivities=Allow administrators to skip otherwised forced activities such as setup of challenge/response answers. 
+Setting_Description_pwmAdmin.allowSkipForcedActivities=Allow administrators to skip otherwise forced activities such as setup of challenge/response answers. 
 Setting_Description_pwm.appProperty.overrides=(Troubleshooting only) Specify an override application properties value.  Do not use unless directed to by a support expert.
 Setting_Description_pwm.forwardURL=Specify a URL that @PwmAppName@ forwards users to after the users complete any activity which does not require a log out.<br/><br/>You can override this setting for any given user session by adding a <i>forwardURL</i> parameter to any HTTP request. If blank, the system forwards the user to the @PwmAppName@ menu.
 Setting_Description_pwm.homeURL=Specify the URL to redirect the user to upon clicking the home button. If blank, the home button returns the user to the application context URL.
@@ -673,6 +673,7 @@ Setting_Description_pwm.publishStats.siteDescription=This optional value can be
 Setting_Description_pwm.securityKey=<p>Specify a Security Key used for cryptographic functions such as the token verification. @PwmAppName@ requires a value if you enabled tokens for any of modules and configured a token storage method. @PwmAppName@ uses this value similar to how a cryptographic security certificate uses the private key.</p> <p>If configured, this value must be at least 32 characters in length.  The longer and more random this value, the more secure its uses are.  If multiple instances are in use, you must configure each instance with the same value.</p><p>Upon initial setup, @PwmAppName@ assigns a random security key to this value that you can change at any time, however, any outstanding tokens or other material generated by an old security key become invalid.</p>
 Setting_Description_pwm.seedlist.location=Specify the location of the seed list in the form of a valid URL. When @PwmAppName@ randomly generates passwords, it can generate a "friendly", random password suggestions to users.  It does this by using a "seed" word or words, and then modifying that word randomly until it is sufficiently complex and meets the configured rules computed for the user.<br/><br/>The value must be a valid URL, using the protocol "file" (local file system), "http", or "https".
 Setting_Description_pwm.selfURL=<p>The URL to this application, as seen by users. @PwmAppName@ uses the value in email macros and other user-facing communications.</p><p>The URL must use a valid fully qualified hostname. Do not use a network address.</p><p>In simple environments, the URL will be the base of the URL in the browser you are currently using to view this page, however in more complex environments the URL will typically be an upstream proxy, gateway or network device.</p><p>The URL should include the path to the base application, typically <code>/@Case:lower:[[@PwmAppName@]]@</code>.</p>
+Setting_Description_pwm.versionCheck.enable=Allow periodically checks for new versions.  If a new version is detected, an item will be shown on the health check.  No information about this installation is sent to the cloud server during the check.
 Setting_Description_pwm.wordlist.location=Specify a word list file URL for dictionary checking to prevent users from using commonly used words as passwords.   Using word lists is an important part of password security.  Word lists are used by intruders to guess common passwords.   The default word list included contains commonly used English passwords.  <br/><br/>The first time a startup occurs with a new word list setting, it takes some time to compile the word list into a database.  See the status screen and logs for progress information.  The word list file format is one or more text files containing a single word per line, enclosed in a ZIP file.  The String <i>\!\#comment\:</i> at the beginning of a line indicates a comment. <br/><br/>The value must be a valid URL, using the protocol "file" (local file system), "http", or "https".
 Setting_Description_pwNotify.storageMode=Select storage mode used by node service module.
 Setting_Description_pwNotify.enable=<p>Enable password expiration notification service.  Operation of this service requires that a node service be configured.  Status of this service can be viewed on the <code>Administration -> Dashboard -> Password Notification</code> page.  The service will nominally execute once per day on the master node server.</p><p>If a job is missed because of an @PwmAppName@, LDAP, or database service interruption it will be run within the next 24 hours as soon as service is restored.  Running a job more than once will not result in duplicate emails sent to the user.</p><p>If a user's password expiration time changes since the last job, a new notification will be sent as appropriate.</p>
@@ -1214,6 +1215,7 @@ Setting_Label_pwm.publishStats.siteDescription=Site Description
 Setting_Label_pwm.securityKey=Security Key
 Setting_Label_pwm.seedlist.location=Seed List File URL
 Setting_Label_pwm.selfURL=Site URL
+Setting_Label_pwm.versionCheck.enable=Enable Version Checking
 Setting_Label_pwm.wordlist.location=Word List File URL
 Setting_Label_pwNotify.storageMode=Storage Mode
 Setting_Label_pwNotify.enable=Enable Password Expiration Notification