pihole.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import requests
  4. from datetime import datetime
  5. from enum import Enum
  6. from influxdb_client import Point
  7. from pandas import DataFrame
  8. ALLOWED_STATUS_TYPES = (2, 3, 12, 13, 14)
  9. BLOCKED_STATUS_TYPES = (1, 4, 5, 6, 7, 8, 9, 10, 11, 15, 16)
  10. class QueryStati(Enum):
  11. """
  12. https://docs.pi-hole.net/database/ftl/#supported-status-types
  13. """
  14. Unknown = 0 # Unknown status (not yet known)
  15. Blocked = 1 # Domain contained in gravity database
  16. Forwarded = 2
  17. Cached = 3 # Known, replied to from cache
  18. Blacklist_regex = 4
  19. Blacklist_exact = 5
  20. Blocked_upstream = 6
  21. Blocked_upstream_zero = 7
  22. Blocked_upstream_nxdomain = 8
  23. Blocked_gravity = 9
  24. Blocked_gravity_regex = 10
  25. Blocked_gravity_exact = 11
  26. Allowed_retried = 12
  27. Allowed_retried_ignored = 13
  28. Allowed_forwarded = 14
  29. Blocked_database_busy = 15
  30. Blocked_special_domain = 16
  31. class ReplyTypes(Enum):
  32. """
  33. https://docs.pi-hole.net/database/ftl/#supported-reply-types
  34. """
  35. unknown = 0 # no reply so far
  36. NODATA = 1
  37. NXDOMAIN = 2
  38. CNAME = 3
  39. IP = 4 # a valid IP record
  40. DOMAIN = 5
  41. RRNAME = 6
  42. SERVFAIL = 7
  43. REFUSED = 8
  44. NOTIMP = 9
  45. OTHER = 10
  46. DNSSEC = 11
  47. NONE = 12 # query was dropped intentionally
  48. BLOB = 13 # binary data
  49. class DnssecStati(Enum):
  50. """
  51. https://docs.pi-hole.net/database/ftl/#dnssec-status
  52. """
  53. Unknown = 0
  54. Secure = 1
  55. Insecure = 2
  56. Bogus = 3
  57. Abandoned = 4
  58. class PiHole:
  59. def __init__(self, host, token):
  60. self.host = host
  61. if host.startswith("http"):
  62. self.url = host
  63. else:
  64. self.url = f"http://{host}"
  65. self.token = token
  66. def query(self, endpoint, params={}):
  67. return requests.get(f"{self.url}/admin/{endpoint}.php", params=params)
  68. def request_all_queries(self, start: float, end: float):
  69. """
  70. keys[]: time, query_type, domain, client, status, destination, reply_type, reply_time, dnssec
  71. """
  72. if not self.token:
  73. raise Exception("Token required")
  74. params = {
  75. "getAllQueries": "",
  76. "from": int(start),
  77. "until": int(end),
  78. "auth": self.token
  79. }
  80. json = self.query("api_db", params=params).json()
  81. if json:
  82. return json['data']
  83. else:
  84. return []
  85. def request_summary(self):
  86. """
  87. keys:
  88. - domains_being_blocked
  89. - dns_queries_today
  90. - ads_blocked_today
  91. - ads_percentage_today
  92. - unique_domains
  93. - queries_forwarded
  94. - queries_cached
  95. - clients_ever_seen
  96. - unique_clients
  97. - dns_queries_all_types
  98. - reply_UNKNOWN
  99. - reply_NODATA
  100. - reply_NXDOMAIN
  101. - reply_CNAME
  102. - reply_IP
  103. - reply_DOMAIN
  104. - reply_RRNAME
  105. - reply_SERVFAIL
  106. - reply_REFUSED
  107. - reply_NOTIMP
  108. - reply_OTHER
  109. - reply_DNSSEC
  110. - reply_NONE
  111. - reply_BLOB
  112. - dns_queries_all_replies
  113. - privacy_level
  114. - status
  115. - gravity_last_update: file_exists, absolute, relative
  116. """
  117. json = self.query("api").json()
  118. return json
  119. def request_forward_destinations(self):
  120. if not self.token:
  121. raise Exception("Token required")
  122. params = {
  123. "getForwardDestinations": "",
  124. "auth": self.token
  125. }
  126. json = self.query("api", params=params).json()
  127. if json:
  128. return json['forward_destinations']
  129. else:
  130. return {}
  131. def request_query_types(self):
  132. if not self.token:
  133. raise Exception("Token required")
  134. params = {
  135. "getQueryTypes": "",
  136. "auth": self.token
  137. }
  138. json = self.query("api", params=params).json()
  139. if json:
  140. return json['querytypes']
  141. else:
  142. return {}
  143. def get_totals_for_influxdb(self):
  144. summary = self.request_summary()
  145. timestamp = datetime.now().astimezone()
  146. yield Point("domains") \
  147. .time(timestamp) \
  148. .tag("hostname", self.host) \
  149. .field("domain_count", summary['domains_being_blocked']) \
  150. .field("unique_domains", summary['unique_domains']) \
  151. .field("forwarded", summary['queries_forwarded']) \
  152. .field("cached", summary['queries_cached'])
  153. yield Point("queries") \
  154. .time(timestamp) \
  155. .tag("hostname", self.host) \
  156. .field("queries", summary['dns_queries_today']) \
  157. .field("blocked", summary['ads_blocked_today']) \
  158. .field("ads_percentage", summary['ads_percentage_today'])
  159. yield Point("clients") \
  160. .time(timestamp) \
  161. .tag("hostname", self.host) \
  162. .field("total_clients", summary['clients_ever_seen']) \
  163. .field("unique_clients", summary['unique_clients']) \
  164. .field("total_queries", summary['dns_queries_all_types'])
  165. yield Point("other") \
  166. .time(timestamp) \
  167. .tag("hostname", self.host) \
  168. .field("status", summary['status'] == 'enabled') \
  169. .field("gravity_last_update", summary['gravity_last_updated']['absolute'])
  170. if self.token:
  171. query_types = self.request_query_types()
  172. for key, value in query_types.items():
  173. yield Point("query_types") \
  174. .time(timestamp) \
  175. .tag("hostname", self.host) \
  176. .tag("query_type", key) \
  177. .field("value", float(value))
  178. forward_destinations = self.request_forward_destinations()
  179. for key, value in forward_destinations.items():
  180. yield Point("forward_destinations") \
  181. .time(timestamp) \
  182. .tag("hostname", self.host) \
  183. .tag("destination", key.split('|')[0]) \
  184. .field("value", float(value))
  185. def get_queries_for_influxdb(self, query_date: datetime, sample_period: int):
  186. # Get all queries since last sample
  187. end_time = query_date.timestamp()
  188. start_time = end_time - sample_period + 1
  189. queries = self.request_all_queries(start_time, end_time)
  190. timestamp = datetime.now().astimezone()
  191. df = DataFrame(queries, columns=['time', 'query_type', 'domain', 'client', 'status', 'destination', 'reply_type', 'reply_time', 'dnssec'])
  192. # we still need some stats from the summary
  193. summary = self.request_summary()
  194. yield Point("domains") \
  195. .time(timestamp) \
  196. .tag("hostname", self.host) \
  197. .field("domain_count", summary['domains_being_blocked']) \
  198. .field("unique_domains", len(df.groupby('domain'))) \
  199. .field("forwarded", len(df[df['status'] == QueryStati.Forwarded.value])) \
  200. .field("cached", len(df[df['status'] == QueryStati.Cached.value]))
  201. blocked_count = len(df[df['status'].isin(BLOCKED_STATUS_TYPES)])
  202. queries_point = Point("queries") \
  203. .time(timestamp) \
  204. .tag("hostname", self.host) \
  205. .field("queries", len(df)) \
  206. .field("blocked", blocked_count) \
  207. .field("ads_percentage", blocked_count * 100.0 / max(1, len(df)))
  208. yield queries_point
  209. for key, client_df in df.groupby('client'):
  210. blocked_count = len(client_df[client_df['status'].isin(BLOCKED_STATUS_TYPES)])
  211. clients_point = Point("clients") \
  212. .time(timestamp) \
  213. .tag("hostname", self.host) \
  214. .tag("client", key) \
  215. .field("queries", len(client_df)) \
  216. .field("blocked", blocked_count) \
  217. .field("ads_percentage", blocked_count * 100.0 / max(1, len(client_df)))
  218. yield clients_point
  219. yield Point("other") \
  220. .time(timestamp) \
  221. .tag("hostname", self.host) \
  222. .field("status", summary['status'] == 'enabled') \
  223. .field("gravity_last_update", summary['gravity_last_updated']['absolute'])
  224. for key, group_df in df.groupby('query_type'):
  225. yield Point("query_types") \
  226. .time(timestamp) \
  227. .tag("hostname", self.host) \
  228. .tag("query_type", key) \
  229. .field("queries", len(group_df))
  230. for key, group_df in df.groupby('destination'):
  231. yield Point("forward_destinations") \
  232. .time(timestamp) \
  233. .tag("hostname", self.host) \
  234. .tag("destination", key.split('|')[0]) \
  235. .field("queries", len(group_df))
  236. def get_query_logs_for_influxdb(self, query_date: datetime, sample_period: int):
  237. end_time = query_date.timestamp()
  238. start_time = end_time - sample_period + 1
  239. for data in self.request_all_queries(start_time, end_time):
  240. timestamp, query_type, domain, client, status, destination, reply_type, reply_time, dnssec = data
  241. p = Point("logs") \
  242. .time(datetime.fromtimestamp(timestamp)) \
  243. .tag("hostname", self.host) \
  244. .tag("query_type", query_type) \
  245. .field("domain", domain) \
  246. .tag("client", client) \
  247. .tag("status", QueryStati(status).name) \
  248. .tag("reply_type", ReplyTypes(reply_type).name) \
  249. .field("reply_time", reply_time) \
  250. .tag("dnssec", DnssecStati(dnssec).name)
  251. if destination:
  252. p.tag("destination", destination)
  253. yield p
  254. if __name__ == "__main__":
  255. import argparse
  256. parser = argparse.ArgumentParser(description='Export Pi-Hole statistics')
  257. parser.add_argument('--host', required=True, type=str, help='Pi-Hole host')
  258. parser.add_argument('--token', '-t', required=True, type=str, help='Pi-Hole API token')
  259. args = parser.parse_args()
  260. pihole = PiHole(host=args.host, token=args.token)
  261. points = list(pihole.get_queries_for_influxdb(datetime.now(), 600))
  262. for p in points:
  263. print(p._time, p._name, p._tags, p._fields)