chg: revoke certificates if repeated ping failed
authorFelix Dörre <felix@dogcraft.de>
Fri, 1 Dec 2017 22:18:38 +0000 (23:18 +0100)
committerLucas Werkmeister <mail@lucaswerkmeister.de>
Mon, 18 Dec 2017 23:08:49 +0000 (00:08 +0100)
Change-Id: I86c1045bb0ab1e47657cc445af4f1eb8c53e031c

19 files changed:
src/club/wpia/gigi/database/DatabaseConnection.java
src/club/wpia/gigi/database/tableStructure.sql
src/club/wpia/gigi/database/upgrade/from_34.sql [new file with mode: 0644]
src/club/wpia/gigi/dbObjects/Domain.java
src/club/wpia/gigi/dbObjects/DomainPingConfiguration.java
src/club/wpia/gigi/dbObjects/DomainPingExecution.java
src/club/wpia/gigi/output/template/Template.java
src/club/wpia/gigi/pages/account/domain/DomainPinglogForm.java
src/club/wpia/gigi/ping/DNSPinger.java
src/club/wpia/gigi/ping/DomainPinger.java
src/club/wpia/gigi/ping/EmailPinger.java
src/club/wpia/gigi/ping/HTTPFetch.java
src/club/wpia/gigi/ping/PingFailedWithActiveCertificates.templ [new file with mode: 0644]
src/club/wpia/gigi/ping/PingerDaemon.java
src/club/wpia/gigi/ping/SSLPinger.java
tests/club/wpia/gigi/ping/TestTiming.java [new file with mode: 0644]
tests/club/wpia/gigi/testUtils/ConfiguredTest.java
tests/club/wpia/gigi/testUtils/PingTest.java
util-testing/club/wpia/gigi/pages/Manager.java

index 10e81e7..40eeae6 100644 (file)
@@ -181,7 +181,7 @@ public class DatabaseConnection {
 
     }
 
-    public static final int CURRENT_SCHEMA_VERSION = 34;
+    public static final int CURRENT_SCHEMA_VERSION = 35;
 
     public static final int CONNECTION_TIMEOUT = 24 * 60 * 60;
 
index 440bdec..82aedc7 100644 (file)
@@ -103,9 +103,11 @@ CREATE TABLE "domainPinglog" (
   "configId" int NOT NULL,
   "state" "pingState" NOT NULL,
   "challenge" varchar(16),
-  "result" varchar(255)
+  "result" varchar(255),
+  "needsAction" boolean DEFAULT false
 );
 CREATE INDEX ON "domainPinglog" ("configId","when");
