From: Felix Dörre Date: Fri, 1 Dec 2017 22:18:38 +0000 (+0100) Subject: chg: revoke certificates if repeated ping failed X-Git-Url: https://code.wpia.club/?p=gigi.git;a=commitdiff_plain;h=7b709637bb12efc4a593a5ca6f312ed27566dad4 chg: revoke certificates if repeated ping failed Change-Id: I86c1045bb0ab1e47657cc445af4f1eb8c53e031c --- diff --git a/src/club/wpia/gigi/database/DatabaseConnection.java b/src/club/wpia/gigi/database/DatabaseConnection.java index 10e81e79..40eeae69 100644 --- a/src/club/wpia/gigi/database/DatabaseConnection.java +++ b/src/club/wpia/gigi/database/DatabaseConnection.java @@ -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; diff --git a/src/club/wpia/gigi/database/tableStructure.sql b/src/club/wpia/gigi/database/tableStructure.sql index 440bdec7..82aedc72 100644 --- a/src/club/wpia/gigi/database/tableStructure.sql +++ b/src/club/wpia/gigi/database/tableStructure.sql @@ -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 index 00000000..0c809ad8 --- /dev/null +++ b/src/club/wpia/gigi/database/upgrade/from_34.sql @@ -0,0 +1,2 @@ +ALTER TABLE "domainPinglog" ADD COLUMN "needsAction" boolean DEFAULT false; +CREATE INDEX ON "domainPinglog" ("when", "needsAction"); diff --git a/src/club/wpia/gigi/dbObjects/Domain.java b/src/club/wpia/gigi/dbObjects/Domain.java index e8accbca..9b356e60 100644 --- a/src/club/wpia/gigi/dbObjects/Domain.java +++ b/src/club/wpia/gigi/dbObjects/Domain.java @@ -90,7 +90,7 @@ public class Domain implements IdCachable, Verifyable { private LinkedList configs = null; - public List getConfiguredPings() throws GigiApiException { + public List getConfiguredPings() { LinkedList 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; + } + } + } diff --git a/src/club/wpia/gigi/dbObjects/DomainPingConfiguration.java b/src/club/wpia/gigi/dbObjects/DomainPingConfiguration.java index bdaad758..59db66cd 100644 --- a/src/club/wpia/gigi/dbObjects/DomainPingConfiguration.java +++ b/src/club/wpia/gigi/dbObjects/DomainPingConfiguration.java @@ -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; + } + } } diff --git a/src/club/wpia/gigi/dbObjects/DomainPingExecution.java b/src/club/wpia/gigi/dbObjects/DomainPingExecution.java index 174b2a18..0f01c1d7 100644 --- a/src/club/wpia/gigi/dbObjects/DomainPingExecution.java +++ b/src/club/wpia/gigi/dbObjects/DomainPingExecution.java @@ -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; } diff --git a/src/club/wpia/gigi/output/template/Template.java b/src/club/wpia/gigi/output/template/Template.java index ad53ac2e..65db37fe 100644 --- a/src/club/wpia/gigi/output/template/Template.java +++ b/src/club/wpia/gigi/output/template/Template.java @@ -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(""); + 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(""); + } } else { out.print(s == null ? "null" : (unescaped ? s.toString() : HTMLEncoder.encodeHTML(s.toString()))); } diff --git a/src/club/wpia/gigi/pages/account/domain/DomainPinglogForm.java b/src/club/wpia/gigi/pages/account/domain/DomainPinglogForm.java index e752557e..63f82d57 100644 --- a/src/club/wpia/gigi/pages/account/domain/DomainPinglogForm.java +++ b/src/club/wpia/gigi/pages/account/domain/DomainPinglogForm.java @@ -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()); diff --git a/src/club/wpia/gigi/ping/DNSPinger.java b/src/club/wpia/gigi/ping/DNSPinger.java index aeb41e5f..a7c74bd2 100644 --- a/src/club/wpia/gigi/ping/DNSPinger.java +++ b/src/club/wpia/gigi/ping/DNSPinger.java @@ -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 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); } } } diff --git a/src/club/wpia/gigi/ping/DomainPinger.java b/src/club/wpia/gigi/ping/DomainPinger.java index fcb8f12e..7b1d404c 100644 --- a/src/club/wpia/gigi/ping/DomainPinger.java +++ b/src/club/wpia/gigi/ping/DomainPinger.java @@ -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(); } diff --git a/src/club/wpia/gigi/ping/EmailPinger.java b/src/club/wpia/gigi/ping/EmailPinger.java index 454ba81d..7468f94a 100644 --- a/src/club/wpia/gigi/ping/EmailPinger.java +++ b/src/club/wpia/gigi/ping/EmailPinger.java @@ -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; } } diff --git a/src/club/wpia/gigi/ping/HTTPFetch.java b/src/club/wpia/gigi/ping/HTTPFetch.java index 7ad08b55..61f8b467 100644 --- a/src/club/wpia/gigi/ping/HTTPFetch.java +++ b/src/club/wpia/gigi/ping/HTTPFetch.java @@ -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 index 00000000..2ff34d7c --- /dev/null +++ b/src/club/wpia/gigi/ping/PingFailedWithActiveCertificates.templ @@ -0,0 +1,20 @@ +Subject: + + + + \ + \ + + + + \ + + + + + +- + + + + diff --git a/src/club/wpia/gigi/ping/PingerDaemon.java b/src/club/wpia/gigi/ping/PingerDaemon.java index d6016c67..895e8c83 100644 --- a/src/club/wpia/gigi/ping/PingerDaemon.java +++ b/src/club/wpia/gigi/ping/PingerDaemon.java @@ -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 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 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(cs) { + + @Override + public void apply(Certificate t, Language l, Map 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() + ")"); } diff --git a/src/club/wpia/gigi/ping/SSLPinger.java b/src/club/wpia/gigi/ping/SSLPinger.java index db0f0f52..97fb30da 100644 --- a/src/club/wpia/gigi/ping/SSLPinger.java +++ b/src/club/wpia/gigi/ping/SSLPinger.java @@ -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 index 00000000..f2b6fe10 --- /dev/null +++ b/tests/club/wpia/gigi/ping/TestTiming.java @@ -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 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; + } + +} diff --git a/tests/club/wpia/gigi/testUtils/ConfiguredTest.java b/tests/club/wpia/gigi/testUtils/ConfiguredTest.java index d8bb2ebf..3ece611c 100644 --- a/tests/club/wpia/gigi/testUtils/ConfiguredTest.java +++ b/tests/club/wpia/gigi/testUtils/ConfiguredTest.java @@ -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); diff --git a/tests/club/wpia/gigi/testUtils/PingTest.java b/tests/club/wpia/gigi/testUtils/PingTest.java index 87ea982f..06a94441 100644 --- a/tests/club/wpia/gigi/testUtils/PingTest.java +++ b/tests/club/wpia/gigi/testUtils/PingTest.java @@ -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(); diff --git a/util-testing/club/wpia/gigi/pages/Manager.java b/util-testing/club/wpia/gigi/pages/Manager.java index 26aeec06..f48c5bed 100644 --- a/util-testing/club/wpia/gigi/pages/Manager.java +++ b/util-testing/club/wpia/gigi/pages/Manager.java @@ -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); } }