1
0

pihole.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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. if not self.token:
  118. raise Exception("Token required")
  119. params = {
  120. "summaryRaw": "",
  121. "auth": self.token
  122. }
  123. json = self.query("api", params=params).json()
  124. return json
  125. def request_forward_destinations(self):
  126. if not self.token:
  127. raise Exception("Token required")
  128. params = {
  129. "getForwardDestinations": "",
  130. "auth": self.token
  131. }
  132. json = self.query("api", params=params).json()
  133. if json:
  134. return json['forward_destinations']
  135. else:
  136. return {}
  137. def request_query_types(self):
  138. if not self.token:
  139. raise Exception("Token required")
  140. params = {
  141. "getQueryTypes": "",
  142. "auth": self.token
  143. }
  144. json = self.query("api", params=params).json()
  145. if json:
  146. return json['querytypes']
  147. else:
  148. return {}
  149. def get_totals_for_influxdb(self):
  150. summary = self.request_summary()
  151. timestamp = datetime.now().astimezone()
  152. yield Point("domains") \
  153. .time(timestamp) \
  154. .tag("hostname", self.host) \
  155. .field("domain_count", summary['domains_being_blocked']) \
  156. .field("unique_domains", summary['unique_domains']) \
  157. .field("forwarded", summary['queries_forwarded']) \
  158. .field("cached", summary['queries_cached'])
  159. yield Point("queries") \
  160. .time(timestamp) \
  161. .tag("hostname", self.host) \
  162. .field("queries", summary['dns_queries_today']) \
  163. .field("blocked", summary['ads_blocked_today']) \
  164. .field("ads_percentage", summary['ads_percentage_today'])
  165. yield Point("clients") \
  166. .time(timestamp) \
  167. .tag("hostname", self.host) \
  168. .field("total_clients", summary['clients_ever_seen']) \
  169. .field("unique_clients", summary['unique_clients']) \
  170. .field("total_queries", summary['dns_queries_all_types'])
  171. yield Point("other") \
  172. .time(timestamp) \
  173. .tag("hostname", self.host) \
  174. .field("status", summary['status'] == 'enabled') \
  175. .field("gravity_last_update", summary['gravity_last_updated']['absolute'])
  176. if self.token:
  177. query_types = self.request_query_types()
  178. for key, value in query_types.items():
  179. yield Point("query_types") \
  180. .time(timestamp) \
  181. .tag("hostname", self.host) \
  182. .tag("query_type", key) \
  183. .field("value", float(value))
  184. forward_destinations = self.request_forward_destinations()
  185. for key, value in forward_destinations.items():
  186. yield Point("forward_destinations") \
  187. .time(timestamp) \
  188. .tag("hostname", self.host) \
  189. .tag("destination", key.split('|')[0]) \
  190. .field("value", float(value))
  191. def get_queries_for_influxdb(self, query_date: datetime, sample_period: int):
  192. # Get all queries since last sample
  193. end_time = query_date.timestamp()
  194. start_time = end_time - sample_period + 1
  195. queries = self.request_all_queries(start_time, end_time)
  196. timestamp = datetime.now().astimezone()
  197. df = DataFrame(queries, columns=['time', 'query_type', 'domain', 'client', 'status', 'destination', 'reply_type', 'reply_time', 'dnssec'])
  198. # we still need some stats from the summary
  199. summary = self.request_summary()
  200. yield Point("domains") \
  201. .time(timestamp) \
  202. .tag("hostname", self.host) \
  203. .field("domain_count", summary['domains_being_blocked']) \
  204. .field("unique_domains", len(df.groupby('domain'))) \
  205. .field("forwarded", len(df[df['status'] == QueryStati.Forwarded.value])) \
  206. .field("cached", len(df[df['status'] == QueryStati.Cached.value]))
  207. blocked_count = len(df[df['status'].isin(BLOCKED_STATUS_TYPES)])
  208. queries_point = Point("queries") \
  209. .time(timestamp) \
  210. .tag("hostname", self.host) \
  211. .field("queries", len(df)) \
  212. .field("blocked", blocked_count) \
  213. .field("ads_percentage", blocked_count * 100.0 / max(1, len(df)))
  214. yield queries_point
  215. for key, client_df in df.groupby('client'):
  216. blocked_count = len(client_df[client_df['status'].isin(BLOCKED_STATUS_TYPES)])
  217. clients_point = Point("clients") \
  218. .time(timestamp) \
  219. .tag("hostname", self.host) \
  220. .tag("client", key) \
  221. .field("queries", len(client_df)) \
  222. .field("blocked", blocked_count) \
  223. .field("ads_percentage", blocked_count * 100.0 / max(1, len(client_df)))
  224. yield clients_point
  225. yield Point("other") \
  226. .time(timestamp) \
  227. .tag("hostname", self.host) \
  228. .field("status", summary['status'] == 'enabled') \
  229. .field("gravity_last_update", summary['gravity_last_updated']['absolute'])
  230. for key, group_df in df.groupby('query_type'):
  231. yield Point("query_types") \
  232. .time(timestamp) \
  233. .tag("hostname", self.host) \
  234. .tag("query_type", key) \
  235. .field("queries", len(group_df))
  236. for key, group_df in df.groupby('destination'):
  237. yield Point("forward_destinations") \
  238. .time(timestamp) \
  239. .tag("hostname", self.host) \
  240. .tag("destination", key.split('|')[0]) \
  241. .field("queries", len(group_df))
  242. def get_query_logs_for_influxdb(self, query_date: datetime, sample_period: int):
  243. end_time = query_date.timestamp()
  244. start_time = end_time - sample_period + 1
  245. for data in self.request_all_queries(start_time, end_time):
  246. timestamp, query_type, domain, client, status, destination, reply_type, reply_time, dnssec = data
  247. p = Point("logs") \
  248. .time(datetime.fromtimestamp(timestamp)) \
  249. .tag("hostname", self.host) \
  250. .tag("query_type", query_type) \
  251. .field("domain", domain) \
  252. .tag("client", client) \
  253. .tag("status", QueryStati(status).name) \
  254. .tag("reply_type", ReplyTypes(reply_type).name) \
  255. .field("reply_time", reply_time) \
  256. .tag("dnssec", DnssecStati(dnssec).name)
  257. if destination:
  258. p.tag("destination", destination)
  259. yield p
  260. if __name__ == "__main__":
  261. import argparse
  262. parser = argparse.ArgumentParser(description='Export Pi-Hole statistics')
  263. parser.add_argument('--host', required=True, type=str, help='Pi-Hole host')
  264. parser.add_argument('--token', '-t', required=True, type=str, help='Pi-Hole API token')
  265. args = parser.parse_args()
  266. pihole = PiHole(host=args.host, token=args.token)
  267. points = list(pihole.get_queries_for_influxdb(datetime.now(), 600))
  268. for p in points:
  269. print(p._time, p._name, p._tags, p._fields)