mirror of
https://github.com/MarginaliaSearch/MarginaliaSearch.git
synced 2025-10-06 17:32:39 +02:00
Compare commits
19 Commits
deploy-025
...
deploy-026
Author | SHA1 | Date | |
---|---|---|---|
|
24ab8398bb | ||
|
d2ceeff4cf | ||
|
cf64214b1c | ||
|
e50d09cc01 | ||
|
bce3892ce0 | ||
|
36581b25c2 | ||
|
52ff7fb4dd | ||
|
a4e49e658a | ||
|
e2c56dc3ca | ||
|
470b866008 | ||
|
4895a2ac7a | ||
|
fd32ae9fa7 | ||
|
470651ea4c | ||
|
8d4829e783 | ||
|
1290bc15dc | ||
|
e7fa558954 | ||
|
720685bf3f | ||
|
cbec63c7da | ||
|
b03ca75785 |
@@ -45,7 +45,7 @@ public class NodeConfigurationService {
|
|||||||
public List<NodeConfiguration> getAll() {
|
public List<NodeConfiguration> getAll() {
|
||||||
try (var conn = dataSource.getConnection();
|
try (var conn = dataSource.getConnection();
|
||||||
var qs = conn.prepareStatement("""
|
var qs = conn.prepareStatement("""
|
||||||
SELECT ID, DESCRIPTION, ACCEPT_QUERIES, AUTO_CLEAN, PRECESSION, KEEP_WARCS, NODE_PROFILE, DISABLED
|
SELECT ID, DESCRIPTION, ACCEPT_QUERIES, AUTO_CLEAN, PRECESSION, AUTO_ASSIGN_DOMAINS, KEEP_WARCS, NODE_PROFILE, DISABLED
|
||||||
FROM NODE_CONFIGURATION
|
FROM NODE_CONFIGURATION
|
||||||
""")) {
|
""")) {
|
||||||
var rs = qs.executeQuery();
|
var rs = qs.executeQuery();
|
||||||
@@ -59,6 +59,7 @@ public class NodeConfigurationService {
|
|||||||
rs.getBoolean("ACCEPT_QUERIES"),
|
rs.getBoolean("ACCEPT_QUERIES"),
|
||||||
rs.getBoolean("AUTO_CLEAN"),
|
rs.getBoolean("AUTO_CLEAN"),
|
||||||
rs.getBoolean("PRECESSION"),
|
rs.getBoolean("PRECESSION"),
|
||||||
|
rs.getBoolean("AUTO_ASSIGN_DOMAINS"),
|
||||||
rs.getBoolean("KEEP_WARCS"),
|
rs.getBoolean("KEEP_WARCS"),
|
||||||
NodeProfile.valueOf(rs.getString("NODE_PROFILE")),
|
NodeProfile.valueOf(rs.getString("NODE_PROFILE")),
|
||||||
rs.getBoolean("DISABLED")
|
rs.getBoolean("DISABLED")
|
||||||
@@ -75,7 +76,7 @@ public class NodeConfigurationService {
|
|||||||
public NodeConfiguration get(int nodeId) throws SQLException {
|
public NodeConfiguration get(int nodeId) throws SQLException {
|
||||||
try (var conn = dataSource.getConnection();
|
try (var conn = dataSource.getConnection();
|
||||||
var qs = conn.prepareStatement("""
|
var qs = conn.prepareStatement("""
|
||||||
SELECT ID, DESCRIPTION, ACCEPT_QUERIES, AUTO_CLEAN, PRECESSION, KEEP_WARCS, NODE_PROFILE, DISABLED
|
SELECT ID, DESCRIPTION, ACCEPT_QUERIES, AUTO_CLEAN, PRECESSION, AUTO_ASSIGN_DOMAINS, KEEP_WARCS, NODE_PROFILE, DISABLED
|
||||||
FROM NODE_CONFIGURATION
|
FROM NODE_CONFIGURATION
|
||||||
WHERE ID=?
|
WHERE ID=?
|
||||||
""")) {
|
""")) {
|
||||||
@@ -88,6 +89,7 @@ public class NodeConfigurationService {
|
|||||||
rs.getBoolean("ACCEPT_QUERIES"),
|
rs.getBoolean("ACCEPT_QUERIES"),
|
||||||
rs.getBoolean("AUTO_CLEAN"),
|
rs.getBoolean("AUTO_CLEAN"),
|
||||||
rs.getBoolean("PRECESSION"),
|
rs.getBoolean("PRECESSION"),
|
||||||
|
rs.getBoolean("AUTO_ASSIGN_DOMAINS"),
|
||||||
rs.getBoolean("KEEP_WARCS"),
|
rs.getBoolean("KEEP_WARCS"),
|
||||||
NodeProfile.valueOf(rs.getString("NODE_PROFILE")),
|
NodeProfile.valueOf(rs.getString("NODE_PROFILE")),
|
||||||
rs.getBoolean("DISABLED")
|
rs.getBoolean("DISABLED")
|
||||||
@@ -102,7 +104,7 @@ public class NodeConfigurationService {
|
|||||||
try (var conn = dataSource.getConnection();
|
try (var conn = dataSource.getConnection();
|
||||||
var us = conn.prepareStatement("""
|
var us = conn.prepareStatement("""
|
||||||
UPDATE NODE_CONFIGURATION
|
UPDATE NODE_CONFIGURATION
|
||||||
SET DESCRIPTION=?, ACCEPT_QUERIES=?, AUTO_CLEAN=?, PRECESSION=?, KEEP_WARCS=?, DISABLED=?, NODE_PROFILE=?
|
SET DESCRIPTION=?, ACCEPT_QUERIES=?, AUTO_CLEAN=?, PRECESSION=?, AUTO_ASSIGN_DOMAINS=?, KEEP_WARCS=?, DISABLED=?, NODE_PROFILE=?
|
||||||
WHERE ID=?
|
WHERE ID=?
|
||||||
"""))
|
"""))
|
||||||
{
|
{
|
||||||
@@ -110,10 +112,11 @@ public class NodeConfigurationService {
|
|||||||
us.setBoolean(2, config.acceptQueries());
|
us.setBoolean(2, config.acceptQueries());
|
||||||
us.setBoolean(3, config.autoClean());
|
us.setBoolean(3, config.autoClean());
|
||||||
us.setBoolean(4, config.includeInPrecession());
|
us.setBoolean(4, config.includeInPrecession());
|
||||||
us.setBoolean(5, config.keepWarcs());
|
us.setBoolean(5, config.autoAssignDomains());
|
||||||
us.setBoolean(6, config.disabled());
|
us.setBoolean(6, config.keepWarcs());
|
||||||
us.setString(7, config.profile().name());
|
us.setBoolean(7, config.disabled());
|
||||||
us.setInt(8, config.node());
|
us.setString(8, config.profile().name());
|
||||||
|
us.setInt(9, config.node());
|
||||||
|
|
||||||
if (us.executeUpdate() <= 0)
|
if (us.executeUpdate() <= 0)
|
||||||
throw new IllegalStateException("Failed to update configuration");
|
throw new IllegalStateException("Failed to update configuration");
|
||||||
|
@@ -5,6 +5,7 @@ public record NodeConfiguration(int node,
|
|||||||
boolean acceptQueries,
|
boolean acceptQueries,
|
||||||
boolean autoClean,
|
boolean autoClean,
|
||||||
boolean includeInPrecession,
|
boolean includeInPrecession,
|
||||||
|
boolean autoAssignDomains,
|
||||||
boolean keepWarcs,
|
boolean keepWarcs,
|
||||||
NodeProfile profile,
|
NodeProfile profile,
|
||||||
boolean disabled
|
boolean disabled
|
||||||
|
@@ -2,6 +2,7 @@ package nu.marginalia.nodecfg;
|
|||||||
|
|
||||||
import com.zaxxer.hikari.HikariConfig;
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import nu.marginalia.nodecfg.model.NodeConfiguration;
|
||||||
import nu.marginalia.nodecfg.model.NodeProfile;
|
import nu.marginalia.nodecfg.model.NodeProfile;
|
||||||
import nu.marginalia.test.TestMigrationLoader;
|
import nu.marginalia.test.TestMigrationLoader;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
@@ -62,6 +63,63 @@ public class NodeConfigurationServiceTest {
|
|||||||
assertEquals(2, list.size());
|
assertEquals(2, list.size());
|
||||||
assertEquals(a, list.get(0));
|
assertEquals(a, list.get(0));
|
||||||
assertEquals(b, list.get(1));
|
assertEquals(b, list.get(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Test all the fields that are only exposed via save()
|
||||||
|
@Test
|
||||||
|
public void testSaveChanges() throws SQLException {
|
||||||
|
var original = nodeConfigurationService.create(1, "Test", false, false, NodeProfile.MIXED);
|
||||||
|
|
||||||
|
assertEquals(1, original.node());
|
||||||
|
assertEquals("Test", original.description());
|
||||||
|
assertFalse(original.acceptQueries());
|
||||||
|
|
||||||
|
var precession = new NodeConfiguration(
|
||||||
|
original.node(),
|
||||||
|
"Foo",
|
||||||
|
true,
|
||||||
|
original.autoClean(),
|
||||||
|
original.includeInPrecession(),
|
||||||
|
!original.autoAssignDomains(),
|
||||||
|
original.keepWarcs(),
|
||||||
|
original.profile(),
|
||||||
|
original.disabled()
|
||||||
|
);
|
||||||
|
|
||||||
|
nodeConfigurationService.save(precession);
|
||||||
|
precession = nodeConfigurationService.get(original.node());
|
||||||
|
assertNotEquals(original.autoAssignDomains(), precession.autoAssignDomains());
|
||||||
|
|
||||||
|
var autoClean = new NodeConfiguration(
|
||||||
|
original.node(),
|
||||||
|
"Foo",
|
||||||
|
true,
|
||||||
|
!original.autoClean(),
|
||||||
|
original.includeInPrecession(),
|
||||||
|
original.autoAssignDomains(),
|
||||||
|
original.keepWarcs(),
|
||||||
|
original.profile(),
|
||||||
|
original.disabled()
|
||||||
|
);
|
||||||
|
|
||||||
|
nodeConfigurationService.save(autoClean);
|
||||||
|
autoClean = nodeConfigurationService.get(original.node());
|
||||||
|
assertNotEquals(original.autoClean(), autoClean.autoClean());
|
||||||
|
|
||||||
|
var disabled = new NodeConfiguration(
|
||||||
|
original.node(),
|
||||||
|
"Foo",
|
||||||
|
true,
|
||||||
|
autoClean.autoClean(),
|
||||||
|
autoClean.includeInPrecession(),
|
||||||
|
autoClean.autoAssignDomains(),
|
||||||
|
autoClean.keepWarcs(),
|
||||||
|
autoClean.profile(),
|
||||||
|
!autoClean.disabled()
|
||||||
|
);
|
||||||
|
nodeConfigurationService.save(disabled);
|
||||||
|
disabled = nodeConfigurationService.get(original.node());
|
||||||
|
assertNotEquals(autoClean.disabled(), disabled.disabled());
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add additional summary columns to DOMAIN_SECURITY_INFORMATION table
|
||||||
|
-- to make it easier to get more information about the SSL certificate's validity
|
||||||
|
|
||||||
|
ALTER TABLE DOMAIN_SECURITY_INFORMATION ADD COLUMN SSL_CHAIN_VALID BOOLEAN DEFAULT NULL;
|
||||||
|
ALTER TABLE DOMAIN_SECURITY_INFORMATION ADD COLUMN SSL_HOST_VALID BOOLEAN DEFAULT NULL;
|
||||||
|
ALTER TABLE DOMAIN_SECURITY_INFORMATION ADD COLUMN SSL_DATE_VALID BOOLEAN DEFAULT NULL;
|
||||||
|
OPTIMIZE TABLE DOMAIN_SECURITY_INFORMATION;
|
@@ -0,0 +1,12 @@
|
|||||||
|
-- Table holding domains to be processed by the NDP in order to figure out whether to add them to
|
||||||
|
-- be crawled.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS NDP_NEW_DOMAINS(
|
||||||
|
DOMAIN_ID INT NOT NULL PRIMARY KEY,
|
||||||
|
STATE ENUM ('NEW', 'ACCEPTED', 'REJECTED') NOT NULL DEFAULT 'NEW',
|
||||||
|
PRIORITY INT NOT NULL DEFAULT 0,
|
||||||
|
TS_CHANGE TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CHECK_COUNT INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS NDP_NEW_DOMAINS__STATE_PRIORITY ON NDP_NEW_DOMAINS (STATE, PRIORITY DESC);
|
@@ -0,0 +1,3 @@
|
|||||||
|
-- Migration script to add AUTO_ASSIGN_DOMAINS column to NODE_CONFIGURATION table
|
||||||
|
|
||||||
|
ALTER TABLE NODE_CONFIGURATION ADD COLUMN AUTO_ASSIGN_DOMAINS BOOLEAN NOT NULL DEFAULT TRUE;
|
@@ -20,6 +20,7 @@ dependencies {
|
|||||||
implementation project(':code:processes:live-crawling-process')
|
implementation project(':code:processes:live-crawling-process')
|
||||||
implementation project(':code:processes:loading-process')
|
implementation project(':code:processes:loading-process')
|
||||||
implementation project(':code:processes:ping-process')
|
implementation project(':code:processes:ping-process')
|
||||||
|
implementation project(':code:processes:new-domain-process')
|
||||||
implementation project(':code:processes:converting-process')
|
implementation project(':code:processes:converting-process')
|
||||||
implementation project(':code:processes:index-constructor-process')
|
implementation project(':code:processes:index-constructor-process')
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@ dependencies {
|
|||||||
implementation project(':code:functions:nsfw-domain-filter')
|
implementation project(':code:functions:nsfw-domain-filter')
|
||||||
implementation project(':code:execution:api')
|
implementation project(':code:execution:api')
|
||||||
|
|
||||||
implementation project(':code:processes:crawling-process:model')
|
|
||||||
implementation project(':code:processes:crawling-process:model')
|
implementation project(':code:processes:crawling-process:model')
|
||||||
implementation project(':code:processes:crawling-process:ft-link-parser')
|
implementation project(':code:processes:crawling-process:ft-link-parser')
|
||||||
implementation project(':code:index:index-journal')
|
implementation project(':code:index:index-journal')
|
||||||
|
@@ -14,6 +14,7 @@ public enum ExecutorActor {
|
|||||||
PROC_CRAWLER_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
PROC_CRAWLER_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
||||||
PROC_PING_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED, NodeProfile.REALTIME),
|
PROC_PING_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED, NodeProfile.REALTIME),
|
||||||
PROC_EXPORT_TASKS_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
PROC_EXPORT_TASKS_SPAWNER(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
||||||
|
PROC_NDP_SPAWNER(NodeProfile.MIXED, NodeProfile.REALTIME),
|
||||||
ADJACENCY_CALCULATION(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
ADJACENCY_CALCULATION(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
||||||
EXPORT_DATA(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
EXPORT_DATA(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
||||||
EXPORT_SEGMENTATION_MODEL(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
EXPORT_SEGMENTATION_MODEL(NodeProfile.BATCH_CRAWL, NodeProfile.MIXED),
|
||||||
|
@@ -49,6 +49,7 @@ public class ExecutorActorControlService {
|
|||||||
RecrawlSingleDomainActor recrawlSingleDomainActor,
|
RecrawlSingleDomainActor recrawlSingleDomainActor,
|
||||||
RestoreBackupActor restoreBackupActor,
|
RestoreBackupActor restoreBackupActor,
|
||||||
ConverterMonitorActor converterMonitorFSM,
|
ConverterMonitorActor converterMonitorFSM,
|
||||||
|
NdpMonitorActor ndpMonitorActor,
|
||||||
PingMonitorActor pingMonitorActor,
|
PingMonitorActor pingMonitorActor,
|
||||||
CrawlerMonitorActor crawlerMonitorActor,
|
CrawlerMonitorActor crawlerMonitorActor,
|
||||||
LiveCrawlerMonitorActor liveCrawlerMonitorActor,
|
LiveCrawlerMonitorActor liveCrawlerMonitorActor,
|
||||||
@@ -93,7 +94,7 @@ public class ExecutorActorControlService {
|
|||||||
register(ExecutorActor.PROC_PING_SPAWNER, pingMonitorActor);
|
register(ExecutorActor.PROC_PING_SPAWNER, pingMonitorActor);
|
||||||
register(ExecutorActor.PROC_LIVE_CRAWL_SPAWNER, liveCrawlerMonitorActor);
|
register(ExecutorActor.PROC_LIVE_CRAWL_SPAWNER, liveCrawlerMonitorActor);
|
||||||
register(ExecutorActor.PROC_EXPORT_TASKS_SPAWNER, exportTasksMonitorActor);
|
register(ExecutorActor.PROC_EXPORT_TASKS_SPAWNER, exportTasksMonitorActor);
|
||||||
|
register(ExecutorActor.PROC_NDP_SPAWNER, ndpMonitorActor);
|
||||||
register(ExecutorActor.MONITOR_PROCESS_LIVENESS, processMonitorFSM);
|
register(ExecutorActor.MONITOR_PROCESS_LIVENESS, processMonitorFSM);
|
||||||
register(ExecutorActor.MONITOR_FILE_STORAGE, fileStorageMonitorActor);
|
register(ExecutorActor.MONITOR_FILE_STORAGE, fileStorageMonitorActor);
|
||||||
|
|
||||||
|
@@ -0,0 +1,29 @@
|
|||||||
|
package nu.marginalia.actor.proc;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import nu.marginalia.actor.monitor.AbstractProcessSpawnerActor;
|
||||||
|
import nu.marginalia.mq.persistence.MqPersistence;
|
||||||
|
import nu.marginalia.mqapi.ProcessInboxNames;
|
||||||
|
import nu.marginalia.process.ProcessService;
|
||||||
|
import nu.marginalia.service.module.ServiceConfiguration;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class NdpMonitorActor extends AbstractProcessSpawnerActor {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public NdpMonitorActor(Gson gson,
|
||||||
|
ServiceConfiguration configuration,
|
||||||
|
MqPersistence persistence,
|
||||||
|
ProcessService processService) {
|
||||||
|
super(gson,
|
||||||
|
configuration,
|
||||||
|
persistence,
|
||||||
|
processService,
|
||||||
|
ProcessInboxNames.NDP_INBOX,
|
||||||
|
ProcessService.ProcessId.NDP);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@@ -8,6 +8,7 @@ import nu.marginalia.crawl.CrawlerMain;
|
|||||||
import nu.marginalia.index.IndexConstructorMain;
|
import nu.marginalia.index.IndexConstructorMain;
|
||||||
import nu.marginalia.livecrawler.LiveCrawlerMain;
|
import nu.marginalia.livecrawler.LiveCrawlerMain;
|
||||||
import nu.marginalia.loading.LoaderMain;
|
import nu.marginalia.loading.LoaderMain;
|
||||||
|
import nu.marginalia.ndp.NdpMain;
|
||||||
import nu.marginalia.ping.PingMain;
|
import nu.marginalia.ping.PingMain;
|
||||||
import nu.marginalia.service.control.ServiceEventLog;
|
import nu.marginalia.service.control.ServiceEventLog;
|
||||||
import nu.marginalia.service.server.BaseServiceParams;
|
import nu.marginalia.service.server.BaseServiceParams;
|
||||||
@@ -57,6 +58,7 @@ public class ProcessService {
|
|||||||
CONVERTER(ConverterMain.class),
|
CONVERTER(ConverterMain.class),
|
||||||
LOADER(LoaderMain.class),
|
LOADER(LoaderMain.class),
|
||||||
INDEX_CONSTRUCTOR(IndexConstructorMain.class),
|
INDEX_CONSTRUCTOR(IndexConstructorMain.class),
|
||||||
|
NDP(NdpMain.class),
|
||||||
EXPORT_TASKS(ExportTasksMain.class),
|
EXPORT_TASKS(ExportTasksMain.class),
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ public class ProcessService {
|
|||||||
case CONVERTER -> "CONVERTER_PROCESS_OPTS";
|
case CONVERTER -> "CONVERTER_PROCESS_OPTS";
|
||||||
case LOADER -> "LOADER_PROCESS_OPTS";
|
case LOADER -> "LOADER_PROCESS_OPTS";
|
||||||
case PING -> "PING_PROCESS_OPTS";
|
case PING -> "PING_PROCESS_OPTS";
|
||||||
|
case NDP -> "NDP_PROCESS_OPTS";
|
||||||
case INDEX_CONSTRUCTOR -> "INDEX_CONSTRUCTION_PROCESS_OPTS";
|
case INDEX_CONSTRUCTOR -> "INDEX_CONSTRUCTION_PROCESS_OPTS";
|
||||||
case EXPORT_TASKS -> "EXPORT_TASKS_PROCESS_OPTS";
|
case EXPORT_TASKS -> "EXPORT_TASKS_PROCESS_OPTS";
|
||||||
};
|
};
|
||||||
|
@@ -115,9 +115,13 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
final SimpleRobotRules robotsRules = fetcher.fetchRobotRules(probedUrl.domain, warcRecorder);
|
final SimpleRobotRules robotsRules = fetcher.fetchRobotRules(probedUrl.domain, warcRecorder);
|
||||||
final CrawlDelayTimer delayTimer = new CrawlDelayTimer(robotsRules.getCrawlDelay());
|
final CrawlDelayTimer delayTimer = new CrawlDelayTimer(robotsRules.getCrawlDelay());
|
||||||
|
|
||||||
|
if (!robotsRules.isAllowed(probedUrl.toString())) {
|
||||||
|
warcRecorder.flagAsRobotsTxtError(probedUrl);
|
||||||
|
yield 1; // Nothing we can do here, we aren't allowed to fetch the root URL
|
||||||
|
}
|
||||||
delayTimer.waitFetchDelay(0); // initial delay after robots.txt
|
delayTimer.waitFetchDelay(0); // initial delay after robots.txt
|
||||||
|
|
||||||
DomainStateDb.SummaryRecord summaryRecord = sniffRootDocument(probedUrl, delayTimer);
|
DomainStateDb.SummaryRecord summaryRecord = sniffRootDocument(probedUrl, robotsRules, delayTimer);
|
||||||
domainStateDb.save(summaryRecord);
|
domainStateDb.save(summaryRecord);
|
||||||
|
|
||||||
if (Thread.interrupted()) {
|
if (Thread.interrupted()) {
|
||||||
@@ -270,11 +274,11 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
private DomainStateDb.SummaryRecord sniffRootDocument(EdgeUrl rootUrl, CrawlDelayTimer timer) {
|
private DomainStateDb.SummaryRecord sniffRootDocument(EdgeUrl rootUrl, SimpleRobotRules robotsRules, CrawlDelayTimer timer) {
|
||||||
Optional<String> feedLink = Optional.empty();
|
Optional<String> feedLink = Optional.empty();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var url = rootUrl.withPathAndParam("/", null);
|
EdgeUrl url = rootUrl.withPathAndParam("/", null);
|
||||||
|
|
||||||
HttpFetchResult result = fetcher.fetchContent(url, warcRecorder, cookies, timer, ContentTags.empty(), HttpFetcher.ProbeType.DISABLED);
|
HttpFetchResult result = fetcher.fetchContent(url, warcRecorder, cookies, timer, ContentTags.empty(), HttpFetcher.ProbeType.DISABLED);
|
||||||
timer.waitFetchDelay(0);
|
timer.waitFetchDelay(0);
|
||||||
@@ -331,7 +335,7 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
|
|
||||||
|
|
||||||
if (feedLink.isEmpty()) {
|
if (feedLink.isEmpty()) {
|
||||||
feedLink = guessFeedUrl(timer);
|
feedLink = guessFeedUrl(timer, robotsRules);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the sitemap if available
|
// Download the sitemap if available
|
||||||
@@ -339,14 +343,18 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
|
|
||||||
// Grab the favicon if it exists
|
// Grab the favicon if it exists
|
||||||
|
|
||||||
if (fetcher.fetchContent(faviconUrl, warcRecorder, cookies, timer, ContentTags.empty(), HttpFetcher.ProbeType.DISABLED) instanceof HttpFetchResult.ResultOk iconResult) {
|
if (robotsRules.isAllowed(faviconUrl.toString())) {
|
||||||
String contentType = iconResult.header("Content-Type");
|
if (fetcher.fetchContent(faviconUrl, warcRecorder, cookies, timer, ContentTags.empty(), HttpFetcher.ProbeType.DISABLED)
|
||||||
byte[] iconData = iconResult.getBodyBytes();
|
instanceof HttpFetchResult.ResultOk iconResult)
|
||||||
|
{
|
||||||
|
String contentType = iconResult.header("Content-Type");
|
||||||
|
byte[] iconData = iconResult.getBodyBytes();
|
||||||
|
|
||||||
domainStateDb.saveIcon(
|
domainStateDb.saveIcon(
|
||||||
domain,
|
domain,
|
||||||
new DomainStateDb.FaviconRecord(contentType, iconData)
|
new DomainStateDb.FaviconRecord(contentType, iconData)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
timer.waitFetchDelay(0);
|
timer.waitFetchDelay(0);
|
||||||
|
|
||||||
@@ -383,7 +391,7 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
"blog/rss"
|
"blog/rss"
|
||||||
);
|
);
|
||||||
|
|
||||||
private Optional<String> guessFeedUrl(CrawlDelayTimer timer) throws InterruptedException {
|
private Optional<String> guessFeedUrl(CrawlDelayTimer timer, SimpleRobotRules robotsRules) throws InterruptedException {
|
||||||
var oldDomainStateRecord = domainStateDb.getSummary(domain);
|
var oldDomainStateRecord = domainStateDb.getSummary(domain);
|
||||||
|
|
||||||
// If we are already aware of an old feed URL, then we can just revalidate it
|
// If we are already aware of an old feed URL, then we can just revalidate it
|
||||||
@@ -396,6 +404,9 @@ public class CrawlerRetreiver implements AutoCloseable {
|
|||||||
|
|
||||||
for (String endpoint : likelyFeedEndpoints) {
|
for (String endpoint : likelyFeedEndpoints) {
|
||||||
String url = "https://" + domain + "/" + endpoint;
|
String url = "https://" + domain + "/" + endpoint;
|
||||||
|
if (!robotsRules.isAllowed(url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (validateFeedUrl(url, timer)) {
|
if (validateFeedUrl(url, timer)) {
|
||||||
return Optional.of(url);
|
return Optional.of(url);
|
||||||
}
|
}
|
||||||
|
12
code/processes/new-domain-process/README.md
Normal file
12
code/processes/new-domain-process/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
The new domain process (NDP) is a process that evaluates new domains for
|
||||||
|
inclusion in the search engine index.
|
||||||
|
|
||||||
|
It visits the root document of each candidate domain, ensures that it's reachable,
|
||||||
|
verifies that the response is valid HTML, and checks for a few factors such as length
|
||||||
|
and links before deciding whether to assign the domain to a node.
|
||||||
|
|
||||||
|
The NDP process will assign new domains to the node with the fewest assigned domains.
|
||||||
|
|
||||||
|
The NDP process is triggered with a goal target number of domains to process, and
|
||||||
|
will find domains until that target is reached. If e.g. a goal of 100 is set,
|
||||||
|
and 50 are in the index, it will find 50 more domains.
|
75
code/processes/new-domain-process/build.gradle
Normal file
75
code/processes/new-domain-process/build.gradle
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
|
||||||
|
id 'application'
|
||||||
|
id 'jvm-test-suite'
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(rootProject.ext.jvmVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass = 'nu.marginalia.ping.PingMain'
|
||||||
|
applicationName = 'ping-process'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.distZip.enabled = false
|
||||||
|
|
||||||
|
apply from: "$rootProject.projectDir/srcsets.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation project(':code:common:db')
|
||||||
|
implementation project(':code:common:model')
|
||||||
|
implementation project(':code:common:config')
|
||||||
|
implementation project(':code:common:service')
|
||||||
|
|
||||||
|
implementation project(':code:libraries:domain-lock')
|
||||||
|
implementation project(':code:libraries:geo-ip')
|
||||||
|
implementation project(':code:libraries:message-queue')
|
||||||
|
implementation project(':code:libraries:blocking-thread-pool')
|
||||||
|
|
||||||
|
implementation project(':code:functions:link-graph:api')
|
||||||
|
|
||||||
|
implementation project(':code:processes:process-mq-api')
|
||||||
|
implementation project(':code:processes:crawling-process:ft-content-type')
|
||||||
|
implementation project(':code:processes:crawling-process:ft-link-parser')
|
||||||
|
|
||||||
|
|
||||||
|
implementation libs.bundles.slf4j
|
||||||
|
implementation libs.notnull
|
||||||
|
implementation libs.guava
|
||||||
|
|
||||||
|
implementation dependencies.create(libs.guice.get()) {
|
||||||
|
exclude group: 'com.google.guava'
|
||||||
|
}
|
||||||
|
implementation libs.gson
|
||||||
|
implementation libs.zstd
|
||||||
|
implementation libs.bucket4j
|
||||||
|
implementation libs.crawlercommons
|
||||||
|
implementation libs.jsoup
|
||||||
|
implementation libs.fastutil
|
||||||
|
implementation libs.bundles.curator
|
||||||
|
implementation libs.bundles.mariadb
|
||||||
|
implementation libs.bundles.httpcomponents
|
||||||
|
implementation libs.commons.lang3
|
||||||
|
|
||||||
|
testImplementation libs.bundles.slf4j.test
|
||||||
|
testImplementation libs.bundles.junit
|
||||||
|
testImplementation libs.mockito
|
||||||
|
|
||||||
|
testImplementation libs.wiremock
|
||||||
|
|
||||||
|
|
||||||
|
testImplementation platform('org.testcontainers:testcontainers-bom:1.17.4')
|
||||||
|
testImplementation libs.commons.codec
|
||||||
|
testImplementation 'org.testcontainers:mariadb:1.17.4'
|
||||||
|
testImplementation 'org.testcontainers:junit-jupiter:1.17.4'
|
||||||
|
testImplementation project(':code:libraries:test-helpers')
|
||||||
|
|
||||||
|
testImplementation project(':code:processes:test-data')
|
||||||
|
}
|
||||||
|
|
@@ -0,0 +1,146 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import nu.marginalia.WmsaHome;
|
||||||
|
import nu.marginalia.contenttype.ContentType;
|
||||||
|
import nu.marginalia.contenttype.DocumentBodyToString;
|
||||||
|
import nu.marginalia.coordination.DomainCoordinator;
|
||||||
|
import nu.marginalia.link_parser.LinkParser;
|
||||||
|
import nu.marginalia.model.EdgeDomain;
|
||||||
|
import nu.marginalia.model.EdgeUrl;
|
||||||
|
import nu.marginalia.ndp.io.HttpClientProvider;
|
||||||
|
import org.apache.hc.client5.http.classic.HttpClient;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
|
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Evaluates a domain to determine if it is worth indexing.
|
||||||
|
* This class fetches the root document, checks the response code, content type,
|
||||||
|
* and parses the HTML to ensure it smells alright.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class DomainEvaluator {
|
||||||
|
private final HttpClient client;
|
||||||
|
private final String userAgentString = WmsaHome.getUserAgent().uaString();
|
||||||
|
|
||||||
|
private final LinkParser linkParser = new LinkParser();
|
||||||
|
private final DomainCoordinator domainCoordinator;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public DomainEvaluator(DomainCoordinator domainCoordinator) throws NoSuchAlgorithmException, KeyManagementException {
|
||||||
|
this.domainCoordinator = domainCoordinator;
|
||||||
|
client = HttpClientProvider.createClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean evaluateDomain(String domainName) {
|
||||||
|
var edgeDomain = new EdgeDomain(domainName);
|
||||||
|
|
||||||
|
// Grab a lock on the domain to prevent concurrent evaluations between processes
|
||||||
|
try (var lock = domainCoordinator.lockDomain(edgeDomain)) {
|
||||||
|
var rootUrl = edgeDomain.toRootUrlHttps();
|
||||||
|
|
||||||
|
var request = ClassicRequestBuilder.get(rootUrl.asURI())
|
||||||
|
.addHeader("User-Agent", userAgentString)
|
||||||
|
.addHeader("Accept-Encoding", "gzip")
|
||||||
|
.addHeader("Accept", "text/html,application/xhtml+xml;q=0.9")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return client.execute(request, (rsp) -> {
|
||||||
|
if (rsp.getEntity() == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the response code indicates a successful fetch
|
||||||
|
if (200 != rsp.getCode()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] content;
|
||||||
|
// Read the content from the response entity
|
||||||
|
try (InputStream contentStream = rsp.getEntity().getContent()) {
|
||||||
|
content = contentStream.readNBytes(8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the content (if it's valid)
|
||||||
|
ContentType contentType = ContentType.parse(rsp.getEntity().getContentType());
|
||||||
|
|
||||||
|
// Validate the content type
|
||||||
|
if (!contentType.contentType().startsWith("text/html") && !contentType.contentType().startsWith("application/xhtml+xml"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Parse the document body to a Jsoup Document
|
||||||
|
final Document document = Jsoup.parse(DocumentBodyToString.getStringData(contentType, content));
|
||||||
|
final String text = document.body().text();
|
||||||
|
|
||||||
|
if (text.length() < 100)
|
||||||
|
return false;
|
||||||
|
if (text.contains("404 Not Found") || text.contains("Page not found"))
|
||||||
|
return false;
|
||||||
|
if (hasMetaRefresh(document))
|
||||||
|
return false; // This almost always indicates a parked domain
|
||||||
|
if (!hasInternalLink(document, edgeDomain, rootUrl))
|
||||||
|
return false; // No internal links means it's not worth indexing
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// May or may not be necessary, but let's ensure we clean up the response entity
|
||||||
|
// to avoid resource leaks
|
||||||
|
EntityUtils.consumeQuietly(rsp.getEntity());
|
||||||
|
|
||||||
|
// Sleep for a while before yielding the lock, to avoid immediately hammering the domain
|
||||||
|
// from another process
|
||||||
|
sleepQuietly(Duration.ofSeconds(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
return false; // If we fail to fetch or parse the domain, we consider it invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasInternalLink(Document document, EdgeDomain currentDomain, EdgeUrl rootUrl) {
|
||||||
|
for (Element atag : document.select("a")) {
|
||||||
|
Optional<EdgeDomain> destDomain = linkParser
|
||||||
|
.parseLink(rootUrl, atag)
|
||||||
|
.map(EdgeUrl::getDomain);
|
||||||
|
|
||||||
|
if (destDomain.isPresent() && Objects.equals(currentDomain, destDomain.get()))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasMetaRefresh(Document document) {
|
||||||
|
for (Element metaTag : document.select("meta")) {
|
||||||
|
if ("refresh".equalsIgnoreCase(metaTag.attr("http-equiv")))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepQuietly(Duration duration) {
|
||||||
|
try {
|
||||||
|
TimeUnit.MILLISECONDS.sleep(duration.toMillis());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,134 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import nu.marginalia.nodecfg.NodeConfigurationService;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.PriorityQueue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** DomainAllocator is responsible for assigning domains to partitions/nodes.
|
||||||
|
* This is ensured to make sure that domains are evenly distributed across the nodes.
|
||||||
|
*/
|
||||||
|
public class DomainNodeAllocator {
|
||||||
|
|
||||||
|
private final NodeConfigurationService nodeConfigurationService;
|
||||||
|
private final HikariDataSource dataSource;
|
||||||
|
private final PriorityQueue<NodeCount> countPerNode = new PriorityQueue<>();
|
||||||
|
|
||||||
|
private volatile boolean initialized = false;
|
||||||
|
|
||||||
|
private record NodeCount(int nodeId, int count)
|
||||||
|
implements Comparable<NodeCount>
|
||||||
|
{
|
||||||
|
public NodeCount incrementCount() {
|
||||||
|
return new NodeCount(nodeId, count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NotNull DomainNodeAllocator.NodeCount o) {
|
||||||
|
return Integer.compare(this.count, o.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public DomainNodeAllocator(NodeConfigurationService nodeConfigurationService, HikariDataSource dataSource) {
|
||||||
|
this.nodeConfigurationService = nodeConfigurationService;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
|
||||||
|
Thread.ofPlatform()
|
||||||
|
.name("DomainNodeAllocator::initialize()")
|
||||||
|
.start(this::initialize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int totalCount() {
|
||||||
|
ensureInitialized();
|
||||||
|
return countPerNode.stream().mapToInt(NodeCount::count).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the next node ID to assign a domain to.
|
||||||
|
* This method is synchronized to ensure thread safety when multiple threads are allocating domains.
|
||||||
|
* The node ID returned is guaranteed to be one of the viable nodes configured in the system.
|
||||||
|
*/
|
||||||
|
public synchronized int nextNodeId() {
|
||||||
|
ensureInitialized();
|
||||||
|
|
||||||
|
// Synchronized is fine here as this is not a hot path
|
||||||
|
// (and PriorityBlockingQueue won't help since we're re-adding the same element with a new count all the time)
|
||||||
|
|
||||||
|
NodeCount allocation = countPerNode.remove();
|
||||||
|
countPerNode.add(allocation.incrementCount());
|
||||||
|
return allocation.nodeId();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void ensureInitialized() {
|
||||||
|
if (initialized) return;
|
||||||
|
|
||||||
|
synchronized (this) {
|
||||||
|
while (!initialized) {
|
||||||
|
try {
|
||||||
|
// Wait until the initialization is complete
|
||||||
|
this.wait(1000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("DomainAllocator initialization interrupted", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void initialize() {
|
||||||
|
if (initialized) return;
|
||||||
|
|
||||||
|
Set<Integer> viableNodes = new HashSet<>();
|
||||||
|
|
||||||
|
// Find all viable nodes that can handle batch crawls
|
||||||
|
for (var node : nodeConfigurationService.getAll()) {
|
||||||
|
if (node.disabled())
|
||||||
|
continue;
|
||||||
|
if (!node.autoAssignDomains())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (node.profile().permitBatchCrawl())
|
||||||
|
viableNodes.add(node.node());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the current counts of domains per node from the database
|
||||||
|
try (var conn = dataSource.getConnection();
|
||||||
|
var stmt = conn.prepareStatement("""
|
||||||
|
SELECT COUNT(*) AS CNT, NODE_AFFINITY
|
||||||
|
FROM EC_DOMAIN
|
||||||
|
WHERE NODE_AFFINITY>0
|
||||||
|
GROUP BY NODE_AFFINITY
|
||||||
|
"""))
|
||||||
|
{
|
||||||
|
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
while (rs.next()) {
|
||||||
|
|
||||||
|
int nodeId = rs.getInt("NODE_AFFINITY");
|
||||||
|
int count = rs.getInt("CNT");
|
||||||
|
|
||||||
|
if (viableNodes.remove(nodeId)) {
|
||||||
|
countPerNode.add(new NodeCount(nodeId, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to load domain counts from database", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining viable nodes that were not found in the database
|
||||||
|
for (int nodeId : viableNodes) {
|
||||||
|
countPerNode.add(new NodeCount(nodeId, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,240 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2IntMap;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
|
||||||
|
import nu.marginalia.api.linkgraph.AggregateLinkGraphClient;
|
||||||
|
import nu.marginalia.ndp.model.DomainToTest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class DomainTestingQueue {
|
||||||
|
private static Logger logger = LoggerFactory.getLogger(DomainTestingQueue.class);
|
||||||
|
|
||||||
|
private final ArrayBlockingQueue<DomainToTest> queue = new ArrayBlockingQueue<>(2);
|
||||||
|
|
||||||
|
// This will grow quite large, but should be manageable in memory, as theoretical maximum is around 100M domains,
|
||||||
|
// order of 2 GB in memory.
|
||||||
|
private final ConcurrentHashMap<String, Boolean> takenDomains = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private final HikariDataSource dataSource;
|
||||||
|
private final AggregateLinkGraphClient linkGraphClient;
|
||||||
|
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public DomainTestingQueue(HikariDataSource dataSource,
|
||||||
|
AggregateLinkGraphClient linkGraphClient
|
||||||
|
) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.linkGraphClient = linkGraphClient;
|
||||||
|
|
||||||
|
Thread.ofPlatform()
|
||||||
|
.name("DomainTestingQueue::fetch()")
|
||||||
|
.start(this::fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DomainToTest next() throws InterruptedException {
|
||||||
|
return queue.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void accept(DomainToTest domain, int nodeId) {
|
||||||
|
try (var conn = dataSource.getConnection();
|
||||||
|
var flagOkStmt = conn.prepareStatement("""
|
||||||
|
UPDATE NDP_NEW_DOMAINS
|
||||||
|
SET STATE='ACCEPTED'
|
||||||
|
WHERE DOMAIN_ID=?
|
||||||
|
""");
|
||||||
|
var assignNodeStmt = conn.prepareStatement("""
|
||||||
|
UPDATE EC_DOMAIN SET NODE_AFFINITY=?
|
||||||
|
WHERE ID=?
|
||||||
|
AND EC_DOMAIN.NODE_AFFINITY < 0
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
flagOkStmt.setInt(1, domain.domainId());
|
||||||
|
flagOkStmt.executeUpdate();
|
||||||
|
|
||||||
|
assignNodeStmt.setInt(1, nodeId);
|
||||||
|
assignNodeStmt.setInt(2, domain.domainId());
|
||||||
|
assignNodeStmt.executeUpdate();
|
||||||
|
conn.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to accept domain in database", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(DomainToTest domain) {
|
||||||
|
try (var conn = dataSource.getConnection();
|
||||||
|
var stmt = conn.prepareStatement("""
|
||||||
|
UPDATE NDP_NEW_DOMAINS
|
||||||
|
SET STATE='REJECTED', CHECK_COUNT=CHECK_COUNT + 1
|
||||||
|
WHERE DOMAIN_ID=?
|
||||||
|
"""))
|
||||||
|
{
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
stmt.setInt(1, domain.domainId());
|
||||||
|
stmt.executeUpdate();
|
||||||
|
conn.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to reject domain in database", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void fetch() {
|
||||||
|
while (true) {
|
||||||
|
List<DomainToTest> domains = new ArrayList<>(2000);
|
||||||
|
try (var conn = dataSource.getConnection();
|
||||||
|
var stmt = conn.prepareStatement("""
|
||||||
|
SELECT DOMAIN_ID, DOMAIN_NAME
|
||||||
|
FROM NDP_NEW_DOMAINS
|
||||||
|
INNER JOIN EC_DOMAIN ON ID=DOMAIN_ID
|
||||||
|
WHERE NDP_NEW_DOMAINS.STATE = 'NEW'
|
||||||
|
ORDER BY PRIORITY DESC
|
||||||
|
LIMIT 2000
|
||||||
|
"""))
|
||||||
|
{
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
|
||||||
|
while (rs.next()) {
|
||||||
|
int domainId = rs.getInt("DOMAIN_ID");
|
||||||
|
String domainName = rs.getString("DOMAIN_NAME");
|
||||||
|
if (takenDomains.put(domainName, true) != null) {
|
||||||
|
logger.warn("Domain {} is already processed, skipping", domainName);
|
||||||
|
continue; // Skip if already taken
|
||||||
|
}
|
||||||
|
domains.add(new DomainToTest(domainName, domainId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domains.isEmpty()) {
|
||||||
|
if (!refreshQueue(conn)) {
|
||||||
|
throw new RuntimeException("No new domains found, aborting!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RuntimeException e) {
|
||||||
|
throw e; // Rethrow runtime exceptions to avoid wrapping them in another runtime exception
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to fetch domains from database", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (var domain : domains) {
|
||||||
|
queue.put(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("Domain fetching interrupted", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean refreshQueue(Connection conn) {
|
||||||
|
logger.info("Refreshing domain queue in database");
|
||||||
|
|
||||||
|
Int2IntMap domainIdToCount = new Int2IntOpenHashMap();
|
||||||
|
|
||||||
|
// Load known domain IDs from the database to avoid inserting duplicates from NDP_NEW_DOMAINS
|
||||||
|
// or domains that are already assigned to a node
|
||||||
|
{
|
||||||
|
IntOpenHashSet knownIds = new IntOpenHashSet();
|
||||||
|
|
||||||
|
try (var stmt = conn.createStatement()) {
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT DOMAIN_ID FROM NDP_NEW_DOMAINS");
|
||||||
|
rs.setFetchSize(10_000);
|
||||||
|
while (rs.next()) {
|
||||||
|
int domainId = rs.getInt("DOMAIN_ID");
|
||||||
|
knownIds.add(domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
rs = stmt.executeQuery("SELECT ID FROM EC_DOMAIN WHERE NODE_AFFINITY>=0");
|
||||||
|
rs.setFetchSize(10_000);
|
||||||
|
while (rs.next()) {
|
||||||
|
int domainId = rs.getInt("ID");
|
||||||
|
knownIds.add(domainId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to load known domain IDs from database", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the link graph is ready before proceeding. This is mainly necessary in a cold reboot
|
||||||
|
// of the entire system.
|
||||||
|
try {
|
||||||
|
logger.info("Waiting for link graph client to be ready...");
|
||||||
|
linkGraphClient.waitReady(Duration.ofHours(1));
|
||||||
|
logger.info("Link graph client is ready, fetching domain links...");
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all domain links from the link graph and count by how many sources each dest domain is linked from
|
||||||
|
var iter = linkGraphClient.getAllDomainLinks().iterator();
|
||||||
|
while (iter.advance()) {
|
||||||
|
int dest = iter.dest();
|
||||||
|
if (!knownIds.contains(dest)) {
|
||||||
|
domainIdToCount.mergeInt(dest, 1, (i, j) -> i + j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean didInsert = false;
|
||||||
|
|
||||||
|
/* Insert new domains into NDP_NEW_DOMAINS table */
|
||||||
|
try (var insertStmt = conn.prepareStatement("""
|
||||||
|
INSERT IGNORE INTO NDP_NEW_DOMAINS (DOMAIN_ID, PRIORITY) VALUES (?, ?)
|
||||||
|
""")) {
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
|
||||||
|
int cnt = 0;
|
||||||
|
for (var entry : domainIdToCount.int2IntEntrySet()) {
|
||||||
|
int domainId = entry.getIntKey();
|
||||||
|
int count = entry.getIntValue();
|
||||||
|
|
||||||
|
insertStmt.setInt(1, domainId);
|
||||||
|
insertStmt.setInt(2, count);
|
||||||
|
insertStmt.addBatch();
|
||||||
|
|
||||||
|
if (++cnt >= 1000) {
|
||||||
|
cnt = 0;
|
||||||
|
insertStmt.executeBatch(); // Execute in batches to avoid memory issues
|
||||||
|
conn.commit();
|
||||||
|
didInsert = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cnt != 0) {
|
||||||
|
insertStmt.executeBatch(); // Execute any remaining batch
|
||||||
|
conn.commit();
|
||||||
|
didInsert = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Queue refreshed successfully");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to refresh queue in database", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up NDP_NEW_DOMAINS table to remove any domains that are already in EC_DOMAIN
|
||||||
|
// This acts not only to clean up domains that we've flagged as ACCEPTED, but also to
|
||||||
|
// repair inconsistent states where domains might have incorrectly been added to NDP_NEW_DOMAINS
|
||||||
|
try (var stmt = conn.createStatement()) {
|
||||||
|
stmt.executeUpdate("DELETE FROM NDP_NEW_DOMAINS WHERE DOMAIN_ID IN (SELECT ID FROM EC_DOMAIN WHERE NODE_AFFINITY>=0)");
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to clean up NDP_NEW_DOMAINS", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return didInsert;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,159 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.inject.Guice;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Injector;
|
||||||
|
import nu.marginalia.WmsaHome;
|
||||||
|
import nu.marginalia.coordination.DomainCoordinationModule;
|
||||||
|
import nu.marginalia.db.DomainBlacklist;
|
||||||
|
import nu.marginalia.geoip.GeoIpDictionary;
|
||||||
|
import nu.marginalia.mq.MessageQueueFactory;
|
||||||
|
import nu.marginalia.mqapi.ProcessInboxNames;
|
||||||
|
import nu.marginalia.mqapi.ndp.NdpRequest;
|
||||||
|
import nu.marginalia.ndp.model.DomainToTest;
|
||||||
|
import nu.marginalia.process.ProcessConfiguration;
|
||||||
|
import nu.marginalia.process.ProcessConfigurationModule;
|
||||||
|
import nu.marginalia.process.ProcessMainClass;
|
||||||
|
import nu.marginalia.process.control.ProcessHeartbeat;
|
||||||
|
import nu.marginalia.service.module.DatabaseModule;
|
||||||
|
import nu.marginalia.service.module.ServiceDiscoveryModule;
|
||||||
|
import nu.marginalia.util.SimpleBlockingThreadPool;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
public class NdpMain extends ProcessMainClass {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(NdpMain.class);
|
||||||
|
private final DomainNodeAllocator domainNodeAllocator;
|
||||||
|
private final DomainTestingQueue domainTestingQueue;
|
||||||
|
private final ProcessHeartbeat processHeartbeat;
|
||||||
|
private final DomainEvaluator domainEvaluator;
|
||||||
|
private final DomainBlacklist domainBlacklist;
|
||||||
|
|
||||||
|
private final AtomicInteger domainCount = new AtomicInteger(0);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public NdpMain(MessageQueueFactory messageQueueFactory,
|
||||||
|
ProcessConfiguration config,
|
||||||
|
DomainNodeAllocator domainNodeAllocator,
|
||||||
|
DomainTestingQueue domainTestingQueue,
|
||||||
|
DomainEvaluator domainEvaluator,
|
||||||
|
DomainBlacklist domainBlacklist,
|
||||||
|
ProcessHeartbeat processHeartbeat,
|
||||||
|
Gson gson)
|
||||||
|
{
|
||||||
|
super(messageQueueFactory, config, gson, ProcessInboxNames.NDP_INBOX);
|
||||||
|
|
||||||
|
this.domainNodeAllocator = domainNodeAllocator;
|
||||||
|
this.domainEvaluator = domainEvaluator;
|
||||||
|
this.domainBlacklist = domainBlacklist;
|
||||||
|
this.domainTestingQueue = domainTestingQueue;
|
||||||
|
this.processHeartbeat = processHeartbeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void run(int goalCount) throws InterruptedException {
|
||||||
|
logger.info("Wait for blacklist to load...");
|
||||||
|
domainBlacklist.waitUntilLoaded();
|
||||||
|
|
||||||
|
SimpleBlockingThreadPool threadPool = new SimpleBlockingThreadPool(
|
||||||
|
"NDP-Worker",
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
SimpleBlockingThreadPool.ThreadType.PLATFORM
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Starting NDP process");
|
||||||
|
|
||||||
|
int toInsertCount = goalCount - domainNodeAllocator.totalCount();
|
||||||
|
|
||||||
|
if (toInsertCount <= 0) {
|
||||||
|
logger.info("No new domains to process. Current count: " + domainNodeAllocator.totalCount());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var hb = processHeartbeat.createAdHocTaskHeartbeat("Growing Index")) {
|
||||||
|
int cnt;
|
||||||
|
while ((cnt = domainCount.get()) < toInsertCount) {
|
||||||
|
if (cnt % 100 == 0) {
|
||||||
|
hb.progress("Discovery Process", cnt, toInsertCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DomainToTest nextDomain = domainTestingQueue.next();
|
||||||
|
threadPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
if (domainEvaluator.evaluateDomain(nextDomain.domainName())) {
|
||||||
|
logger.info("Accepting: {}", nextDomain.domainName());
|
||||||
|
domainCount.incrementAndGet();
|
||||||
|
domainTestingQueue.accept(nextDomain, domainNodeAllocator.nextNodeId());
|
||||||
|
} else {
|
||||||
|
logger.info("Rejecting: {}", nextDomain.domainName());
|
||||||
|
domainTestingQueue.reject(nextDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
domainTestingQueue.reject(nextDomain);
|
||||||
|
logger.error("Error evaluating domain: " + nextDomain.domainId(), e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
threadPool.shutDown();
|
||||||
|
// Wait for all tasks to complete or give up after 1 hour
|
||||||
|
threadPool.awaitTermination(1, TimeUnit.HOURS);
|
||||||
|
|
||||||
|
logger.info("NDP process completed. Total domains processed: " + domainCount.get());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
// Prevent Java from caching DNS lookups forever (filling up the system RAM as a result)
|
||||||
|
Security.setProperty("networkaddress.cache.ttl" , "3600");
|
||||||
|
|
||||||
|
// This must run *early*
|
||||||
|
System.setProperty("http.agent", WmsaHome.getUserAgent().uaString());
|
||||||
|
|
||||||
|
// If these aren't set properly, the JVM will hang forever on some requests
|
||||||
|
System.setProperty("sun.net.client.defaultConnectTimeout", "30000");
|
||||||
|
System.setProperty("sun.net.client.defaultReadTimeout", "30000");
|
||||||
|
|
||||||
|
// Set the maximum number of connections to keep alive in the connection pool
|
||||||
|
System.setProperty("jdk.httpclient.idleTimeout", "15"); // 15 seconds
|
||||||
|
System.setProperty("jdk.httpclient.connectionPoolSize", "256");
|
||||||
|
|
||||||
|
// We don't want to use too much memory caching sessions for https
|
||||||
|
System.setProperty("javax.net.ssl.sessionCacheSize", "2048");
|
||||||
|
|
||||||
|
|
||||||
|
Injector injector = Guice.createInjector(
|
||||||
|
new NdpModule(),
|
||||||
|
new ServiceDiscoveryModule(),
|
||||||
|
new DomainCoordinationModule(),
|
||||||
|
new ProcessConfigurationModule("ndp"),
|
||||||
|
new DatabaseModule(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
GeoIpDictionary geoIpDictionary = injector.getInstance(GeoIpDictionary.class);
|
||||||
|
|
||||||
|
geoIpDictionary.waitReady(); // Ensure the GeoIpDictionary is ready before proceeding
|
||||||
|
|
||||||
|
NdpMain main = injector.getInstance(NdpMain.class);
|
||||||
|
|
||||||
|
var instructions = main.fetchInstructions(NdpRequest.class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
main.run(instructions.value().goal());
|
||||||
|
instructions.ok();
|
||||||
|
}
|
||||||
|
catch (Throwable ex) {
|
||||||
|
logger.error("Error running ping process", ex);
|
||||||
|
instructions.err();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,8 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
|
||||||
|
public class NdpModule extends AbstractModule {
|
||||||
|
public void configure() {
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,126 @@
|
|||||||
|
package nu.marginalia.ndp.io;
|
||||||
|
|
||||||
|
import com.google.inject.Provider;
|
||||||
|
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
|
||||||
|
import org.apache.hc.client5.http.classic.HttpClient;
|
||||||
|
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||||
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
|
import org.apache.hc.client5.http.cookie.StandardCookieSpec;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
|
||||||
|
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||||
|
import org.apache.hc.core5.http.HeaderElement;
|
||||||
|
import org.apache.hc.core5.http.HeaderElements;
|
||||||
|
import org.apache.hc.core5.http.HttpResponse;
|
||||||
|
import org.apache.hc.core5.http.io.SocketConfig;
|
||||||
|
import org.apache.hc.core5.http.message.MessageSupport;
|
||||||
|
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||||
|
import org.apache.hc.core5.util.TimeValue;
|
||||||
|
import org.apache.hc.core5.util.Timeout;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class HttpClientProvider implements Provider<HttpClient> {
|
||||||
|
private static final HttpClient client;
|
||||||
|
private static PoolingHttpClientConnectionManager connectionManager;
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(HttpClientProvider.class);
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
client = createClient();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CloseableHttpClient createClient() throws NoSuchAlgorithmException, KeyManagementException {
|
||||||
|
final ConnectionConfig connectionConfig = ConnectionConfig.custom()
|
||||||
|
.setSocketTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.setConnectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.setValidateAfterInactivity(TimeValue.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
|
||||||
|
connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
|
||||||
|
.setMaxConnPerRoute(2)
|
||||||
|
.setMaxConnTotal(50)
|
||||||
|
.setDefaultConnectionConfig(connectionConfig)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
connectionManager.setDefaultSocketConfig(SocketConfig.custom()
|
||||||
|
.setSoLinger(TimeValue.ofSeconds(-1))
|
||||||
|
.setSoTimeout(Timeout.ofSeconds(10))
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
Thread.ofPlatform().daemon(true).start(() -> {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
TimeUnit.SECONDS.sleep(15);
|
||||||
|
logger.info("Connection pool stats: {}", connectionManager.getTotalStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final RequestConfig defaultRequestConfig = RequestConfig.custom()
|
||||||
|
.setCookieSpec(StandardCookieSpec.IGNORE)
|
||||||
|
.setResponseTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.setConnectionRequestTimeout(5, TimeUnit.MINUTES)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return HttpClients.custom()
|
||||||
|
.setConnectionManager(connectionManager)
|
||||||
|
.setRetryStrategy(new RetryStrategy())
|
||||||
|
.setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
|
||||||
|
// Default keep-alive duration is 3 minutes, but this is too long for us,
|
||||||
|
// as we are either going to re-use it fairly quickly or close it for a long time.
|
||||||
|
//
|
||||||
|
// So we set it to 30 seconds or clamp the server-provided value to a minimum of 10 seconds.
|
||||||
|
private static final TimeValue defaultValue = TimeValue.ofSeconds(30);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeValue getKeepAliveDuration(HttpResponse response, HttpContext context) {
|
||||||
|
final Iterator<HeaderElement> it = MessageSupport.iterate(response, HeaderElements.KEEP_ALIVE);
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
final HeaderElement he = it.next();
|
||||||
|
final String param = he.getName();
|
||||||
|
final String value = he.getValue();
|
||||||
|
|
||||||
|
if (value == null)
|
||||||
|
continue;
|
||||||
|
if (!"timeout".equalsIgnoreCase(param))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
long timeout = Long.parseLong(value);
|
||||||
|
timeout = Math.clamp(timeout, 30, defaultValue.toSeconds());
|
||||||
|
return TimeValue.ofSeconds(timeout);
|
||||||
|
} catch (final NumberFormatException ignore) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.disableRedirectHandling()
|
||||||
|
.setDefaultRequestConfig(defaultRequestConfig)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HttpClient get() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@@ -0,0 +1,79 @@
|
|||||||
|
package nu.marginalia.ndp.io;
|
||||||
|
|
||||||
|
import org.apache.hc.client5.http.HttpHostConnectException;
|
||||||
|
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
|
||||||
|
import org.apache.hc.core5.http.HttpRequest;
|
||||||
|
import org.apache.hc.core5.http.HttpResponse;
|
||||||
|
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||||
|
import org.apache.hc.core5.util.TimeValue;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
|
||||||
|
public class RetryStrategy implements HttpRequestRetryStrategy {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(RetryStrategy.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean retryRequest(HttpRequest request, IOException exception, int executionCount, HttpContext context) {
|
||||||
|
return switch (exception) {
|
||||||
|
case SocketTimeoutException ste -> false;
|
||||||
|
case SSLException ssle -> false;
|
||||||
|
case UnknownHostException uhe -> false;
|
||||||
|
case HttpHostConnectException ex -> executionCount < 2;
|
||||||
|
case SocketException ex -> executionCount < 2;
|
||||||
|
default -> executionCount <= 3;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean retryRequest(HttpResponse response, int executionCount, HttpContext context) {
|
||||||
|
return switch (response.getCode()) {
|
||||||
|
case 500, 503 -> executionCount <= 2;
|
||||||
|
case 429 -> executionCount <= 3;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeValue getRetryInterval(HttpRequest request, IOException exception, int executionCount, HttpContext context) {
|
||||||
|
return TimeValue.ofSeconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeValue getRetryInterval(HttpResponse response, int executionCount, HttpContext context) {
|
||||||
|
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
|
||||||
|
// Give 503 a bit more time
|
||||||
|
if (statusCode == 503) return TimeValue.ofSeconds(5);
|
||||||
|
|
||||||
|
if (statusCode == 429) {
|
||||||
|
// get the Retry-After header
|
||||||
|
var retryAfterHeader = response.getFirstHeader("Retry-After");
|
||||||
|
if (retryAfterHeader == null) {
|
||||||
|
return TimeValue.ofSeconds(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
String retryAfter = retryAfterHeader.getValue();
|
||||||
|
if (retryAfter == null) {
|
||||||
|
return TimeValue.ofSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int retryAfterTime = Integer.parseInt(retryAfter);
|
||||||
|
retryAfterTime = Math.clamp(retryAfterTime, 1, 5);
|
||||||
|
|
||||||
|
return TimeValue.ofSeconds(retryAfterTime);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("Invalid Retry-After header: {}", retryAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeValue.ofSeconds(2);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,4 @@
|
|||||||
|
package nu.marginalia.ndp.model;
|
||||||
|
|
||||||
|
public record DomainToTest(String domainName, int domainId) {
|
||||||
|
}
|
@@ -0,0 +1,29 @@
|
|||||||
|
package nu.marginalia.ndp;
|
||||||
|
|
||||||
|
import nu.marginalia.coordination.LocalDomainCoordinator;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class DomainEvaluatorTest {
|
||||||
|
|
||||||
|
@Tag("flaky") // Exclude from CI runs due to potential network issues
|
||||||
|
@Test
|
||||||
|
public void testSunnyDay() throws NoSuchAlgorithmException, KeyManagementException {
|
||||||
|
DomainEvaluator evaluator = new DomainEvaluator(new LocalDomainCoordinator());
|
||||||
|
|
||||||
|
// Should be a valid domain
|
||||||
|
assertTrue(evaluator.evaluateDomain("www.marginalia.nu"));
|
||||||
|
|
||||||
|
// Should be a redirect to www.marginalia.nu
|
||||||
|
assertFalse(evaluator.evaluateDomain("memex.marginalia.nu"));
|
||||||
|
|
||||||
|
// Should fail on Anubis
|
||||||
|
assertFalse(evaluator.evaluateDomain("marginalia-search.com"));
|
||||||
|
}
|
||||||
|
}
|
12
code/processes/ping-process/README.md
Normal file
12
code/processes/ping-process/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
The ping process (which has nothing to do with ICMP ping) keeps track of
|
||||||
|
the aliveness of websites. It also gathers fingerprint information about
|
||||||
|
the security posture of the website, as well as DNS information.
|
||||||
|
|
||||||
|
This is kept to build an idea of when a website is down, and to identify
|
||||||
|
ownership changes, as well as other significant events in the lifecycle
|
||||||
|
of a website.
|
||||||
|
|
||||||
|
# Central Classes
|
||||||
|
|
||||||
|
* [PingMain](java/nu/marginalia/ping/PingMain.java) main class.
|
||||||
|
* [PingJobScheduler](java/nu/marginalia/ping/PingJobScheduler.java) service that dispatches pings.
|
@@ -112,7 +112,7 @@ public class HttpClientProvider implements Provider<HttpClient> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final RequestConfig defaultRequestConfig = RequestConfig.custom()
|
final RequestConfig defaultRequestConfig = RequestConfig.custom()
|
||||||
.setCookieSpec(StandardCookieSpec.RELAXED)
|
.setCookieSpec(StandardCookieSpec.IGNORE)
|
||||||
.setResponseTimeout(10, TimeUnit.SECONDS)
|
.setResponseTimeout(10, TimeUnit.SECONDS)
|
||||||
.setConnectionRequestTimeout(5, TimeUnit.MINUTES)
|
.setConnectionRequestTimeout(5, TimeUnit.MINUTES)
|
||||||
.build();
|
.build();
|
||||||
|
@@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import javax.net.ssl.SSLException;
|
import javax.net.ssl.SSLException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.SocketException;
|
||||||
import java.net.SocketTimeoutException;
|
import java.net.SocketTimeoutException;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@ public class RetryStrategy implements HttpRequestRetryStrategy {
|
|||||||
case SocketTimeoutException ste -> false;
|
case SocketTimeoutException ste -> false;
|
||||||
case SSLException ssle -> false;
|
case SSLException ssle -> false;
|
||||||
case UnknownHostException uhe -> false;
|
case UnknownHostException uhe -> false;
|
||||||
case HttpHostConnectException ex -> executionCount <= 2; // Only retry once for connection errors
|
case HttpHostConnectException ex -> executionCount < 2;
|
||||||
|
case SocketException ex -> executionCount < 2;
|
||||||
default -> executionCount <= 3;
|
default -> executionCount <= 3;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -43,7 +43,10 @@ public record DomainSecurityRecord(
|
|||||||
@Nullable String headerXXssProtection,
|
@Nullable String headerXXssProtection,
|
||||||
@Nullable String headerServer,
|
@Nullable String headerServer,
|
||||||
@Nullable String headerXPoweredBy,
|
@Nullable String headerXPoweredBy,
|
||||||
@Nullable Instant tsLastUpdate
|
@Nullable Instant tsLastUpdate,
|
||||||
|
@Nullable Boolean sslChainValid,
|
||||||
|
@Nullable Boolean sslHostValid,
|
||||||
|
@Nullable Boolean sslDateValid
|
||||||
)
|
)
|
||||||
implements WritableModel
|
implements WritableModel
|
||||||
{
|
{
|
||||||
@@ -103,7 +106,11 @@ public record DomainSecurityRecord(
|
|||||||
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_X_XSS_PROTECTION"),
|
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_X_XSS_PROTECTION"),
|
||||||
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_SERVER"),
|
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_SERVER"),
|
||||||
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_X_POWERED_BY"),
|
rs.getString("DOMAIN_SECURITY_INFORMATION.HEADER_X_POWERED_BY"),
|
||||||
rs.getObject("DOMAIN_SECURITY_INFORMATION.TS_LAST_UPDATE", Instant.class));
|
rs.getObject("DOMAIN_SECURITY_INFORMATION.TS_LAST_UPDATE", Instant.class),
|
||||||
|
rs.getObject("SSL_CHAIN_VALID", Boolean.class),
|
||||||
|
rs.getObject("SSL_HOST_VALID", Boolean.class),
|
||||||
|
rs.getObject("SSL_DATE_VALID", Boolean.class)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpSchema httpSchemaFromString(@Nullable String schema) {
|
private static HttpSchema httpSchemaFromString(@Nullable String schema) {
|
||||||
@@ -150,8 +157,11 @@ public record DomainSecurityRecord(
|
|||||||
header_x_powered_by,
|
header_x_powered_by,
|
||||||
ssl_cert_public_key_hash,
|
ssl_cert_public_key_hash,
|
||||||
asn,
|
asn,
|
||||||
ts_last_update)
|
ts_last_update,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
ssl_chain_valid,
|
||||||
|
ssl_host_valid,
|
||||||
|
ssl_date_valid)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
"""))
|
"""))
|
||||||
{
|
{
|
||||||
ps.setInt(1, domainId());
|
ps.setInt(1, domainId());
|
||||||
@@ -295,6 +305,25 @@ public record DomainSecurityRecord(
|
|||||||
} else {
|
} else {
|
||||||
ps.setTimestamp(32, java.sql.Timestamp.from(tsLastUpdate()));
|
ps.setTimestamp(32, java.sql.Timestamp.from(tsLastUpdate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sslChainValid() == null) {
|
||||||
|
ps.setNull(33, java.sql.Types.BOOLEAN);
|
||||||
|
} else {
|
||||||
|
ps.setBoolean(33, sslChainValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sslHostValid() == null) {
|
||||||
|
ps.setNull(34, java.sql.Types.BOOLEAN);
|
||||||
|
} else {
|
||||||
|
ps.setBoolean(34, sslHostValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sslDateValid() == null) {
|
||||||
|
ps.setNull(35, java.sql.Types.BOOLEAN);
|
||||||
|
} else {
|
||||||
|
ps.setBoolean(35, sslDateValid());
|
||||||
|
}
|
||||||
|
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,6 +362,11 @@ public record DomainSecurityRecord(
|
|||||||
private String headerXPoweredBy;
|
private String headerXPoweredBy;
|
||||||
private Instant tsLastUpdate;
|
private Instant tsLastUpdate;
|
||||||
|
|
||||||
|
private Boolean isCertChainValid;
|
||||||
|
private Boolean isCertHostValid;
|
||||||
|
private Boolean isCertDateValid;
|
||||||
|
|
||||||
|
|
||||||
private static Instant MAX_UNIX_TIMESTAMP = Instant.ofEpochSecond(Integer.MAX_VALUE);
|
private static Instant MAX_UNIX_TIMESTAMP = Instant.ofEpochSecond(Integer.MAX_VALUE);
|
||||||
|
|
||||||
public Builder() {
|
public Builder() {
|
||||||
@@ -508,6 +542,21 @@ public record DomainSecurityRecord(
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Builder sslChainValid(@Nullable Boolean isCertChainValid) {
|
||||||
|
this.isCertChainValid = isCertChainValid;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder sslHostValid(@Nullable Boolean isCertHostValid) {
|
||||||
|
this.isCertHostValid = isCertHostValid;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder sslDateValid(@Nullable Boolean isCertDateValid) {
|
||||||
|
this.isCertDateValid = isCertDateValid;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public DomainSecurityRecord build() {
|
public DomainSecurityRecord build() {
|
||||||
return new DomainSecurityRecord(
|
return new DomainSecurityRecord(
|
||||||
domainId,
|
domainId,
|
||||||
@@ -541,7 +590,10 @@ public record DomainSecurityRecord(
|
|||||||
headerXXssProtection,
|
headerXXssProtection,
|
||||||
headerServer,
|
headerServer,
|
||||||
headerXPoweredBy,
|
headerXPoweredBy,
|
||||||
tsLastUpdate
|
tsLastUpdate,
|
||||||
|
isCertChainValid,
|
||||||
|
isCertHostValid,
|
||||||
|
isCertDateValid
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,20 +13,25 @@ import org.bouncycastle.asn1.x509.*;
|
|||||||
import org.bouncycastle.cert.X509CertificateHolder;
|
import org.bouncycastle.cert.X509CertificateHolder;
|
||||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||||
import org.bouncycastle.cms.CMSSignedData;
|
import org.bouncycastle.cms.CMSSignedData;
|
||||||
|
import org.bouncycastle.openssl.PEMParser;
|
||||||
import org.bouncycastle.util.Store;
|
import org.bouncycastle.util.Store;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.TrustAnchor;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public class AIACertificateFetcher {
|
public class CertificateFetcher {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AIACertificateFetcher.class);
|
private static final Logger logger = LoggerFactory.getLogger(CertificateFetcher.class);
|
||||||
|
|
||||||
private static HttpClient client = HttpClientBuilder.create()
|
private static HttpClient client = HttpClientBuilder.create()
|
||||||
.build();
|
.build();
|
||||||
@@ -107,6 +112,27 @@ public class AIACertificateFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<X509Certificate> parseMultiplePEM(byte[] data) throws Exception {
|
||||||
|
List<X509Certificate> certificates = new ArrayList<>();
|
||||||
|
|
||||||
|
try (StringReader stringReader = new StringReader(new String(data, StandardCharsets.UTF_8));
|
||||||
|
PEMParser pemParser = new PEMParser(stringReader)) {
|
||||||
|
|
||||||
|
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
|
||||||
|
Object object;
|
||||||
|
|
||||||
|
while ((object = pemParser.readObject()) != null) {
|
||||||
|
if (object instanceof X509CertificateHolder) {
|
||||||
|
X509CertificateHolder certHolder = (X509CertificateHolder) object;
|
||||||
|
certificates.add(converter.getCertificate(certHolder));
|
||||||
|
} else if (object instanceof X509Certificate) {
|
||||||
|
certificates.add((X509Certificate) object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates;
|
||||||
|
}
|
||||||
private static X509Certificate parseX509(byte[] data) throws Exception {
|
private static X509Certificate parseX509(byte[] data) throws Exception {
|
||||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||||
return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(data));
|
return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(data));
|
||||||
@@ -215,4 +241,33 @@ public class AIACertificateFetcher {
|
|||||||
|
|
||||||
return ocspUrls;
|
return ocspUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Set<TrustAnchor> getRootCerts(String bundleUrl) throws Exception {
|
||||||
|
ClassicHttpRequest request = ClassicRequestBuilder.create("GET")
|
||||||
|
.addHeader("User-Agent", WmsaHome.getUserAgent() + " (Certificate Fetcher)")
|
||||||
|
.setUri(bundleUrl)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
byte[] data = client.execute(request, rsp -> {
|
||||||
|
var entity = rsp.getEntity();
|
||||||
|
if (entity == null) {
|
||||||
|
logger.warn("GET request returned no content for {}", bundleUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entity.getContent().readAllBytes();
|
||||||
|
});
|
||||||
|
|
||||||
|
List<TrustAnchor> anchors = new ArrayList<>();
|
||||||
|
for (var cert : parseMultiplePEM(data)) {
|
||||||
|
try {
|
||||||
|
anchors.add(new TrustAnchor(cert, null));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to create TrustAnchor for certificate: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Loaded {} root certificates from {}", anchors.size(), bundleUrl);
|
||||||
|
|
||||||
|
return Set.copyOf(anchors);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -4,11 +4,7 @@ import org.bouncycastle.asn1.ASN1OctetString;
|
|||||||
import org.bouncycastle.asn1.ASN1Primitive;
|
import org.bouncycastle.asn1.ASN1Primitive;
|
||||||
import org.bouncycastle.asn1.x509.*;
|
import org.bouncycastle.asn1.x509.*;
|
||||||
|
|
||||||
import javax.net.ssl.TrustManager;
|
|
||||||
import javax.net.ssl.TrustManagerFactory;
|
|
||||||
import javax.net.ssl.X509TrustManager;
|
|
||||||
import javax.security.auth.x500.X500Principal;
|
import javax.security.auth.x500.X500Principal;
|
||||||
import java.security.KeyStore;
|
|
||||||
import java.security.cert.TrustAnchor;
|
import java.security.cert.TrustAnchor;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -16,16 +12,24 @@ import java.util.*;
|
|||||||
/** Utility class for validating X.509 certificates.
|
/** Utility class for validating X.509 certificates.
|
||||||
* This class provides methods to validate certificate chains, check expiration,
|
* This class provides methods to validate certificate chains, check expiration,
|
||||||
* hostname validity, and revocation status.
|
* hostname validity, and revocation status.
|
||||||
|
* <p></p>
|
||||||
|
* This is extremely unsuitable for actual SSL/TLS validation,
|
||||||
|
* and is only to be used in analyzing certificates for fingerprinting
|
||||||
|
* and diagnosing servers!
|
||||||
*/
|
*/
|
||||||
public class CertificateValidator {
|
public class CertificateValidator {
|
||||||
|
// If true, will attempt to fetch missing intermediate certificates via AIA urls.
|
||||||
|
private static final boolean TRY_FETCH_MISSING_CERTS = false;
|
||||||
|
|
||||||
public static class ValidationResult {
|
public static class ValidationResult {
|
||||||
public boolean chainValid = false;
|
public boolean chainValid = false;
|
||||||
public boolean certificateExpired = false;
|
public boolean certificateExpired = false;
|
||||||
public boolean certificateRevoked = false;
|
public boolean certificateRevoked = false;
|
||||||
|
public boolean selfSigned = false;
|
||||||
public boolean hostnameValid = false;
|
public boolean hostnameValid = false;
|
||||||
|
|
||||||
public boolean isValid() {
|
public boolean isValid() {
|
||||||
return chainValid && !certificateExpired && !certificateRevoked && hostnameValid;
|
return !selfSigned && !certificateExpired && !certificateRevoked && hostnameValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> errors = new ArrayList<>();
|
public List<String> errors = new ArrayList<>();
|
||||||
@@ -40,6 +44,7 @@ public class CertificateValidator {
|
|||||||
sb.append("Not Expired: ").append(!certificateExpired ? "✓" : "✗").append("\n");
|
sb.append("Not Expired: ").append(!certificateExpired ? "✓" : "✗").append("\n");
|
||||||
sb.append("Not Revoked: ").append(!certificateRevoked ? "✓" : "✗").append("\n");
|
sb.append("Not Revoked: ").append(!certificateRevoked ? "✓" : "✗").append("\n");
|
||||||
sb.append("Hostname Valid: ").append(hostnameValid ? "✓" : "✗").append("\n");
|
sb.append("Hostname Valid: ").append(hostnameValid ? "✓" : "✗").append("\n");
|
||||||
|
sb.append("Self-Signed: ").append(selfSigned ? "✓" : "✗").append("\n");
|
||||||
|
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
sb.append("\nErrors:\n");
|
sb.append("\nErrors:\n");
|
||||||
@@ -66,32 +71,6 @@ public class CertificateValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final Set<TrustAnchor> trustAnchors = loadDefaultTrustAnchors();
|
|
||||||
|
|
||||||
private static Set<TrustAnchor> loadDefaultTrustAnchors() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
Set<TrustAnchor> trustAnchors = new HashSet<>();
|
|
||||||
|
|
||||||
TrustManagerFactory tmf = TrustManagerFactory
|
|
||||||
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
|
||||||
tmf.init((KeyStore) null);
|
|
||||||
|
|
||||||
for (TrustManager tm : tmf.getTrustManagers()) {
|
|
||||||
if (tm instanceof X509TrustManager x509tm) {
|
|
||||||
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
|
|
||||||
trustAnchors.add(new TrustAnchor(cert, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trustAnchors;
|
|
||||||
}
|
|
||||||
catch (Exception e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ValidationResult validateCertificate(X509Certificate[] certChain,
|
public static ValidationResult validateCertificate(X509Certificate[] certChain,
|
||||||
String hostname) {
|
String hostname) {
|
||||||
return validateCertificate(certChain, hostname, false);
|
return validateCertificate(certChain, hostname, false);
|
||||||
@@ -115,11 +94,15 @@ public class CertificateValidator {
|
|||||||
// 2. Check hostname validity
|
// 2. Check hostname validity
|
||||||
result.hostnameValid = checkHostname(leafCert, hostname, result);
|
result.hostnameValid = checkHostname(leafCert, hostname, result);
|
||||||
|
|
||||||
// 3. Check certificate chain validity (with AIA fetching)
|
// 3. Not really checking if it's self-signed, but if the chain is incomplete (and likely self-signed)
|
||||||
result.chainValid = checkChainValidity(certChain, trustAnchors, result, autoTrustFetchedRoots);
|
result.selfSigned = certChain.length <= 1;
|
||||||
|
|
||||||
// 4. Check revocation status
|
// 4. Check certificate chain validity (optionally with AIA fetching)
|
||||||
result.certificateRevoked = checkRevocation(leafCert, result);
|
result.chainValid = checkChainValidity(certChain, RootCerts.getTrustAnchors(), result, autoTrustFetchedRoots);
|
||||||
|
|
||||||
|
// 5. Check revocation status
|
||||||
|
result.certificateRevoked = false; // not implemented
|
||||||
|
// checkRevocation(leafCert, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -199,8 +182,15 @@ public class CertificateValidator {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else if (!TRY_FETCH_MISSING_CERTS) {
|
||||||
|
result.errors.addAll(originalResult.issues);
|
||||||
|
result.details.put("chainLength", originalChain.length);
|
||||||
|
result.details.put("chainExtended", false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<X509Certificate> repairedChain = AIACertificateFetcher.buildCompleteChain(originalChain[0]);
|
List<X509Certificate> repairedChain = CertificateFetcher.buildCompleteChain(originalChain[0]);
|
||||||
|
|
||||||
if (!repairedChain.isEmpty()) {
|
if (!repairedChain.isEmpty()) {
|
||||||
|
|
||||||
@@ -397,7 +387,7 @@ public class CertificateValidator {
|
|||||||
private static boolean checkOCSP(X509Certificate cert, ValidationResult result) {
|
private static boolean checkOCSP(X509Certificate cert, ValidationResult result) {
|
||||||
// For now, just extract OCSP URL and note that we found it
|
// For now, just extract OCSP URL and note that we found it
|
||||||
try {
|
try {
|
||||||
List<String> ocspUrls = AIACertificateFetcher.getOCSPUrls(cert);
|
List<String> ocspUrls = CertificateFetcher.getOCSPUrls(cert);
|
||||||
if (!ocspUrls.isEmpty()) {
|
if (!ocspUrls.isEmpty()) {
|
||||||
result.details.put("ocspUrls", ocspUrls);
|
result.details.put("ocspUrls", ocspUrls);
|
||||||
result.warnings.add("OCSP checking not implemented - found OCSP URLs: " + ocspUrls);
|
result.warnings.add("OCSP checking not implemented - found OCSP URLs: " + ocspUrls);
|
||||||
|
@@ -0,0 +1,57 @@
|
|||||||
|
package nu.marginalia.ping.ssl;
|
||||||
|
|
||||||
|
import java.security.cert.TrustAnchor;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class RootCerts {
|
||||||
|
private static final String MOZILLA_CA_BUNDLE_URL = "https://curl.se/ca/cacert.pem";
|
||||||
|
|
||||||
|
volatile static boolean initialized = false;
|
||||||
|
volatile static Set<TrustAnchor> trustAnchors;
|
||||||
|
|
||||||
|
public static Set<TrustAnchor> getTrustAnchors() {
|
||||||
|
if (!initialized) {
|
||||||
|
try {
|
||||||
|
synchronized (RootCerts.class) {
|
||||||
|
while (!initialized) {
|
||||||
|
RootCerts.class.wait(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("RootCerts initialization interrupted", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trustAnchors;
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
Thread.ofPlatform()
|
||||||
|
.name("RootCertsUpdater")
|
||||||
|
.daemon()
|
||||||
|
.unstarted(RootCerts::updateTrustAnchors)
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void updateTrustAnchors() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
trustAnchors = CertificateFetcher.getRootCerts(MOZILLA_CA_BUNDLE_URL);
|
||||||
|
synchronized (RootCerts.class) {
|
||||||
|
initialized = true;
|
||||||
|
RootCerts.class.notifyAll(); // Notify any waiting threads
|
||||||
|
}
|
||||||
|
Thread.sleep(Duration.ofHours(24));
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break; // Exit if interrupted
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Log the exception and continue to retry
|
||||||
|
System.err.println("Failed to update trust anchors: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -126,6 +126,9 @@ public class DomainSecurityInformationFactory {
|
|||||||
.sslCertWildcard(isWildcard)
|
.sslCertWildcard(isWildcard)
|
||||||
.sslCertificateChainLength(sslCertificates.length)
|
.sslCertificateChainLength(sslCertificates.length)
|
||||||
.sslCertificateValid(validationResult.isValid())
|
.sslCertificateValid(validationResult.isValid())
|
||||||
|
.sslHostValid(validationResult.hostnameValid)
|
||||||
|
.sslChainValid(validationResult.chainValid)
|
||||||
|
.sslDateValid(!validationResult.certificateExpired)
|
||||||
.httpVersion(httpResponse.version())
|
.httpVersion(httpResponse.version())
|
||||||
.tsLastUpdate(Instant.now())
|
.tsLastUpdate(Instant.now())
|
||||||
.build();
|
.build();
|
||||||
|
@@ -243,6 +243,7 @@ class PingDaoTest {
|
|||||||
.headerServer("Apache/2.4.41 (Ubuntu)")
|
.headerServer("Apache/2.4.41 (Ubuntu)")
|
||||||
.headerXPoweredBy("PHP/7.4.3")
|
.headerXPoweredBy("PHP/7.4.3")
|
||||||
.tsLastUpdate(Instant.now())
|
.tsLastUpdate(Instant.now())
|
||||||
|
.sslHostValid(true)
|
||||||
.build();
|
.build();
|
||||||
var svc = new PingDao(dataSource);
|
var svc = new PingDao(dataSource);
|
||||||
svc.write(foo);
|
svc.write(foo);
|
||||||
|
@@ -60,6 +60,7 @@ class PingHttpServiceTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Tag("flaky") // Do not run this test in CI
|
||||||
@Test
|
@Test
|
||||||
public void testGetSslInfo() throws Exception {
|
public void testGetSslInfo() throws Exception {
|
||||||
var provider = new HttpClientProvider();
|
var provider = new HttpClientProvider();
|
||||||
@@ -71,7 +72,7 @@ class PingHttpServiceTest {
|
|||||||
),
|
),
|
||||||
new DomainSecurityInformationFactory());
|
new DomainSecurityInformationFactory());
|
||||||
|
|
||||||
var output = pingService.pingDomain(new DomainReference(1, 1, "thecavalierclub.co.uk"), null, null);
|
var output = pingService.pingDomain(new DomainReference(1, 1, "www.marginalia.nu"), null, null);
|
||||||
for (var model : output) {
|
for (var model : output) {
|
||||||
System.out.println(model);
|
System.out.println(model);
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ public class ProcessInboxNames {
|
|||||||
public static final String CONVERTER_INBOX = "converter";
|
public static final String CONVERTER_INBOX = "converter";
|
||||||
public static final String LOADER_INBOX = "loader";
|
public static final String LOADER_INBOX = "loader";
|
||||||
public static final String PING_INBOX = "ping";
|
public static final String PING_INBOX = "ping";
|
||||||
|
public static final String NDP_INBOX = "ndp";
|
||||||
public static final String CRAWLER_INBOX = "crawler";
|
public static final String CRAWLER_INBOX = "crawler";
|
||||||
public static final String LIVE_CRAWLER_INBOX = "live-crawler";
|
public static final String LIVE_CRAWLER_INBOX = "live-crawler";
|
||||||
|
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
package nu.marginalia.mqapi.ndp;
|
||||||
|
|
||||||
|
public record NdpRequest(int goal) {
|
||||||
|
}
|
@@ -25,6 +25,12 @@ into the [MariaDB database](../common/db).
|
|||||||
The [index-construction-process](index-constructor-process/) constructs indices from
|
The [index-construction-process](index-constructor-process/) constructs indices from
|
||||||
the data generated by the loader.
|
the data generated by the loader.
|
||||||
|
|
||||||
|
## 5. Other Processes
|
||||||
|
|
||||||
|
* Ping Process: The [ping-process](ping-process/) keeps track of the aliveness of websites, gathering fingerprint information about the security posture of the website, as well as DNS information.
|
||||||
|
* New Domain Process (NDP): The [new-domain-process](new-domain-process/) evaluates new domains for inclusion in the search engine index.
|
||||||
|
* Live-Crawling Process: The [live-crawling-process](live-crawling-process/) is a process that crawls websites in real-time based on RSS feeds, updating a smaller index with the latest content.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Schematically the crawling and loading process looks like this:
|
Schematically the crawling and loading process looks like this:
|
||||||
|
@@ -22,6 +22,7 @@ import nu.marginalia.search.model.NavbarModel;
|
|||||||
import nu.marginalia.search.model.ResultsPage;
|
import nu.marginalia.search.model.ResultsPage;
|
||||||
import nu.marginalia.search.model.UrlDetails;
|
import nu.marginalia.search.model.UrlDetails;
|
||||||
import nu.marginalia.search.svc.SearchFlagSiteService.FlagSiteFormData;
|
import nu.marginalia.search.svc.SearchFlagSiteService.FlagSiteFormData;
|
||||||
|
import nu.marginalia.service.server.RateLimiter;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ public class SearchSiteInfoService {
|
|||||||
private final HikariDataSource dataSource;
|
private final HikariDataSource dataSource;
|
||||||
private final SearchSiteSubscriptionService searchSiteSubscriptions;
|
private final SearchSiteSubscriptionService searchSiteSubscriptions;
|
||||||
|
|
||||||
|
private final RateLimiter rateLimiter = RateLimiter.custom(60);
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public SearchSiteInfoService(SearchOperator searchOperator,
|
public SearchSiteInfoService(SearchOperator searchOperator,
|
||||||
DomainInfoClient domainInfoClient,
|
DomainInfoClient domainInfoClient,
|
||||||
@@ -238,6 +241,7 @@ public class SearchSiteInfoService {
|
|||||||
boolean hasScreenshot = screenshotService.hasScreenshot(domainId);
|
boolean hasScreenshot = screenshotService.hasScreenshot(domainId);
|
||||||
boolean isSubscribed = searchSiteSubscriptions.isSubscribed(context, domain);
|
boolean isSubscribed = searchSiteSubscriptions.isSubscribed(context, domain);
|
||||||
|
|
||||||
|
boolean rateLimited = !rateLimiter.isAllowed();
|
||||||
if (domainId < 0) {
|
if (domainId < 0) {
|
||||||
domainInfoFuture = CompletableFuture.failedFuture(new Exception("Unknown Domain ID"));
|
domainInfoFuture = CompletableFuture.failedFuture(new Exception("Unknown Domain ID"));
|
||||||
similarSetFuture = CompletableFuture.failedFuture(new Exception("Unknown Domain ID"));
|
similarSetFuture = CompletableFuture.failedFuture(new Exception("Unknown Domain ID"));
|
||||||
@@ -250,6 +254,12 @@ public class SearchSiteInfoService {
|
|||||||
linkingDomainsFuture = CompletableFuture.failedFuture(new Exception("Assistant Service Unavailable"));
|
linkingDomainsFuture = CompletableFuture.failedFuture(new Exception("Assistant Service Unavailable"));
|
||||||
feedItemsFuture = CompletableFuture.failedFuture(new Exception("Assistant Service Unavailable"));
|
feedItemsFuture = CompletableFuture.failedFuture(new Exception("Assistant Service Unavailable"));
|
||||||
}
|
}
|
||||||
|
else if (rateLimited) {
|
||||||
|
domainInfoFuture = domainInfoClient.domainInformation(domainId);
|
||||||
|
similarSetFuture = CompletableFuture.failedFuture(new Exception("Rate limit exceeded"));
|
||||||
|
linkingDomainsFuture = CompletableFuture.failedFuture(new Exception("Rate limit exceeded"));
|
||||||
|
feedItemsFuture = CompletableFuture.failedFuture(new Exception("Rate limit exceeded"));
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
domainInfoFuture = domainInfoClient.domainInformation(domainId);
|
domainInfoFuture = domainInfoClient.domainInformation(domainId);
|
||||||
similarSetFuture = domainInfoClient.similarDomains(domainId, 25);
|
similarSetFuture = domainInfoClient.similarDomains(domainId, 25);
|
||||||
@@ -257,7 +267,14 @@ public class SearchSiteInfoService {
|
|||||||
feedItemsFuture = feedsClient.getFeed(domainId);
|
feedItemsFuture = feedsClient.getFeed(domainId);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<UrlDetails> sampleResults = searchOperator.doSiteSearch(domainName, domainId,5, 1).results;
|
List<UrlDetails> sampleResults;
|
||||||
|
if (rateLimited) {
|
||||||
|
sampleResults = List.of();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sampleResults = searchOperator.doSiteSearch(domainName, domainId, 5, 1).results;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sampleResults.isEmpty()) {
|
if (!sampleResults.isEmpty()) {
|
||||||
url = sampleResults.getFirst().url.withPathAndParam("/", null).toString();
|
url = sampleResults.getFirst().url.withPathAndParam("/", null).toString();
|
||||||
}
|
}
|
||||||
@@ -276,8 +293,9 @@ public class SearchSiteInfoService {
|
|||||||
sampleResults
|
sampleResults
|
||||||
);
|
);
|
||||||
|
|
||||||
requestMissingScreenshots(result);
|
if (!rateLimited) {
|
||||||
|
requestMissingScreenshots(result);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
@param String message
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8">
|
||||||
|
<title>Unavailable</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Service Overloaded</h1>
|
||||||
|
<p>${message}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
@@ -280,6 +280,7 @@ public class ControlNodeService {
|
|||||||
"on".equalsIgnoreCase(request.queryParams("autoClean")),
|
"on".equalsIgnoreCase(request.queryParams("autoClean")),
|
||||||
"on".equalsIgnoreCase(request.queryParams("includeInPrecession")),
|
"on".equalsIgnoreCase(request.queryParams("includeInPrecession")),
|
||||||
"on".equalsIgnoreCase(request.queryParams("keepWarcs")),
|
"on".equalsIgnoreCase(request.queryParams("keepWarcs")),
|
||||||
|
"on".equalsIgnoreCase(request.queryParams("autoAssignDomains")),
|
||||||
NodeProfile.valueOf(request.queryParams("profile")),
|
NodeProfile.valueOf(request.queryParams("profile")),
|
||||||
"on".equalsIgnoreCase(request.queryParams("disabled"))
|
"on".equalsIgnoreCase(request.queryParams("disabled"))
|
||||||
);
|
);
|
||||||
|
@@ -66,13 +66,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" name="autoAssignDomains" {{#if config.autoAssignDomains}}checked{{/if}}>
|
||||||
|
<label class="form-check-label" for="autoClean">Auto-Assign Domains</label>
|
||||||
|
|
||||||
|
<div class="form-text">If true, the New Domain Process will assign new domains to this node and all other nodes with this setting enabled.
|
||||||
|
This is the default behavior, but can be overridden if you want one node with a specific manual domain assignment.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This is not currently used, but may be in the future
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" name="includeInPrecession" {{#if config.includeInPrecession}}checked{{/if}}>
|
<input class="form-check-input" type="checkbox" role="switch" name="includeInPrecession" {{#if config.includeInPrecession}}checked{{/if}}>
|
||||||
<label class="form-check-label" for="includeInPrecession">Include in crawling precession</label>
|
<label class="form-check-label" for="includeInPrecession">Include in crawling precession</label>
|
||||||
|
|
||||||
<div class="form-text">If true, this node will be included in the crawling precession.</div>
|
<div class="form-text">If true, this node will be included in the crawling precession.</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" name="keepWarcs" {{#if config.keepWarcs}}checked{{/if}}>
|
<input class="form-check-input" type="checkbox" role="switch" name="keepWarcs" {{#if config.keepWarcs}}checked{{/if}}>
|
||||||
<label class="form-check-label" for="includeInPrecession">Keep WARC files during crawling</label>
|
<label class="form-check-label" for="includeInPrecession">Keep WARC files during crawling</label>
|
||||||
|
@@ -69,6 +69,7 @@ include 'code:processes:crawling-process:ft-link-parser'
|
|||||||
include 'code:processes:crawling-process:ft-content-type'
|
include 'code:processes:crawling-process:ft-content-type'
|
||||||
include 'code:processes:live-crawling-process'
|
include 'code:processes:live-crawling-process'
|
||||||
include 'code:processes:ping-process'
|
include 'code:processes:ping-process'
|
||||||
|
include 'code:processes:new-domain-process'
|
||||||
|
|
||||||
include 'code:processes:process-mq-api'
|
include 'code:processes:process-mq-api'
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user