diff --git a/bundles/org.openhab.binding.pihole/README.md b/bundles/org.openhab.binding.pihole/README.md index d07593c3a8e55..51585fe6cf33d 100644 --- a/bundles/org.openhab.binding.pihole/README.md +++ b/bundles/org.openhab.binding.pihole/README.md @@ -22,9 +22,10 @@ The Pi-hole Binding allows you to monitor Pi-hole statistics and control its fun | Name | Type | Description | Default | Required | Advanced | |-----------------|---------|-------------------------------------------------------------------------------------------|---------|----------|----------| -| hostname | text | Hostname or IP address of the device | N/A | yes | no | +| hostname | text | URL (hostname or IP address) of the pihole web server | N/A | yes | no | | token | text | Token to access the device. To generate token go to `settings` > `API` > `Show API token` | N/A | yes | no | -| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes | +| refreshInterval | integer | Interval the device is polled in sec | 600 | no | yes | +| serverVersion | text | Defines the API to be used with this server | v5 | no | no | ## Channels diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java index 0b63f0e45e1e2..ced04025ba23f 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java @@ -53,15 +53,11 @@ public void disableBlocking( return; } - if (timeUnit == null) { - timeUnit = SECONDS; - } - var local = handler; if (local == null) { return; } - local.disableBlocking(timeUnit.toSeconds(time)); + local.disableBlocking((timeUnit == null ? SECONDS : timeUnit).toSeconds(time)); } public static void disableBlocking(@Nullable ThingActions actions, long time, @Nullable TimeUnit timeUnit) diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java index b7f65b315dfa0..763d5b3ff944f 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java @@ -21,7 +21,11 @@ */ @NonNullByDefault public class PiHoleConfiguration { + public static final String API_V6 = "v6"; + public static final String API_V5 = "v5"; + public String hostname = ""; public String token = ""; public int refreshIntervalSeconds = 600; + public String serverVersion = API_V5; } diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java index f2f790ca8a1c5..ea11fcc001a0e 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java @@ -33,8 +33,10 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DisableEnable; import org.openhab.binding.pihole.internal.rest.AdminService; import org.openhab.binding.pihole.internal.rest.JettyAdminService; +import org.openhab.binding.pihole.internal.rest.JettyAdminServiceV6; import org.openhab.binding.pihole.internal.rest.model.DnsStatistics; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; @@ -51,6 +53,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + /** * The {@link PiHoleHandler} is responsible for handling commands, which are * sent to one of the channels. @@ -58,21 +62,23 @@ * @author Martin Grzeslowski - Initial contribution */ @NonNullByDefault -public class PiHoleHandler extends BaseThingHandler implements AdminService { +public class PiHoleHandler extends BaseThingHandler { private static final int HTTP_DELAY_SECONDS = 1; private final Logger logger = LoggerFactory.getLogger(PiHoleHandler.class); private final Object lock = new Object(); private final TimeZoneProvider timeZoneProvider; private final HttpClient httpClient; + private final Gson gson; private @Nullable AdminService adminService; private @Nullable DnsStatistics dnsStatistics; private @Nullable ScheduledFuture scheduledFuture; - public PiHoleHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient) { + public PiHoleHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient, Gson gson) { super(thing); this.timeZoneProvider = timeZoneProvider; this.httpClient = httpClient; + this.gson = gson; } @Override @@ -101,7 +107,11 @@ public void initialize() { updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/handler.init.noToken"); return; } - adminService = new JettyAdminService(config.token, hostname, httpClient); + + adminService = PiHoleConfiguration.API_V6.equals(config.serverVersion) + ? new JettyAdminServiceV6(config.token, hostname, httpClient, gson) + : new JettyAdminService(config.token, hostname, httpClient, gson); + scheduledFuture = scheduler.scheduleWithFixedDelay(this::update, 0, config.refreshIntervalSeconds, SECONDS); // do not set status here, the background task will do it. @@ -234,7 +244,6 @@ public void dispose() { super.dispose(); } - @Override public Optional summary() throws PiHoleException { var local = adminService; if (local == null) { @@ -243,7 +252,6 @@ public Optional summary() throws PiHoleException { return local.summary(); } - @Override public void disableBlocking(long seconds) throws PiHoleException { var local = adminService; if (local == null) { @@ -259,7 +267,6 @@ public void disableBlocking(long seconds) throws PiHoleException { } } - @Override public void enableBlocking() throws PiHoleException { var local = adminService; if (local == null) { diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java index 42647fd73b4ed..1b4d8f581d902 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java @@ -29,6 +29,10 @@ import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + /** * The {@link PiHoleHandlerFactory} is responsible for creating things and thing * handlers. @@ -38,8 +42,10 @@ @NonNullByDefault @Component(configurationPid = "binding.pihole", service = ThingHandlerFactory.class) public class PiHoleHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(PI_HOLE_TYPE); + private static final Gson GSON = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create(); + private final TimeZoneProvider timeZoneProvider; private final HttpClientFactory httpClientFactory; @@ -60,9 +66,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (PI_HOLE_TYPE.equals(thingTypeUID)) { - return new PiHoleHandler(thing, timeZoneProvider, httpClientFactory.getCommonHttpClient()); + return new PiHoleHandler(thing, timeZoneProvider, httpClientFactory.getCommonHttpClient(), GSON); } - return null; } } diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java index b197b0b48f5d7..c6cba9b589a1b 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java @@ -13,23 +13,41 @@ package org.openhab.binding.pihole.internal.rest; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; import org.openhab.binding.pihole.internal.PiHoleException; import org.openhab.binding.pihole.internal.rest.model.DnsStatistics; +import com.google.gson.Gson; + /** * @author Martin Grzeslowski - Initial contribution + * @author Gaël L'hopital - Changed from 'interface' to abstract class */ @NonNullByDefault -public interface AdminService { +public abstract class AdminService { + protected static final long TIMEOUT_SECONDS = 10L; + + protected final HttpClient client; + protected final Gson gson; + + public AdminService(HttpClient client, Gson gson) { + this.client = client; + this.gson = gson; + } + /** * Retrieves a summary of DNS statistics. * * @return An optional containing the DNS statistics. * @throws PiHoleException In case of error */ - Optional summary() throws PiHoleException; + public abstract Optional summary() throws PiHoleException; /** * Disables blocking for a specified duration. @@ -37,12 +55,21 @@ public interface AdminService { * @param seconds The duration in seconds for which blocking should be disabled. * @throws PiHoleException In case of error */ - void disableBlocking(long seconds) throws PiHoleException; + public abstract void disableBlocking(long seconds) throws PiHoleException; /** * Enables blocking. * * @throws PiHoleException In case of error */ - void enableBlocking() throws PiHoleException; + public abstract void enableBlocking() throws PiHoleException; + + protected ContentResponse send(Request request) throws PiHoleException { + try { + return request.send(); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new PiHoleException( + "Exception while sending request to Pi-hole. %s".formatted(e.getLocalizedMessage()), e); + } + } } diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java index 1dade2803f013..0cc4bdb00a6dc 100644 --- a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java @@ -16,39 +16,30 @@ import java.net.URI; import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; import org.openhab.binding.pihole.internal.PiHoleException; import org.openhab.binding.pihole.internal.rest.model.DnsStatistics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; /** * @author Martin Grzeslowski - Initial contribution */ @NonNullByDefault -public class JettyAdminService implements AdminService { - private static final Gson GSON = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create(); - private static final long TIMEOUT_SECONDS = 10L; +public class JettyAdminService extends AdminService { private final Logger logger = LoggerFactory.getLogger(JettyAdminService.class); - private final String token; + private final URI baseUrl; - private final HttpClient client; + private final String token; - public JettyAdminService(String token, URI baseUrl, HttpClient client) { - this.token = token; + public JettyAdminService(String token, URI baseUrl, HttpClient client, Gson gson) { + super(client, gson); this.baseUrl = baseUrl; - this.client = client; + this.token = token; } @Override @@ -58,16 +49,7 @@ public Optional summary() throws PiHoleException { var request = client.newRequest(url).timeout(TIMEOUT_SECONDS, SECONDS); var response = send(request); var content = response.getContentAsString(); - return Optional.ofNullable(GSON.fromJson(content, DnsStatistics.class)); - } - - private static ContentResponse send(Request request) throws PiHoleException { - try { - return request.send(); - } catch (InterruptedException | TimeoutException | ExecutionException e) { - throw new PiHoleException( - "Exception while sending request to Pi-hole. %s".formatted(e.getLocalizedMessage()), e); - } + return Optional.ofNullable(gson.fromJson(content, DnsStatistics.class)); } @Override diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceV6.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceV6.java new file mode 100644 index 0000000000000..668e29b45e083 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceV6.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.openhab.binding.pihole.internal.PiHoleException; +import org.openhab.binding.pihole.internal.rest.model.DnsStatistics; +import org.openhab.binding.pihole.internal.rest.model.GravityLastUpdated; +import org.openhab.binding.pihole.internal.rest.model.Relative; +import org.openhab.binding.pihole.internal.rest.model.v6.Blocking; +import org.openhab.binding.pihole.internal.rest.model.v6.ConfigAnswer; +import org.openhab.binding.pihole.internal.rest.model.v6.DnsBlockingAnswer; +import org.openhab.binding.pihole.internal.rest.model.v6.HistoryClients; +import org.openhab.binding.pihole.internal.rest.model.v6.Password; +import org.openhab.binding.pihole.internal.rest.model.v6.SessionAnswer; +import org.openhab.binding.pihole.internal.rest.model.v6.SessionAnswer.Session; +import org.openhab.binding.pihole.internal.rest.model.v6.StatAnswer; +import org.openhab.binding.pihole.internal.rest.model.v6.StatAnswer.Gravity; +import org.openhab.binding.pihole.internal.rest.model.v6.StatAnswer.Queries; +import org.openhab.binding.pihole.internal.rest.model.v6.StatAnswer.Replies; +import org.openhab.binding.pihole.internal.rest.model.v6.StatDatabaseSummary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class JettyAdminServiceV6 extends AdminService { + private final Logger logger = LoggerFactory.getLogger(JettyAdminServiceV6.class); + private final URI authURI; + private final URI statsSummaryURI; + private final URI dnsBlockingURI; + private final URI databaseSummaryURI; + private final URI historyClientsURI; + private final URI configURI; + private final String tokenJson; + + private @Nullable String sid; + private @Nullable Instant sessionValidity; + private @Nullable Instant midnightUtc; + + public JettyAdminServiceV6(String token, URI baseUrl, HttpClient client, Gson gson) { + super(client, gson); + tokenJson = gson.toJson(new Password(token)); + + UriBuilder apiUriBuilder = UriBuilder.fromUri(baseUrl).path("api"); + authURI = apiUriBuilder.clone().path("auth").build(); + configURI = apiUriBuilder.clone().path("config").build(); + dnsBlockingURI = apiUriBuilder.clone().path("dns").path("blocking").build(); + historyClientsURI = apiUriBuilder.clone().path("history").path("clients").build(); + + UriBuilder statsUriBuilder = apiUriBuilder.clone().path("stats"); + statsSummaryURI = statsUriBuilder.clone().path("summary").build(); + databaseSummaryURI = statsUriBuilder.clone().path("database").path("summary").build(); + } + + private String updateSid() throws PiHoleException { + logger.debug("Get or update session ID"); + var request = client.newRequest(authURI).timeout(TIMEOUT_SECONDS, SECONDS).method(HttpMethod.POST) + .header(HttpHeader.ACCEPT, "application/json").content(new StringContentProvider(tokenJson)); + var content = send(request).getContentAsString(); + + if (gson.fromJson(content, SessionAnswer.class) instanceof SessionAnswer answer) { + Session session = answer.session(); + sid = session.sid(); + sessionValidity = Instant.now().plusSeconds(session.cautiousValidity()); + return session.sid(); + } + + throw new PiHoleException("Error deserializing '%s'".formatted(content)); + } + + private String getSid() throws PiHoleException { + if (sid instanceof String localSid && sessionValidity instanceof Instant validUntil + && Instant.now().isBefore(validUntil)) { + return localSid; + } + return updateSid(); + } + + private long getTodayMidnight() { + Instant now = Instant.now(); + Instant local = midnightUtc; + + if (local == null || now.minus(1, ChronoUnit.DAYS).isAfter(local)) { + local = now.truncatedTo(ChronoUnit.DAYS); + midnightUtc = local; + } + + return local.getEpochSecond(); + } + + @Override + public Optional summary() throws PiHoleException { + logger.debug("Building the as if it was a v5 API"); + StatAnswer statAnswer = get(statsSummaryURI, StatAnswer.class); + Gravity gravity = statAnswer.gravity(); + Queries statQueries = statAnswer.queries(); + Replies replies = statQueries.replies(); + + DnsBlockingAnswer blockingAnswer = get(dnsBlockingURI, DnsBlockingAnswer.class); + + long todayMidnight = getTodayMidnight(); + StatDatabaseSummary statDatabase = get(databaseSummaryURI, StatDatabaseSummary.class, "from", + Long.toString(todayMidnight), "until", Long.toString(todayMidnight + 24 * 60 * 60)); + + HistoryClients historyClients = get(historyClientsURI, HistoryClients.class, "N", "0"); + ConfigAnswer configAnswer = get(configURI, ConfigAnswer.class); + Duration duration = Duration.between(gravity.instant(), Instant.now()); + Relative relative = new Relative((int) duration.toDaysPart(), duration.toHoursPart(), duration.toMinutesPart()); + + DnsStatistics translated = new DnsStatistics(gravity.domainsBeingBlocked(), statDatabase.sumQueries(), + statDatabase.sumBlocked(), statDatabase.percentBlocked(), statQueries.uniqueDomains(), + statQueries.forwarded(), statQueries.cached(), historyClients.clients().size(), null, + statQueries.types().all(), replies.unknown(), replies.nodata(), replies.nxdomain(), replies.cname(), + replies.ip(), replies.domain(), replies.rrname(), replies.servfail(), replies.refused(), + replies.notimp(), replies.other(), replies.dnssec(), replies.none(), replies.blob(), replies.all(), + configAnswer.config().misc().privacylevel(), blockingAnswer.blocking(), new GravityLastUpdated( + configAnswer.config().files().gravity() != null, gravity.lastUpdate(), relative)); + + return Optional.of(translated); + } + + @Override + public void disableBlocking(long seconds) throws PiHoleException { + logger.debug("Disabling blocking for {} seconds", seconds); + post(dnsBlockingURI, new Blocking(false, seconds)); + } + + @Override + public void enableBlocking() throws PiHoleException { + logger.debug("Enabling blocking"); + post(dnsBlockingURI, Blocking.BLOCK); + } + + private void post(URI targetURI, Object object) throws PiHoleException { + var request = client.newRequest(targetURI).timeout(TIMEOUT_SECONDS, SECONDS).method(HttpMethod.POST) + .header(HttpHeader.ACCEPT, "application/json").header("sid", getSid()) + .content(new StringContentProvider(gson.toJson(object))); + send(request); + } + + private T get(URI targetURI, Class clazz, @Nullable Object... params) throws PiHoleException { + var request = client.newRequest(targetURI).timeout(TIMEOUT_SECONDS, SECONDS) + .header(HttpHeader.ACCEPT, "application/json").header("sid", getSid()); + + if (params.length % 2 != 0) { + throw new IllegalArgumentException("params count must be even"); + } + for (int i = 0; i < params.length; i += 2) { + if (params[i] instanceof String name && params[i + 1] instanceof String param) { + request = request.param(name, param); + } else { + throw new IllegalArgumentException("parameters must be String"); + } + } + + var content = send(request).getContentAsString(); + T answer = gson.fromJson(content, clazz); + if (answer != null) { + return answer; + } + throw new PiHoleException("Error deserializing %s".formatted(content)); + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Blocking.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Blocking.java new file mode 100644 index 0000000000000..394c528f5ad4f --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Blocking.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record Blocking(boolean blocking, @Nullable Long timer) { + public static Blocking BLOCK = new Blocking(true, null); +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/ConfigAnswer.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/ConfigAnswer.java new file mode 100644 index 0000000000000..48bf383a2bf62 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/ConfigAnswer.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record ConfigAnswer(Config config, double took) { + + public record Temp(int limit, String unit) { + } + + public record Api(int maxSessions, boolean prettyJSON, String pwhash, String password, String totpSecret, + String appPwhash, boolean appSudo, boolean cliPw, List excludeClients, List excludeDomains, + int maxHistory, int maxClients, boolean clientHistoryGlobalMax, boolean allowDestructive, Temp temp) { + } + + public record Host(boolean force4, String iPv4, boolean force6, String iPv6) { + } + + public record Blocking(boolean active, String mode, String edns) { + } + + public record Blocking_1(boolean force4, String iPv4, boolean force6, String iPv6) { + } + + public record Cache(int size, int optimizer, int upstreamBlockedTTL) { + } + + public record Network(boolean parseARPcache, int expire) { + } + + public record Database(boolean dBimport, int maxDBdays, int dBinterval, boolean useWAL, Network network) { + } + + public record Debug(boolean database, boolean networking, boolean locks, boolean queries, boolean flags, + boolean shmem, boolean gc, boolean arp, boolean regex, boolean api, boolean tls, boolean overtime, + boolean status, boolean caps, boolean dnssec, boolean vectors, boolean resolver, boolean edns0, + boolean clients, boolean aliasclients, boolean events, boolean helper, boolean config, boolean inotify, + boolean webserver, boolean extra, boolean reserved, boolean ntp, boolean netlink, boolean all) { + } + + public record Dhcp(boolean active, String start, String end, String router, String netmask, String leaseTime, + boolean ipv6, boolean rapidCommit, boolean multiDNS, boolean logging, boolean ignoreUnknownClients, + List hosts) { + } + + public record SpecialDomains(boolean mozillaCanary, boolean iCloudPrivateRelay, boolean designatedResolver) { + } + + public record Reply(Host host, Blocking_1 blocking) { + } + + public record RateLimit(int count, int interval) { + } + + public record Dns(List upstreams, boolean cNAMEdeepInspect, boolean blockESNI, boolean edns0ecs, + boolean ignoreLocalhost, boolean showDNSSEC, boolean analyzeOnlyAandAAAA, String piholePTR, + String replyWhenBusy, int blockTTL, List hosts, boolean domainNeeded, boolean expandHosts, + String domain, boolean bogusPriv, boolean dnssec, String _interface, String hostRecord, + String listeningMode, boolean queryLogging, List cnameRecords, int port, List revServers, + Cache cache, Blocking blocking, SpecialDomains specialDomains, Reply reply, RateLimit rateLimit) { + } + + public record Files(String pid, String database, @Nullable String gravity, String gravityTmp, String macvendor, + String pcap, Log log) { + } + + public record Interface(boolean boxed, String theme) { + } + + public record Ipv4(boolean active, String address) { + } + + public record Ipv6(boolean active, String address) { + } + + public record Log(String ftl, String dnsmasq, String webserver) { + } + + public record Check(boolean load, int shmem, int disk) { + } + + public record Misc(int privacylevel, int delayStartup, int nice, boolean addr2line, boolean etcDnsmasqD, + List dnsmasqLines, boolean extraLogging, boolean readOnly, Check check) { + } + + public record Rtc(boolean set, String device, boolean utc) { + } + + public record Sync(boolean active, String server, int interval, int count, Rtc rtc) { + } + + public record Ntp(Ipv4 ipv4, Ipv6 ipv6, Sync sync) { + } + + public record Paths(String webroot, String webhome, String prefix) { + } + + public record Resolver(boolean resolveIPv4, boolean resolveIPv6, boolean networkNames, String refreshNames) { + } + + public record Session(int timeout, boolean restore) { + } + + public record Tls(String cert) { + } + + public record Webserver(String domain, String acl, String port, int threads, List headers, boolean serveAll, + Session session, Tls tls, Paths paths, Interface _interface, Api api) { + } + + public record Config(Dns dns, Dhcp dhcp, Ntp ntp, Resolver resolver, Database database, Webserver webserver, + Files files, Misc misc, Debug debug) { + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/DnsBlockingAnswer.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/DnsBlockingAnswer.java new file mode 100644 index 0000000000000..7260cd8ae7a51 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/DnsBlockingAnswer.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record DnsBlockingAnswer(String blocking, Double timer, double took) { +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryAnswer.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryAnswer.java new file mode 100644 index 0000000000000..e6200b74a442e --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryAnswer.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record HistoryAnswer(History history, double took) { + public record History(double timestamp, int total, int cached, int blocked, int forwarded) { + + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryClients.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryClients.java new file mode 100644 index 0000000000000..804c45d8eb937 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/HistoryClients.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record HistoryClients(Map clients, List history, double took) { + public record Client(String name, int total) { + + } + + public record History(double timestamp, Map data) { + + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Password.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Password.java new file mode 100644 index 0000000000000..9246058470e4a --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/Password.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record Password(String password) { +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/SessionAnswer.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/SessionAnswer.java new file mode 100644 index 0000000000000..b57697d66c548 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/SessionAnswer.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record SessionAnswer(Session session, double took) { + public record Session(boolean valid, boolean totp, String sid, String csrf, int validity, String message) { + public int cautiousValidity() { + return validity / 3 * 2; + } + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatAnswer.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatAnswer.java new file mode 100644 index 0000000000000..5b8c4c9f9db14 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatAnswer.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record StatAnswer(Queries queries, Clients client, Gravity gravity, double took) { + record Clients(int active, int total) { + + } + + public record Gravity(int domainsBeingBlocked, long lastUpdate) { + public Instant instant() { + return Instant.ofEpochSecond(lastUpdate); + } + } + + public record Queries(int total, int blocked, double percentBlocked, int uniqueDomains, int forwarded, int cached, + double frequency, Types types, Status status, Replies replies) { + + } + + public record Replies(@SerializedName("UNKNOWN") int unknown, // + @SerializedName("NODATA") int nodata, // + @SerializedName("NXDOMAIN") int nxdomain, // + @SerializedName("CNAME") int cname, // + @SerializedName("IP") int ip, // + @SerializedName("DOMAIN") int domain, // + @SerializedName("RRNAME") int rrname, // + @SerializedName("SERVFAIL") int servfail, // + @SerializedName("REFUSED") int refused, // + @SerializedName("NOTIMP") int notimp, // + @SerializedName("OTHER") int other, // + @SerializedName("DNSSEC") int dnssec, // + @SerializedName("NONE") int none, // + @SerializedName("BLOB") int blob) { + + public int all() { + return unknown + nodata + nxdomain + cname + ip + domain + rrname + servfail + refused + notimp + other + + dnssec + none + blob; + } + } + + public record Status(@SerializedName("UNKNOWN") int unknown, // + @SerializedName("GRAVITY") int gravity, // + @SerializedName("FORWARDED") int forwarded, // + @SerializedName("CACHE") int cache, // + @SerializedName("REGEX") int regex, // + @SerializedName("DENYLIST") int denylist, // + @SerializedName("EXTERNAL_BLOCKED_IP") int externalBlockedIp, // + @SerializedName("EXTERNAL_BLOCKED_NULL") int externalBlockedNull, // + @SerializedName("EXTERNAL_BLOCKED_NXRA") int externalBlockedNxra, // + @SerializedName("GRAVITY_CNAME") int gravityCname, // + @SerializedName("REGEX_CNAME") int regexCname, // + @SerializedName("DENYLIST_CNAME") int denylistCname, // + @SerializedName("RETRIED") int retried, // + @SerializedName("RETRIED_DNSSEC") int retriedDnssec, // + @SerializedName("IN_PROGRESS") int inProgress, // + @SerializedName("DBBUSY") int dbbusy, // + @SerializedName("SPECIAL_DOMAIN") int specialDomain, // + @SerializedName("CACHE_STALE") int cacheStale, // + @SerializedName("EXTERNAL_BLOCKED_EDE15") int externalBlockedEde15) { + } + + public record Types(@SerializedName("A") int a, // + @SerializedName("AAAA") int aaaa, // + @SerializedName("ANY") int any, // + @SerializedName("SRV") int srv, // + @SerializedName("SOA") int soa, // + @SerializedName("PTR") int ptr, // + @SerializedName("TXT") int txt, // + @SerializedName("NAPTR") int naptr, // + @SerializedName("MX") int mx, // + @SerializedName("DS") int ds, // + @SerializedName("RRSIG") int rrsig, // + @SerializedName("DNSKEY") int dnskey, // + @SerializedName("NS") int ns, // + @SerializedName("SVCB") int svcb, // + @SerializedName("HTTPS") int https, // + @SerializedName("OTHER") int other) { + + public int all() { + return a + aaaa + any + srv + soa + ptr + txt + naptr + mx + ds + rrsig + dnskey + ns + svcb + https + + other; + } + } +} diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatDatabaseSummary.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatDatabaseSummary.java new file mode 100644 index 0000000000000..e4f8916313d36 --- /dev/null +++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/v6/StatDatabaseSummary.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pihole.internal.rest.model.v6; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public record StatDatabaseSummary(int sumQueries, int sumBlocked, double percentBlocked, int totalClients, + double took) { +} diff --git a/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml index 27949e316bd54..9cc3c89e5d64d 100644 --- a/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml @@ -123,8 +123,8 @@ network-address - - Hostname or IP address of the device + + URL (hostname or IP address) of the pihole web server password @@ -137,6 +137,15 @@ 600 true + + + Defines the API to be used with this server. + v5 + + + + + diff --git a/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java b/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java index df3caed50947e..6e4f9b96d5309 100644 --- a/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java +++ b/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java @@ -29,11 +29,18 @@ import org.openhab.binding.pihole.internal.rest.model.GravityLastUpdated; import org.openhab.binding.pihole.internal.rest.model.Relative; +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + /** * @author Martin Grzeslowski - Initial contribution */ @NonNullByDefault public class JettyAdminServiceTest { + private static final Gson GSON = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create(); + String content = """ { "domains_being_blocked": 131355, @@ -83,7 +90,7 @@ public void testReturnsDnsStatisticsObjectWithValidTokenAndBaseUrl() throws Exce var token = "validToken"; var baseUrl = URI.create("https://example.com"); var client = mock(HttpClient.class); - var adminService = new JettyAdminService(token, baseUrl, client); + var adminService = new JettyAdminService(token, baseUrl, client, GSON); var dnsStatistics = new DnsStatistics(131355, // domains_being_blocked 27459, // dns_queries_today 2603, // ads_blocked_today