+CREATE INDEX ON "domainPinglog" ("when", "needsAction");
 
 DROP TABLE IF EXISTS "baddomains";
 CREATE TABLE "baddomains" (
@@ -378,7 +380,7 @@ CREATE TABLE "schemeVersion" (
   "version" smallint NOT NULL,
   PRIMARY KEY ("version")
 );
-INSERT INTO "schemeVersion" (version)  VALUES(34);
+INSERT INTO "schemeVersion" (version)  VALUES(35);
 
 DROP TABLE IF EXISTS `passwordResetTickets`;
 CREATE TABLE `passwordResetTickets` (
diff --git a/src/club/wpia/gigi/database/upgrade/from_34.sql b/src/club/wpia/gigi/database/upgrade/from_34.sql
new file mode 100644 (file)
index 0000000..0c809ad
--- /dev/null
@@ -0,0 +1,2 @@
+ALTER TABLE "domainPinglog" ADD COLUMN "needsAction" boolean DEFAULT false;
+CREATE INDEX ON "domainPinglog" ("when", "needsAction");
index e8accbc..9b356e6 100644 (file)
@@ -90,7 +90,7 @@ public class Domain implements IdCachable, Verifyable {
 
     private LinkedList<DomainPingConfiguration> configs = null;
 
-    public List<DomainPingConfiguration> getConfiguredPings() throws GigiApiException {
+    public List<DomainPingConfiguration> getConfiguredPings() {
         LinkedList<DomainPingConfiguration> configs = this.configs;
         if (configs == null) {
             configs = new LinkedList<>();
@@ -143,12 +143,26 @@ public class Domain implements IdCachable, Verifyable {
         }
     }
 
+    /**
+     * Determines current domain validity. A domain is valid, iff at least two
+     * configured pings are currently successful.
+     * 
+     * @return true, iff domain is valid
+     * @throws GigiApiException
+     */
     public boolean isVerified() {
-        try (GigiPreparedStatement ps = new GigiPreparedStatement("SELECT 1 FROM `domainPinglog` INNER JOIN `pingconfig` ON `pingconfig`.`id`=`domainPinglog`.`configId` WHERE `domainid`=? AND `state`='success'")) {
-            ps.setInt(1, id);
-            GigiResultSet rs = ps.executeQuery();
-            return rs.next();
+        int count = 0;
+        boolean[] used = new boolean[DomainPingType.values().length];
+        for (DomainPingConfiguration config : getConfiguredPings()) {
+            if (config.isValid() && !used[config.getType().ordinal()]) {
+                count++;
+                used[config.getType().ordinal()] = true;
+            }
+            if (count >= 2) {
+                return true;
+            }
         }
+        return false;
     }
 
     public DomainPingExecution[] getPings() throws GigiApiException {
@@ -195,4 +209,22 @@ public class Domain implements IdCachable, Verifyable {
         }
     }
 
+    public Certificate[] fetchActiveCertificates() {
+        try (GigiPreparedStatement ps = new GigiPreparedStatement("SELECT `certs`.`id` FROM `certs` INNER JOIN `subjectAlternativeNames` ON `subjectAlternativeNames`.`certId` = `certs`.`id` WHERE (`contents`=? OR RIGHT(`contents`,LENGTH(?)+1)=CONCAT('.',?::VARCHAR)) AND `type`='DNS' AND `revoked` IS NULL AND `expire` > CURRENT_TIMESTAMP AND `memid`=? GROUP BY `certs`.`id`", true)) {
+            ps.setString(1, suffix);
+            ps.setString(2, suffix);
+            ps.setString(3, suffix);
+            ps.setInt(4, owner.getId());
+            GigiResultSet rs = ps.executeQuery();
+            rs.last();
+            Certificate[] res = new Certificate[rs.getRow()];
+            rs.beforeFirst();
+            int i = 0;
+            while (rs.next()) {
+                res[i++] = Certificate.getById(rs.getInt(1));
+            }
+            return res;
+        }
+    }
+
 }
index bdaad75..59db66c 100644 (file)
@@ -1,5 +1,6 @@
 package club.wpia.gigi.dbObjects;
 
+import java.sql.Timestamp;
 import java.util.Date;
 
 import club.wpia.gigi.Gigi;
@@ -92,4 +93,47 @@ public class DomainPingConfiguration implements IdCachable {
         }
         throw new GigiApiException(SprintfCommand.createSimple("Reping is only allowed after {0} minutes, yours end at {1}.", REPING_MINIMUM_DELAY / 60 / 1000, new Date(lastExecution.getTime() + REPING_MINIMUM_DELAY)));
     }
+
+    /**
+     * Return true when there was a last execution and it succeeded.
+     * 
+     * @return if this ping is currently valid.
+     */
+    public boolean isValid() {
+        try (GigiPreparedStatement ps = new GigiPreparedStatement("SELECT state='success' AS bool from `domainPinglog` WHERE `configId`=? ORDER BY `when` DESC LIMIT 1")) {
+            ps.setInt(1, id);
+            GigiResultSet rs = ps.executeQuery();
+            if ( !rs.next()) {
+                return false;
+            }
+            return rs.getBoolean(1);
+        }
+    }
+
+    /**
+     * Return true when this ping has not been successful within the last 2
+     * weeks.
+     * 
+     * @param time
+     *            the point in time for which the determination is carried out.
+     * @return the value for this ping.
+     */
+    public boolean isStrictlyInvalid(Date time) {
+        Date lastSuccess = getLastSuccess();
+        if (lastSuccess.getTime() == 0) {
+            // never a successful ping
+            return true;
+        }
+        try (GigiPreparedStatement ps = new GigiPreparedStatement("SELECT `when` AS stamp from `domainPinglog` WHERE `configId`=? AND state='failed' AND `when` > ? ORDER BY `when` ASC LIMIT 1")) {
+            ps.setInt(1, id);
+            ps.setTimestamp(2, new Timestamp(lastSuccess.getTime()));
+            GigiResultSet rs = ps.executeQuery();
+            if (rs.next()) {
+                Date turnedInvalid = new Date(rs.getTimestamp("stamp").getTime());
+                // turned invalid older than 2 weeks ago
+                return turnedInvalid.getTime() < time.getTime() - 2L * 7 * 24 * 60 * 60 * 1000;
+            }
+            return false;
+        }
+    }
 }
index 174b2a1..0f01c1d 100644 (file)
@@ -3,24 +3,26 @@ package club.wpia.gigi.dbObjects;
 import java.sql.Timestamp;
 import java.util.Date;
 
+import club.wpia.gigi.database.GigiPreparedStatement;
 import club.wpia.gigi.database.GigiResultSet;
+import club.wpia.gigi.ping.DomainPinger.PingState;
 
 public class DomainPingExecution {
 
-    private String state;
+    private final PingState state;
 
-    private String type;
+    private final String type;
 
-    private String info;
+    private final String info;
 
-    private String result;
+    private final String result;
 
-    private DomainPingConfiguration config;
+    private final DomainPingConfiguration config;
 
-    private Timestamp date;
+    private final Timestamp date;
 
-    public DomainPingExecution(GigiResultSet rs) {
-        state = rs.getString(1);
+    protected DomainPingExecution(GigiResultSet rs) {
+        state = PingState.valueOf(rs.getString(1).toUpperCase());
         type = rs.getString(2);
         info = rs.getString(3);
         result = rs.getString(4);
@@ -28,7 +30,27 @@ public class DomainPingExecution {
         date = rs.getTimestamp(6);
     }
 
-    public String getState() {
+    public DomainPingExecution(PingState state, String result, DomainPingConfiguration config, String challenge) {
+        this.state = state;
+        this.type = config.getType().getDBName();
+        this.info = config.getInfo();
+        this.result = result;
+        this.config = config;
+        this.date = new Timestamp(System.currentTimeMillis());
+        try (GigiPreparedStatement enterPingResult = new GigiPreparedStatement("INSERT INTO `domainPinglog` SET `configId`=?, `state`=?::`pingState`, `result`=?, `challenge`=?, `when`=?, `needsAction`=?")) {
+            enterPingResult.setInt(1, config.getId());
+            enterPingResult.setEnum(2, state);
+            enterPingResult.setString(3, result);
+            enterPingResult.setString(4, challenge);
+            enterPingResult.setTimestamp(5, this.date);
+            // Ping results with current state "failed" need followup action in
+            // two weeks to revoke any remaining active certificates.
+            enterPingResult.setBoolean(6, state == PingState.FAILED);
+            enterPingResult.execute();
+        }
+    }
+
+    public PingState getState() {
         return state;
     }
 
index ad53ac2..65db37f 100644 (file)
@@ -264,10 +264,14 @@ public class Template implements Outputable {
             out.print(((Boolean) s) ? l.getTranslation("yes") : l.getTranslation("no"));
         } else if (s instanceof Date) {
             SimpleDateFormat sdfUI = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
-            out.print("<time datetime=\"" + sdf.format(s) + "\">");
-            out.print(sdfUI.format(s));
-            out.print(" UTC</time>");
+            if (vars.containsKey(Outputable.OUT_KEY_PLAIN)) {
+                out.print(sdfUI.format(s));
+            } else {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+                out.print("<time datetime=\"" + sdf.format(s) + "\">");
+                out.print(sdfUI.format(s));
+                out.print(" UTC</time>");
+            }
         } else {
             out.print(s == null ? "null" : (unescaped ? s.toString() : HTMLEncoder.encodeHTML(s.toString())));
         }
index e752557..63f82d5 100644 (file)
@@ -64,7 +64,7 @@ public class DomainPinglogForm extends Form {
                 if (counter >= pings.length) {
                     return false;
                 }
-                vars.put("state", pings[counter].getState());
+                vars.put("state", pings[counter].getState().getDBName());
                 vars.put("type", pings[counter].getType());
                 vars.put("config", pings[counter].getInfo());
                 vars.put("date", pings[counter].getDate());
index aeb41e5..a7c74bd 100644 (file)
@@ -7,20 +7,21 @@ import javax.naming.NamingException;
 
 import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import club.wpia.gigi.util.DNSUtil;
 import club.wpia.gigi.util.SystemKeywords;
 
 public class DNSPinger extends DomainPinger {
 
     @Override
-    public void ping(Domain domain, String expToken, CertificateOwner u, int confId) {
+    public DomainPingExecution ping(Domain domain, String expToken, CertificateOwner u, DomainPingConfiguration conf) {
         String[] tokenParts = expToken.split(":", 2);
         List<String> nameservers;
         try {
             nameservers = Arrays.asList(DNSUtil.getNSNames(domain.getSuffix()));
         } catch (NamingException e) {
-            enterPingResult(confId, "error", "No authorative nameserver found.", null);
-            return;
+            return enterPingResult(conf, "error", "No authorative nameserver found.", null);
         }
         StringBuffer result = new StringBuffer();
         result.append("failed: ");
@@ -51,9 +52,9 @@ public class DNSPinger extends DomainPinger {
 
         }
         if ( !failed) {
-            enterPingResult(confId, PING_SUCCEDED, "", null);
+            return enterPingResult(conf, PING_SUCCEDED, "", null);
         } else {
-            enterPingResult(confId, "error", result.toString(), null);
+            return enterPingResult(conf, "error", result.toString(), null);
         }
     }
 }
index fcb8f12..7b1d404 100644 (file)
@@ -4,6 +4,8 @@ import club.wpia.gigi.database.DBEnum;
 import club.wpia.gigi.database.GigiPreparedStatement;
 import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 
 public abstract class DomainPinger {
 
@@ -20,24 +22,18 @@ public abstract class DomainPinger {
 
     public static final String PING_SUCCEDED = "";
 
-    public abstract void ping(Domain domain, String configuration, CertificateOwner target, int confId);
+    public abstract DomainPingExecution ping(Domain domain, String configuration, CertificateOwner target, DomainPingConfiguration conf);
 
-    protected static void enterPingResult(int configId, String state, String result, String token) {
+    protected static DomainPingExecution enterPingResult(DomainPingConfiguration config, String state, String result, String token) {
         PingState estate = DomainPinger.PING_STILL_PENDING == state ? PingState.OPEN : DomainPinger.PING_SUCCEDED.equals(state) ? PingState.SUCCESS : PingState.FAILED;
-        try (GigiPreparedStatement enterPingResult = new GigiPreparedStatement("INSERT INTO `domainPinglog` SET `configId`=?, `state`=?::`pingState`, `result`=?, `challenge`=?")) {
-            enterPingResult.setInt(1, configId);
-            enterPingResult.setEnum(2, estate);
-            enterPingResult.setString(3, result);
-            enterPingResult.setString(4, token);
-            enterPingResult.execute();
-        }
+        return new DomainPingExecution(estate, result, config, token);
     }
 
-    protected static void updatePingResult(int configId, String state, String result, String token) {
+    protected static void updatePingResult(DomainPingConfiguration config, String state, String result, String token) {
         try (GigiPreparedStatement updatePingResult = new GigiPreparedStatement("UPDATE `domainPinglog` SET `state`=?::`pingState`, `result`=? WHERE `configId`=? AND `challenge`=?")) {
             updatePingResult.setString(1, DomainPinger.PING_STILL_PENDING == state ? "open" : DomainPinger.PING_SUCCEDED.equals(state) ? "success" : "failed");
             updatePingResult.setString(2, result);
-            updatePingResult.setInt(3, configId);
+            updatePingResult.setInt(3, config.getId());
             updatePingResult.setString(4, token);
             updatePingResult.execute();
         }
index 454ba81..7468f94 100644 (file)
@@ -5,6 +5,8 @@ import java.util.Locale;
 
 import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import club.wpia.gigi.dbObjects.User;
 import club.wpia.gigi.email.MailProbe;
 import club.wpia.gigi.localisation.Language;
@@ -13,11 +15,11 @@ import club.wpia.gigi.util.RandomToken;
 public class EmailPinger extends DomainPinger {
 
     @Override
-    public void ping(Domain domain, String configuration, CertificateOwner u, int confId) {
+    public DomainPingExecution ping(Domain domain, String configuration, CertificateOwner u, DomainPingConfiguration conf) {
         String mail = configuration + "@" + domain.getSuffix();
         String token = RandomToken.generateToken(16);
+        DomainPingExecution r = enterPingResult(conf, PING_STILL_PENDING, "", token);
         try {
-            enterPingResult(confId, PING_STILL_PENDING, "", token);
             Locale l = Locale.ENGLISH;
             if (u instanceof User) {
                 l = ((User) u).getPreferredLocale();
@@ -26,8 +28,9 @@ public class EmailPinger extends DomainPinger {
             MailProbe.sendMailProbe(Language.getInstance(l), "domain", domain.getId(), token, mail);
         } catch (IOException e) {
             e.printStackTrace();
-            updatePingResult(confId, "error", "Mail connection interrupted", token);
+            updatePingResult(conf, "error", "Mail connection interrupted", token);
         }
+        return r;
     }
 
 }
index 7ad08b5..61f8b46 100644 (file)
@@ -8,36 +8,33 @@ import java.net.URL;
 
 import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import club.wpia.gigi.util.SystemKeywords;
 
 public class HTTPFetch extends DomainPinger {
 
     @Override
-    public void ping(Domain domain, String expToken, CertificateOwner user, int confId) {
+    public DomainPingExecution ping(Domain domain, String expToken, CertificateOwner user, DomainPingConfiguration conf) {
         try {
             String[] tokenParts = expToken.split(":", 2);
             URL u = new URL("http://" + domain.getSuffix() + "/" + SystemKeywords.HTTP_CHALLENGE_PREFIX + tokenParts[0] + ".txt");
             HttpURLConnection huc = (HttpURLConnection) u.openConnection();
             if (huc.getResponseCode() != 200) {
-                enterPingResult(confId, "error", "Invalid status code " + huc.getResponseCode() + ".", null);
-                return;
+                return enterPingResult(conf, "error", "Invalid status code " + huc.getResponseCode() + ".", null);
             }
             BufferedReader br = new BufferedReader(new InputStreamReader(huc.getInputStream(), "UTF-8"));
             String line = br.readLine();
             if (line == null) {
-                enterPingResult(confId, "error", "Empty document.", null);
-                return;
+                return enterPingResult(conf, "error", "Empty document.", null);
             }
             if (line.trim().equals(tokenParts[1])) {
-                enterPingResult(confId, PING_SUCCEDED, "", null);
-                return;
+                return enterPingResult(conf, PING_SUCCEDED, "", null);
             }
-            enterPingResult(confId, "error", "Challenge tokens differed.", null);
-            return;
+            return enterPingResult(conf, "error", "Challenge tokens differed.", null);
         } catch (IOException e) {
             e.printStackTrace();
-            enterPingResult(confId, "error", "Exception: connection closed.", null);
-            return;
+            return enterPingResult(conf, "error", "Exception: connection closed.", null);
         }
     }
 }
diff --git a/src/club/wpia/gigi/ping/PingFailedWithActiveCertificates.templ b/src/club/wpia/gigi/ping/PingFailedWithActiveCertificates.templ
new file mode 100644 (file)
index 0000000..2ff34d7
--- /dev/null
@@ -0,0 +1,20 @@
+Subject: <?=_Ping for domain '${domain}' failed.?>
+
+<?=_Hi,?>
+
+<?=_A check of the ownership for your domain '${domain}' failed.?> \
+<?=_This check might either have been triggered manually or automatically for routine housekeeping.?> \
+<?=_Seeing a check fail we assume you might have lost ownership of this domain.?>
+
+<? if($valid) { ?>
+<?=_However there are currently enough succeeding proofs so the state of your domain is not endangered yet.?> \
+<?=_You might however want to correct (or remove) this ping to ensure that your domain stays valid.?>
+<? } else { ?>
+<?=_If you keep lacking sufficient proof of ownership for this domain after the grace period of two weeks expired we are going to revoke all affected certificates.?>
+
+<?=_Affected certificates:?>
+<? foreach($certs){ ?>- <?=_serial: ${serial} issued by ${ca} valid from ${from} to ${to}.?>
+<? } ?>
+<? } ?>
+
+<?=_Visit ${domainLink} to check the current state of pings of your domain and to find out more information about which ping failed.?>
index d6016c6..895e8c8 100644 (file)
@@ -1,17 +1,38 @@
 package club.wpia.gigi.ping;
 
+import java.io.IOException;
+import java.security.GeneralSecurityException;
 import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.sql.Timestamp;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedList;
+import java.util.Locale;
+import java.util.Map;
 import java.util.Queue;
 
+import club.wpia.gigi.GigiApiException;
 import club.wpia.gigi.database.DatabaseConnection;
 import club.wpia.gigi.database.DatabaseConnection.Link;
 import club.wpia.gigi.database.GigiPreparedStatement;
 import club.wpia.gigi.database.GigiResultSet;
+import club.wpia.gigi.dbObjects.Certificate;
+import club.wpia.gigi.dbObjects.Certificate.RevocationType;
+import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
 import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import club.wpia.gigi.dbObjects.DomainPingType;
+import club.wpia.gigi.dbObjects.Organisation;
+import club.wpia.gigi.dbObjects.User;
+import club.wpia.gigi.localisation.Language;
+import club.wpia.gigi.output.ArrayIterable;
+import club.wpia.gigi.output.template.MailTemplate;
+import club.wpia.gigi.pages.account.domain.EditDomain;
+import club.wpia.gigi.ping.DomainPinger.PingState;
+import club.wpia.gigi.util.ServerConstants;
+import club.wpia.gigi.util.ServerConstants.Host;
 
 public class PingerDaemon extends Thread {
 
@@ -21,6 +42,8 @@ public class PingerDaemon extends Thread {
 
     private Queue<DomainPingConfiguration> toExecute = new LinkedList<>();
 
+    private final MailTemplate pingFailedMail = new MailTemplate(PingerDaemon.class.getResource("PingFailedWithActiveCertificates.templ"));
+
     public PingerDaemon(KeyStore truststore) {
         this.truststore = truststore;
     }
@@ -36,10 +59,7 @@ public class PingerDaemon extends Thread {
 
     public void runWithConnection() {
 
-        pingers.put(DomainPingType.EMAIL, new EmailPinger());
-        pingers.put(DomainPingType.SSL, new SSLPinger(truststore));
-        pingers.put(DomainPingType.HTTP, new HTTPFetch());
-        pingers.put(DomainPingType.DNS, new DNSPinger());
+        initializeConnectionUsage();
 
         while (true) {
             try {
@@ -53,22 +73,8 @@ public class PingerDaemon extends Thread {
                     }
                     notifyAll();
                 }
-                try (GigiPreparedStatement searchNeededPings = new GigiPreparedStatement("SELECT `pc`.`id`" //
-                        + " FROM `pingconfig` AS `pc`" //
-                        + " INNER JOIN `domains` AS `d` ON `d`.`id` = `pc`.`domainid`" //
-                        + " WHERE `d`.`deleted` IS NULL" //
-                        + "  AND `pc`.`deleted` IS NULL" //
-                        + "  AND NOT EXISTS (" //
-                        + "    SELECT 1 FROM `domainPinglog` AS `dpl`" //
-                        + "    WHERE `dpl`.`configId` = `pc`.`id`" //
-                        + "     AND `dpl`.`when` >= CURRENT_TIMESTAMP - interval '6 mons')")) {
-
-                    GigiResultSet rs = searchNeededPings.executeQuery();
-                    while (rs.next()) {
-                        worked = true;
-                        handle(DomainPingConfiguration.getById(rs.getInt("id")));
-                    }
-                }
+                long time = System.currentTimeMillis();
+                worked |= executeNeededPings(new Date(time));
                 try {
                     if ( !worked) {
                         Thread.sleep(5000);
@@ -85,7 +91,90 @@ public class PingerDaemon extends Thread {
         }
     }
 
-    private void handle(DomainPingConfiguration conf) {
+    protected void initializeConnectionUsage() {
+        pingers.put(DomainPingType.EMAIL, new EmailPinger());
+        pingers.put(DomainPingType.SSL, new SSLPinger(truststore));
+        pingers.put(DomainPingType.HTTP, new HTTPFetch());
+        pingers.put(DomainPingType.DNS, new DNSPinger());
+    }
+
+    public synchronized boolean executeNeededPings(Date time) {
+        boolean worked = false;
+        try (GigiPreparedStatement searchNeededPings = new GigiPreparedStatement("SELECT `d`.`id`, `dpl`.`configId`, `dpl`.`when`," //
+        // .. for all found pings we want to know, if we do not have a more
+        // recent successful ping
+                + "  NOT EXISTS (" //
+                + "    SELECT 1 FROM `domainPinglog` AS `dpl2`" //
+                + "    WHERE `dpl`.`configId` = `dpl2`.`configId`" //
+                + "     AND `dpl2`.state = 'success' AND `dpl2`.`when` > `dpl`.`when`) AS `resucceeded`" //
+                + " FROM `domainPinglog` AS `dpl`" //
+                // We search valid pings
+                + " INNER JOIN `pingconfig` AS `pc` ON `pc`.`id` = `dpl`.`configId` AND `pc`.`deleted` IS NULL" //
+                + " INNER JOIN `domains` AS `d` ON `d`.`id` = `pc`.`domainid` AND `d`.`deleted` IS NULL" //
+                // .. that failed, ..
+                + " WHERE `dpl`.`state` = 'failed'" //
+                // .. are older than 2 weeks
+                + " AND `dpl`.`when` <= ?::timestamp - interval '2 weeks'" //
+                // .. and are flagged for corrective action
+                + " AND `dpl`.`needsAction`" //
+        )) {
+            searchNeededPings.setTimestamp(1, new Timestamp(time.getTime()));
+            GigiResultSet rs = searchNeededPings.executeQuery();
+            try (GigiPreparedStatement updateDone = new GigiPreparedStatement("UPDATE `domainPinglog` SET `needsAction`=false WHERE `configId`=? AND `when`=?")) {
+                while (rs.next()) {
+                    worked = true;
+                    // Give this ping a last chance to succeed.
+                    handle(DomainPingConfiguration.getById(rs.getInt(2)));
+                    // We only consider revoking if this ping has not been
+                    // superseded by a following successful ping.
+                    if (rs.getBoolean(4)) {
+                        Domain d = Domain.getById(rs.getInt(1));
+                        int ct = 0;
+                        boolean[] used = new boolean[DomainPingType.values().length];
+                        // We only revoke, there are not 2 pings that are not
+                        // 'strictly invalid'
+                        for (DomainPingConfiguration cfg : d.getConfiguredPings()) {
+                            if ( !cfg.isStrictlyInvalid(time) && !used[cfg.getType().ordinal()]) {
+                                ct++;
+                                used[cfg.getType().ordinal()] = true;
+                            }
+                            if (ct >= 2) {
+                                break;
+                            }
+                        }
+                        if (ct < 2) {
+                            for (Certificate c : d.fetchActiveCertificates()) {
+                                // TODO notify user
+                                c.revoke(RevocationType.PING_TIMEOUT);
+                            }
+                        }
+                    }
+                    updateDone.setInt(1, rs.getInt(2));
+                    updateDone.setTimestamp(2, rs.getTimestamp(3));
+                    updateDone.executeUpdate();
+                }
+            }
+        }
+        try (GigiPreparedStatement searchNeededPings = new GigiPreparedStatement("SELECT `pc`.`id`" //
+                + " FROM `pingconfig` AS `pc`" //
+                + " INNER JOIN `domains` AS `d` ON `d`.`id` = `pc`.`domainid`" //
+                + " WHERE `d`.`deleted` IS NULL" //
+                + "  AND `pc`.`deleted` IS NULL" //
+                + "  AND NOT EXISTS (" //
+                + "    SELECT 1 FROM `domainPinglog` AS `dpl`" //
+                + "    WHERE `dpl`.`configId` = `pc`.`id`" //
+                + "     AND `dpl`.`when` >= ?::timestamp - interval '6 mons')")) {
+            searchNeededPings.setTimestamp(1, new Timestamp(time.getTime()));
+            GigiResultSet rs = searchNeededPings.executeQuery();
+            while (rs.next()) {
+                worked = true;
+                handle(DomainPingConfiguration.getById(rs.getInt("id")));
+            }
+        }
+        return worked;
+    }
+
+    protected void handle(DomainPingConfiguration conf) {
         DomainPingType type = conf.getType();
         String config = conf.getInfo();
         DomainPinger dp = pingers.get(type);
@@ -93,10 +182,53 @@ public class PingerDaemon extends Thread {
             Domain target = conf.getTarget();
             System.err.println("Executing " + dp + " on " + target + " (" + System.currentTimeMillis() + ")");
             try {
-                dp.ping(target, config, target.getOwner(), conf.getId());
+                DomainPingExecution x = dp.ping(target, config, target.getOwner(), conf);
+                if (x.getState() == PingState.FAILED) {
+                    Certificate[] cs = target.fetchActiveCertificates();
+                    if (cs.length != 0) {
+                        CertificateOwner o = target.getOwner();
+                        Locale l = Locale.ENGLISH;
+                        String contact;
+                        if (o instanceof User) {
+                            l = ((User) o).getPreferredLocale();
+                            contact = ((User) o).getEmail();
+                        } else if (o instanceof Organisation) {
+                            contact = ((Organisation) o).getContactEmail();
+
+                        } else {
+                            throw new Error();
+                        }
+                        HashMap<String, Object> vars = new HashMap<>();
+                        vars.put("valid", target.isVerified());
+                        vars.put("domain", target.getSuffix());
+                        vars.put("domainLink", "https://" + ServerConstants.getHostNamePortSecure(Host.WWW) + "/" + EditDomain.PATH + target.getId());
+                        vars.put("certs", new ArrayIterable<Certificate>(cs) {
+
+                            @Override
+                            public void apply(Certificate t, Language l, Map<String, Object> vars) {
+                                vars.put("serial", t.getSerial());
+                                vars.put("ca", t.getParent().getKeyname());
+                                try {
+                                    X509Certificate c = t.cert();
+                                    vars.put("from", c.getNotBefore());
+                                    vars.put("to", c.getNotAfter());
+                                } catch (IOException e) {
+                                    e.printStackTrace();
+                                } catch (GeneralSecurityException e) {
+                                    e.printStackTrace();
+                                } catch (GigiApiException e) {
+                                    e.printStackTrace();
+                                }
+                            }
+
+                        });
+                        pingFailedMail.sendMail(Language.getInstance(l), vars, contact);
+                        System.out.println("Ping failed with active certificates");
+                    }
+                }
             } catch (Throwable t) {
                 t.printStackTrace();
-                DomainPinger.enterPingResult(conf.getId(), "error", "exception", null);
+                DomainPinger.enterPingResult(conf, "error", "exception", null);
             }
             System.err.println("done (" + System.currentTimeMillis() + ")");
         }
index db0f0f5..97fb30d 100644 (file)
@@ -33,6 +33,8 @@ import club.wpia.gigi.dbObjects.CACertificate;
 import club.wpia.gigi.dbObjects.Certificate;
 import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import sun.security.x509.AVA;
 import sun.security.x509.X500Name;
 
@@ -51,7 +53,7 @@ public class SSLPinger extends DomainPinger {
     }
 
     @Override
-    public void ping(Domain domain, String configuration, CertificateOwner u, int confId) {
+    public DomainPingExecution ping(Domain domain, String configuration, CertificateOwner u, DomainPingConfiguration conf) {
         try (SocketChannel sch = SocketChannel.open()) {
             sch.socket().setSoTimeout(5000);
             String[] parts = configuration.split(":", 4);
@@ -76,11 +78,9 @@ public class SSLPinger extends DomainPinger {
             String key = parts[0];
             String value = parts[1];
             String res = test(sch, domain.getSuffix(), u, value);
-            enterPingResult(confId, res, res, null);
-            return;
+            return enterPingResult(conf, res, res, null);
         } catch (IOException e) {
-            enterPingResult(confId, "error", "connection Failed", null);
-            return;
+            return enterPingResult(conf, "error", "connection Failed", null);
         }
 
     }
diff --git a/tests/club/wpia/gigi/ping/TestTiming.java b/tests/club/wpia/gigi/ping/TestTiming.java
new file mode 100644 (file)
index 0000000..f2b6fe1
--- /dev/null
@@ -0,0 +1,164 @@
+package club.wpia.gigi.ping;
+
+import static org.junit.Assert.*;
+import static org.junit.Assume.*;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.util.Date;
+import java.util.List;
+
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+
+import club.wpia.gigi.GigiApiException;
+import club.wpia.gigi.dbObjects.Certificate;
+import club.wpia.gigi.dbObjects.Certificate.CSRType;
+import club.wpia.gigi.dbObjects.Certificate.CertificateStatus;
+import club.wpia.gigi.dbObjects.Certificate.SANType;
+import club.wpia.gigi.dbObjects.CertificateProfile;
+import club.wpia.gigi.dbObjects.Digest;
+import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
+import club.wpia.gigi.dbObjects.DomainPingType;
+import club.wpia.gigi.ping.DomainPinger.PingState;
+import club.wpia.gigi.testUtils.PingTest;
+import club.wpia.gigi.testUtils.TestEmailReceiver.TestMail;
+import club.wpia.gigi.util.RandomToken;
+import club.wpia.gigi.util.SimpleSigner;
+
+public class TestTiming extends PingTest {
+
+    @Test
+    public void httpAndMailSuccessCert() throws GigiApiException, IOException, InterruptedException, GeneralSecurityException {
+        httpAndMailSuccess(true, false);
+    }
+
+    @Test
+    public void httpAndMailSuccessCertAndCorrect() throws GigiApiException, IOException, InterruptedException, GeneralSecurityException {
+        httpAndMailSuccess(true, true);
+    }
+
+    @Test
+    public void httpAndMailSuccessNoCert() throws GigiApiException, IOException, InterruptedException, GeneralSecurityException {
+        httpAndMailSuccess(false, false);
+    }
+
+    public void httpAndMailSuccess(boolean certs, boolean correct) throws GigiApiException, IOException, InterruptedException, GeneralSecurityException {
+        String test = getTestProps().getProperty("domain.http");
+        assumeNotNull(test);
+
+        // When we have a domain.
+        Domain d = new Domain(u, u, test);
+        String token = RandomToken.generateToken(16);
+        String value = RandomToken.generateToken(16);
+
+        // If we run the sub case that we have certificates on the domain,
+        // create a certificate now.
+        Certificate c = null;
+        if (certs) {
+            KeyPair kp = generateKeypair();
+            String key = generatePEMCSR(kp, "CN=testmail@example.com");
+            c = new Certificate(u, u, Certificate.buildDN("CN", "testmail@example.com"), Digest.SHA256, key, CSRType.CSR, CertificateProfile.getByName("server"), new Certificate.SubjectAlternateName(SANType.DNS, test));
+            await(c.issue(null, "2y", u));
+        }
+
+        // Register HTTP and Email pings.
+        updateService(token, value, "http");
+        d.addPing(DomainPingType.EMAIL, "postmaster");
+        d.addPing(DomainPingType.HTTP, token + ":" + value);
+
+        // Two successful pings
+        getMailReceiver().receive("postmaster@" + test).verify();
+        waitForPings(2);
+
+        assertEquals(0, countFailed(d.getPings(), 2));
+
+        // An own Pinger Daemon to control ping execution locally.
+        PingerDaemon pd = new PingerDaemon(null);
+        pd.initializeConnectionUsage();
+
+        // After 6 months the pings are executed again
+        pd.executeNeededPings(new Date(System.currentTimeMillis() + 6 * 31 * 24 * 60 * 60L * 1000));
+        getMailReceiver().receive("postmaster@" + test).verify();
+        waitForPings(4);
+        assertEquals(0, countFailed(d.getPings(), 4));
+
+        // After 6 months the pings are executed again, but when the HTTP file
+        // is wrong, that ping fails.
+        updateService(token, value + "broken", "http");
+        // Note that the time is still 6 months in the future, as the pings from
+        // before were still executed (and logged)
+        // as executed now.
+        pd.executeNeededPings(new Date(System.currentTimeMillis() + 6 * 31 * 24 * 60 * 60L * 1000));
+        getMailReceiver().receive("postmaster@" + test).verify();
+        waitForPings(6);
+        assertEquals(1, countFailed(d.getPings(), 6));
+        // Which renders the domain invalid
+        assertFalse(d.isVerified());
+
+        if (certs) {
+            // And the user gets a warning-mail if there was a cert
+            TestMail mail = getMailReceiver().receive(u.getEmail());
+            assertThat(mail.getMessage(), CoreMatchers.containsString(d.getSuffix()));
+            assertThat(mail.getMessage(), CoreMatchers.containsString(c.getSerial()));
+            if ( !correct) {
+                // If the user ignores the warning, after two weeks
+                pd.executeNeededPings(new Date(System.currentTimeMillis() + 15 * 24 * 60 * 60L * 1000));
+                // The user receives another warning mail.
+                mail = getMailReceiver().receive(u.getEmail());
+                assertThat(mail.getMessage(), CoreMatchers.containsString(d.getSuffix()));
+                assertThat(mail.getMessage(), CoreMatchers.containsString(c.getSerial()));
+                // And when the revocation is carried out
+                SimpleSigner.ping();
+                // ... and the certificate gets revoked.
+                assertEquals(CertificateStatus.REVOKED, c.getStatus());
+            } else {
+                // But if the user corrects the ping, ...
+                updateService(token, value, "http");
+                // ... and the ping is re-executed,
+                pd.handle(getPing(d.getConfiguredPings(), DomainPingType.HTTP));
+                waitForPings(7);
+                assertEquals(1, countFailed(d.getPings(), 7));
+
+                // Even after two weeks
+                pd.executeNeededPings(new Date(System.currentTimeMillis() + 15 * 24 * 60 * 60L * 1000));
+                // and all resulting jobs are executed
+                SimpleSigner.ping();
+                // ... the certificate stays valid.
+                assertEquals(CertificateStatus.ISSUED, c.getStatus());
+            }
+        } else {
+            // otherwise there is no mail
+        }
+
+    }
+
+    private DomainPingConfiguration getPing(List<DomainPingConfiguration> cp, DomainPingType tp) {
+        for (DomainPingConfiguration d : cp) {
+            if (d.getType() == tp) {
+                return d;
+            }
+        }
+        throw new Error("Type not found.");
+    }
+
+    private int countFailed(DomainPingExecution[] pg, int count) {
+        assertEquals(count, pg.length);
+        int fld = 0;
+        for (DomainPingExecution e : pg) {
+            PingState state = e.getState();
+            if (e.getConfig().getType() == DomainPingType.HTTP) {
+                if (state == PingState.FAILED) {
+                    fld++;
+                    continue;
+                }
+            }
+            assertEquals(PingState.SUCCESS, state);
+        }
+        return fld;
+    }
+
+}
index d8bb2eb..3ece611 100644 (file)
@@ -351,6 +351,12 @@ public abstract class ConfiguredTest {
             d.addPing(DomainPingType.EMAIL, "admin");
             TestMail testMail = getMailReceiver().receive("admin@" + d.getSuffix());
             testMail.verify();
+            // Enforce successful ping :-)
+            d.addPing(DomainPingType.HTTP, "a:b");
+            try (GigiPreparedStatement gps = new GigiPreparedStatement("INSERT INTO `domainPinglog` SET `configId`=(SELECT `id` FROM `pingconfig` WHERE `domainid`=? AND `type`='http'), state='success', needsAction=false")) {
+                gps.setInt(1, d.getId());
+                gps.execute();
+            }
             assertTrue(d.isVerified());
         } catch (GigiApiException e) {
             throw new Error(e);
index 87ea982..06a9444 100644 (file)
@@ -21,7 +21,7 @@ import club.wpia.gigi.util.SystemKeywords;
 
 /**
  * Base class for test suites that check extensively if the domain-ping
- * functionality wroks as expected.
+ * functionality works as expected.
  */
 public abstract class PingTest extends ClientTest {
 
@@ -34,7 +34,7 @@ public abstract class PingTest extends ClientTest {
         assertEquals(200, ((HttpURLConnection) new URL(url).openConnection()).getResponseCode());
     }
 
-    protected void waitForPings(int count) throws SQLException, InterruptedException {
+    protected void waitForPings(int count) throws InterruptedException {
         try (GigiPreparedStatement ps = new GigiPreparedStatement("SELECT COUNT(*) FROM `domainPinglog`")) {
             long start = System.currentTimeMillis();
             while (System.currentTimeMillis() - start < 10000) {
@@ -77,6 +77,12 @@ public abstract class PingTest extends ClientTest {
         return m;
     }
 
+    /**
+     * Needs to be @After class. In order to init {@link ClientTest#id}
+     * correctly, this needs to be after test. Before test this might be
+     * executed after the init of {@link ClientTest#id} and make that value
+     * invalid.
+     */
     @After
     public void purgeDbAfterTest() throws SQLException, IOException {
         purgeDatabase();
index 26aeec0..f48c5be 100644 (file)
@@ -39,6 +39,8 @@ import club.wpia.gigi.dbObjects.CertificateOwner;
 import club.wpia.gigi.dbObjects.Country;
 import club.wpia.gigi.dbObjects.Digest;
 import club.wpia.gigi.dbObjects.Domain;
+import club.wpia.gigi.dbObjects.DomainPingConfiguration;
+import club.wpia.gigi.dbObjects.DomainPingExecution;
 import club.wpia.gigi.dbObjects.DomainPingType;
 import club.wpia.gigi.dbObjects.EmailAddress;
 import club.wpia.gigi.dbObjects.Group;
@@ -233,14 +235,14 @@ public class Manager extends Page {
         }
 
         @Override
-        public void ping(Domain domain, String configuration, CertificateOwner target, int confId) {
+        public DomainPingExecution ping(Domain domain, String configuration, CertificateOwner target, DomainPingConfiguration conf) {
             System.err.println("TestManager: " + domain.getSuffix());
             if (pingExempt.contains(domain.getSuffix())) {
-                enterPingResult(confId, DomainPinger.PING_SUCCEDED, "Succeeded by TestManager pass-by", null);
+                return enterPingResult(conf, DomainPinger.PING_SUCCEDED, "Succeeded by TestManager pass-by", null);
             } else {
                 DomainPinger pinger = dps.get(dpt);
                 System.err.println("Forward to old pinger: " + pinger);
-                pinger.ping(domain, configuration, target, confId);
+                return pinger.ping(domain, configuration, target, conf);
             }
         }