├── .circleci └── config.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── LICENSE_TEMPLATE ├── Makefile ├── README.md ├── build.rs ├── debian ├── .gitignore ├── changelog ├── compat ├── control ├── pihole-API.service ├── pihole-api.postinst ├── rules └── source │ └── format ├── docker ├── aarch64 │ └── Dockerfile ├── arm │ └── Dockerfile ├── armhf │ └── Dockerfile ├── x86_32 │ └── Dockerfile ├── x86_64-musl │ └── Dockerfile └── x86_64 │ └── Dockerfile ├── rust-toolchain ├── rustfmt.toml ├── src ├── databases │ ├── ftl │ │ ├── mod.rs │ │ ├── model.rs │ │ └── schema.rs │ └── mod.rs ├── env │ ├── config.rs │ ├── env_impl.rs │ ├── file.rs │ └── mod.rs ├── ftl │ ├── lock_thread.rs │ ├── memory_model │ │ ├── client.rs │ │ ├── counters.rs │ │ ├── domain.rs │ │ ├── lock.rs │ │ ├── mod.rs │ │ ├── over_time.rs │ │ ├── query.rs │ │ ├── settings.rs │ │ ├── strings.rs │ │ └── upstream.rs │ ├── mod.rs │ ├── shared_lock.rs │ ├── shared_memory.rs │ └── socket.rs ├── lib.rs ├── main.rs ├── routes │ ├── auth.rs │ ├── dns │ │ ├── add_list.rs │ │ ├── common.rs │ │ ├── delete_list.rs │ │ ├── get_list.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ └── status.rs │ ├── mod.rs │ ├── settings │ │ ├── common.rs │ │ ├── dhcp.rs │ │ ├── dns.rs │ │ ├── get_ftl.rs │ │ ├── get_ftldb.rs │ │ ├── get_network.rs │ │ ├── mod.rs │ │ └── web.rs │ ├── stats │ │ ├── clients.rs │ │ ├── common.rs │ │ ├── database │ │ │ ├── mod.rs │ │ │ ├── over_time_clients_db.rs │ │ │ ├── over_time_history_db.rs │ │ │ ├── query_types_db.rs │ │ │ ├── summary_db.rs │ │ │ ├── top_clients_db.rs │ │ │ ├── top_domains_db.rs │ │ │ └── upstreams_db.rs │ │ ├── history │ │ │ ├── database.rs │ │ │ ├── endpoints.rs │ │ │ ├── filters │ │ │ │ ├── blocked.rs │ │ │ │ ├── client.rs │ │ │ │ ├── dnssec.rs │ │ │ │ ├── domain.rs │ │ │ │ ├── exclude_clients.rs │ │ │ │ ├── exclude_domains.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── private.rs │ │ │ │ ├── query_type.rs │ │ │ │ ├── reply.rs │ │ │ │ ├── setup_vars.rs │ │ │ │ ├── status.rs │ │ │ │ ├── time.rs │ │ │ │ └── upstream.rs │ │ │ ├── get_history.rs │ │ │ ├── map_query_to_json.rs │ │ │ ├── mod.rs │ │ │ ├── skip_to_cursor.rs │ │ │ └── testing.rs │ │ ├── mod.rs │ │ ├── over_time_clients.rs │ │ ├── over_time_history.rs │ │ ├── query_types.rs │ │ ├── recent_blocked.rs │ │ ├── summary.rs │ │ ├── top_clients.rs │ │ ├── top_domains.rs │ │ └── upstreams.rs │ ├── version.rs │ └── web.rs ├── settings │ ├── dnsmasq.rs │ ├── entries.rs │ ├── mod.rs │ ├── privacy_level.rs │ └── value_type.rs ├── setup.rs ├── testing.rs └── util.rs ├── test └── FTL.db └── web └── .gitignore /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | .job_template: &job_template 4 | docker: 5 | - image: pihole/api-build:$CIRCLE_JOB 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | # Find a cache corresponding to this specific target and Cargo.lock checksum. 11 | # When the checksum is different, this key will fail. 12 | # There are two dashes used between job and checksum to avoid x86_64 using the x86_64-musl cache 13 | - v3-cargo-{{ .Environment.CIRCLE_JOB }}--{{ checksum "Cargo.lock" }} 14 | # Find a cache corresponding to this specific target. 15 | # When the target is different, this key will fail. 16 | - v3-cargo-{{ .Environment.CIRCLE_JOB }}-- 17 | - run: 18 | name: "Download Web" 19 | command: | 20 | root="https://ftl.pi-hole.net" 21 | file="pihole-web.tar.gz" 22 | 23 | # Try the branch of the same name, then dev, then master, and if none exist then fail 24 | if curl --output /dev/null --silent --head --fail "$root/$CIRCLE_BRANCH/$file"; then 25 | branch="$CIRCLE_BRANCH" 26 | elif curl --output /dev/null --silent --head --fail "$root/development/$file"; then 27 | branch="development" 28 | elif curl --output /dev/null --silent --head --fail "$root/master/$file"; then 29 | branch="master" 30 | else 31 | echo "Could not find any web builds. Luckily they are not required for this CI yet." 32 | exit 0 33 | fi 34 | 35 | echo "Using the $branch branch" 36 | 37 | # If web directory already exists, empty it out 38 | rm -rf web 39 | # Uncompress the files to the "web" folder 40 | curl "$root/$branch/$file" -o web.tar.gz 41 | mkdir web 42 | tar -xzf web.tar.gz -C web 43 | - run: 44 | name: "Code Style Check" 45 | command: | 46 | [[ "$CIRCLE_JOB" != "x86_64" ]] || cargo fmt -- --check 47 | - run: 48 | name: "Code Lint Check" 49 | command: | 50 | [[ "$CIRCLE_JOB" != "x86_64" ]] || cargo clippy --release --all-targets --all-features -- -D clippy::all 51 | - run: 52 | name: "Build" 53 | command: | 54 | time cargo build --release --target $TARGET 55 | cp target/$TARGET/release/pihole_api ${BIN_NAME} 56 | - run: 57 | name: "Test" 58 | command: | 59 | [[ "$CIRCLE_JOB" != "x86_64" ]] || time cargo test --release --target $TARGET 60 | - run: 61 | name: "Build DEB" 62 | command: | 63 | export DEB_BUILD_OPTIONS=nostrip 64 | dpkg-buildpackage -b -a $DEB_ARCH 65 | mv ../pihole-api*.deb . 66 | [[ "$CIRCLE_JOB" != "arm" ]] || for file in pihole-api*.deb; do mv $file ${file//armhf/arm}; done 67 | - run: 68 | name: "Upload" 69 | command: | 70 | [ -z "$FTL_SECRET" ] && exit 0 71 | sha1sum ${BIN_NAME} > ${BIN_NAME}.sha1 72 | cat ${BIN_NAME}.sha1 73 | curl https://ftl.pi-hole.net:8080/FTL-client -o FTL-client 74 | chmod +x ./FTL-client 75 | [[ "$CIRCLE_PR_NUMBER" == "" ]] && ./FTL-client "${CIRCLE_BRANCH}" "${BIN_NAME}" "${FTL_SECRET}" 76 | [[ "$CIRCLE_PR_NUMBER" == "" ]] && ./FTL-client "${CIRCLE_BRANCH}" "${BIN_NAME}.sha1" "${FTL_SECRET}" 77 | [[ "$CIRCLE_PR_NUMBER" == "" ]] && ./FTL-client "${CIRCLE_BRANCH}" pihole-api*.deb "${FTL_SECRET}" 78 | rm ./FTL-client 79 | - save_cache: 80 | key: v3-cargo-{{ .Environment.CIRCLE_JOB }}--{{ checksum "Cargo.lock" }} 81 | paths: 82 | - target 83 | - /root/.cargo 84 | 85 | jobs: 86 | arm: 87 | <<: *job_template 88 | environment: 89 | BIN_NAME: "pihole-API-arm-linux-gnueabi" 90 | TARGET: "arm-unknown-linux-gnueabi" 91 | DEB_ARCH: "armhf" 92 | 93 | armhf: 94 | <<: *job_template 95 | environment: 96 | BIN_NAME: "pihole-API-arm-linux-gnueabihf" 97 | TARGET: "armv7-unknown-linux-gnueabihf" 98 | DEB_ARCH: "armhf" 99 | 100 | aarch64: 101 | <<: *job_template 102 | environment: 103 | BIN_NAME: "pihole-API-aarch64-linux-gnu" 104 | TARGET: "aarch64-unknown-linux-gnu" 105 | DEB_ARCH: "arm64" 106 | 107 | x86_64: 108 | <<: *job_template 109 | environment: 110 | BIN_NAME: "pihole-API-linux-x86_64" 111 | TARGET: "x86_64-unknown-linux-gnu" 112 | DEB_ARCH: "amd64" 113 | 114 | x86_64-musl: 115 | <<: *job_template 116 | environment: 117 | BIN_NAME: "pihole-API-musl-linux-x86_64" 118 | TARGET: "x86_64-unknown-linux-musl" 119 | DEB_ARCH: "musl-linux-amd64" 120 | 121 | x86_32: 122 | <<: *job_template 123 | environment: 124 | BIN_NAME: "pihole-API-linux-x86_32" 125 | TARGET: "i686-unknown-linux-gnu" 126 | DEB_ARCH: "i386" 127 | 128 | workflows: 129 | version: 2 130 | build: 131 | jobs: 132 | - arm 133 | - armhf 134 | - aarch64 135 | - x86_64 136 | - x86_64-musl 137 | - x86_32 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### CMake 2 | CMakeLists.txt 3 | /cmake-build-debug/ 4 | 5 | ### Rust 6 | # Generated by Cargo 7 | # will have compiled files and executables 8 | /target/ 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | ### JetBrains 14 | 15 | # All idea files, with execptions 16 | .idea 17 | !.idea/codeStyles/* 18 | !.idea/codeStyleSettings.xml 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pihole_api" 3 | version = "0.1.0" 4 | authors = ["Mcat12 "] 5 | description = "Work in progress HTTP API for Pi-hole." 6 | homepage = "https://pi-hole.net" 7 | repository = "https://github.com/pi-hole/api" 8 | readme = "README.md" 9 | license = "EUPL-1.2" 10 | publish = false 11 | edition = "2018" 12 | 13 | [profile.release] 14 | lto = true 15 | 16 | [dependencies] 17 | diesel = { version = "1.4", features = ["sqlite"]} 18 | rocket = "0.4" 19 | rocket_cors = { version = "0.4", default-features = false } 20 | serde = "1.0" 21 | serde_derive = "1.0" 22 | serde_json = "1.0" 23 | rmp = "0.8" 24 | regex = "1.0.0" 25 | rust-embed = "4.3" 26 | toml = "0.4" 27 | failure = "0.1.1" 28 | failure_derive = "0.1.1" 29 | hostname = "0.1.5" 30 | tempfile = "3.0.2" 31 | get_if_addrs = "0.5.3" 32 | shmem = "0.2.0" 33 | libc = "0.2.42" 34 | nix = "0.13" 35 | base64 = "0.10" 36 | task_scheduler = "0.2.0" 37 | 38 | [dependencies.rocket_contrib] 39 | version = "0.4" 40 | features = ["diesel_sqlite_pool"] 41 | 42 | [dev-dependencies] 43 | serde_json = "1.0" 44 | -------------------------------------------------------------------------------- /LICENSE_TEMPLATE: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // {.+} 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This Makefile is only used for packaging, and should not be used in any other 2 | # context 3 | 4 | .PHONY: all clean install 5 | 6 | all: 7 | @# Do nothing 8 | 9 | clean: 10 | @# Do nothing 11 | 12 | install: 13 | mkdir -p $(DESTDIR)/usr/bin 14 | install -m 755 target/$(TARGET)/release/pihole_api $(DESTDIR)/usr/bin/pihole-API 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repository has been archived. For more information please see https://github.com/pi-hole/FTL/pull/659 2 | 3 | ## Pi-hole API 4 | 5 | Work in progress HTTP API for Pi-hole. 6 | The API reads FTL's shared memory so it can directly read the statistics FTL 7 | generates. This API is the replacement for most of FTL's socket/telnet API, as 8 | well as the PHP API of the pre-5.0 web interface. 9 | 10 | ### Getting Started (Development) 11 | 12 | - Install Rust: https://www.rust-lang.org/tools/install 13 | - Currently the project uses Rust nightly. The exact version used is stored 14 | in [`rust-toolchain`](rust-toolchain). The version should be detected and 15 | used automatically when you run a Rust command in the project directory, 16 | such as `cargo check` (this is a feature of `rustup`) 17 | - After installing, make sure the Rust tools are on your PATH: 18 | ``` 19 | source ~/.cargo/env 20 | ``` 21 | - Install your distro's build tools 22 | - `build-essential` for Debian distros, `gcc-c++` and `make` for RHEL 23 | distros 24 | - Install libsqlite3 25 | - `libsqlite3-dev` for Debian distros, `sqlite-devel` for RHEL 26 | - Fork the repository and clone to your computer (not the Pi-hole). In 27 | production the Pi-hole only needs the compiled output of the project, not its 28 | source code 29 | - Checkout the `development` branch for the latest changes. 30 | - Run `cargo check`. This will download the Rust nightly toolchain and project 31 | dependencies, and it will check the program for errors. If everything was set 32 | up correctly, the final output should look like this: 33 | ``` 34 | Finished dev [unoptimized + debuginfo] target(s) in 1m 11s 35 | ``` 36 | - Run `cargo test`. This will compile and run the tests. They should all pass 37 | :wink: 38 | - If you've never used Rust, you should look at the [documentation][Rust Docs], 39 | including the [Rust Book], before diving too deep into the code. 40 | - When you are ready to make changes, make a branch off of `development` in your 41 | fork to work in. When you're ready to make a pull request, base the PR against 42 | `development`. 43 | 44 | [Rust Docs]: https://www.rust-lang.org/learn 45 | [Rust Book]: https://doc.rust-lang.org/book/ 46 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Build Script For Retrieving VCS Data 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use std::process::Command; 12 | 13 | fn main() { 14 | // Read Git data and expose it to the API at compile time 15 | let tag_raw = Command::new("git") 16 | .args(&["describe", "--tags", "--abbrev=0", "--exact-match"]) 17 | .output() 18 | .map(|output| output.stdout) 19 | .unwrap_or_default(); 20 | let tag = String::from_utf8(tag_raw).unwrap(); 21 | 22 | let branch_raw = Command::new("git") 23 | .args(&["rev-parse", "--abbrev-ref", "HEAD"]) 24 | .output() 25 | .map(|output| output.stdout) 26 | .unwrap_or_default(); 27 | let branch = String::from_utf8(branch_raw).unwrap(); 28 | 29 | let hash_raw = Command::new("git") 30 | .args(&["rev-parse", "HEAD"]) 31 | .output() 32 | .map(|output| output.stdout) 33 | .unwrap_or_default(); 34 | let hash = String::from_utf8(hash_raw).unwrap(); 35 | 36 | // This lets us use the `env!()` macro to read these variables at compile time 37 | println!("cargo:rustc-env=GIT_TAG={}", tag.trim()); 38 | println!("cargo:rustc-env=GIT_BRANCH={}", branch.trim()); 39 | println!("cargo:rustc-env=GIT_HASH={}", hash.trim()); 40 | } 41 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | debhelper-build-stamp 2 | files 3 | *.debhelper.log 4 | *.debhelper 5 | *.substvars 6 | pihole-api -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pihole-api (0.1.0-1) stretch; urgency=medium 2 | 3 | * Initial package 4 | 5 | -- Mark Drobnak Sat, 02 Feb 2019 16:01:45 -0800 -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pihole-api 2 | Priority: optional 3 | Maintainer: The Pi-hole Team 4 | Build-Depends: debhelper (>= 10), dh-systemd (>= 1.5) 5 | 6 | Package: pihole-api 7 | Architecture: any 8 | Depends: libsqlite3-0, libcap2-bin, ${shlibs:Depends}, ${misc:Depends} 9 | Description: The Pi-hole API, including the Web Interface 10 | The Pi-hole API provides a RESTful service for the web interface. The web 11 | interface is embedded into the API and exposed under /admin. 12 | -------------------------------------------------------------------------------- /debian/pihole-API.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description="The Pi-hole API, including the Web Interface" 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | User=pihole 8 | Group=pihole 9 | Environment=RUST_BACKTRACE=1 10 | ExecStart=/usr/bin/pihole-API 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /debian/pihole-api.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "configure" ]; then 4 | # Create a pihole user and group if they don't already exist 5 | adduser --system --group --quiet pihole 6 | 7 | # Give the API permission to bind to low ports 8 | setcap CAP_NET_BIND_SERVICE+eip /usr/bin/pihole-API 9 | fi 10 | 11 | # This will be replaced with code from debhelper 12 | #DEBHELPER# 13 | # End code from debhelper 14 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ --with systemd 5 | 6 | # This rule is overridden to install the service with a different name than the 7 | # package 8 | override_dh_systemd_enable: 9 | dh_systemd_enable --name pihole-API 10 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) -------------------------------------------------------------------------------- /docker/aarch64/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | # Install Rust 4 | RUN dpkg --add-architecture arm64 && \ 5 | apt-get update && \ 6 | apt-get install -y --no-install-recommends curl ca-certificates git \ 7 | gcc libc-dev libsqlite3-dev:arm64 gcc-aarch64-linux-gnu libc-dev-arm64-cross \ 8 | build-essential debhelper dh-systemd && \ 9 | rm -rf /var/lib/apt/lists/* && \ 10 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 11 | export PATH="/root/.cargo/bin:$PATH" && \ 12 | rustup target add aarch64-unknown-linux-gnu 13 | 14 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 15 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 16 | tar -xzf ghr.tar.gz && \ 17 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 18 | 19 | ENV PATH="/root/.cargo/bin:$PATH" \ 20 | TARGET_CC=aarch64-linux-gnu-gcc \ 21 | CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ 22 | CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc -------------------------------------------------------------------------------- /docker/arm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | RUN dpkg --add-architecture armel && \ 4 | apt-get update && \ 5 | apt-get install -y --no-install-recommends curl ca-certificates git \ 6 | gcc libc-dev libsqlite3-dev:armel gcc-arm-linux-gnueabi libc-dev-armel-cross \ 7 | build-essential debhelper dh-systemd && \ 8 | rm -rf /var/lib/apt/lists/* && \ 9 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 10 | export PATH="/root/.cargo/bin:$PATH" && \ 11 | rustup target add arm-unknown-linux-gnueabi 12 | 13 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 14 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 15 | tar -xzf ghr.tar.gz && \ 16 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 17 | 18 | ENV PATH="/root/.cargo/bin:$PATH" \ 19 | TARGET_CC=arm-linux-gnueabi-gcc \ 20 | CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABI_LINKER=arm-linux-gnueabi-gcc \ 21 | CC_arm_unknown_linux_gnueabi=arm-linux-gnueabi-gcc -------------------------------------------------------------------------------- /docker/armhf/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | RUN dpkg --add-architecture armhf && \ 4 | apt-get update && \ 5 | apt-get install -y --no-install-recommends curl ca-certificates git \ 6 | gcc libc-dev libsqlite3-dev:armhf gcc-arm-linux-gnueabihf libc6-dev-armhf-cross \ 7 | build-essential debhelper dh-systemd && \ 8 | rm -rf /var/lib/apt/lists/* && \ 9 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 10 | export PATH="/root/.cargo/bin:$PATH" && \ 11 | rustup target add armv7-unknown-linux-gnueabihf 12 | 13 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 14 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 15 | tar -xzf ghr.tar.gz && \ 16 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 17 | 18 | ENV PATH="/root/.cargo/bin:$PATH" \ 19 | TARGET_CC=arm-linux-gnueabihf-gcc \ 20 | CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc \ 21 | CC_armv7_unknown_linux_gnueabihf=arm-linux-gnueabihf-gcc 22 | -------------------------------------------------------------------------------- /docker/x86_32/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | # Install Rust 4 | RUN dpkg --add-architecture i386 && \ 5 | apt-get update && \ 6 | apt-get install -y --no-install-recommends curl ca-certificates gcc gcc-multilib git \ 7 | libsqlite3-dev:i386 build-essential debhelper dh-systemd && \ 8 | rm -rf /var/lib/apt/lists/* && \ 9 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 10 | export PATH="/root/.cargo/bin:$PATH" && \ 11 | rustup target add i686-unknown-linux-gnu 12 | 13 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 14 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 15 | tar -xzf ghr.tar.gz && \ 16 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 17 | 18 | ENV PATH="/root/.cargo/bin:$PATH" 19 | -------------------------------------------------------------------------------- /docker/x86_64-musl/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | # Install Rust 4 | RUN apt-get update && \ 5 | apt-get install -y --no-install-recommends curl ca-certificates libc-dev musl-tools git \ 6 | libsqlite3-dev build-essential debhelper dh-systemd && \ 7 | rm -rf /var/lib/apt/lists/* && \ 8 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 9 | export PATH="/root/.cargo/bin:$PATH" && \ 10 | rustup target add x86_64-unknown-linux-musl 11 | 12 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 13 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 14 | tar -xzf ghr.tar.gz && \ 15 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 16 | 17 | ENV PATH="/root/.cargo/bin:$PATH" \ 18 | TARGET_CC=musl-gcc \ 19 | CARGO_TARGET_POWERPC64LE_UNKNOWN_LINUX_GNU_LINKER=musl-gcc \ 20 | CC_x86_64_unknown_linux_musl=musl-gcc 21 | -------------------------------------------------------------------------------- /docker/x86_64/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | # Install Rust 4 | RUN apt-get update && \ 5 | apt-get install -y --no-install-recommends curl ca-certificates gcc libc-dev git \ 6 | libsqlite3-dev build-essential debhelper dh-systemd && \ 7 | rm -rf /var/lib/apt/lists/* && \ 8 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly-2019-01-09 && \ 9 | export PATH="/root/.cargo/bin:$PATH" 10 | 11 | ENV PATH="/root/.cargo/bin:$PATH" 12 | 13 | RUN rustup component add rustfmt clippy 14 | 15 | # Install ghr for GitHub Releases: https://github.com/tcnksm/ghr 16 | RUN curl -L -o ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.12.0/ghr_v0.12.0_linux_amd64.tar.gz && \ 17 | tar -xzf ghr.tar.gz && \ 18 | mv ghr_*_linux_amd64/ghr /usr/bin/ghr 19 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | nightly-2019-01-09 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | trailing_comma = "Never" 2 | use_field_init_shorthand = true 3 | use_try_shorthand = true 4 | wrap_comments = true 5 | color = "Always" 6 | license_template_path = "LICENSE_TEMPLATE" 7 | merge_imports = true 8 | -------------------------------------------------------------------------------- /src/databases/ftl/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Database Support 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | #[cfg(test)] 12 | use diesel::{sqlite::SqliteConnection, Connection}; 13 | 14 | mod model; 15 | mod schema; 16 | 17 | pub use self::{model::*, schema::*}; 18 | 19 | #[cfg(test)] 20 | pub const TEST_FTL_DATABASE_PATH: &str = "test/FTL.db"; 21 | 22 | /// Connect to the testing database 23 | #[cfg(test)] 24 | pub fn connect_to_test_db() -> SqliteConnection { 25 | SqliteConnection::establish(TEST_FTL_DATABASE_PATH).unwrap() 26 | } 27 | -------------------------------------------------------------------------------- /src/databases/ftl/model.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Database Models 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::{FtlDnssecType, FtlQueryReplyType}; 12 | use rocket_contrib::json::JsonValue; 13 | 14 | #[database("ftl_database")] 15 | pub struct FtlDatabase(diesel::SqliteConnection); 16 | 17 | #[allow(dead_code)] 18 | pub enum FtlTableEntry { 19 | Version, 20 | LastTimestamp, 21 | FirstCounterTimestamp 22 | } 23 | 24 | #[allow(dead_code)] 25 | pub enum CounterTableEntry { 26 | TotalQueries, 27 | BlockedQueries 28 | } 29 | 30 | #[cfg_attr(test, derive(PartialEq, Debug))] 31 | #[derive(Queryable)] 32 | pub struct FtlDbQuery { 33 | pub id: Option, 34 | pub timestamp: i32, 35 | pub query_type: i32, 36 | pub status: i32, 37 | pub domain: String, 38 | pub client: String, 39 | pub upstream: Option 40 | } 41 | 42 | impl Into for FtlDbQuery { 43 | fn into(self) -> JsonValue { 44 | json!({ 45 | "timestamp": self.timestamp, 46 | "type": self.query_type as u8, 47 | "status": self.status as u8, 48 | "domain": self.domain, 49 | "client": self.client, 50 | "dnssec": FtlDnssecType::Unknown as u8, 51 | "reply": FtlQueryReplyType::Unknown as u8, 52 | "response_time": 0 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/databases/ftl/schema.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Database Schema 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | table! { 12 | counters (id) { 13 | id -> Integer, 14 | value -> Integer, 15 | } 16 | } 17 | 18 | table! { 19 | ftl (id) { 20 | id -> Integer, 21 | value -> Binary, 22 | } 23 | } 24 | 25 | table! { 26 | network (id) { 27 | id -> Integer, 28 | ip -> Text, 29 | hwaddr -> Text, 30 | interface -> Text, 31 | name -> Nullable, 32 | firstSeen -> Integer, 33 | lastQuery -> Integer, 34 | numQueries -> Integer, 35 | macVendor -> Nullable, 36 | } 37 | } 38 | 39 | table! { 40 | queries (id) { 41 | id -> Nullable, 42 | timestamp -> Integer, 43 | #[sql_name = "type"] 44 | query_type -> Integer, 45 | status -> Integer, 46 | domain -> Text, 47 | client -> Text, 48 | #[sql_name = "forward"] 49 | upstream -> Nullable, 50 | } 51 | } 52 | 53 | allow_tables_to_appear_in_same_query!(counters, ftl, network, queries,); 54 | -------------------------------------------------------------------------------- /src/databases/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Databases 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | settings::{ConfigEntry, FtlConfEntry}, 14 | util::Error 15 | }; 16 | use rocket::config::Value; 17 | use std::collections::HashMap; 18 | 19 | #[cfg(test)] 20 | use crate::databases::ftl::TEST_FTL_DATABASE_PATH; 21 | 22 | pub mod ftl; 23 | 24 | /// Load the database URLs from the API config into the Rocket config format 25 | pub fn load_databases(env: &Env) -> Result>, Error> { 26 | let mut databases = HashMap::new(); 27 | let mut ftl_database = HashMap::new(); 28 | 29 | ftl_database.insert("url", Value::from(FtlConfEntry::DbFile.read(env)?)); 30 | databases.insert("ftl_database", ftl_database); 31 | 32 | Ok(databases) 33 | } 34 | 35 | /// Load test database URLs into the Rocket config format 36 | #[cfg(test)] 37 | pub fn load_test_databases() -> HashMap<&'static str, HashMap<&'static str, Value>> { 38 | let mut databases = HashMap::new(); 39 | let mut ftl_database = HashMap::new(); 40 | 41 | ftl_database.insert("url", Value::from(TEST_FTL_DATABASE_PATH)); 42 | databases.insert("ftl_database", ftl_database); 43 | 44 | databases 45 | } 46 | -------------------------------------------------------------------------------- /src/env/env_impl.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Environment Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::{Config, PiholeFile}, 13 | util::{Error, ErrorKind} 14 | }; 15 | use failure::ResultExt; 16 | use std::{ 17 | fs::{self, File, OpenOptions}, 18 | io::{BufRead, BufReader}, 19 | os::unix::fs::OpenOptionsExt, 20 | path::Path 21 | }; 22 | 23 | #[cfg(test)] 24 | use std::{ 25 | collections::HashMap, 26 | io::{Read, Write} 27 | }; 28 | #[cfg(test)] 29 | use tempfile::{tempfile, NamedTempFile}; 30 | 31 | /// Environment of the Pi-hole API. Stores the config and abstracts away some 32 | /// systems to make testing easier. 33 | pub enum Env { 34 | Production(Config), 35 | #[cfg(test)] 36 | Test(Config, HashMap) 37 | } 38 | 39 | impl Clone for Env { 40 | fn clone(&self) -> Self { 41 | match self { 42 | Env::Production(config) => Env::Production(config.clone()), 43 | // There is no good way to copy NamedTempFiles, and we shouldn't be 44 | // doing that during a test anyways 45 | #[cfg(test)] 46 | Env::Test(_, _) => unimplemented!() 47 | } 48 | } 49 | } 50 | 51 | impl Env { 52 | /// Get the API config that was loaded 53 | pub fn config(&self) -> &Config { 54 | match self { 55 | Env::Production(config) => config, 56 | #[cfg(test)] 57 | Env::Test(config, _) => config 58 | } 59 | } 60 | 61 | /// Get the location of a file 62 | pub fn file_location(&self, file: PiholeFile) -> &str { 63 | match self { 64 | Env::Production(config) => config.file_location(file), 65 | #[cfg(test)] 66 | Env::Test(_, _) => file.default_location() 67 | } 68 | } 69 | 70 | /// Open a file for reading 71 | pub fn read_file(&self, file: PiholeFile) -> Result { 72 | match self { 73 | Env::Production(_) => { 74 | let file_location = self.file_location(file); 75 | File::open(file_location) 76 | .context(ErrorKind::FileRead(file_location.to_owned())) 77 | .map_err(Error::from) 78 | } 79 | #[cfg(test)] 80 | Env::Test(_, map) => match map.get(&file) { 81 | Some(file) => file 82 | .reopen() 83 | .context(ErrorKind::Unknown) 84 | .map_err(Error::from), 85 | None => tempfile().context(ErrorKind::Unknown).map_err(Error::from) 86 | } 87 | } 88 | } 89 | 90 | /// Open a file and read its lines. This uses a `BufReader` under the hood 91 | /// and skips lines with errors (invalid UTF-8). 92 | pub fn read_file_lines(&self, file: PiholeFile) -> Result, Error> { 93 | let reader = BufReader::new(self.read_file(file)?); 94 | Ok(reader.lines().filter_map(Result::ok).collect()) 95 | } 96 | 97 | /// Open a file for writing. If `append` is false, the file will be 98 | /// truncated. 99 | pub fn write_file(&self, file: PiholeFile, append: bool) -> Result { 100 | match self { 101 | Env::Production(_) => { 102 | let mut open_options = OpenOptions::new(); 103 | open_options.create(true).write(true).mode(0o644); 104 | 105 | if append { 106 | open_options.append(true); 107 | } else { 108 | open_options.truncate(true); 109 | } 110 | 111 | let file_location = self.file_location(file); 112 | open_options 113 | .open(file_location) 114 | .context(ErrorKind::FileWrite(file_location.to_owned())) 115 | .map_err(Error::from) 116 | } 117 | #[cfg(test)] 118 | Env::Test(_, map) => { 119 | let file = match map.get(&file) { 120 | Some(file) => file.reopen().context(ErrorKind::Unknown)?, 121 | None => return tempfile().context(ErrorKind::Unknown).map_err(Error::from) 122 | }; 123 | 124 | if !append { 125 | file.set_len(0).context(ErrorKind::Unknown)?; 126 | } 127 | 128 | Ok(file) 129 | } 130 | } 131 | } 132 | 133 | /// Rename (move) a file from `from` to `to` 134 | pub fn rename_file(&self, from: PiholeFile, to: PiholeFile) -> Result<(), Error> { 135 | match self { 136 | Env::Production(_) => { 137 | let to_path = self.file_location(to); 138 | 139 | fs::rename(self.file_location(from), to_path) 140 | .context(ErrorKind::FileWrite(to_path.to_owned()))?; 141 | 142 | Ok(()) 143 | } 144 | #[cfg(test)] 145 | Env::Test(_, ref map) => { 146 | let mut from_file = match map.get(&from) { 147 | Some(file) => file.reopen().context(ErrorKind::Unknown)?, 148 | // It's an error if the from file does not exist 149 | None => return Err(Error::from(ErrorKind::Unknown)) 150 | }; 151 | 152 | let mut to_file = match map.get(&to) { 153 | Some(file) => file.reopen().context(ErrorKind::Unknown)?, 154 | // It's fine if the two file does not exist, create one 155 | None => tempfile().context(ErrorKind::Unknown)? 156 | }; 157 | 158 | // Copy the data from the "from" file to the "to" file. 159 | // At the end, the "from" file is empty and the "to" file has 160 | // the original contents of the "from" file. 161 | let mut buffer = Vec::new(); 162 | from_file 163 | .read_to_end(&mut buffer) 164 | .context(ErrorKind::Unknown)?; 165 | to_file.set_len(0).context(ErrorKind::Unknown)?; 166 | to_file.write_all(&buffer).context(ErrorKind::Unknown)?; 167 | from_file.set_len(0).context(ErrorKind::Unknown)?; 168 | 169 | Ok(()) 170 | } 171 | } 172 | } 173 | 174 | /// Check if a file exists 175 | pub fn file_exists(&self, file: PiholeFile) -> bool { 176 | match self { 177 | Env::Production(_) => Path::new(self.file_location(file)).is_file(), 178 | #[cfg(test)] 179 | Env::Test(_, map) => map.contains_key(&file) 180 | } 181 | } 182 | 183 | /// Check if we're in a testing environment 184 | pub fn is_test(&self) -> bool { 185 | match self { 186 | Env::Production(_) => false, 187 | #[cfg(test)] 188 | Env::Test(_, _) => true 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/env/file.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Pi-hole Files 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | /// Pi-hole files used by the API 12 | #[derive(Eq, PartialEq, Hash, Copy, Clone)] 13 | pub enum PiholeFile { 14 | DnsmasqConfig, 15 | Whitelist, 16 | Blacklist, 17 | Regexlist, 18 | SetupVars, 19 | FtlConfig, 20 | LocalVersions, 21 | LocalBranches, 22 | AuditLog, 23 | Gravity, 24 | GravityBackup, 25 | BlackList, 26 | BlackListBackup 27 | } 28 | 29 | impl PiholeFile { 30 | /// Get the default location of the file 31 | pub fn default_location(self) -> &'static str { 32 | match self { 33 | PiholeFile::DnsmasqConfig => "/etc/dnsmasq.d/pihole.conf", 34 | PiholeFile::Whitelist => "/etc/pihole/whitelist.txt", 35 | PiholeFile::Blacklist => "/etc/pihole/blacklist.txt", 36 | PiholeFile::Regexlist => "/etc/pihole/regex.list", 37 | PiholeFile::SetupVars => "/etc/pihole/setupVars.conf", 38 | PiholeFile::FtlConfig => "/etc/pihole/pihole-FTL.conf", 39 | PiholeFile::LocalVersions => "/etc/pihole/localversions", 40 | PiholeFile::LocalBranches => "/etc/pihole/localbranches", 41 | PiholeFile::AuditLog => "/etc/pihole/auditlog.list", 42 | PiholeFile::Gravity => "/etc/pihole/gravity.list", 43 | PiholeFile::GravityBackup => "/etc/pihole/gravity.list.bck", 44 | PiholeFile::BlackList => "/etc/pihole/black.list", 45 | PiholeFile::BlackListBackup => "/etc/pihole/black.list.bck" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/env/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Configuration 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod config; 12 | mod env_impl; 13 | mod file; 14 | 15 | pub use self::{config::Config, env_impl::Env, file::PiholeFile}; 16 | -------------------------------------------------------------------------------- /src/ftl/memory_model/client.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Client Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::memory_model::{over_time::OVERTIME_SLOTS, strings::FtlStrings}; 12 | use libc; 13 | use std::hash::{Hash, Hasher}; 14 | 15 | #[cfg(test)] 16 | use crate::ftl::memory_model::MAGIC_BYTE; 17 | #[cfg(test)] 18 | use std::fmt::{ 19 | self, {Debug, Formatter} 20 | }; 21 | 22 | /// Represents an FTL client in API responses 23 | #[derive(Serialize)] 24 | #[cfg_attr(test, derive(Debug, PartialEq))] 25 | pub struct ClientReply { 26 | pub name: String, 27 | pub ip: String 28 | } 29 | 30 | /// The client struct stored in shared memory. 31 | /// 32 | /// Many traits, such as Debug and PartialEq, have to be manually implemented 33 | /// because arrays longer than 32 do not implement these traits, which means 34 | /// structs using them can not automatically derive the traits. For arrays of 35 | /// any length to have these traits automatically implemented, the const 36 | /// generics feature is required. That feature is still WIP: 37 | /// https://github.com/rust-lang/rfcs/blob/master/text/2000-const-generics.md 38 | #[repr(C)] 39 | #[derive(Copy, Clone)] 40 | pub struct FtlClient { 41 | magic: libc::c_uchar, 42 | pub query_count: libc::c_int, 43 | pub blocked_count: libc::c_int, 44 | ip_str_id: libc::c_ulonglong, 45 | name_str_id: libc::c_ulonglong, 46 | is_name_unknown: bool, 47 | pub over_time: [libc::c_int; OVERTIME_SLOTS], 48 | last_query_time: libc::time_t, 49 | arp_query_count: libc::c_uint 50 | } 51 | 52 | impl FtlClient { 53 | #[cfg(test)] 54 | pub fn new( 55 | query_count: usize, 56 | blocked_count: usize, 57 | ip_str_id: usize, 58 | name_str_id: Option 59 | ) -> FtlClient { 60 | FtlClient { 61 | magic: MAGIC_BYTE, 62 | query_count: query_count as libc::c_int, 63 | blocked_count: blocked_count as libc::c_int, 64 | ip_str_id: ip_str_id as libc::c_ulonglong, 65 | name_str_id: name_str_id.unwrap_or_default() as libc::c_ulonglong, 66 | is_name_unknown: name_str_id.is_none(), 67 | over_time: [0; OVERTIME_SLOTS], 68 | last_query_time: 0, 69 | arp_query_count: 0 70 | } 71 | } 72 | 73 | /// Set the client's overTime data. The data given will be right-padded with 74 | /// zeros up to the required capacity (OVERTIME_SLOTS) 75 | #[cfg(test)] 76 | pub fn with_over_time(mut self, over_time: Vec) -> Self { 77 | let mut over_time_array = [0; OVERTIME_SLOTS]; 78 | 79 | for (i, item) in over_time.into_iter().enumerate() { 80 | over_time_array[i] = item; 81 | } 82 | 83 | self.over_time = over_time_array; 84 | self 85 | } 86 | 87 | /// Get the IP address of the client 88 | pub fn get_ip<'a>(&self, strings: &'a FtlStrings) -> &'a str { 89 | strings.get_str(self.ip_str_id as usize).unwrap_or_default() 90 | } 91 | 92 | /// Get the name of the client, or `None` if it hasn't been resolved or 93 | /// doesn't exist 94 | pub fn get_name<'a>(&self, strings: &'a FtlStrings) -> Option<&'a str> { 95 | if !self.is_name_unknown && self.name_str_id != 0 { 96 | strings.get_str(self.name_str_id as usize) 97 | } else { 98 | None 99 | } 100 | } 101 | 102 | /// Convert this FTL client into the reply format 103 | pub fn as_reply(&self, strings: &FtlStrings) -> ClientReply { 104 | let name = self.get_name(&strings).unwrap_or_default(); 105 | let ip = self.get_ip(&strings); 106 | 107 | ClientReply { 108 | name: name.to_owned(), 109 | ip: ip.to_owned() 110 | } 111 | } 112 | } 113 | 114 | #[cfg(test)] 115 | impl Default for FtlClient { 116 | fn default() -> Self { 117 | FtlClient { 118 | magic: MAGIC_BYTE, 119 | query_count: 0, 120 | blocked_count: 0, 121 | ip_str_id: 0, 122 | name_str_id: 0, 123 | is_name_unknown: true, 124 | over_time: [0; OVERTIME_SLOTS], 125 | last_query_time: 0, 126 | arp_query_count: 0 127 | } 128 | } 129 | } 130 | 131 | impl PartialEq for FtlClient { 132 | fn eq(&self, other: &FtlClient) -> bool { 133 | self.magic == other.magic 134 | && self.query_count == other.query_count 135 | && self.blocked_count == other.blocked_count 136 | && self.ip_str_id == other.ip_str_id 137 | && self.name_str_id == other.name_str_id 138 | && self.is_name_unknown == other.is_name_unknown 139 | && self.over_time[..] == other.over_time[..] 140 | && self.last_query_time == other.last_query_time 141 | && self.arp_query_count == other.arp_query_count 142 | } 143 | } 144 | 145 | impl Eq for FtlClient {} 146 | 147 | impl Hash for FtlClient { 148 | fn hash(&self, state: &mut H) { 149 | self.magic.hash(state); 150 | self.query_count.hash(state); 151 | self.blocked_count.hash(state); 152 | self.ip_str_id.hash(state); 153 | self.name_str_id.hash(state); 154 | self.is_name_unknown.hash(state); 155 | self.over_time.len().hash(state); 156 | Hash::hash_slice(&self.over_time, state); 157 | self.last_query_time.hash(state); 158 | self.arp_query_count.hash(state); 159 | } 160 | } 161 | 162 | #[cfg(test)] 163 | impl Debug for FtlClient { 164 | fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { 165 | f.debug_struct("FtlClient") 166 | .field("magic", &self.magic) 167 | .field("query_count", &self.query_count) 168 | .field("blocked_count", &self.blocked_count) 169 | .field("ip_str_id", &self.ip_str_id) 170 | .field("name_str_id", &self.name_str_id) 171 | .field("is_name_unknown", &self.is_name_unknown) 172 | .field("over_time", &format!("{:?}", self.over_time.to_vec())) 173 | .field("last_query_time", &self.last_query_time) 174 | .field("arp_query_count", &self.arp_query_count) 175 | .finish() 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/ftl/memory_model/counters.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Counters Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use libc; 12 | use rocket::{http::RawStr, request::FromFormValue}; 13 | 14 | /// The FTL counters stored in shared memory 15 | #[repr(C)] 16 | #[cfg_attr(test, derive(Default))] 17 | #[derive(Copy, Clone)] 18 | pub struct FtlCounters { 19 | pub total_queries: libc::c_int, 20 | pub blocked_queries: libc::c_int, 21 | pub cached_queries: libc::c_int, 22 | pub unknown_queries: libc::c_int, 23 | pub total_upstreams: libc::c_int, 24 | pub total_clients: libc::c_int, 25 | pub total_domains: libc::c_int, 26 | pub query_capacity: libc::c_int, 27 | pub upstream_capacity: libc::c_int, 28 | pub client_capacity: libc::c_int, 29 | pub domain_capacity: libc::c_int, 30 | pub string_capacity: libc::c_int, 31 | pub gravity_size: libc::c_int, 32 | pub gravity_conf: libc::c_int, 33 | pub query_type_counters: [libc::c_int; 7], 34 | pub forwarded_queries: libc::c_int, 35 | pub reply_count_nodata: libc::c_int, 36 | pub reply_count_nxdomain: libc::c_int, 37 | pub reply_count_cname: libc::c_int, 38 | pub reply_count_ip: libc::c_int, 39 | pub reply_count_domain: libc::c_int 40 | } 41 | 42 | impl FtlCounters { 43 | pub fn query_type(&self, query_type: FtlQueryType) -> usize { 44 | self.query_type_counters[query_type as usize - 1] as usize 45 | } 46 | } 47 | 48 | /// The query types stored by FTL. Use this enum for [`FtlCounters::query_type`] 49 | /// 50 | /// [`FtlCounters::query_type`]: struct.FtlCounters.html#method.query_type 51 | #[repr(u8)] 52 | #[derive(Copy, Clone, PartialEq, Debug, Eq, Hash)] 53 | pub enum FtlQueryType { 54 | A = 1, 55 | AAAA, 56 | ANY, 57 | SRV, 58 | SOA, 59 | PTR, 60 | TXT 61 | } 62 | 63 | impl<'v> FromFormValue<'v> for FtlQueryType { 64 | type Error = &'v RawStr; 65 | 66 | fn from_form_value(form_value: &'v RawStr) -> Result { 67 | let num = form_value.parse::().map_err(|_| form_value)?; 68 | Self::from_number(num as isize).ok_or(form_value) 69 | } 70 | } 71 | 72 | impl FtlQueryType { 73 | /// A list of all `FtlQueryType` variants. There is no built in way to get 74 | /// a list of enum variants. 75 | pub fn variants() -> &'static [FtlQueryType] { 76 | &[ 77 | FtlQueryType::A, 78 | FtlQueryType::AAAA, 79 | FtlQueryType::ANY, 80 | FtlQueryType::SRV, 81 | FtlQueryType::SOA, 82 | FtlQueryType::PTR, 83 | FtlQueryType::TXT 84 | ] 85 | } 86 | 87 | /// Get the query type from its ordinal value 88 | pub fn from_number(num: isize) -> Option { 89 | match num { 90 | 1 => Some(FtlQueryType::A), 91 | 2 => Some(FtlQueryType::AAAA), 92 | 3 => Some(FtlQueryType::ANY), 93 | 4 => Some(FtlQueryType::SRV), 94 | 5 => Some(FtlQueryType::SOA), 95 | 6 => Some(FtlQueryType::PTR), 96 | 7 => Some(FtlQueryType::TXT), 97 | _ => None 98 | } 99 | } 100 | 101 | /// Get the name of the query type 102 | pub fn get_name(self) -> String { 103 | format!("{:?}", self) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/ftl/memory_model/domain.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Domain Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::FtlStrings; 12 | use libc; 13 | 14 | #[cfg(test)] 15 | use crate::ftl::memory_model::MAGIC_BYTE; 16 | 17 | /// The domain struct stored in shared memory 18 | #[repr(C)] 19 | #[cfg_attr(test, derive(PartialEq, Debug))] 20 | #[derive(Copy, Clone)] 21 | pub struct FtlDomain { 22 | magic: libc::c_uchar, 23 | pub query_count: libc::c_int, 24 | pub blocked_count: libc::c_int, 25 | domain_str_id: libc::c_ulonglong, 26 | pub regex_match: FtlRegexMatch 27 | } 28 | 29 | impl FtlDomain { 30 | #[cfg(test)] 31 | pub fn new( 32 | total_count: usize, 33 | blocked_count: usize, 34 | domain_str_id: usize, 35 | regex_match: FtlRegexMatch 36 | ) -> FtlDomain { 37 | FtlDomain { 38 | magic: MAGIC_BYTE, 39 | query_count: total_count as libc::c_int, 40 | blocked_count: blocked_count as libc::c_int, 41 | domain_str_id: domain_str_id as libc::c_ulonglong, 42 | regex_match 43 | } 44 | } 45 | 46 | /// Get the domain name 47 | pub fn get_domain<'a>(&self, strings: &'a FtlStrings) -> &'a str { 48 | strings 49 | .get_str(self.domain_str_id as usize) 50 | .unwrap_or_default() 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | impl Default for FtlDomain { 56 | fn default() -> Self { 57 | FtlDomain { 58 | magic: MAGIC_BYTE, 59 | query_count: 0, 60 | blocked_count: 0, 61 | domain_str_id: 0, 62 | regex_match: FtlRegexMatch::Unknown 63 | } 64 | } 65 | } 66 | 67 | /// The regex state a domain can hold. Unknown is the default state, before it 68 | /// is checked when a query of the domain comes in. 69 | #[repr(u8)] 70 | #[cfg_attr(test, derive(PartialEq, Debug))] 71 | #[derive(Copy, Clone)] 72 | #[allow(dead_code)] 73 | pub enum FtlRegexMatch { 74 | Unknown, 75 | Blocked, 76 | NotBlocked 77 | } 78 | -------------------------------------------------------------------------------- /src/ftl/memory_model/lock.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Lock Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use libc; 12 | 13 | /// The lock structure used to synchronize access to shared memory 14 | #[derive(Copy, Clone)] 15 | #[repr(C)] 16 | pub struct FtlLock { 17 | pub lock: libc::pthread_mutex_t, 18 | pub ftl_waiting_for_lock: bool 19 | } 20 | -------------------------------------------------------------------------------- /src/ftl/memory_model/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Shared Memory Data Types 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | #[cfg(test)] 12 | use libc; 13 | 14 | /// Used by FTL to check memory integrity in various structs 15 | #[cfg(test)] 16 | pub const MAGIC_BYTE: libc::c_uchar = 0x57; 17 | 18 | mod client; 19 | mod counters; 20 | mod domain; 21 | mod lock; 22 | mod over_time; 23 | mod query; 24 | mod settings; 25 | mod strings; 26 | mod upstream; 27 | 28 | pub use self::{ 29 | client::*, 30 | counters::{FtlCounters, FtlQueryType}, 31 | domain::{FtlDomain, FtlRegexMatch}, 32 | lock::FtlLock, 33 | over_time::*, 34 | query::{FtlDnssecType, FtlQuery, FtlQueryReplyType, FtlQueryStatus, BLOCKED_STATUSES}, 35 | settings::FtlSettings, 36 | strings::FtlStrings, 37 | upstream::FtlUpstream 38 | }; 39 | -------------------------------------------------------------------------------- /src/ftl/memory_model/over_time.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Client Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use libc; 12 | 13 | #[cfg(test)] 14 | use crate::ftl::memory_model::MAGIC_BYTE; 15 | 16 | pub const MAX_LOG_AGE: usize = 24; 17 | pub const OVERTIME_INTERVAL: usize = 600; 18 | pub const OVERTIME_SLOTS: usize = (MAX_LOG_AGE + 1) * 3600 / OVERTIME_INTERVAL; 19 | 20 | #[repr(C)] 21 | #[cfg_attr(test, derive(Debug))] 22 | #[derive(Copy, Clone)] 23 | pub struct FtlOverTime { 24 | magic: libc::c_uchar, 25 | pub timestamp: libc::time_t, 26 | pub total_queries: libc::c_int, 27 | pub blocked_queries: libc::c_int, 28 | pub cached_queries: libc::c_int, 29 | pub forwarded_queries: libc::c_int, 30 | query_types: [libc::c_int; 7] 31 | } 32 | 33 | impl FtlOverTime { 34 | #[cfg(test)] 35 | pub fn new( 36 | timestamp: usize, 37 | total_queries: usize, 38 | blocked_queries: usize, 39 | cached_queries: usize, 40 | forwarded_queries: usize, 41 | query_types: [libc::c_int; 7] 42 | ) -> FtlOverTime { 43 | FtlOverTime { 44 | magic: MAGIC_BYTE, 45 | timestamp: timestamp as libc::time_t, 46 | total_queries: total_queries as libc::c_int, 47 | blocked_queries: blocked_queries as libc::c_int, 48 | cached_queries: cached_queries as libc::c_int, 49 | forwarded_queries: forwarded_queries as libc::c_int, 50 | query_types 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ftl/memory_model/query.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Query Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::FtlQueryType; 12 | use libc; 13 | use rocket::{http::RawStr, request::FromFormValue}; 14 | 15 | /// A list of query statuses which mark a query as blocked 16 | pub const BLOCKED_STATUSES: [i32; 4] = [ 17 | FtlQueryStatus::Gravity as i32, 18 | FtlQueryStatus::Wildcard as i32, 19 | FtlQueryStatus::Blacklist as i32, 20 | FtlQueryStatus::ExternalBlock as i32 21 | ]; 22 | 23 | /// The query struct stored in shared memory 24 | #[repr(C)] 25 | #[cfg_attr(test, derive(PartialEq, Debug))] 26 | #[derive(Copy, Clone)] 27 | pub struct FtlQuery { 28 | pub magic: libc::c_uchar, 29 | pub timestamp: libc::time_t, 30 | pub time_index: libc::c_uint, 31 | pub query_type: FtlQueryType, 32 | pub status: FtlQueryStatus, 33 | pub domain_id: libc::c_int, 34 | pub client_id: libc::c_int, 35 | pub upstream_id: libc::c_int, 36 | pub database_id: i64, 37 | pub id: libc::c_int, 38 | pub is_complete: bool, 39 | pub is_private: bool, 40 | /// Saved in units of 1/10 milliseconds (1 = 0.1ms, 2 = 0.2ms, 41 | /// 2500 = 250.0ms, etc.) 42 | pub response_time: libc::c_ulong, 43 | pub reply_type: FtlQueryReplyType, 44 | pub dnssec_type: FtlDnssecType, 45 | pub ad_bit: bool 46 | } 47 | 48 | impl FtlQuery { 49 | /// Check if the query was blocked 50 | pub fn is_blocked(&self) -> bool { 51 | BLOCKED_STATUSES.contains(&(self.status as i32)) 52 | } 53 | } 54 | 55 | /// The statuses an FTL query can have 56 | #[repr(u8)] 57 | #[cfg_attr(test, derive(Debug))] 58 | #[derive(Copy, Clone, PartialEq)] 59 | pub enum FtlQueryStatus { 60 | Unknown, 61 | Gravity, 62 | Forward, 63 | Cache, 64 | Wildcard, 65 | Blacklist, 66 | ExternalBlock 67 | } 68 | 69 | impl FtlQueryStatus { 70 | /// Get the query status from its ordinal value 71 | pub fn from_number(num: isize) -> Option { 72 | match num { 73 | 0 => Some(FtlQueryStatus::Unknown), 74 | 1 => Some(FtlQueryStatus::Gravity), 75 | 2 => Some(FtlQueryStatus::Forward), 76 | 3 => Some(FtlQueryStatus::Cache), 77 | 4 => Some(FtlQueryStatus::Wildcard), 78 | 5 => Some(FtlQueryStatus::Blacklist), 79 | 6 => Some(FtlQueryStatus::ExternalBlock), 80 | _ => None 81 | } 82 | } 83 | } 84 | 85 | impl<'v> FromFormValue<'v> for FtlQueryStatus { 86 | type Error = &'v RawStr; 87 | 88 | fn from_form_value(form_value: &'v RawStr) -> Result { 89 | let num = form_value.parse::().map_err(|_| form_value)?; 90 | Self::from_number(num as isize).ok_or(form_value) 91 | } 92 | } 93 | 94 | /// The reply types an FTL query can have 95 | #[repr(u8)] 96 | #[cfg_attr(test, derive(Debug))] 97 | #[derive(Copy, Clone, PartialEq)] 98 | pub enum FtlQueryReplyType { 99 | Unknown, 100 | NODATA, 101 | NXDOMAIN, 102 | CNAME, 103 | IP, 104 | DOMAIN, 105 | RRNAME, 106 | SERVFAIL, 107 | REFUSED, 108 | NOTIMP, 109 | OTHER 110 | } 111 | 112 | impl FtlQueryReplyType { 113 | /// Get the query reply type from its ordinal value 114 | pub fn from_number(num: isize) -> Option { 115 | match num { 116 | 0 => Some(FtlQueryReplyType::Unknown), 117 | 1 => Some(FtlQueryReplyType::NODATA), 118 | 2 => Some(FtlQueryReplyType::NXDOMAIN), 119 | 3 => Some(FtlQueryReplyType::CNAME), 120 | 4 => Some(FtlQueryReplyType::IP), 121 | 5 => Some(FtlQueryReplyType::DOMAIN), 122 | 6 => Some(FtlQueryReplyType::RRNAME), 123 | 7 => Some(FtlQueryReplyType::SERVFAIL), 124 | 8 => Some(FtlQueryReplyType::REFUSED), 125 | 9 => Some(FtlQueryReplyType::NOTIMP), 126 | 10 => Some(FtlQueryReplyType::OTHER), 127 | _ => None 128 | } 129 | } 130 | } 131 | 132 | impl<'v> FromFormValue<'v> for FtlQueryReplyType { 133 | type Error = &'v RawStr; 134 | 135 | fn from_form_value(form_value: &'v RawStr) -> Result { 136 | let num = form_value.parse::().map_err(|_| form_value)?; 137 | Self::from_number(num as isize).ok_or(form_value) 138 | } 139 | } 140 | 141 | /// The DNSSEC reply types an FTL query can have 142 | #[repr(u8)] 143 | #[cfg_attr(test, derive(Debug))] 144 | #[derive(Copy, Clone, PartialEq)] 145 | pub enum FtlDnssecType { 146 | Unspecified, 147 | Secure, 148 | Insecure, 149 | Bogus, 150 | Abandoned, 151 | Unknown 152 | } 153 | 154 | impl FtlDnssecType { 155 | /// Get the DNSSEC type from its ordinal value 156 | pub fn from_number(num: isize) -> Option { 157 | match num { 158 | 0 => Some(FtlDnssecType::Unspecified), 159 | 1 => Some(FtlDnssecType::Secure), 160 | 2 => Some(FtlDnssecType::Insecure), 161 | 3 => Some(FtlDnssecType::Bogus), 162 | 4 => Some(FtlDnssecType::Abandoned), 163 | 5 => Some(FtlDnssecType::Unknown), 164 | _ => None 165 | } 166 | } 167 | } 168 | 169 | impl<'v> FromFormValue<'v> for FtlDnssecType { 170 | type Error = &'v RawStr; 171 | 172 | fn from_form_value(form_value: &'v RawStr) -> Result { 173 | let num = form_value.parse::().map_err(|_| form_value)?; 174 | Self::from_number(num as isize).ok_or(form_value) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/ftl/memory_model/settings.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Settings Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use libc; 12 | 13 | /// The settings structure used to share version information and other settings 14 | #[derive(Copy, Clone)] 15 | #[repr(C)] 16 | pub struct FtlSettings { 17 | pub version: libc::c_int, 18 | pub global_shm_counter: libc::c_uint, 19 | pub next_str_pos: libc::c_uint 20 | } 21 | 22 | impl Default for FtlSettings { 23 | fn default() -> Self { 24 | FtlSettings { 25 | version: 0, 26 | global_shm_counter: 0, 27 | next_str_pos: 1 // 0 is used as the empty string 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ftl/memory_model/strings.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Strings Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use libc; 12 | use shmem::Array; 13 | use std::{ffi::CStr, marker::PhantomData}; 14 | 15 | #[cfg(test)] 16 | use std::collections::HashMap; 17 | 18 | /// A safe wrapper around FTL's strings. It is used to access the strings 19 | /// referenced by other shared memory structs. 20 | /// 21 | /// Note: When testing, the 0 entry will be ignore in favor of returning the 22 | /// empty string 23 | pub enum FtlStrings<'test> { 24 | // Use `PhantomData` because when not in testing mode, the 'test lifetime will be unused and 25 | // will cause an error. The `PhantomData` is zero-sized, so it will not actually exist. 26 | Production(Array, PhantomData<&'test bool>), 27 | #[cfg(test)] 28 | Test(&'test HashMap) 29 | } 30 | 31 | impl<'test> FtlStrings<'test> { 32 | /// Read a string from FTL's string memory. If the string does not exist, 33 | /// `None` is returned. The `id` is the position of the string in 34 | /// shared memory, which can be obtained from the other shared memory 35 | /// structs. 36 | pub fn get_str(&self, id: usize) -> Option<&str> { 37 | match self { 38 | FtlStrings::Production(strings, ..) => Self::get_str_prod(strings, id), 39 | #[cfg(test)] 40 | FtlStrings::Test(strings) => { 41 | if id == 0 { 42 | Some("") 43 | } else { 44 | strings.get(&id).map(|string| string.as_str()) 45 | } 46 | } 47 | } 48 | } 49 | 50 | /// This function is used for `FtlStrings::Production`. It checks to see 51 | /// if the string exists, and then creates a `CStr` from a pointer. It 52 | /// is assumed that the string has a null terminator. Then the `CStr` is 53 | /// converted into `&str`. If the conversion fails, `None` is returned. 54 | fn get_str_prod(strings: &[libc::c_char], id: usize) -> Option<&str> { 55 | if strings.get(id).is_some() { 56 | unsafe { CStr::from_ptr(&strings[id]) }.to_str().ok() 57 | } else { 58 | None 59 | } 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use super::FtlStrings; 66 | use libc; 67 | use std::collections::HashMap; 68 | 69 | #[test] 70 | fn get_str_valid() { 71 | let mut data = HashMap::new(); 72 | data.insert(0, "".to_owned()); 73 | data.insert(1, "test".to_owned()); 74 | let strings = FtlStrings::Test(&data); 75 | 76 | assert_eq!(strings.get_str(0), Some("")); 77 | assert_eq!(strings.get_str(1), Some("test")); 78 | } 79 | 80 | #[test] 81 | fn get_str_prod() { 82 | let strings: Vec = ['\0', 't', 'e', 's', 't', '\0'] 83 | .iter() 84 | .map(|&c| c as libc::c_char) 85 | .collect(); 86 | 87 | assert_eq!(FtlStrings::get_str_prod(&strings, 0), Some("")); 88 | assert_eq!(FtlStrings::get_str_prod(&strings, 1), Some("test")); 89 | assert_eq!(FtlStrings::get_str_prod(&strings, 6), None); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ftl/memory_model/upstream.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Shared Memory Upstream Structure 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::FtlStrings; 12 | use libc; 13 | 14 | #[cfg(test)] 15 | use crate::ftl::memory_model::MAGIC_BYTE; 16 | 17 | /// The upstream (forward destination) struct stored in shared memory 18 | #[repr(C)] 19 | #[derive(Copy, Clone)] 20 | pub struct FtlUpstream { 21 | magic: libc::c_uchar, 22 | pub query_count: libc::c_int, 23 | pub failed_count: libc::c_int, 24 | ip_str_id: libc::c_ulonglong, 25 | name_str_id: libc::c_ulonglong, 26 | is_name_unknown: bool 27 | } 28 | 29 | impl FtlUpstream { 30 | #[cfg(test)] 31 | pub fn new( 32 | query_count: usize, 33 | failed_count: usize, 34 | ip_str_id: usize, 35 | name_str_id: Option 36 | ) -> FtlUpstream { 37 | FtlUpstream { 38 | magic: MAGIC_BYTE, 39 | query_count: query_count as libc::c_int, 40 | failed_count: failed_count as libc::c_int, 41 | ip_str_id: ip_str_id as libc::c_ulonglong, 42 | name_str_id: name_str_id.unwrap_or_default() as libc::c_ulonglong, 43 | is_name_unknown: name_str_id.is_none() 44 | } 45 | } 46 | 47 | /// Get the IP address of the upstream 48 | pub fn get_ip<'a>(&self, strings: &'a FtlStrings) -> &'a str { 49 | strings.get_str(self.ip_str_id as usize).unwrap_or_default() 50 | } 51 | 52 | /// Get the name of the upstream, or `None` if it hasn't been resolved or 53 | /// doesn't exist 54 | pub fn get_name<'a>(&self, strings: &'a FtlStrings) -> Option<&'a str> { 55 | if !self.is_name_unknown && self.name_str_id != 0 { 56 | Some( 57 | strings 58 | .get_str(self.name_str_id as usize) 59 | .unwrap_or_default() 60 | ) 61 | } else { 62 | None 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ftl/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Utilities 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod lock_thread; 12 | mod memory_model; 13 | mod shared_lock; 14 | mod shared_memory; 15 | mod socket; 16 | 17 | pub use self::{ 18 | memory_model::*, 19 | shared_lock::{ShmLock, ShmLockGuard}, 20 | shared_memory::FtlMemory, 21 | socket::{FtlConnection, FtlConnectionType} 22 | }; 23 | -------------------------------------------------------------------------------- /src/ftl/socket.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Socket Communication 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::util::{Error, ErrorKind}; 12 | use failure::{Fail, ResultExt}; 13 | use rmp::{ 14 | decode::{self, DecodeStringError, ValueReadError}, 15 | Marker 16 | }; 17 | use std::{ 18 | io::{prelude::*, BufReader}, 19 | os::unix::net::UnixStream 20 | }; 21 | 22 | #[cfg(test)] 23 | use std::collections::HashMap; 24 | #[cfg(test)] 25 | use std::io::Cursor; 26 | 27 | /// The location of the FTL socket 28 | const SOCKET_LOCATION: &str = "/var/run/pihole/FTL.sock"; 29 | 30 | /// A wrapper around the FTL socket to easily read in data. It takes a 31 | /// Box so that it can be tested with fake data from a Vec 32 | pub struct FtlConnection<'test>(Box); 33 | 34 | /// A marker for the type of FTL connection to make. 35 | /// 36 | /// - Socket refers to the normal Unix socket connection. 37 | /// - Test is for testing, so that a test can pass in arbitrary MessagePack 38 | /// data to be processed. The map in Test maps FTL commands to data. 39 | pub enum FtlConnectionType { 40 | Socket, 41 | #[cfg(test)] 42 | Test(HashMap>) 43 | } 44 | 45 | impl FtlConnectionType { 46 | /// Connect to FTL and run the specified command 47 | pub fn connect(&self, command: &str) -> Result { 48 | // Determine the type of connection to create 49 | match *self { 50 | FtlConnectionType::Socket => { 51 | // Try to connect to FTL 52 | let mut stream = match UnixStream::connect(SOCKET_LOCATION) { 53 | Ok(s) => s, 54 | Err(_) => return Err(Error::from(ErrorKind::FtlConnectionFail)) 55 | }; 56 | 57 | // Send the command 58 | stream 59 | .write_all(format!(">{}\n", command).as_bytes()) 60 | .context(ErrorKind::FtlConnectionFail)?; 61 | 62 | // Return the connection so the API can read the response 63 | Ok(FtlConnection(Box::new(BufReader::new(stream)))) 64 | } 65 | #[cfg(test)] 66 | FtlConnectionType::Test(ref map) => { 67 | // Return a connection reading the testing data 68 | Ok(FtlConnection(Box::new(Cursor::new( 69 | // Try to get the testing data for this command 70 | match map.get(command) { 71 | Some(data) => data, 72 | None => return Err(Error::from(ErrorKind::FtlConnectionFail)) 73 | } 74 | )))) 75 | } 76 | } 77 | } 78 | } 79 | 80 | impl<'test> FtlConnection<'test> { 81 | fn handle_eom_value(result: Result) -> Result { 82 | result.map_err(|e| { 83 | if let ValueReadError::TypeMismatch(marker) = e { 84 | if marker == Marker::Reserved { 85 | // Received EOM 86 | return Error::from(e.context(ErrorKind::FtlEomError)); 87 | } 88 | } 89 | 90 | Error::from(e.context(ErrorKind::FtlReadError)) 91 | }) 92 | } 93 | 94 | fn handle_eom_str(result: Result) -> Result { 95 | result.map_err(|e| { 96 | if let DecodeStringError::TypeMismatch(ref marker) = e { 97 | if *marker == Marker::Reserved { 98 | // Received EOM 99 | return Error::from(ErrorKind::FtlEomError); 100 | } 101 | } 102 | 103 | Error::from(ErrorKind::FtlReadError) 104 | }) 105 | } 106 | 107 | /// We expect an end of message (EOM) response when FTL has finished 108 | /// sending data 109 | pub fn expect_eom(&mut self) -> Result<(), Error> { 110 | let mut buffer: [u8; 1] = [0]; 111 | 112 | // Read exactly 1 byte 113 | match self.0.read_exact(&mut buffer) { 114 | Ok(_) => (), 115 | Err(e) => return Err(Error::from(e.context(ErrorKind::FtlReadError))) 116 | } 117 | 118 | // Check if it was the EOM byte 119 | if buffer[0] != 0xc1 { 120 | return Err(Error::from(ErrorKind::FtlReadError)); 121 | } 122 | 123 | Ok(()) 124 | } 125 | 126 | /// Read in an i32 (signed int) value 127 | pub fn read_i32(&mut self) -> Result { 128 | FtlConnection::handle_eom_value(decode::read_i32(&mut self.0)) 129 | } 130 | 131 | /// Read in an i64 (signed long int) value 132 | pub fn read_i64(&mut self) -> Result { 133 | FtlConnection::handle_eom_value(decode::read_i64(&mut self.0)) 134 | } 135 | 136 | /// Read in a string using the buffer 137 | pub fn read_str<'a>(&mut self, buffer: &'a mut [u8]) -> Result<&'a str, Error> { 138 | FtlConnection::handle_eom_str(decode::read_str(&mut self.0, buffer)) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Root Library File 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | #![feature(proc_macro_hygiene, decl_macro)] 12 | #![allow(clippy::cast_lossless)] 13 | 14 | #[macro_use] 15 | extern crate diesel; 16 | #[macro_use] 17 | extern crate rocket; 18 | #[macro_use] 19 | extern crate serde_derive; 20 | #[macro_use] 21 | extern crate rocket_contrib; 22 | #[macro_use] 23 | extern crate rust_embed; 24 | 25 | pub use crate::setup::start; 26 | 27 | mod databases; 28 | mod env; 29 | mod ftl; 30 | mod routes; 31 | mod settings; 32 | mod setup; 33 | mod util; 34 | 35 | #[cfg(test)] 36 | mod testing; 37 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Program Main 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | fn main() { 12 | if let Err(e) = pihole_api::start() { 13 | e.print_stacktrace(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/auth.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Authentication Functions And Routes 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::util::{reply_success, Error, ErrorKind, Reply}; 12 | use rocket::{ 13 | http::{Cookie, Cookies}, 14 | outcome::IntoOutcome, 15 | request::{self, FromRequest, Request, State}, 16 | Outcome 17 | }; 18 | use std::sync::atomic::{AtomicUsize, Ordering}; 19 | 20 | const USER_ATTR: &str = "user_id"; 21 | const AUTH_HEADER: &str = "X-Pi-hole-Authenticate"; 22 | 23 | /// When used as a request guard, requests must be authenticated 24 | pub struct User { 25 | pub id: usize 26 | } 27 | 28 | /// Stores the API key in the server state 29 | pub struct AuthData { 30 | key: String, 31 | next_id: AtomicUsize 32 | } 33 | 34 | impl User { 35 | /// Try to authenticate the user using `input_key`. If it succeeds, a new 36 | /// cookie will be created. 37 | fn authenticate(request: &Request, input_key: &str) -> request::Outcome { 38 | let auth_data: State = match request.guard().succeeded() { 39 | Some(auth_data) => auth_data, 40 | None => return Error::from(ErrorKind::Unknown).into_outcome() 41 | }; 42 | 43 | if auth_data.key_matches(input_key) { 44 | let user = auth_data.create_user(); 45 | 46 | // Set a new encrypted cookie with the user's ID 47 | request.cookies().add_private( 48 | Cookie::build(USER_ATTR, user.id.to_string()) 49 | // Allow the web interface to read the cookie 50 | .http_only(false) 51 | .finish() 52 | ); 53 | 54 | Outcome::Success(user) 55 | } else { 56 | Error::from(ErrorKind::Unauthorized).into_outcome() 57 | } 58 | } 59 | 60 | /// Try to get the user ID from cookies. An error is returned if none are 61 | /// found. 62 | fn check_cookies(mut cookies: Cookies) -> request::Outcome { 63 | cookies 64 | .get_private(USER_ATTR) 65 | .and_then(|cookie| cookie.value().parse().ok()) 66 | .map(|id| User { id }) 67 | .into_outcome(( 68 | ErrorKind::Unauthorized.status(), 69 | Error::from(ErrorKind::Unauthorized) 70 | )) 71 | } 72 | 73 | /// Log the user out by removing the cookie 74 | fn logout(&self, mut cookies: Cookies) { 75 | cookies.remove_private(Cookie::named(USER_ATTR)); 76 | } 77 | } 78 | 79 | impl<'a, 'r> FromRequest<'a, 'r> for User { 80 | type Error = Error; 81 | 82 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 83 | match request.headers().get_one(AUTH_HEADER) { 84 | // Try to authenticate, and if that fails check cookies 85 | Some(key) => { 86 | let auth_result = User::authenticate(request, key); 87 | 88 | if auth_result.is_success() { 89 | auth_result 90 | } else { 91 | User::check_cookies(request.cookies()) 92 | } 93 | } 94 | // No attempt to authenticate, so check cookies 95 | None => User::check_cookies(request.cookies()) 96 | } 97 | } 98 | } 99 | 100 | impl AuthData { 101 | /// Create a new API key 102 | pub fn new(key: String) -> AuthData { 103 | AuthData { 104 | key, 105 | next_id: AtomicUsize::new(1) 106 | } 107 | } 108 | 109 | /// Check if the key matches the server's key 110 | fn key_matches(&self, key: &str) -> bool { 111 | self.key == key 112 | } 113 | 114 | /// Create a new user and increment `next_id` 115 | fn create_user(&self) -> User { 116 | User { 117 | id: self.next_id.fetch_add(1, Ordering::Relaxed) 118 | } 119 | } 120 | } 121 | 122 | /// Provides an endpoint to authenticate or check if already authenticated 123 | #[get("/auth")] 124 | pub fn check(_user: User) -> Reply { 125 | reply_success() 126 | } 127 | 128 | /// Clears the user's authentication 129 | #[delete("/auth")] 130 | pub fn logout(user: User, cookies: Cookies) -> Reply { 131 | user.logout(cookies); 132 | reply_success() 133 | } 134 | 135 | #[cfg(test)] 136 | mod test { 137 | use crate::testing::TestBuilder; 138 | use rocket::http::{Header, Status}; 139 | use serde_json::Value; 140 | 141 | /// Providing the correct authentication should authorize the request 142 | #[test] 143 | fn authenticated() { 144 | TestBuilder::new() 145 | .endpoint("/admin/api/auth") 146 | .should_auth(true) 147 | .expect_json(json!({ 148 | "status": "success" 149 | })) 150 | .test() 151 | } 152 | 153 | /// Providing no authorization should not authorize the request 154 | #[test] 155 | fn unauthenticated() { 156 | TestBuilder::new() 157 | .endpoint("/admin/api/auth") 158 | .should_auth(false) 159 | .expect_status(Status::Unauthorized) 160 | .expect_json(json!({ 161 | "error": { 162 | "key": "unauthorized", 163 | "message": "Unauthorized", 164 | "data": Value::Null 165 | } 166 | })) 167 | .test() 168 | } 169 | 170 | /// Providing incorrect authorization should not authorize the request 171 | #[test] 172 | fn wrong_password() { 173 | TestBuilder::new() 174 | .endpoint("/admin/api/auth") 175 | .should_auth(false) 176 | .header(Header::new( 177 | "X-Pi-hole-Authenticate", 178 | "obviously_not_correct" 179 | )) 180 | .expect_status(Status::Unauthorized) 181 | .expect_json(json!({ 182 | "error": { 183 | "key": "unauthorized", 184 | "message": "Unauthorized", 185 | "data": Value::Null 186 | } 187 | })) 188 | .test(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/routes/dns/add_list.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Endpoints For Adding Domains To Lists 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | ftl::FtlConnectionType, 14 | routes::{ 15 | auth::User, 16 | dns::{common::reload_gravity, list::List} 17 | }, 18 | util::{reply_success, Reply} 19 | }; 20 | use rocket::State; 21 | use rocket_contrib::json::Json; 22 | 23 | /// Represents an API input containing a domain 24 | #[derive(Deserialize)] 25 | pub struct DomainInput { 26 | domain: String 27 | } 28 | 29 | /// Add a domain to the whitelist 30 | #[post("/dns/whitelist", data = "")] 31 | pub fn add_whitelist(_auth: User, env: State, domain_input: Json) -> Reply { 32 | let domain = &domain_input.0.domain; 33 | 34 | // We need to add it to the whitelist and remove it from the blacklist 35 | List::White.add(domain, &env)?; 36 | List::Black.try_remove(domain, &env)?; 37 | 38 | // At this point, since we haven't hit an error yet, reload gravity 39 | reload_gravity(List::White, &env)?; 40 | reply_success() 41 | } 42 | 43 | /// Add a domain to the blacklist 44 | #[post("/dns/blacklist", data = "")] 45 | pub fn add_blacklist(_auth: User, env: State, domain_input: Json) -> Reply { 46 | let domain = &domain_input.0.domain; 47 | 48 | // We need to add it to the blacklist and remove it from the whitelist 49 | List::Black.add(domain, &env)?; 50 | List::White.try_remove(domain, &env)?; 51 | 52 | // At this point, since we haven't hit an error yet, reload gravity 53 | reload_gravity(List::Black, &env)?; 54 | reply_success() 55 | } 56 | 57 | /// Add a domain to the regex list 58 | #[post("/dns/regexlist", data = "")] 59 | pub fn add_regexlist( 60 | _auth: User, 61 | env: State, 62 | ftl: State, 63 | domain_input: Json 64 | ) -> Reply { 65 | let domain = &domain_input.0.domain; 66 | 67 | // We only need to add it to the regex list 68 | List::Regex.add(domain, &env)?; 69 | 70 | // At this point, since we haven't hit an error yet, tell FTL to recompile regex 71 | ftl.connect("recompile-regex")?.expect_eom()?; 72 | reply_success() 73 | } 74 | 75 | #[cfg(test)] 76 | mod test { 77 | use crate::{ 78 | env::PiholeFile, 79 | testing::{write_eom, TestBuilder} 80 | }; 81 | use rocket::http::Method; 82 | 83 | #[test] 84 | fn test_add_whitelist() { 85 | TestBuilder::new() 86 | .endpoint("/admin/api/dns/whitelist") 87 | .method(Method::Post) 88 | .file_expect(PiholeFile::Whitelist, "", "example.com\n") 89 | .file(PiholeFile::Blacklist, "") 90 | .file(PiholeFile::Regexlist, "") 91 | .file(PiholeFile::SetupVars, "") 92 | .body(json!({ "domain": "example.com" })) 93 | .expect_json(json!({ "status": "success" })) 94 | .test(); 95 | } 96 | 97 | #[test] 98 | fn test_add_blacklist() { 99 | TestBuilder::new() 100 | .endpoint("/admin/api/dns/blacklist") 101 | .method(Method::Post) 102 | .file_expect(PiholeFile::Blacklist, "", "example.com\n") 103 | .file(PiholeFile::Whitelist, "") 104 | .file(PiholeFile::Regexlist, "") 105 | .file(PiholeFile::SetupVars, "") 106 | .body(json!({ "domain": "example.com" })) 107 | .expect_json(json!({ "status": "success" })) 108 | .test(); 109 | } 110 | 111 | #[test] 112 | fn test_add_regexlist() { 113 | let mut data = Vec::new(); 114 | write_eom(&mut data); 115 | 116 | TestBuilder::new() 117 | .endpoint("/admin/api/dns/regexlist") 118 | .method(Method::Post) 119 | .ftl("recompile-regex", data) 120 | .file_expect(PiholeFile::Regexlist, "", "^.*example.com$\n") 121 | .file(PiholeFile::Whitelist, "") 122 | .file(PiholeFile::Blacklist, "") 123 | .file(PiholeFile::SetupVars, "IPV4_ADDRESS=10.1.1.1") 124 | .body(json!({ "domain": "^.*example.com$" })) 125 | .expect_json(json!({ "status": "success" })) 126 | .test(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/routes/dns/common.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Common Functions For DNS Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | routes::dns::list::List, 14 | util::{Error, ErrorKind} 15 | }; 16 | use failure::ResultExt; 17 | use nix::{ 18 | sys::signal::{kill, Signal}, 19 | unistd::Pid 20 | }; 21 | use regex::Regex; 22 | use std::process::{Command, Stdio}; 23 | 24 | /// Check if a domain is valid 25 | pub fn is_valid_domain(domain: &str) -> bool { 26 | let valid_chars_regex = Regex::new( 27 | "^((-|_)*[a-z0-9]((-|_)*[a-z0-9])*(-|_)*)(\\.(-|_)*([a-z0-9]((-|_)*[a-z0-9])*))*$" 28 | ) 29 | .unwrap(); 30 | let total_length_regex = Regex::new("^.{1,253}$").unwrap(); 31 | let label_length_regex = Regex::new("^[^\\.]{1,63}(\\.[^\\.]{1,63})*$").unwrap(); 32 | 33 | valid_chars_regex.is_match(domain) 34 | && total_length_regex.is_match(domain) 35 | && label_length_regex.is_match(domain) 36 | } 37 | 38 | /// Check if a regex is valid 39 | pub fn is_valid_regex(regex_str: &str) -> bool { 40 | Regex::new(regex_str).is_ok() 41 | } 42 | 43 | /// Reload Gravity to activate changes in lists 44 | pub fn reload_gravity(list: List, env: &Env) -> Result<(), Error> { 45 | // Don't actually reload Gravity during testing 46 | if env.is_test() { 47 | return Ok(()); 48 | } 49 | 50 | let status = Command::new("sudo") 51 | .arg("pihole") 52 | .arg("-g") 53 | .arg("--skip-download") 54 | // Based on what list we modified, only reload what is necessary 55 | .arg(match list { 56 | List::White => "--whitelist-only", 57 | List::Black => "--blacklist-only", 58 | _ => return Err(Error::from(ErrorKind::Unknown)) 59 | }) 60 | // Ignore stdin, stdout, and stderr 61 | .stdin(Stdio::null()) 62 | .stdout(Stdio::null()) 63 | .stderr(Stdio::null()) 64 | // Get the returned status code 65 | .status() 66 | .context(ErrorKind::GravityError)?; 67 | 68 | if status.success() { 69 | Ok(()) 70 | } else { 71 | Err(Error::from(ErrorKind::GravityError)) 72 | } 73 | } 74 | 75 | /// Reload the DNS server to activate config changes 76 | pub fn reload_dns(env: &Env) -> Result<(), Error> { 77 | // Don't actually reload the DNS server during testing 78 | if env.is_test() { 79 | return Ok(()); 80 | } 81 | 82 | // Get the PID of FTLDNS. There doesn't seem to be a better way than to run 83 | // pidof in a shell. 84 | let output = Command::new("pidof") 85 | .arg("pihole-FTL") 86 | .output() 87 | .context(ErrorKind::ReloadDnsError)?; 88 | 89 | // Check if it returned successfully 90 | if !output.status.success() { 91 | return Err(Error::from(ErrorKind::ReloadDnsError)); 92 | } 93 | 94 | // Parse the output for the PID 95 | let pid_str = String::from_utf8_lossy(&output.stdout); 96 | let pid = pid_str 97 | .trim() 98 | .parse::() 99 | .context(ErrorKind::ReloadDnsError)?; 100 | 101 | // Send SIGHUP to FTLDNS so it reloads the lists 102 | kill(Pid::from_raw(pid as libc::pid_t), Signal::SIGHUP).context(ErrorKind::ReloadDnsError)?; 103 | 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /src/routes/dns/delete_list.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Endpoints For Removing Domains From Lists 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | ftl::FtlConnectionType, 14 | routes::{ 15 | auth::User, 16 | dns::{common::reload_gravity, list::List} 17 | }, 18 | util::{reply_success, Reply} 19 | }; 20 | use rocket::State; 21 | 22 | /// Delete a domain from the whitelist 23 | #[delete("/dns/whitelist/")] 24 | pub fn delete_whitelist(_auth: User, env: State, domain: String) -> Reply { 25 | List::White.remove(&domain, &env)?; 26 | reload_gravity(List::White, &env)?; 27 | reply_success() 28 | } 29 | 30 | /// Delete a domain from the blacklist 31 | #[delete("/dns/blacklist/")] 32 | pub fn delete_blacklist(_auth: User, env: State, domain: String) -> Reply { 33 | List::Black.remove(&domain, &env)?; 34 | reload_gravity(List::Black, &env)?; 35 | reply_success() 36 | } 37 | 38 | /// Delete a domain from the regex list 39 | #[delete("/dns/regexlist/")] 40 | pub fn delete_regexlist( 41 | _auth: User, 42 | env: State, 43 | ftl: State, 44 | domain: String 45 | ) -> Reply { 46 | List::Regex.remove(&domain, &env)?; 47 | ftl.connect("recompile-regex")?.expect_eom()?; 48 | reply_success() 49 | } 50 | 51 | #[cfg(test)] 52 | mod test { 53 | use crate::{ 54 | env::PiholeFile, 55 | testing::{write_eom, TestBuilder} 56 | }; 57 | use rocket::http::Method; 58 | 59 | #[test] 60 | fn test_delete_whitelist() { 61 | TestBuilder::new() 62 | .endpoint("/admin/api/dns/whitelist/example.com") 63 | .method(Method::Delete) 64 | .file_expect(PiholeFile::Whitelist, "example.com\n", "") 65 | .expect_json(json!({ "status": "success" })) 66 | .test(); 67 | } 68 | 69 | #[test] 70 | fn test_delete_blacklist() { 71 | TestBuilder::new() 72 | .endpoint("/admin/api/dns/blacklist/example.com") 73 | .method(Method::Delete) 74 | .file_expect(PiholeFile::Blacklist, "example.com\n", "") 75 | .expect_json(json!({ "status": "success" })) 76 | .test(); 77 | } 78 | 79 | #[test] 80 | fn test_delete_regexlist() { 81 | let mut data = Vec::new(); 82 | write_eom(&mut data); 83 | 84 | TestBuilder::new() 85 | .endpoint("/admin/api/dns/regexlist/%5E.%2Aexample.com%24") 86 | .method(Method::Delete) 87 | .ftl("recompile-regex", data) 88 | .file_expect(PiholeFile::Regexlist, "^.*example.com$\n", "") 89 | .expect_json(json!({ "status": "success" })) 90 | .test(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/routes/dns/get_list.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Endpoints For Reading Domain Lists 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | routes::dns::list::List, 14 | util::{reply_result, Reply} 15 | }; 16 | use rocket::State; 17 | 18 | /// Get the Whitelist domains 19 | #[get("/dns/whitelist")] 20 | pub fn get_whitelist(env: State) -> Reply { 21 | reply_result(List::White.get(&env)) 22 | } 23 | 24 | /// Get the Blacklist domains 25 | #[get("/dns/blacklist")] 26 | pub fn get_blacklist(env: State) -> Reply { 27 | reply_result(List::Black.get(&env)) 28 | } 29 | 30 | /// Get the Regex list domains 31 | #[get("/dns/regexlist")] 32 | pub fn get_regexlist(env: State) -> Reply { 33 | reply_result(List::Regex.get(&env)) 34 | } 35 | 36 | #[cfg(test)] 37 | mod test { 38 | use crate::{env::PiholeFile, testing::TestBuilder}; 39 | 40 | #[test] 41 | fn test_get_whitelist() { 42 | TestBuilder::new() 43 | .endpoint("/admin/api/dns/whitelist") 44 | .file(PiholeFile::Whitelist, "example.com\nexample.net\n") 45 | .expect_json(json!(["example.com", "example.net"])) 46 | .test(); 47 | } 48 | 49 | #[test] 50 | fn test_get_blacklist() { 51 | TestBuilder::new() 52 | .endpoint("/admin/api/dns/blacklist") 53 | .file(PiholeFile::Blacklist, "example.com\nexample.net\n") 54 | .expect_json(json!(["example.com", "example.net"])) 55 | .test(); 56 | } 57 | 58 | #[test] 59 | fn test_get_regexlist() { 60 | TestBuilder::new() 61 | .endpoint("/admin/api/dns/regexlist") 62 | .file(PiholeFile::Regexlist, "^.*example.com$\nexample.net\n") 63 | .expect_json(json!(["^.*example.com$", "example.net"])) 64 | .test(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/routes/dns/list.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // List Structure And Operations For DNS Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::{Env, PiholeFile}, 13 | routes::dns::common::{is_valid_domain, is_valid_regex}, 14 | util::{Error, ErrorKind} 15 | }; 16 | use failure::ResultExt; 17 | use std::io::{prelude::*, BufWriter}; 18 | 19 | pub enum List { 20 | White, 21 | Black, 22 | Regex 23 | } 24 | 25 | impl List { 26 | /// Get the associated `PiholeFile` 27 | fn file(&self) -> PiholeFile { 28 | match *self { 29 | List::White => PiholeFile::Whitelist, 30 | List::Black => PiholeFile::Blacklist, 31 | List::Regex => PiholeFile::Regexlist 32 | } 33 | } 34 | 35 | /// Check if the list accepts the domain as valid 36 | fn accepts(&self, domain: &str) -> bool { 37 | match *self { 38 | List::Regex => is_valid_regex(domain), 39 | _ => is_valid_domain(domain) 40 | } 41 | } 42 | 43 | /// Read in the domains from the list 44 | pub fn get(&self, env: &Env) -> Result, Error> { 45 | let domains = match env.read_file_lines(self.file()) { 46 | Ok(domains) => domains, 47 | Err(e) => { 48 | if e.kind() == ErrorKind::NotFound { 49 | // If the file is not found, then the list is empty 50 | return Ok(Vec::new()); 51 | } else { 52 | return Err(e); 53 | } 54 | } 55 | }; 56 | 57 | Ok(domains 58 | .into_iter() 59 | .filter(|domain| !domain.is_empty()) 60 | .collect()) 61 | } 62 | 63 | /// Add a domain to the list 64 | pub fn add(&self, domain: &str, env: &Env) -> Result<(), Error> { 65 | // Check if it's a valid domain before doing anything 66 | if !self.accepts(domain) { 67 | return Err(Error::from(ErrorKind::InvalidDomain)); 68 | } 69 | 70 | // Check if the domain is already in the list 71 | if self.get(env)?.contains(&domain.to_owned()) { 72 | return Err(Error::from(ErrorKind::AlreadyExists)); 73 | } 74 | 75 | // Open the list file in append mode (and create it if it doesn't exist) 76 | let mut file = env.write_file(self.file(), true)?; 77 | 78 | // Add the domain to the list 79 | writeln!(file, "{}", domain).context(ErrorKind::FileWrite( 80 | env.file_location(self.file()).to_owned() 81 | ))?; 82 | 83 | Ok(()) 84 | } 85 | 86 | /// Try to remove a domain from the list, but it is not an error if the 87 | /// domain does not exist 88 | pub fn try_remove(&self, domain: &str, env: &Env) -> Result<(), Error> { 89 | match self.remove(domain, env) { 90 | // Pass through successful results 91 | Ok(_) => Ok(()), 92 | Err(e) => { 93 | // Ignore NotFound errors 94 | if e.kind() == ErrorKind::NotFound { 95 | Ok(()) 96 | } else { 97 | Err(e) 98 | } 99 | } 100 | } 101 | } 102 | 103 | /// Remove a domain from the list 104 | pub fn remove(&self, domain: &str, env: &Env) -> Result<(), Error> { 105 | // Check if it's a valid domain before doing anything 106 | if !self.accepts(domain) { 107 | return Err(Error::from(ErrorKind::InvalidDomain)); 108 | } 109 | 110 | // Check if the domain is not in the list 111 | let domains = self.get(env)?; 112 | if !domains.contains(&domain.to_owned()) { 113 | return Err(Error::from(ErrorKind::NotFound)); 114 | } 115 | 116 | // Open the list file (and create it if it doesn't exist). This will truncate 117 | // the list so we can add all the domains except the one we are deleting 118 | let file = env.write_file(self.file(), false)?; 119 | let mut writer = BufWriter::new(file); 120 | 121 | // Write all domains except the one we're deleting 122 | for domain in domains.into_iter().filter(|item| item != domain) { 123 | writeln!(writer, "{}", domain).context(ErrorKind::FileWrite( 124 | env.file_location(self.file()).to_owned() 125 | ))?; 126 | } 127 | 128 | Ok(()) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/routes/dns/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // DNS API Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod add_list; 12 | mod common; 13 | mod delete_list; 14 | mod get_list; 15 | mod list; 16 | mod status; 17 | 18 | pub use self::{add_list::*, delete_list::*, get_list::*, status::*}; 19 | -------------------------------------------------------------------------------- /src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // API Routes 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | pub mod auth; 12 | pub mod dns; 13 | pub mod settings; 14 | pub mod stats; 15 | pub mod version; 16 | pub mod web; 17 | -------------------------------------------------------------------------------- /src/routes/settings/common.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Common Functions For Settings Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | util::{Error, ErrorKind} 14 | }; 15 | use failure::ResultExt; 16 | use std::process::{Command, Stdio}; 17 | 18 | /// Restart the DNS server (via `pihole restartdns`) 19 | pub fn restart_dns(env: &Env) -> Result<(), Error> { 20 | // Don't actually run anything during a test 21 | if env.is_test() { 22 | return Ok(()); 23 | } 24 | 25 | let status = Command::new("sudo") 26 | .arg("pihole") 27 | .arg("restartdns") 28 | .stdin(Stdio::null()) 29 | .stdout(Stdio::null()) 30 | .stderr(Stdio::null()) 31 | .status() 32 | .context(ErrorKind::RestartDnsError)?; 33 | 34 | if status.success() { 35 | Ok(()) 36 | } else { 37 | Err(Error::from(ErrorKind::RestartDnsError)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/routes/settings/get_ftl.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Settings 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | routes::auth::User, 14 | settings::{ConfigEntry, FtlConfEntry}, 15 | util::{reply_data, Reply} 16 | }; 17 | use rocket::State; 18 | 19 | /// Read FTL's settings 20 | #[get("/settings/ftl")] 21 | pub fn get_ftl(env: State, _auth: User) -> Reply { 22 | // if setting is not present, report default 23 | let socket_listening = FtlConfEntry::SocketListening.read(&env)?; 24 | let query_display = FtlConfEntry::QueryDisplay.read(&env)?; 25 | let aaaa_query_analysis = FtlConfEntry::AaaaQueryAnalysis.read(&env)?; 26 | let resolve_ipv6 = FtlConfEntry::ResolveIpv6.read(&env)?; 27 | let resolve_ipv4 = FtlConfEntry::ResolveIpv4.read(&env)?; 28 | let max_db_days: i32 = FtlConfEntry::MaxDbDays.read_as(&env)?; 29 | let db_interval: f32 = FtlConfEntry::DbInterval.read_as(&env)?; 30 | let db_file = FtlConfEntry::DbFile.read(&env)?; 31 | let max_log_age: f32 = FtlConfEntry::MaxLogAge.read_as(&env)?; 32 | let ftl_port: usize = FtlConfEntry::FtlPort.read_as(&env)?; 33 | let privacy_level: i32 = FtlConfEntry::PrivacyLevel.read_as(&env)?; 34 | let ignore_local_host = FtlConfEntry::IgnoreLocalHost.read(&env)?; 35 | let blocking_mode = FtlConfEntry::BlockingMode.read(&env)?; 36 | let regex_debug_mode = FtlConfEntry::RegexDebugMode.is_true(&env)?; 37 | 38 | reply_data(json!({ 39 | "socket_listening": socket_listening, 40 | "query_display": query_display, 41 | "aaaa_query_analysis": aaaa_query_analysis, 42 | "resolve_ipv6": resolve_ipv6, 43 | "resolve_ipv4": resolve_ipv4, 44 | "max_db_days": max_db_days, 45 | "db_interval": db_interval, 46 | "db_file": db_file, 47 | "max_log_age": max_log_age, 48 | "ftl_port": ftl_port, 49 | "privacy_level": privacy_level, 50 | "ignore_local_host": ignore_local_host, 51 | "blocking_mode": blocking_mode, 52 | "regex_debug_mode": regex_debug_mode 53 | })) 54 | } 55 | 56 | #[cfg(test)] 57 | mod test { 58 | use crate::{env::PiholeFile, testing::TestBuilder}; 59 | 60 | /// Test that correct settings are reported from populated file 61 | #[test] 62 | fn test_get_ftl_populated() { 63 | TestBuilder::new() 64 | .endpoint("/admin/api/settings/ftl") 65 | .file( 66 | PiholeFile::FtlConfig, 67 | "SOCKET_LISTENING=all\n\ 68 | QUERY_DISPLAY=no\n\ 69 | AAAA_QUERY_ANALYSIS=no\n\ 70 | RESOLVE_IPV6=no\n\ 71 | RESOLVE_IPV4=no\n\ 72 | MAXDBDAYS=30\n\ 73 | DBINTERVAL=3.0\n\ 74 | DBFILE=/etc/pihole/test/pihole-FTL.db\n\ 75 | MAXLOGAGE=48.0\n\ 76 | FTLPORT=38911\n\ 77 | PRIVACYLEVEL=2\n\ 78 | IGNORE_LOCALHOST=yes\n\ 79 | BLOCKINGMODE=NXDOMAIN\n\ 80 | REGEX_DEBUGMODE=true\n" 81 | ) 82 | .expect_json(json!({ 83 | "socket_listening": "all", 84 | "query_display": "no", 85 | "aaaa_query_analysis": "no", 86 | "resolve_ipv6": "no", 87 | "resolve_ipv4": "no", 88 | "max_db_days": 30, 89 | "db_interval": 3.0, 90 | "db_file": "/etc/pihole/test/pihole-FTL.db", 91 | "max_log_age": 48.0, 92 | "ftl_port": 38911, 93 | "privacy_level": 2, 94 | "ignore_local_host": "yes", 95 | "blocking_mode": "NXDOMAIN", 96 | "regex_debug_mode": true 97 | })) 98 | .test(); 99 | } 100 | 101 | /// Test that default settings are reported if not present 102 | #[test] 103 | fn test_get_ftl_default() { 104 | TestBuilder::new() 105 | .endpoint("/admin/api/settings/ftl") 106 | .file(PiholeFile::FtlConfig, "") 107 | .expect_json(json!({ 108 | "socket_listening": "localonly", 109 | "query_display": "yes", 110 | "aaaa_query_analysis": "yes", 111 | "resolve_ipv6": "yes", 112 | "resolve_ipv4": "yes", 113 | "max_db_days": 365, 114 | "db_interval": 1.0, 115 | "db_file": "/etc/pihole/pihole-FTL.db", 116 | "max_log_age": 24.0, 117 | "ftl_port": 4711, 118 | "privacy_level": 0, 119 | "ignore_local_host": "no", 120 | "blocking_mode": "NULL", 121 | "regex_debug_mode": false 122 | })) 123 | .test(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/routes/settings/get_ftldb.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Settings - Database Statistics 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | ftl::FtlConnectionType, 13 | routes::auth::User, 14 | util::{reply_data, Reply} 15 | }; 16 | use rocket::State; 17 | 18 | /// Read db stats from FTL 19 | #[get("/settings/ftldb")] 20 | pub fn get_ftldb(ftl: State, _auth: User) -> Reply { 21 | let mut con = ftl.connect("dbstats")?; 22 | 23 | // Read in FTL's database stats 24 | let db_queries = con.read_i32()?; 25 | let db_filesize = con.read_i64()?; 26 | let mut version_buffer = [0u8; 64]; 27 | let db_sqlite_version = con.read_str(&mut version_buffer)?; 28 | con.expect_eom()?; 29 | 30 | reply_data(json!({ 31 | "queries": db_queries, 32 | "filesize": db_filesize, 33 | "sqlite_version": db_sqlite_version 34 | })) 35 | } 36 | 37 | #[cfg(test)] 38 | mod test { 39 | use crate::testing::{write_eom, TestBuilder}; 40 | use rmp::encode; 41 | 42 | /// Basic test for reported values 43 | #[test] 44 | fn test_get_ftldb() { 45 | let mut data = Vec::new(); 46 | encode::write_i32(&mut data, 1_048_576).unwrap(); 47 | encode::write_i64(&mut data, 32768).unwrap(); 48 | encode::write_str(&mut data, "3.0.1").unwrap(); 49 | write_eom(&mut data); 50 | 51 | TestBuilder::new() 52 | .endpoint("/admin/api/settings/ftldb") 53 | .ftl("dbstats", data) 54 | .expect_json(json!({ 55 | "queries": 1_048_576, 56 | "filesize": 32768, 57 | "sqlite_version": "3.0.1" 58 | })) 59 | .test(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/routes/settings/get_network.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Local Network Settings 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | routes::auth::User, 14 | settings::{ConfigEntry, SetupVarsEntry}, 15 | util::{reply_data, Reply} 16 | }; 17 | use hostname::get_hostname; 18 | use rocket::State; 19 | 20 | /// Get Pi-hole local network information 21 | #[get("/settings/network")] 22 | pub fn get_network(env: State, _auth: User) -> Reply { 23 | let ipv4_full = SetupVarsEntry::Ipv4Address.read(&env)?; 24 | let ipv4_address: Vec<&str> = ipv4_full.split('/').collect(); 25 | let ipv6_full = SetupVarsEntry::Ipv6Address.read(&env)?; 26 | let ipv6_address: Vec<&str> = ipv6_full.split('/').collect(); 27 | 28 | reply_data(json!({ 29 | "interface": SetupVarsEntry::PiholeInterface.read(&env)?, 30 | "ipv4_address": ipv4_address[0], 31 | "ipv6_address": ipv6_address[0], 32 | "hostname": get_hostname().unwrap_or_else(|| "unknown".to_owned()) 33 | })) 34 | } 35 | 36 | #[cfg(test)] 37 | mod test { 38 | use crate::{env::PiholeFile, testing::TestBuilder}; 39 | use hostname::get_hostname; 40 | 41 | /// Basic test for reported settings 42 | #[test] 43 | fn test_get_network() { 44 | let current_host = get_hostname().unwrap_or_else(|| "unknown".to_owned()); 45 | 46 | TestBuilder::new() 47 | .endpoint("/admin/api/settings/network") 48 | .file( 49 | PiholeFile::SetupVars, 50 | "IPV4_ADDRESS=192.168.1.205/24\n\ 51 | IPV6_ADDRESS=fd06:fb62:d251:9033:0:0:0:33\n\ 52 | PIHOLE_INTERFACE=eth0\n" 53 | ) 54 | .expect_json(json!({ 55 | "interface": "eth0", 56 | "ipv4_address": "192.168.1.205", 57 | "ipv6_address": "fd06:fb62:d251:9033:0:0:0:33", 58 | "hostname": current_host 59 | })) 60 | .test(); 61 | } 62 | 63 | /// Test for common configuration of ipv4 only (no ipv6) 64 | #[test] 65 | fn test_get_network_ipv4only() { 66 | let current_host = get_hostname().unwrap_or_else(|| "unknown".to_owned()); 67 | 68 | TestBuilder::new() 69 | .endpoint("/admin/api/settings/network") 70 | .file( 71 | PiholeFile::SetupVars, 72 | "IPV4_ADDRESS=192.168.1.205/24\n\ 73 | IPV6_ADDRESS=\n\ 74 | PIHOLE_INTERFACE=eth0\n" 75 | ) 76 | .expect_json(json!({ 77 | "interface": "eth0", 78 | "ipv4_address": "192.168.1.205", 79 | "ipv6_address": "", 80 | "hostname": current_host 81 | })) 82 | .test(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/routes/settings/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Setting Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod common; 12 | mod dhcp; 13 | mod dns; 14 | mod get_ftl; 15 | mod get_ftldb; 16 | mod get_network; 17 | mod web; 18 | 19 | pub use self::{common::*, dhcp::*, dns::*, get_ftl::*, get_ftldb::*, get_network::*, web::*}; 20 | -------------------------------------------------------------------------------- /src/routes/settings/web.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Web Interface Settings Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | routes::auth::User, 14 | settings::{ConfigEntry, SetupVarsEntry}, 15 | util::{reply_data, reply_success, Error, ErrorKind, Reply} 16 | }; 17 | use rocket::State; 18 | use rocket_contrib::json::Json; 19 | 20 | /// Get web interface settings 21 | #[get("/settings/web")] 22 | pub fn get_web(env: State) -> Reply { 23 | let settings = WebSettings { 24 | layout: SetupVarsEntry::WebLayout.read(&env)?, 25 | language: SetupVarsEntry::WebLanguage.read(&env)? 26 | }; 27 | 28 | reply_data(settings) 29 | } 30 | 31 | /// Update web interface settings 32 | #[put("/settings/web", data = "")] 33 | pub fn put_web(_auth: User, env: State, settings: Json) -> Reply { 34 | let settings = settings.into_inner(); 35 | 36 | if !settings.is_valid() { 37 | return Err(Error::from(ErrorKind::InvalidSettingValue)); 38 | } 39 | 40 | SetupVarsEntry::WebLayout.write(&settings.layout, &env)?; 41 | SetupVarsEntry::WebLanguage.write(&settings.language, &env)?; 42 | 43 | reply_success() 44 | } 45 | 46 | #[derive(Serialize, Deserialize)] 47 | pub struct WebSettings { 48 | layout: String, 49 | language: String 50 | } 51 | 52 | impl WebSettings { 53 | /// Check if all the web settings are valid 54 | fn is_valid(&self) -> bool { 55 | SetupVarsEntry::WebLayout.is_valid(&self.layout) 56 | && SetupVarsEntry::WebLanguage.is_valid(&self.language) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/routes/stats/clients.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Clients Endpoint 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | ftl::{ClientReply, FtlClient, FtlMemory, ShmLockGuard}, 14 | routes::{ 15 | auth::User, 16 | stats::common::{remove_excluded_clients, remove_hidden_clients} 17 | }, 18 | settings::{ConfigEntry, FtlConfEntry, FtlPrivacyLevel}, 19 | util::{reply_result, Error, Reply} 20 | }; 21 | use rocket::{request::Form, State}; 22 | 23 | /// Get client information 24 | #[get("/stats/clients?")] 25 | pub fn clients( 26 | _auth: User, 27 | ftl_memory: State, 28 | env: State, 29 | params: Form 30 | ) -> Reply { 31 | reply_result(get_clients(&ftl_memory, &env, params.into_inner())) 32 | } 33 | 34 | /// The possible GET parameters for `/stats/clients` 35 | #[derive(FromForm, Default)] 36 | pub struct ClientParams { 37 | inactive: Option 38 | } 39 | 40 | /// Get client data for API output according to the parameters 41 | fn get_clients( 42 | ftl_memory: &FtlMemory, 43 | env: &Env, 44 | params: ClientParams 45 | ) -> Result, Error> { 46 | let lock = ftl_memory.lock()?; 47 | let strings = ftl_memory.strings(&lock)?; 48 | let clients = ftl_memory.clients(&lock)?; 49 | 50 | Ok( 51 | filter_ftl_clients(ftl_memory, &lock, &clients, env, params)? 52 | .iter() 53 | .map(|client| client.as_reply(&strings)) 54 | .collect::>() 55 | ) 56 | } 57 | 58 | /// Get FTL clients which are allowed to be used according to settings and 59 | /// parameters 60 | pub fn filter_ftl_clients<'a>( 61 | ftl_memory: &'a FtlMemory, 62 | lock: &ShmLockGuard<'a>, 63 | clients: &'a [FtlClient], 64 | env: &Env, 65 | params: ClientParams 66 | ) -> Result, Error> { 67 | // Check if client details are private 68 | if FtlConfEntry::PrivacyLevel.read_as::(&env)? 69 | >= FtlPrivacyLevel::HideDomainsAndClients 70 | { 71 | return Ok(Vec::new()); 72 | } 73 | 74 | let strings = ftl_memory.strings(&lock)?; 75 | let counters = ftl_memory.counters(&lock)?; 76 | 77 | // Get an array of valid client references (FTL allocates more than it uses) 78 | let mut clients = clients 79 | .iter() 80 | .take(counters.total_clients as usize) 81 | .collect(); 82 | 83 | // Ignore hidden and excluded clients 84 | remove_hidden_clients(&mut clients, &strings); 85 | remove_excluded_clients(&mut clients, &env, &strings)?; 86 | 87 | // Ignore inactive clients by default (retain active clients) 88 | if !params.inactive.unwrap_or(false) { 89 | clients.retain(|client| client.query_count > 0); 90 | } 91 | 92 | Ok(clients) 93 | } 94 | 95 | #[cfg(test)] 96 | mod test { 97 | use crate::{ 98 | env::PiholeFile, 99 | ftl::{FtlClient, FtlCounters, FtlMemory, FtlSettings}, 100 | testing::TestBuilder 101 | }; 102 | use std::collections::HashMap; 103 | 104 | /// There are 6 clients, two inactive, one hidden, and two with names. 105 | fn test_data() -> FtlMemory { 106 | let mut strings = HashMap::new(); 107 | strings.insert(1, "10.1.1.1".to_owned()); 108 | strings.insert(2, "client1".to_owned()); 109 | strings.insert(3, "10.1.1.2".to_owned()); 110 | strings.insert(4, "10.1.1.3".to_owned()); 111 | strings.insert(5, "client3".to_owned()); 112 | strings.insert(6, "10.1.1.4".to_owned()); 113 | strings.insert(7, "10.1.1.5".to_owned()); 114 | strings.insert(8, "0.0.0.0".to_owned()); 115 | 116 | FtlMemory::Test { 117 | clients: vec![ 118 | FtlClient::new(1, 0, 1, Some(2)), 119 | FtlClient::new(1, 0, 3, None), 120 | FtlClient::new(1, 0, 4, Some(5)), 121 | FtlClient::new(1, 0, 6, None), 122 | FtlClient::new(0, 0, 7, None), 123 | FtlClient::new(0, 0, 8, None), 124 | ], 125 | domains: Vec::new(), 126 | over_time: Vec::new(), 127 | strings, 128 | upstreams: Vec::new(), 129 | queries: Vec::new(), 130 | counters: FtlCounters { 131 | total_clients: 6, 132 | ..FtlCounters::default() 133 | }, 134 | settings: FtlSettings::default() 135 | } 136 | } 137 | 138 | /// The default behavior lists all active clients 139 | #[test] 140 | fn default_params() { 141 | TestBuilder::new() 142 | .endpoint("/admin/api/stats/clients") 143 | .ftl_memory(test_data()) 144 | .expect_json(json!([ 145 | { "name": "client1", "ip": "10.1.1.1" }, 146 | { "name": "", "ip": "10.1.1.2" }, 147 | { "name": "client3", "ip": "10.1.1.3" }, 148 | { "name": "", "ip": "10.1.1.4" } 149 | ])) 150 | .test(); 151 | } 152 | 153 | /// Privacy level 2 does not show any clients 154 | #[test] 155 | fn privacy_hides_clients() { 156 | TestBuilder::new() 157 | .endpoint("/admin/api/stats/clients") 158 | .ftl_memory(test_data()) 159 | .file(PiholeFile::FtlConfig, "PRIVACYLEVEL=2") 160 | .expect_json(json!([])) 161 | .test(); 162 | } 163 | 164 | /// Inactive clients are shown, but hidden clients are still not shown 165 | #[test] 166 | fn inactive_clients() { 167 | TestBuilder::new() 168 | .endpoint("/admin/api/stats/clients?inactive=true") 169 | .ftl_memory(test_data()) 170 | .expect_json(json!([ 171 | { "name": "client1", "ip": "10.1.1.1" }, 172 | { "name": "", "ip": "10.1.1.2" }, 173 | { "name": "client3", "ip": "10.1.1.3" }, 174 | { "name": "", "ip": "10.1.1.4" }, 175 | { "name": "", "ip": "10.1.1.5" } 176 | ])) 177 | .test(); 178 | } 179 | 180 | /// Excluded clients are not shown 181 | #[test] 182 | fn excluded_clients() { 183 | TestBuilder::new() 184 | .endpoint("/admin/api/stats/clients") 185 | .ftl_memory(test_data()) 186 | .file( 187 | PiholeFile::SetupVars, 188 | "API_EXCLUDE_CLIENTS=client3,10.1.1.2" 189 | ) 190 | .expect_json(json!([ 191 | { "name": "client1", "ip": "10.1.1.1" }, 192 | { "name": "", "ip": "10.1.1.4" } 193 | ])) 194 | .test(); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/routes/stats/database/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Statistic API Database Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod over_time_clients_db; 12 | mod over_time_history_db; 13 | mod query_types_db; 14 | mod summary_db; 15 | mod top_clients_db; 16 | mod top_domains_db; 17 | mod upstreams_db; 18 | 19 | pub use self::{ 20 | over_time_clients_db::*, over_time_history_db::*, query_types_db::*, summary_db::*, 21 | top_clients_db::*, top_domains_db::*, upstreams_db::* 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/stats/database/query_types_db.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Types Endpoint - DB Version 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::FtlDatabase, 13 | ftl::FtlQueryType, 14 | routes::{auth::User, stats::query_types::QueryTypeReply}, 15 | util::{reply_result, Error, ErrorKind, Reply} 16 | }; 17 | use diesel::{dsl::sql, prelude::*, sql_types::BigInt, sqlite::SqliteConnection}; 18 | use failure::ResultExt; 19 | use std::collections::HashMap; 20 | 21 | /// Get query type counts from the database 22 | #[get("/stats/database/query_types?&")] 23 | pub fn query_types_db(from: u64, until: u64, _auth: User, db: FtlDatabase) -> Reply { 24 | reply_result(query_types_db_impl(from, until, &db as &SqliteConnection)) 25 | } 26 | 27 | /// Get query type counts from the database 28 | fn query_types_db_impl( 29 | from: u64, 30 | until: u64, 31 | db: &SqliteConnection 32 | ) -> Result, Error> { 33 | let query_types = get_query_type_counts(db, from, until)?; 34 | 35 | Ok(FtlQueryType::variants() 36 | .iter() 37 | .map(|variant| QueryTypeReply { 38 | name: variant.get_name(), 39 | count: query_types[variant] 40 | }) 41 | .collect()) 42 | } 43 | 44 | /// Get the number of queries with each query type in the specified time range 45 | pub fn get_query_type_counts( 46 | db: &SqliteConnection, 47 | from: u64, 48 | until: u64 49 | ) -> Result, Error> { 50 | use crate::databases::ftl::queries::dsl::*; 51 | 52 | let mut counts: HashMap = queries 53 | // Select the query types and their counts. 54 | // The raw SQL is used due to a limitation of Diesel, in that it doesn't 55 | // have full support for mixing aggregate and non-aggregate data when 56 | // using group_by. See https://github.com/diesel-rs/diesel/issues/1781 57 | .select((query_type, sql::("COUNT(*)"))) 58 | // Search in the specified time interval 59 | .filter(timestamp.le(until as i32).and(timestamp.ge(from as i32))) 60 | // Group the results by query type 61 | .group_by(query_type) 62 | // Execute the query 63 | .get_results::<(i32, i64)>(db) 64 | // Add error context and check for errors 65 | .context(ErrorKind::FtlDatabase)? 66 | // Turn the resulting Vec into an iterator 67 | .into_iter() 68 | // Map the values into (FtlQueryType, usize) 69 | .map(|(q_type, count)| { 70 | (FtlQueryType::from_number(q_type as isize).unwrap(), count as usize) 71 | }) 72 | // Turn the iterator into a HashMap 73 | .collect(); 74 | 75 | // Fill in the rest of the query types not found in the database 76 | for q_type in FtlQueryType::variants() { 77 | if !counts.contains_key(q_type) { 78 | counts.insert(*q_type, 0); 79 | } 80 | } 81 | 82 | Ok(counts) 83 | } 84 | 85 | #[cfg(test)] 86 | mod test { 87 | use super::get_query_type_counts; 88 | use crate::{databases::ftl::connect_to_test_db, ftl::FtlQueryType}; 89 | use std::collections::HashMap; 90 | 91 | const FROM_TIMESTAMP: u64 = 0; 92 | const UNTIL_TIMESTAMP: u64 = 177_180; 93 | 94 | /// Verify the query type counts are accurate 95 | #[test] 96 | fn query_type_counts() { 97 | let mut expected = HashMap::new(); 98 | expected.insert(FtlQueryType::A, 36); 99 | expected.insert(FtlQueryType::AAAA, 35); 100 | expected.insert(FtlQueryType::ANY, 0); 101 | expected.insert(FtlQueryType::SRV, 0); 102 | expected.insert(FtlQueryType::SOA, 0); 103 | expected.insert(FtlQueryType::PTR, 23); 104 | expected.insert(FtlQueryType::TXT, 0); 105 | 106 | let db = connect_to_test_db(); 107 | let actual = get_query_type_counts(&db, FROM_TIMESTAMP, UNTIL_TIMESTAMP).unwrap(); 108 | 109 | assert_eq!(actual, expected); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/routes/stats/database/upstreams_db.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Upstream Servers Endpoint - DB Version 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::FtlDatabase, 13 | ftl::FtlQueryStatus, 14 | routes::{ 15 | auth::User, 16 | stats::{ 17 | database::{get_blocked_query_count, get_query_status_count}, 18 | upstreams::{UpstreamItemReply, UpstreamsReply} 19 | } 20 | }, 21 | util::{reply_result, Error, ErrorKind, Reply} 22 | }; 23 | use diesel::{dsl::sql, prelude::*, sql_types::BigInt, sqlite::SqliteConnection}; 24 | use failure::ResultExt; 25 | use std::collections::HashMap; 26 | 27 | /// Get upstream data from the database 28 | #[get("/stats/database/upstreams?&")] 29 | pub fn upstreams_db(from: u64, until: u64, _auth: User, db: FtlDatabase) -> Reply { 30 | reply_result(upstreams_db_impl(from, until, &db as &SqliteConnection)) 31 | } 32 | 33 | /// Get upstream data from the database 34 | fn upstreams_db_impl( 35 | from: u64, 36 | until: u64, 37 | db: &SqliteConnection 38 | ) -> Result { 39 | let upstream_counts = get_upstream_counts(from, until, db)?; 40 | let blocked_count = get_blocked_query_count(db, from, until)?; 41 | let cached_count = get_query_status_count(db, from, until, FtlQueryStatus::Cache)?; 42 | 43 | // Total queries is the sum of the upstream counts 44 | let total_queries = upstream_counts.values().sum::() as usize; 45 | // Forwarded queries are the sum of all upstream counts where the upstream is 46 | // not null 47 | let forwarded_queries = total_queries - upstream_counts[&None] as usize; 48 | 49 | // Capacity is the number of upstreams plus 1 for blocklists and 1 for 50 | // cache. upstream_counts.len() equals the number of upstreams plus 1 51 | // (blocklists and cache), so we just need to add one more slot. 52 | let mut upstreams = Vec::with_capacity(upstream_counts.len() + 1); 53 | 54 | // Add blocklist and cache upstreams 55 | upstreams.push(UpstreamItemReply { 56 | name: "blocklist".to_owned(), 57 | ip: "blocklist".to_owned(), 58 | count: blocked_count 59 | }); 60 | upstreams.push(UpstreamItemReply { 61 | name: "cache".to_owned(), 62 | ip: "cache".to_owned(), 63 | count: cached_count 64 | }); 65 | 66 | // Convert the upstreams into the reply structs 67 | let mut upstream_counts: Vec = upstream_counts 68 | .into_iter() 69 | .filter_map(|(ip, count)| { 70 | if let Some(ip) = ip { 71 | Some(UpstreamItemReply { 72 | name: "".to_owned(), 73 | ip, 74 | count: count as usize 75 | }) 76 | } else { 77 | // Ignore the blocked and cached queries. These have already 78 | // been added above 79 | None 80 | } 81 | }) 82 | .collect(); 83 | 84 | // Sort the upstreams (descending by count) 85 | upstream_counts.sort_by(|a, b| b.count.cmp(&a.count)); 86 | 87 | // Add the upstreams to the final list 88 | upstreams.extend(upstream_counts.into_iter()); 89 | 90 | Ok(UpstreamsReply { 91 | upstreams, 92 | total_queries, 93 | forwarded_queries 94 | }) 95 | } 96 | 97 | /// Get the number of queries for each upstream in the specified interval from 98 | /// the database. Queries with no upstream (`None`) were either cached or 99 | /// blocked. 100 | fn get_upstream_counts( 101 | from: u64, 102 | until: u64, 103 | db: &SqliteConnection 104 | ) -> Result, i64>, Error> { 105 | use crate::databases::ftl::queries::dsl::*; 106 | 107 | Ok(queries 108 | .select((upstream, sql::("COUNT(*)"))) 109 | // Search in the specified time interval 110 | .filter(timestamp.ge(from as i32)) 111 | .filter(timestamp.le(until as i32)) 112 | // Group the results by upstream 113 | .group_by(upstream) 114 | // Execute the query 115 | .get_results::<(Option, i64)>(db) 116 | // Add error context and check for errors 117 | .context(ErrorKind::FtlDatabase)? 118 | // Turn the resulting Vec into a HashMap 119 | .into_iter() 120 | .collect()) 121 | } 122 | 123 | #[cfg(test)] 124 | mod test { 125 | use super::{get_upstream_counts, upstreams_db_impl}; 126 | use crate::{ 127 | databases::ftl::connect_to_test_db, 128 | routes::stats::upstreams::{UpstreamItemReply, UpstreamsReply} 129 | }; 130 | use std::collections::HashMap; 131 | 132 | const FROM_TIMESTAMP: u64 = 0; 133 | const UNTIL_TIMESTAMP: u64 = 177_180; 134 | 135 | /// Verify that the upstream data returned using the database is accurate 136 | #[test] 137 | fn upstreams_impl() { 138 | let expected = UpstreamsReply { 139 | upstreams: vec![ 140 | UpstreamItemReply { 141 | name: "blocklist".to_owned(), 142 | ip: "blocklist".to_owned(), 143 | count: 0 144 | }, 145 | UpstreamItemReply { 146 | name: "cache".to_owned(), 147 | ip: "cache".to_owned(), 148 | count: 28 149 | }, 150 | UpstreamItemReply { 151 | name: "".to_owned(), 152 | ip: "8.8.4.4".to_owned(), 153 | count: 22 154 | }, 155 | UpstreamItemReply { 156 | name: "".to_owned(), 157 | ip: "8.8.8.8".to_owned(), 158 | count: 4 159 | }, 160 | ], 161 | total_queries: 94, 162 | forwarded_queries: 26 163 | }; 164 | 165 | let db = connect_to_test_db(); 166 | let actual = upstreams_db_impl(FROM_TIMESTAMP, UNTIL_TIMESTAMP, &db).unwrap(); 167 | 168 | assert_eq!(actual, expected); 169 | } 170 | 171 | /// Verify that the upstream count data is accurate 172 | #[test] 173 | fn upstream_counts() { 174 | let mut expected: HashMap, i64> = HashMap::new(); 175 | expected.insert(None, 68); 176 | expected.insert(Some("8.8.4.4".to_owned()), 22); 177 | expected.insert(Some("8.8.8.8".to_owned()), 4); 178 | 179 | let db = connect_to_test_db(); 180 | let actual = get_upstream_counts(FROM_TIMESTAMP, UNTIL_TIMESTAMP, &db).unwrap(); 181 | 182 | assert_eq!(actual, expected); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/routes/stats/history/database.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Database Integration For History 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::{queries, FtlDbQuery}, 13 | env::Env, 14 | routes::stats::history::{ 15 | endpoints::{HistoryCursor, HistoryParams}, 16 | filters::*, 17 | skip_to_cursor::skip_to_cursor_db 18 | }, 19 | util::{Error, ErrorKind} 20 | }; 21 | use diesel::{prelude::*, sqlite::Sqlite}; 22 | use failure::ResultExt; 23 | 24 | /// Load queries from the database according to the parameters. A cursor is 25 | /// also returned, if more queries can be loaded. 26 | /// 27 | /// # Arguments: 28 | /// - `db`: A connection to the FTL database 29 | /// - `start_id`: The query ID to start searching from. If this is `None` then 30 | /// the search will start from the most recent queries 31 | /// - `params`: Parameters given to the history endpoint (filters) 32 | /// - `limit`: The maximum number of queries to load 33 | pub fn load_queries_from_database( 34 | db: &SqliteConnection, 35 | start_id: Option, 36 | params: &HistoryParams, 37 | env: &Env, 38 | limit: usize 39 | ) -> Result<(Vec, Option), Error> { 40 | // Use the Diesel DSL of this table for easy querying 41 | use crate::databases::ftl::queries::dsl::*; 42 | 43 | // Start creating the database query 44 | let db_query = queries 45 | // The query must be boxed, because we are dynamically building it 46 | .into_boxed() 47 | // Take up to the limit, plus one to build the cursor 48 | .limit((limit + 1) as i64) 49 | // Start with the most recently inserted queries 50 | .order(id.desc()); 51 | 52 | // If a start ID is given, ignore any queries before it 53 | let db_query = skip_to_cursor_db(db_query, start_id); 54 | 55 | // Apply filters 56 | let db_query = filter_time_from_db(db_query, params); 57 | let db_query = filter_time_until_db(db_query, params); 58 | let db_query = filter_domain_db(db_query, params); 59 | let db_query = filter_client_db(db_query, params); 60 | let db_query = filter_upstream_db(db_query, params); 61 | let db_query = filter_query_type_db(db_query, params); 62 | let db_query = filter_status_db(db_query, params); 63 | let db_query = filter_blocked_db(db_query, params); 64 | let db_query = filter_excluded_domains_db(db_query, env)?; 65 | let db_query = filter_excluded_clients_db(db_query, env)?; 66 | let db_query = filter_setup_vars_setting_db(db_query, env)?; 67 | 68 | // Execute the query and load the results 69 | let mut results: Vec = execute_query(db, db_query)?; 70 | 71 | // If more queries could be loaded beyond the given limit (if we loaded 72 | // limit + 1 queries), then set the cursor to use the limit + 1 query's ID. 73 | let cursor = if results.len() == limit + 1 { 74 | Some(HistoryCursor { 75 | id: None, 76 | db_id: Some(results[limit].id.unwrap() as i64) 77 | }) 78 | } else { 79 | None 80 | }; 81 | 82 | // Drop the limit + 1 query, if it exists. It is only used to determine 83 | // the new cursor. 84 | results.truncate(limit); 85 | 86 | Ok((results, cursor)) 87 | } 88 | 89 | /// Execute a database query for DNS queries on an FTL database. 90 | /// The database could be real, or it could be a test database. 91 | pub fn execute_query( 92 | db: &SqliteConnection, 93 | db_query: queries::BoxedQuery 94 | ) -> Result, Error> { 95 | db_query 96 | .load(db) 97 | .context(ErrorKind::FtlDatabase) 98 | .map_err(Error::from) 99 | } 100 | 101 | #[cfg(test)] 102 | mod test { 103 | use super::load_queries_from_database; 104 | use crate::{ 105 | databases::ftl::connect_to_test_db, 106 | env::{Config, Env}, 107 | routes::stats::history::endpoints::{HistoryCursor, HistoryParams} 108 | }; 109 | use std::collections::HashMap; 110 | 111 | /// Queries are ordered by id, descending 112 | #[test] 113 | fn order_by_id() { 114 | let env = Env::Test(Config::default(), HashMap::new()); 115 | 116 | let (queries, cursor) = load_queries_from_database( 117 | &connect_to_test_db(), 118 | Some(2), 119 | &HistoryParams::default(), 120 | &env, 121 | 100 122 | ) 123 | .unwrap(); 124 | 125 | assert_eq!(cursor, None); 126 | assert_eq!(queries.len(), 2); 127 | assert!(queries[0].id > queries[1].id); 128 | } 129 | 130 | /// The max number of queries returned is specified by the limit 131 | #[test] 132 | fn limit() { 133 | let env = Env::Test(Config::default(), HashMap::new()); 134 | let expected_cursor = Some(HistoryCursor { 135 | id: None, 136 | db_id: Some(1) 137 | }); 138 | 139 | let (queries, cursor) = load_queries_from_database( 140 | &connect_to_test_db(), 141 | Some(3), 142 | &HistoryParams::default(), 143 | &env, 144 | 2 145 | ) 146 | .unwrap(); 147 | 148 | assert_eq!(queries.len(), 2); 149 | assert_eq!(cursor, expected_cursor); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/routes/stats/history/endpoints.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::FtlDatabase, 13 | env::Env, 14 | ftl::{FtlDnssecType, FtlMemory, FtlQueryReplyType, FtlQueryStatus, FtlQueryType}, 15 | routes::{auth::User, stats::history::get_history::get_history}, 16 | util::{Error, ErrorKind, Reply} 17 | }; 18 | use base64::{decode, encode}; 19 | use failure::ResultExt; 20 | use rocket::{ 21 | http::RawStr, 22 | request::{Form, FromFormValue}, 23 | State 24 | }; 25 | 26 | /// Get the query history according to the specified parameters 27 | #[get("/stats/history?")] 28 | pub fn history( 29 | _auth: User, 30 | ftl_memory: State, 31 | env: State, 32 | params: Form, 33 | db: FtlDatabase 34 | ) -> Reply { 35 | get_history(&ftl_memory, &env, params.into_inner(), &db) 36 | } 37 | 38 | /// Represents the possible GET parameters on `/stats/history` 39 | #[derive(FromForm)] 40 | pub struct HistoryParams { 41 | pub cursor: Option, 42 | pub from: Option, 43 | pub until: Option, 44 | pub domain: Option, 45 | pub client: Option, 46 | pub upstream: Option, 47 | pub query_type: Option, 48 | pub status: Option, 49 | pub blocked: Option, 50 | pub dnssec: Option, 51 | pub reply: Option, 52 | pub limit: Option 53 | } 54 | 55 | impl Default for HistoryParams { 56 | fn default() -> Self { 57 | HistoryParams { 58 | cursor: None, 59 | from: None, 60 | until: None, 61 | domain: None, 62 | client: None, 63 | upstream: None, 64 | query_type: None, 65 | status: None, 66 | blocked: None, 67 | dnssec: None, 68 | reply: None, 69 | limit: Some(100) 70 | } 71 | } 72 | } 73 | 74 | /// The cursor object used for history pagination 75 | #[cfg_attr(test, derive(PartialEq, Debug))] 76 | #[derive(Copy, Clone, Serialize, Deserialize)] 77 | pub struct HistoryCursor { 78 | pub id: Option, 79 | pub db_id: Option 80 | } 81 | 82 | impl HistoryCursor { 83 | /// Get the Base64 representation of the cursor 84 | pub fn as_base64(&self) -> Result { 85 | let bytes = serde_json::to_vec(self).context(ErrorKind::Unknown)?; 86 | 87 | Ok(encode(&bytes)) 88 | } 89 | } 90 | 91 | impl<'a> FromFormValue<'a> for HistoryCursor { 92 | type Error = Error; 93 | 94 | fn from_form_value(form_value: &'a RawStr) -> Result { 95 | // Decode from Base64 96 | let decoded = decode(form_value).context(ErrorKind::BadRequest)?; 97 | 98 | // Deserialize from JSON 99 | let cursor = serde_json::from_slice(&decoded).context(ErrorKind::BadRequest)?; 100 | 101 | Ok(cursor) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/blocked.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Blocked Query Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, 13 | ftl::{FtlQuery, BLOCKED_STATUSES}, 14 | routes::stats::history::endpoints::HistoryParams 15 | }; 16 | use diesel::{prelude::*, sqlite::Sqlite}; 17 | 18 | /// Only show allowed/blocked queries 19 | pub fn filter_blocked<'a>( 20 | queries_iter: Box + 'a>, 21 | params: &HistoryParams 22 | ) -> Box + 'a> { 23 | if let Some(blocked) = params.blocked { 24 | if blocked { 25 | Box::new(queries_iter.filter(|query| query.is_blocked())) 26 | } else { 27 | Box::new(queries_iter.filter(|query| !query.is_blocked())) 28 | } 29 | } else { 30 | queries_iter 31 | } 32 | } 33 | 34 | /// Only show allowed/blocked queries in database results 35 | pub fn filter_blocked_db<'a>( 36 | db_query: queries::BoxedQuery<'a, Sqlite>, 37 | params: &HistoryParams 38 | ) -> queries::BoxedQuery<'a, Sqlite> { 39 | // Use the Diesel DSL of this table for easy querying 40 | use self::queries::dsl::*; 41 | 42 | if let Some(blocked) = params.blocked { 43 | if blocked { 44 | db_query.filter(status.eq_any(&BLOCKED_STATUSES)) 45 | } else { 46 | db_query.filter(status.ne_all(&BLOCKED_STATUSES)) 47 | } 48 | } else { 49 | db_query 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod test { 55 | use super::{filter_blocked, filter_blocked_db}; 56 | use crate::{ 57 | databases::ftl::connect_to_test_db, 58 | ftl::{FtlQuery, BLOCKED_STATUSES}, 59 | routes::stats::history::{ 60 | database::execute_query, endpoints::HistoryParams, testing::test_queries 61 | } 62 | }; 63 | use diesel::prelude::*; 64 | 65 | /// Only return allowed/blocked queries 66 | #[test] 67 | fn test_filter_blocked() { 68 | let queries = test_queries(); 69 | let expected_queries = vec![&queries[3], &queries[5], &queries[6], &queries[7]]; 70 | let filtered_queries: Vec<&FtlQuery> = filter_blocked( 71 | Box::new(queries.iter()), 72 | &HistoryParams { 73 | blocked: Some(true), 74 | ..HistoryParams::default() 75 | } 76 | ) 77 | .collect(); 78 | 79 | assert_eq!(filtered_queries, expected_queries); 80 | } 81 | 82 | /// Only queries with a blocked/unblocked status are returned. This is a 83 | /// database filter. 84 | #[test] 85 | fn database() { 86 | use crate::databases::ftl::queries::dsl::*; 87 | 88 | let blocked = false; 89 | let params = HistoryParams { 90 | blocked: Some(blocked), 91 | ..HistoryParams::default() 92 | }; 93 | 94 | let db_query = filter_blocked_db(queries.into_boxed(), ¶ms); 95 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 96 | 97 | for query in filtered_queries { 98 | assert!(!BLOCKED_STATUSES.contains(&(query.status as i32))); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/client.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Client Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, 13 | ftl::{FtlMemory, FtlQuery, ShmLockGuard}, 14 | routes::stats::history::endpoints::HistoryParams, 15 | util::Error 16 | }; 17 | use diesel::{prelude::*, sqlite::Sqlite}; 18 | use std::{collections::HashSet, iter}; 19 | 20 | /// Only show queries of the specified client 21 | pub fn filter_client<'a>( 22 | queries_iter: Box + 'a>, 23 | params: &HistoryParams, 24 | ftl_memory: &FtlMemory, 25 | ftl_lock: &ShmLockGuard<'a> 26 | ) -> Result + 'a>, Error> { 27 | if let Some(ref client_filter) = params.client { 28 | // Find the matching clients. If none are found, return an empty 29 | // iterator because no query can match the client requested 30 | let counters = ftl_memory.counters(ftl_lock)?; 31 | let strings = ftl_memory.strings(ftl_lock)?; 32 | let clients = ftl_memory.clients(ftl_lock)?; 33 | let client_ids: HashSet = clients 34 | .iter() 35 | .take(counters.total_clients as usize) 36 | .enumerate() 37 | .filter_map(|(i, client)| { 38 | let ip = client.get_ip(&strings); 39 | let name = client.get_name(&strings).unwrap_or_default(); 40 | 41 | if ip.contains(client_filter) || name.contains(client_filter) { 42 | Some(i) 43 | } else { 44 | None 45 | } 46 | }) 47 | .collect(); 48 | 49 | if !client_ids.is_empty() { 50 | Ok(Box::new(queries_iter.filter(move |query| { 51 | client_ids.contains(&(query.client_id as usize)) 52 | }))) 53 | } else { 54 | Ok(Box::new(iter::empty())) 55 | } 56 | } else { 57 | Ok(queries_iter) 58 | } 59 | } 60 | 61 | /// Only show queries of the specified client in database results 62 | pub fn filter_client_db<'a>( 63 | db_query: queries::BoxedQuery<'a, Sqlite>, 64 | params: &HistoryParams 65 | ) -> queries::BoxedQuery<'a, Sqlite> { 66 | // Use the Diesel DSL of this table for easy querying 67 | use self::queries::dsl::*; 68 | 69 | if let Some(ref search_client) = params.client { 70 | db_query.filter(client.like(format!("%{}%", search_client))) 71 | } else { 72 | db_query 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod test { 78 | use super::{filter_client, filter_client_db}; 79 | use crate::{ 80 | databases::ftl::connect_to_test_db, 81 | ftl::{FtlQuery, ShmLockGuard}, 82 | routes::stats::history::{ 83 | database::execute_query, 84 | endpoints::HistoryParams, 85 | testing::{test_memory, test_queries} 86 | } 87 | }; 88 | use diesel::prelude::*; 89 | 90 | /// Only return queries from the specified client IP 91 | #[test] 92 | fn ip() { 93 | let queries = test_queries(); 94 | let expected_queries = vec![&queries[0], &queries[1], &queries[2]]; 95 | let filtered_queries: Vec<&FtlQuery> = filter_client( 96 | Box::new(queries.iter()), 97 | &HistoryParams { 98 | client: Some("192.168.1.10".to_owned()), 99 | ..HistoryParams::default() 100 | }, 101 | &test_memory(), 102 | &ShmLockGuard::Test 103 | ) 104 | .unwrap() 105 | .collect(); 106 | 107 | assert_eq!(filtered_queries, expected_queries); 108 | } 109 | 110 | /// Only return queries from the specified client IP. This test uses 111 | /// substring matching. 112 | #[test] 113 | fn ip_substring() { 114 | let queries = test_queries(); 115 | let expected_queries = vec![&queries[0], &queries[1], &queries[2]]; 116 | let filtered_queries: Vec<&FtlQuery> = filter_client( 117 | Box::new(queries.iter()), 118 | &HistoryParams { 119 | client: Some(".10".to_owned()), 120 | ..HistoryParams::default() 121 | }, 122 | &test_memory(), 123 | &ShmLockGuard::Test 124 | ) 125 | .unwrap() 126 | .collect(); 127 | 128 | assert_eq!(filtered_queries, expected_queries); 129 | } 130 | 131 | /// Only return queries from the specified client name 132 | #[test] 133 | fn name() { 134 | let queries = test_queries(); 135 | let expected_queries = vec![&queries[0], &queries[1], &queries[2]]; 136 | let filtered_queries: Vec<&FtlQuery> = filter_client( 137 | Box::new(queries.iter()), 138 | &HistoryParams { 139 | client: Some("client1".to_owned()), 140 | ..HistoryParams::default() 141 | }, 142 | &test_memory(), 143 | &ShmLockGuard::Test 144 | ) 145 | .unwrap() 146 | .collect(); 147 | 148 | assert_eq!(filtered_queries, expected_queries); 149 | } 150 | 151 | /// Only return queries from the specified client name. This test uses 152 | /// substring matching. 153 | #[test] 154 | fn name_substring() { 155 | let queries = test_queries(); 156 | let expected_queries = vec![&queries[0], &queries[1], &queries[2]]; 157 | let filtered_queries: Vec<&FtlQuery> = filter_client( 158 | Box::new(queries.iter()), 159 | &HistoryParams { 160 | client: Some("t1".to_owned()), 161 | ..HistoryParams::default() 162 | }, 163 | &test_memory(), 164 | &ShmLockGuard::Test 165 | ) 166 | .unwrap() 167 | .collect(); 168 | 169 | assert_eq!(filtered_queries, expected_queries); 170 | } 171 | 172 | /// Only queries with a client similar to the input are returned. This is a 173 | /// database filter. 174 | #[test] 175 | fn database() { 176 | use crate::databases::ftl::queries::dsl::*; 177 | 178 | let params = HistoryParams { 179 | client: Some("10.1".to_owned()), 180 | ..HistoryParams::default() 181 | }; 182 | 183 | let db_query = filter_client_db(queries.into_boxed(), ¶ms); 184 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 185 | 186 | assert_eq!(filtered_queries.len(), 1); 187 | assert_eq!(filtered_queries[0].client, "10.1.1.1"); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/dnssec.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // DNSSEC Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams}; 12 | 13 | /// Only show queries of the specified DNSSEC type 14 | pub fn filter_dnssec<'a>( 15 | queries_iter: Box + 'a>, 16 | params: &HistoryParams 17 | ) -> Box + 'a> { 18 | if let Some(dnssec) = params.dnssec { 19 | Box::new(queries_iter.filter(move |query| query.dnssec_type == dnssec)) 20 | } else { 21 | queries_iter 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod test { 27 | use super::filter_dnssec; 28 | use crate::{ 29 | ftl::{FtlDnssecType, FtlQuery}, 30 | routes::stats::history::{endpoints::HistoryParams, testing::test_queries} 31 | }; 32 | 33 | /// Only return queries of the specified DNSSEC type 34 | #[test] 35 | fn test_filter_dnssec() { 36 | let queries = test_queries(); 37 | let expected_queries = vec![&queries[0]]; 38 | let filtered_queries: Vec<&FtlQuery> = filter_dnssec( 39 | Box::new(queries.iter()), 40 | &HistoryParams { 41 | dnssec: Some(FtlDnssecType::Secure), 42 | ..HistoryParams::default() 43 | } 44 | ) 45 | .collect(); 46 | 47 | assert_eq!(filtered_queries, expected_queries); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/domain.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Domain Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, 13 | ftl::{FtlMemory, FtlQuery, ShmLockGuard}, 14 | routes::stats::history::endpoints::HistoryParams, 15 | util::Error 16 | }; 17 | use diesel::{prelude::*, sqlite::Sqlite}; 18 | use std::{collections::HashSet, iter}; 19 | 20 | /// Only show queries of the specified domain 21 | pub fn filter_domain<'a>( 22 | queries_iter: Box + 'a>, 23 | params: &HistoryParams, 24 | ftl_memory: &FtlMemory, 25 | ftl_lock: &ShmLockGuard<'a> 26 | ) -> Result + 'a>, Error> { 27 | if let Some(ref domain_filter) = params.domain { 28 | // Find the matching domains. If none are found, return an empty 29 | // iterator because no query can match the domain requested 30 | let counters = ftl_memory.counters(ftl_lock)?; 31 | let strings = ftl_memory.strings(ftl_lock)?; 32 | let domains = ftl_memory.domains(ftl_lock)?; 33 | let domain_ids: HashSet = domains 34 | .iter() 35 | .take(counters.total_domains as usize) 36 | .enumerate() 37 | .filter_map(|(i, domain)| { 38 | if domain.get_domain(&strings).contains(domain_filter) { 39 | Some(i) 40 | } else { 41 | None 42 | } 43 | }) 44 | .collect(); 45 | 46 | if !domain_ids.is_empty() { 47 | Ok(Box::new(queries_iter.filter(move |query| { 48 | domain_ids.contains(&(query.domain_id as usize)) 49 | }))) 50 | } else { 51 | Ok(Box::new(iter::empty())) 52 | } 53 | } else { 54 | Ok(queries_iter) 55 | } 56 | } 57 | 58 | /// Only show queries of the specified domain in database results 59 | pub fn filter_domain_db<'a>( 60 | db_query: queries::BoxedQuery<'a, Sqlite>, 61 | params: &HistoryParams 62 | ) -> queries::BoxedQuery<'a, Sqlite> { 63 | // Use the Diesel DSL of this table for easy querying 64 | use self::queries::dsl::*; 65 | 66 | if let Some(ref search_domain) = params.domain { 67 | db_query.filter(domain.like(format!("%{}%", search_domain))) 68 | } else { 69 | db_query 70 | } 71 | } 72 | 73 | #[cfg(test)] 74 | mod test { 75 | use super::{filter_domain, filter_domain_db}; 76 | use crate::{ 77 | databases::ftl::connect_to_test_db, 78 | ftl::{FtlQuery, ShmLockGuard}, 79 | routes::stats::history::{ 80 | database::execute_query, 81 | endpoints::HistoryParams, 82 | testing::{test_memory, test_queries} 83 | } 84 | }; 85 | use diesel::prelude::*; 86 | 87 | /// Only return queries of the specified domain 88 | #[test] 89 | fn simple() { 90 | let queries = test_queries(); 91 | let expected_queries = vec![&queries[3]]; 92 | let filtered_queries: Vec<&FtlQuery> = filter_domain( 93 | Box::new(queries.iter()), 94 | &HistoryParams { 95 | domain: Some("domain2.com".to_owned()), 96 | ..HistoryParams::default() 97 | }, 98 | &test_memory(), 99 | &ShmLockGuard::Test 100 | ) 101 | .unwrap() 102 | .collect(); 103 | 104 | assert_eq!(filtered_queries, expected_queries); 105 | } 106 | 107 | /// Only return queries of the specified domain. This test uses substring 108 | /// matching. 109 | #[test] 110 | fn substring() { 111 | let queries = test_queries(); 112 | let expected_queries = vec![&queries[3]]; 113 | let filtered_queries: Vec<&FtlQuery> = filter_domain( 114 | Box::new(queries.iter()), 115 | &HistoryParams { 116 | domain: Some("2.c".to_owned()), 117 | ..HistoryParams::default() 118 | }, 119 | &test_memory(), 120 | &ShmLockGuard::Test 121 | ) 122 | .unwrap() 123 | .collect(); 124 | 125 | assert_eq!(filtered_queries, expected_queries); 126 | } 127 | 128 | /// Only queries with domains similar to the input are returned. This is a 129 | /// database filter. 130 | #[test] 131 | fn database() { 132 | use crate::databases::ftl::queries::dsl::*; 133 | 134 | let params = HistoryParams { 135 | domain: Some("goog".to_owned()), 136 | ..HistoryParams::default() 137 | }; 138 | 139 | let db_query = filter_domain_db(queries.into_boxed(), ¶ms); 140 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 141 | 142 | assert_eq!(filtered_queries.len(), 1); 143 | assert_eq!(filtered_queries[0].domain, "google.com"); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/exclude_clients.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // SetupVars API_EXCLUDE_CLIENTS Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, 13 | env::Env, 14 | ftl::{FtlMemory, FtlQuery, ShmLockGuard}, 15 | settings::{ConfigEntry, SetupVarsEntry}, 16 | util::Error 17 | }; 18 | use diesel::{prelude::*, sqlite::Sqlite}; 19 | use std::collections::HashSet; 20 | 21 | /// Apply the `SetupVarsEntry::ApiExcludeClients` setting 22 | pub fn filter_excluded_clients<'a>( 23 | queries_iter: Box + 'a>, 24 | env: &Env, 25 | ftl_memory: &FtlMemory, 26 | ftl_lock: &ShmLockGuard<'a> 27 | ) -> Result + 'a>, Error> { 28 | // Get the excluded clients list 29 | let excluded_clients: Vec = SetupVarsEntry::ApiExcludeClients 30 | .read_list(env)? 31 | .into_iter() 32 | .map(|s| s.to_lowercase()) 33 | .collect(); 34 | let excluded_clients: HashSet<&str> = excluded_clients.iter().map(String::as_str).collect(); 35 | 36 | // End early if there are no excluded clients 37 | if excluded_clients.is_empty() { 38 | return Ok(queries_iter); 39 | } 40 | 41 | // Find the client IDs of the excluded clients 42 | let counters = ftl_memory.counters(ftl_lock)?; 43 | let strings = ftl_memory.strings(ftl_lock)?; 44 | let clients = ftl_memory.clients(ftl_lock)?; 45 | let excluded_client_ids: HashSet = clients 46 | .iter() 47 | .take(counters.total_clients as usize) 48 | .enumerate() 49 | .filter_map(|(i, client)| { 50 | let ip = client.get_ip(&strings); 51 | let name = client.get_name(&strings).unwrap_or_default(); 52 | 53 | if excluded_clients.contains(ip) || excluded_clients.contains(name) { 54 | Some(i) 55 | } else { 56 | None 57 | } 58 | }) 59 | .collect(); 60 | 61 | // End if no clients match the excluded clients 62 | if excluded_client_ids.is_empty() { 63 | return Ok(queries_iter); 64 | } 65 | 66 | // Filter out the excluded domains using the domain IDs 67 | Ok(Box::new(queries_iter.filter(move |query| { 68 | !excluded_client_ids.contains(&(query.client_id as usize)) 69 | }))) 70 | } 71 | 72 | /// Apply the `SetupVarsEntry::ApiExcludeClients` setting to database queries 73 | pub fn filter_excluded_clients_db<'a>( 74 | db_query: queries::BoxedQuery<'a, Sqlite>, 75 | env: &Env 76 | ) -> Result, Error> { 77 | // Use the Diesel DSL of this table for easy querying 78 | use self::queries::dsl::*; 79 | 80 | // Get the excluded clients list 81 | let excluded_clients: HashSet = SetupVarsEntry::ApiExcludeClients 82 | .read_list(env)? 83 | .into_iter() 84 | .map(|s| s.to_lowercase()) 85 | .collect(); 86 | 87 | if excluded_clients.is_empty() { 88 | Ok(db_query) 89 | } else { 90 | Ok(db_query.filter(client.ne_all(excluded_clients))) 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::{filter_excluded_clients, filter_excluded_clients_db}; 97 | use crate::{ 98 | databases::ftl::connect_to_test_db, 99 | env::{Config, Env, PiholeFile}, 100 | ftl::{FtlQuery, ShmLockGuard}, 101 | routes::stats::history::{ 102 | database::execute_query, 103 | testing::{test_memory, test_queries} 104 | }, 105 | testing::TestEnvBuilder 106 | }; 107 | use diesel::prelude::*; 108 | 109 | /// No queries should be filtered out if `API_EXCLUDE_CLIENTS` is empty 110 | #[test] 111 | fn clients_empty() { 112 | let env = Env::Test( 113 | Config::default(), 114 | TestEnvBuilder::new() 115 | .file(PiholeFile::SetupVars, "API_EXCLUDE_CLIENTS=") 116 | .build() 117 | ); 118 | let queries = test_queries(); 119 | let expected_queries: Vec<&FtlQuery> = queries.iter().collect(); 120 | let filtered_queries: Vec<&FtlQuery> = filter_excluded_clients( 121 | Box::new(queries.iter()), 122 | &env, 123 | &test_memory(), 124 | &ShmLockGuard::Test 125 | ) 126 | .unwrap() 127 | .collect(); 128 | 129 | assert_eq!(filtered_queries, expected_queries); 130 | } 131 | 132 | /// Queries with a client in the `API_EXCLUDE_CLIENTS` list should be 133 | /// removed 134 | #[test] 135 | fn clients() { 136 | let env = Env::Test( 137 | Config::default(), 138 | TestEnvBuilder::new() 139 | .file(PiholeFile::SetupVars, "API_EXCLUDE_CLIENTS=192.168.1.11") 140 | .build() 141 | ); 142 | let queries = test_queries(); 143 | let expected_queries = vec![ 144 | &queries[0], 145 | &queries[1], 146 | &queries[2], 147 | &queries[6], 148 | &queries[7], 149 | &queries[8], 150 | ]; 151 | let filtered_queries: Vec<&FtlQuery> = filter_excluded_clients( 152 | Box::new(queries.iter()), 153 | &env, 154 | &test_memory(), 155 | &ShmLockGuard::Test 156 | ) 157 | .unwrap() 158 | .collect(); 159 | 160 | assert_eq!(filtered_queries, expected_queries); 161 | } 162 | 163 | /// Queries with a client in the `API_EXCLUDE_CLIENTS` list should be 164 | /// removed. This is a database filter. 165 | #[test] 166 | fn clients_db() { 167 | use crate::databases::ftl::queries::dsl::*; 168 | 169 | let env = Env::Test( 170 | Config::default(), 171 | TestEnvBuilder::new() 172 | .file(PiholeFile::SetupVars, "API_EXCLUDE_CLIENTS=127.0.0.1") 173 | .build() 174 | ); 175 | 176 | let db_query = filter_excluded_clients_db(queries.into_boxed(), &env).unwrap(); 177 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 178 | 179 | assert_eq!(filtered_queries.len(), 1); 180 | assert_eq!(filtered_queries[0].client, "10.1.1.1".to_owned()); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/exclude_domains.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // SetupVars API_EXCLUDE_DOMAINS Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, 13 | env::Env, 14 | ftl::{FtlMemory, FtlQuery, ShmLockGuard}, 15 | settings::{ConfigEntry, SetupVarsEntry}, 16 | util::Error 17 | }; 18 | use diesel::{prelude::*, sqlite::Sqlite}; 19 | use std::collections::HashSet; 20 | 21 | /// Apply the `SetupVarsEntry::ApiExcludeDomains` setting 22 | pub fn filter_excluded_domains<'a>( 23 | queries_iter: Box + 'a>, 24 | env: &Env, 25 | ftl_memory: &FtlMemory, 26 | ftl_lock: &ShmLockGuard<'a> 27 | ) -> Result + 'a>, Error> { 28 | // Get the excluded domains list 29 | let excluded_domains: Vec = SetupVarsEntry::ApiExcludeDomains 30 | .read_list(env)? 31 | .into_iter() 32 | .map(|s| s.to_lowercase()) 33 | .collect(); 34 | let excluded_domains: HashSet<&str> = excluded_domains.iter().map(String::as_str).collect(); 35 | 36 | // End early if there are no excluded domains 37 | if excluded_domains.is_empty() { 38 | return Ok(queries_iter); 39 | } 40 | 41 | // Find the domain IDs of the excluded domains 42 | let counters = ftl_memory.counters(ftl_lock)?; 43 | let strings = ftl_memory.strings(ftl_lock)?; 44 | let domains = ftl_memory.domains(ftl_lock)?; 45 | let excluded_domain_ids: HashSet = domains 46 | .iter() 47 | .take(counters.total_domains as usize) 48 | .enumerate() 49 | .filter_map(|(i, domain)| { 50 | if excluded_domains.contains(domain.get_domain(&strings)) { 51 | Some(i) 52 | } else { 53 | None 54 | } 55 | }) 56 | .collect(); 57 | 58 | // End if no domains match the excluded domains 59 | if excluded_domain_ids.is_empty() { 60 | return Ok(queries_iter); 61 | } 62 | 63 | // Filter out the excluded domains using the domain IDs 64 | Ok(Box::new(queries_iter.filter(move |query| { 65 | !excluded_domain_ids.contains(&(query.domain_id as usize)) 66 | }))) 67 | } 68 | 69 | /// Apply the `SetupVarsEntry::ApiExcludeDomains` setting to database queries 70 | pub fn filter_excluded_domains_db<'a>( 71 | db_query: queries::BoxedQuery<'a, Sqlite>, 72 | env: &Env 73 | ) -> Result, Error> { 74 | // Use the Diesel DSL of this table for easy querying 75 | use self::queries::dsl::*; 76 | 77 | // Get the excluded domains list 78 | let excluded_domains: HashSet = SetupVarsEntry::ApiExcludeDomains 79 | .read_list(env)? 80 | .into_iter() 81 | .map(|s| s.to_lowercase()) 82 | .collect(); 83 | 84 | if excluded_domains.is_empty() { 85 | Ok(db_query) 86 | } else { 87 | Ok(db_query.filter(domain.ne_all(excluded_domains))) 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::{filter_excluded_domains, filter_excluded_domains_db}; 94 | use crate::{ 95 | databases::ftl::connect_to_test_db, 96 | env::{Config, Env, PiholeFile}, 97 | ftl::{FtlQuery, ShmLockGuard}, 98 | routes::stats::history::{ 99 | database::execute_query, 100 | testing::{test_memory, test_queries} 101 | }, 102 | testing::TestEnvBuilder 103 | }; 104 | use diesel::prelude::*; 105 | 106 | /// No queries should be filtered out if `API_EXCLUDE_DOMAINS` is empty 107 | #[test] 108 | fn domains_empty() { 109 | let env = Env::Test( 110 | Config::default(), 111 | TestEnvBuilder::new() 112 | .file(PiholeFile::SetupVars, "API_EXCLUDE_DOMAINS=") 113 | .build() 114 | ); 115 | let queries = test_queries(); 116 | let expected_queries: Vec<&FtlQuery> = queries.iter().collect(); 117 | let filtered_queries: Vec<&FtlQuery> = filter_excluded_domains( 118 | Box::new(queries.iter()), 119 | &env, 120 | &test_memory(), 121 | &ShmLockGuard::Test 122 | ) 123 | .unwrap() 124 | .collect(); 125 | 126 | assert_eq!(filtered_queries, expected_queries); 127 | } 128 | 129 | /// Queries with a domain in the `API_EXCLUDE_DOMAINS` list should be 130 | /// removed 131 | #[test] 132 | fn domains() { 133 | let env = Env::Test( 134 | Config::default(), 135 | TestEnvBuilder::new() 136 | .file(PiholeFile::SetupVars, "API_EXCLUDE_DOMAINS=domain2.com") 137 | .build() 138 | ); 139 | let queries = test_queries(); 140 | let expected_queries: Vec<&FtlQuery> = 141 | queries.iter().filter(|query| query.id != 4).collect(); 142 | let filtered_queries: Vec<&FtlQuery> = filter_excluded_domains( 143 | Box::new(queries.iter()), 144 | &env, 145 | &test_memory(), 146 | &ShmLockGuard::Test 147 | ) 148 | .unwrap() 149 | .collect(); 150 | 151 | assert_eq!(filtered_queries, expected_queries); 152 | } 153 | 154 | /// Queries with a domain in the `API_EXCLUDE_DOMAIN` list should be 155 | /// removed. This is a database filter. 156 | #[test] 157 | fn domains_db() { 158 | use crate::databases::ftl::queries::dsl::*; 159 | 160 | let env = Env::Test( 161 | Config::default(), 162 | TestEnvBuilder::new() 163 | .file( 164 | PiholeFile::SetupVars, 165 | "API_EXCLUDE_DOMAINS=0.ubuntu.pool.ntp.org,1.ubuntu.pool.ntp.org" 166 | ) 167 | .build() 168 | ); 169 | 170 | let db_query = filter_excluded_domains_db(queries.into_boxed(), &env).unwrap(); 171 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 172 | 173 | for query in filtered_queries { 174 | assert_ne!(query.domain, "0.ubuntu.pool.ntp.org".to_owned()); 175 | assert_ne!(query.domain, "1.ubuntu.pool.ntp.org".to_owned()); 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Filters 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod blocked; 12 | mod client; 13 | mod dnssec; 14 | mod domain; 15 | mod exclude_clients; 16 | mod exclude_domains; 17 | mod private; 18 | mod query_type; 19 | mod reply; 20 | mod setup_vars; 21 | mod status; 22 | mod time; 23 | mod upstream; 24 | 25 | pub use self::{ 26 | blocked::*, client::*, dnssec::*, domain::*, exclude_clients::*, exclude_domains::*, 27 | private::*, query_type::*, reply::*, setup_vars::*, status::*, time::*, upstream::* 28 | }; 29 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/private.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Private Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::FtlQuery; 12 | 13 | /// Filter out private queries 14 | pub fn filter_private_queries<'a>( 15 | queries_iter: Box + 'a> 16 | ) -> Box + 'a> { 17 | Box::new(queries_iter.filter(|query| !query.is_private)) 18 | } 19 | 20 | #[cfg(test)] 21 | mod test { 22 | use super::filter_private_queries; 23 | use crate::{ftl::FtlQuery, routes::stats::history::testing::test_queries}; 24 | 25 | /// Private queries should not pass the filter 26 | #[test] 27 | fn test_filter_private_queries() { 28 | let queries = test_queries(); 29 | let expected_queries: Vec<&FtlQuery> = queries.iter().take(8).collect(); 30 | let filtered_queries: Vec<&FtlQuery> = 31 | filter_private_queries(Box::new(queries.iter())).collect(); 32 | 33 | assert_eq!(filtered_queries, expected_queries); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/query_type.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Type Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams 13 | }; 14 | use diesel::{prelude::*, sqlite::Sqlite}; 15 | 16 | /// Only show queries with the specified query type 17 | pub fn filter_query_type<'a>( 18 | queries_iter: Box + 'a>, 19 | params: &HistoryParams 20 | ) -> Box + 'a> { 21 | if let Some(query_type) = params.query_type { 22 | Box::new(queries_iter.filter(move |query| query.query_type == query_type)) 23 | } else { 24 | queries_iter 25 | } 26 | } 27 | 28 | /// Only show queries with the specified query type in database results 29 | pub fn filter_query_type_db<'a>( 30 | db_query: queries::BoxedQuery<'a, Sqlite>, 31 | params: &HistoryParams 32 | ) -> queries::BoxedQuery<'a, Sqlite> { 33 | // Use the Diesel DSL of this table for easy querying 34 | use self::queries::dsl::*; 35 | 36 | if let Some(search_query_type) = params.query_type { 37 | db_query.filter(query_type.eq(search_query_type as i32)) 38 | } else { 39 | db_query 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod test { 45 | use super::{filter_query_type, filter_query_type_db}; 46 | use crate::{ 47 | databases::ftl::connect_to_test_db, 48 | ftl::{FtlQuery, FtlQueryType}, 49 | routes::stats::history::{ 50 | database::execute_query, endpoints::HistoryParams, testing::test_queries 51 | } 52 | }; 53 | use diesel::prelude::*; 54 | 55 | /// Only return queries with the specified query type 56 | #[test] 57 | fn query_type() { 58 | let queries = test_queries(); 59 | let expected_queries = vec![&queries[0], &queries[3], &queries[6], &queries[8]]; 60 | let filtered_queries: Vec<&FtlQuery> = filter_query_type( 61 | Box::new(queries.iter()), 62 | &HistoryParams { 63 | query_type: Some(FtlQueryType::A), 64 | ..HistoryParams::default() 65 | } 66 | ) 67 | .collect(); 68 | 69 | assert_eq!(filtered_queries, expected_queries); 70 | } 71 | 72 | /// Only queries with the input query type are returned. This is a database 73 | /// filter. 74 | #[test] 75 | fn database() { 76 | use crate::databases::ftl::queries::dsl::*; 77 | 78 | let expected_query_type = FtlQueryType::PTR; 79 | let params = HistoryParams { 80 | query_type: Some(expected_query_type), 81 | ..HistoryParams::default() 82 | }; 83 | 84 | let db_query = filter_query_type_db(queries.into_boxed(), ¶ms); 85 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 86 | 87 | for query in filtered_queries { 88 | assert_eq!(query.query_type, expected_query_type as i32); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/reply.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Reply Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams}; 12 | 13 | /// Only show queries of the specified reply type 14 | pub fn filter_reply<'a>( 15 | queries_iter: Box + 'a>, 16 | params: &HistoryParams 17 | ) -> Box + 'a> { 18 | if let Some(reply) = params.reply { 19 | Box::new(queries_iter.filter(move |query| query.reply_type == reply)) 20 | } else { 21 | queries_iter 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod test { 27 | use super::filter_reply; 28 | use crate::{ 29 | ftl::{FtlQuery, FtlQueryReplyType}, 30 | routes::stats::history::{endpoints::HistoryParams, testing::test_queries} 31 | }; 32 | 33 | /// Only return queries of the specified reply type 34 | #[test] 35 | fn test_filter_reply() { 36 | let queries = test_queries(); 37 | let expected_queries = vec![&queries[0]]; 38 | let filtered_queries: Vec<&FtlQuery> = filter_reply( 39 | Box::new(queries.iter()), 40 | &HistoryParams { 41 | reply: Some(FtlQueryReplyType::CNAME), 42 | ..HistoryParams::default() 43 | } 44 | ) 45 | .collect(); 46 | 47 | assert_eq!(filtered_queries, expected_queries); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/status.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Status Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams 13 | }; 14 | use diesel::{prelude::*, sqlite::Sqlite}; 15 | 16 | /// Only show queries with the specific status 17 | pub fn filter_status<'a>( 18 | queries_iter: Box + 'a>, 19 | params: &HistoryParams 20 | ) -> Box + 'a> { 21 | if let Some(status) = params.status { 22 | Box::new(queries_iter.filter(move |query| query.status == status)) 23 | } else { 24 | queries_iter 25 | } 26 | } 27 | 28 | /// Only show queries with the specific status in database results 29 | pub fn filter_status_db<'a>( 30 | db_query: queries::BoxedQuery<'a, Sqlite>, 31 | params: &HistoryParams 32 | ) -> queries::BoxedQuery<'a, Sqlite> { 33 | // Use the Diesel DSL of this table for easy querying 34 | use self::queries::dsl::*; 35 | 36 | if let Some(search_status) = params.status { 37 | db_query.filter(status.eq(search_status as i32)) 38 | } else { 39 | db_query 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod test { 45 | use super::{filter_status, filter_status_db}; 46 | use crate::{ 47 | databases::ftl::connect_to_test_db, 48 | ftl::{FtlQuery, FtlQueryStatus}, 49 | routes::stats::history::{ 50 | database::execute_query, endpoints::HistoryParams, testing::test_queries 51 | } 52 | }; 53 | use diesel::prelude::*; 54 | 55 | /// Only return queries with the specified status 56 | #[test] 57 | fn test_filter_status() { 58 | let queries = test_queries(); 59 | let expected_queries = vec![&queries[3]]; 60 | let filtered_queries: Vec<&FtlQuery> = filter_status( 61 | Box::new(queries.iter()), 62 | &HistoryParams { 63 | status: Some(FtlQueryStatus::Gravity), 64 | ..HistoryParams::default() 65 | } 66 | ) 67 | .collect(); 68 | 69 | assert_eq!(filtered_queries, expected_queries); 70 | } 71 | 72 | /// Only queries with the input query status are returned. This is a 73 | /// database filter. 74 | #[test] 75 | fn database() { 76 | use crate::databases::ftl::queries::dsl::*; 77 | 78 | let expected_status = FtlQueryStatus::Forward; 79 | let params = HistoryParams { 80 | status: Some(expected_status), 81 | ..HistoryParams::default() 82 | }; 83 | 84 | let db_query = filter_status_db(queries.into_boxed(), ¶ms); 85 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 86 | 87 | for query in filtered_queries { 88 | assert_eq!(query.status, expected_status as i32); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/routes/stats/history/filters/time.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Time Filter 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams 13 | }; 14 | use diesel::{prelude::*, sqlite::Sqlite}; 15 | 16 | /// Filter out queries before the `from` timestamp 17 | pub fn filter_time_from<'a>( 18 | queries_iter: Box + 'a>, 19 | params: &HistoryParams 20 | ) -> Box + 'a> { 21 | if let Some(from) = params.from { 22 | Box::new(queries_iter.filter(move |query| query.timestamp as u64 >= from)) 23 | } else { 24 | queries_iter 25 | } 26 | } 27 | 28 | /// Filter out queries after the `until` timestamp 29 | pub fn filter_time_until<'a>( 30 | queries_iter: Box + 'a>, 31 | params: &HistoryParams 32 | ) -> Box + 'a> { 33 | if let Some(until) = params.until { 34 | Box::new(queries_iter.filter(move |query| query.timestamp as u64 <= until)) 35 | } else { 36 | queries_iter 37 | } 38 | } 39 | 40 | /// Filter out queries before the `from` timestamp in database results 41 | pub fn filter_time_from_db<'a>( 42 | db_query: queries::BoxedQuery<'a, Sqlite>, 43 | params: &HistoryParams 44 | ) -> queries::BoxedQuery<'a, Sqlite> { 45 | // Use the Diesel DSL of this table for easy querying 46 | use self::queries::dsl::*; 47 | 48 | if let Some(from) = params.from { 49 | db_query.filter(timestamp.ge(from as i32)) 50 | } else { 51 | db_query 52 | } 53 | } 54 | 55 | /// Filter out queries after the `until` timestamp in database results 56 | pub fn filter_time_until_db<'a>( 57 | db_query: queries::BoxedQuery<'a, Sqlite>, 58 | params: &HistoryParams 59 | ) -> queries::BoxedQuery<'a, Sqlite> { 60 | // Use the Diesel DSL of this table for easy querying 61 | use self::queries::dsl::*; 62 | 63 | if let Some(until) = params.until { 64 | db_query.filter(timestamp.le(until as i32)) 65 | } else { 66 | db_query 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod test { 72 | use super::{filter_time_from, filter_time_from_db, filter_time_until, filter_time_until_db}; 73 | use crate::{ 74 | databases::ftl::connect_to_test_db, 75 | ftl::FtlQuery, 76 | routes::stats::history::{ 77 | database::execute_query, endpoints::HistoryParams, testing::test_queries 78 | } 79 | }; 80 | use diesel::prelude::*; 81 | 82 | /// Skip queries before the timestamp 83 | #[test] 84 | fn from() { 85 | let queries = test_queries(); 86 | let expected_queries: Vec<&FtlQuery> = queries.iter().skip(4).collect(); 87 | let filtered_queries: Vec<&FtlQuery> = filter_time_from( 88 | Box::new(queries.iter()), 89 | &HistoryParams { 90 | from: Some(263_584), 91 | ..HistoryParams::default() 92 | } 93 | ) 94 | .collect(); 95 | 96 | assert_eq!(filtered_queries, expected_queries); 97 | } 98 | 99 | /// Skip queries after the timestamp 100 | #[test] 101 | fn until() { 102 | let queries = test_queries(); 103 | let expected_queries: Vec<&FtlQuery> = queries.iter().take(5).collect(); 104 | let filtered_queries: Vec<&FtlQuery> = filter_time_until( 105 | Box::new(queries.iter()), 106 | &HistoryParams { 107 | until: Some(263_584), 108 | ..HistoryParams::default() 109 | } 110 | ) 111 | .collect(); 112 | 113 | assert_eq!(filtered_queries, expected_queries); 114 | } 115 | 116 | /// Only queries newer than `from` are returned. This is a database filter. 117 | #[test] 118 | fn from_db() { 119 | use crate::databases::ftl::queries::dsl::*; 120 | 121 | let from = 177_179; 122 | let params = HistoryParams { 123 | from: Some(from), 124 | ..HistoryParams::default() 125 | }; 126 | 127 | let db_query = filter_time_from_db(queries.into_boxed(), ¶ms); 128 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 129 | 130 | assert!(filtered_queries 131 | .iter() 132 | .all(|query| query.timestamp >= from as i32)); 133 | } 134 | 135 | /// Only queries older than `until` are returned. This is a database filter. 136 | #[test] 137 | fn until_db() { 138 | use crate::databases::ftl::queries::dsl::*; 139 | 140 | let until = 1; 141 | let params = HistoryParams { 142 | until: Some(until), 143 | ..HistoryParams::default() 144 | }; 145 | 146 | let db_query = filter_time_until_db(queries.into_boxed(), ¶ms); 147 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 148 | 149 | assert!(filtered_queries 150 | .iter() 151 | .all(|query| query.timestamp <= until as i32)); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/routes/stats/history/map_query_to_json.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Query To JSON Functionality 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | ftl::{FtlMemory, FtlQuery, ShmLockGuard}, 13 | util::Error 14 | }; 15 | use rocket_contrib::json::JsonValue; 16 | 17 | /// Create a function to map `FtlQuery` structs to JSON `Value` structs. 18 | pub fn map_query_to_json<'a>( 19 | ftl_memory: &'a FtlMemory, 20 | ftl_lock: &ShmLockGuard<'a> 21 | ) -> Result JsonValue + 'a, Error> { 22 | let domains = ftl_memory.domains(ftl_lock)?; 23 | let clients = ftl_memory.clients(ftl_lock)?; 24 | let strings = ftl_memory.strings(ftl_lock)?; 25 | 26 | Ok(move |query: &FtlQuery| { 27 | let domain = domains[query.domain_id as usize].get_domain(&strings); 28 | let client = clients[query.client_id as usize]; 29 | 30 | // Try to get the client name first, but if it doesn't exist use the IP 31 | let client = client 32 | .get_name(&strings) 33 | .unwrap_or_else(|| client.get_ip(&strings)); 34 | 35 | // Check if response was received (response time should be smaller than 30min) 36 | let response_time = if query.response_time < 18_000_000 { 37 | query.response_time 38 | } else { 39 | 0 40 | }; 41 | 42 | json!({ 43 | "timestamp": query.timestamp, 44 | "type": query.query_type as u8, 45 | "status": query.status as u8, 46 | "domain": domain, 47 | "client": client, 48 | "dnssec": query.dnssec_type as u8, 49 | "reply": query.reply_type as u8, 50 | "response_time": response_time 51 | }) 52 | }) 53 | } 54 | 55 | #[cfg(test)] 56 | mod test { 57 | use super::map_query_to_json; 58 | use crate::{ 59 | ftl::ShmLockGuard, 60 | routes::stats::history::testing::{test_memory, test_queries} 61 | }; 62 | 63 | /// Verify that queries are mapped to JSON correctly 64 | #[test] 65 | fn test_map_query_to_json() { 66 | let query = test_queries()[0]; 67 | let ftl_memory = test_memory(); 68 | let map_function = map_query_to_json(&ftl_memory, &ShmLockGuard::Test).unwrap(); 69 | let mapped_query = map_function(&query); 70 | 71 | assert_eq!( 72 | mapped_query, 73 | json!({ 74 | "timestamp": 263_581, 75 | "type": 1, 76 | "status": 2, 77 | "domain": "domain1.com", 78 | "client": "client1", 79 | "dnssec": 1, 80 | "reply": 3, 81 | "response_time": 1 82 | }) 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/routes/stats/history/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod database; 12 | mod endpoints; 13 | mod filters; 14 | mod get_history; 15 | mod map_query_to_json; 16 | mod skip_to_cursor; 17 | 18 | #[cfg(test)] 19 | mod testing; 20 | 21 | pub use self::endpoints::*; 22 | -------------------------------------------------------------------------------- /src/routes/stats/history/skip_to_cursor.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Skip To Cursor Functionality 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::ftl::queries, ftl::FtlQuery, routes::stats::history::endpoints::HistoryParams 13 | }; 14 | use diesel::{prelude::*, sqlite::Sqlite}; 15 | 16 | /// Skip iteration until the query which corresponds to the cursor. 17 | pub fn skip_to_cursor<'a>( 18 | queries_iter: Box + 'a>, 19 | params: &HistoryParams 20 | ) -> Box + 'a> { 21 | if let Some(cursor) = params.cursor { 22 | if let Some(id) = cursor.id { 23 | Box::new(queries_iter.skip_while(move |query| query.id as i32 != id)) 24 | } else if let Some(db_id) = cursor.db_id { 25 | Box::new(queries_iter.skip_while(move |query| query.database_id != db_id)) 26 | } else { 27 | // No cursor data, don't skip any queries 28 | queries_iter 29 | } 30 | } else { 31 | queries_iter 32 | } 33 | } 34 | 35 | /// Skip database queries until the query which corresponds to the cursor. 36 | pub fn skip_to_cursor_db( 37 | db_query: queries::BoxedQuery, 38 | start_id: Option 39 | ) -> queries::BoxedQuery { 40 | // Use the Diesel DSL of this table for easy querying 41 | use self::queries::dsl::*; 42 | 43 | // If a start ID is given, ignore any queries before it 44 | if let Some(start_id) = start_id { 45 | db_query.filter(id.le(start_id as i32)) 46 | } else { 47 | db_query 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod test { 53 | use super::{skip_to_cursor, skip_to_cursor_db}; 54 | use crate::{ 55 | databases::ftl::{connect_to_test_db, FtlDbQuery}, 56 | ftl::FtlQuery, 57 | routes::stats::history::{ 58 | database::execute_query, 59 | endpoints::{HistoryCursor, HistoryParams}, 60 | testing::test_queries 61 | } 62 | }; 63 | use diesel::prelude::*; 64 | 65 | /// Skip queries according to the cursor (dnsmasq ID) 66 | #[test] 67 | fn dnsmasq_cursor() { 68 | let queries = test_queries(); 69 | let expected_queries: Vec<&FtlQuery> = queries.iter().skip(7).collect(); 70 | let filtered_queries: Vec<&FtlQuery> = skip_to_cursor( 71 | Box::new(queries.iter()), 72 | &HistoryParams { 73 | cursor: Some(HistoryCursor { 74 | id: Some(8), 75 | db_id: None 76 | }), 77 | ..HistoryParams::default() 78 | } 79 | ) 80 | .collect(); 81 | 82 | assert_eq!(filtered_queries, expected_queries); 83 | } 84 | 85 | /// Skip queries according to the cursor (database ID) 86 | #[test] 87 | fn database_cursor() { 88 | let queries = test_queries(); 89 | let expected_queries: Vec<&FtlQuery> = queries.iter().skip(4).collect(); 90 | let filtered_queries: Vec<&FtlQuery> = skip_to_cursor( 91 | Box::new(queries.iter()), 92 | &HistoryParams { 93 | cursor: Some(HistoryCursor { 94 | id: None, 95 | db_id: Some(99) 96 | }), 97 | ..HistoryParams::default() 98 | } 99 | ) 100 | .collect(); 101 | 102 | assert_eq!(filtered_queries, expected_queries); 103 | } 104 | 105 | /// Search starts from the start_id. This is a database filter. 106 | #[test] 107 | fn database() { 108 | use crate::databases::ftl::queries::dsl::*; 109 | 110 | let expected_queries = vec![FtlDbQuery { 111 | id: Some(1), 112 | timestamp: 0, 113 | query_type: 6, 114 | status: 3, 115 | domain: "1.1.1.10.in-addr.arpa".to_owned(), 116 | client: "127.0.0.1".to_owned(), 117 | upstream: None 118 | }]; 119 | 120 | let db_query = skip_to_cursor_db(queries.into_boxed(), Some(1)); 121 | let filtered_queries = execute_query(&connect_to_test_db(), db_query).unwrap(); 122 | 123 | assert_eq!(filtered_queries, expected_queries); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/routes/stats/history/testing.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // History Test Functions 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::ftl::{ 12 | FtlClient, FtlCounters, FtlDnssecType, FtlDomain, FtlMemory, FtlQuery, FtlQueryReplyType, 13 | FtlQueryStatus, FtlQueryType, FtlRegexMatch, FtlSettings, FtlUpstream, MAGIC_BYTE 14 | }; 15 | use std::collections::HashMap; 16 | 17 | /// Shorthand for making `FtlQuery` structs 18 | macro_rules! query { 19 | ( 20 | $id:expr, 21 | $database:expr, 22 | $qtype:ident, 23 | $status:ident, 24 | $domain:expr, 25 | $client:expr, 26 | $upstream:expr, 27 | $timestamp:expr, 28 | $private:expr 29 | ) => { 30 | FtlQuery { 31 | magic: MAGIC_BYTE, 32 | id: $id, 33 | database_id: $database, 34 | timestamp: $timestamp, 35 | time_index: 1, 36 | response_time: 1, 37 | domain_id: $domain, 38 | client_id: $client, 39 | upstream_id: $upstream, 40 | query_type: FtlQueryType::$qtype, 41 | status: FtlQueryStatus::$status, 42 | reply_type: FtlQueryReplyType::IP, 43 | dnssec_type: FtlDnssecType::Unspecified, 44 | is_complete: true, 45 | is_private: $private, 46 | ad_bit: false 47 | } 48 | }; 49 | } 50 | 51 | /// Creates an `FtlMemory` struct from the other test data functions 52 | pub fn test_memory() -> FtlMemory { 53 | FtlMemory::Test { 54 | clients: test_clients(), 55 | counters: test_counters(), 56 | domains: test_domains(), 57 | over_time: Vec::new(), 58 | strings: test_strings(), 59 | queries: test_queries(), 60 | upstreams: test_upstreams(), 61 | settings: FtlSettings::default() 62 | } 63 | } 64 | 65 | /// 9 queries. Query 9 is private. Last two are not in the database. Query 1 66 | /// has a DNSSEC type of Secure and a reply type of CNAME. The database 67 | /// timestamps end at 177180, so the in memory queries start at 263581 68 | /// (24 hours after). The database ids end at 94, so the in memory database IDs 69 | /// start at 95. 70 | /// 71 | /// | ID | DB | Type | Status | Domain | Client | Upstream | Timestamp | 72 | /// | -- | --- | ---- | ---------- | ------ | ------ | -------- | --------- | 73 | /// | 1 | 95 | A | Forward | 0 | 0 | 0 | 263581 | 74 | /// | 2 | 96 | AAAA | Forward | 0 | 0 | 0 | 263582 | 75 | /// | 3 | 97 | PTR | Forward | 0 | 0 | 0 | 263583 | 76 | /// | 4 | 98 | A | Gravity | 1 | 1 | 0 | 263583 | 77 | /// | 5 | 99 | AAAA | Cache | 0 | 1 | 0 | 263584 | 78 | /// | 6 | 100 | AAAA | Wildcard | 2 | 1 | 0 | 263585 | 79 | /// | 7 | 101 | A | Blacklist | 3 | 2 | 0 | 263585 | 80 | /// | 8 | 0 | AAAA | ExternalB. | 4 | 2 | 1 | 263586 | 81 | /// | 9 | 0 | A | Forward | 5 | 3 | 0 | 263587 | 82 | pub fn test_queries() -> Vec { 83 | vec![ 84 | FtlQuery { 85 | magic: MAGIC_BYTE, 86 | id: 1, 87 | database_id: 95, 88 | timestamp: 263_581, 89 | time_index: 1, 90 | response_time: 1, 91 | domain_id: 0, 92 | client_id: 0, 93 | upstream_id: 0, 94 | query_type: FtlQueryType::A, 95 | status: FtlQueryStatus::Forward, 96 | reply_type: FtlQueryReplyType::CNAME, 97 | dnssec_type: FtlDnssecType::Secure, 98 | is_complete: true, 99 | is_private: false, 100 | ad_bit: false 101 | }, 102 | query!(2, 96, AAAA, Forward, 0, 0, 0, 263_582, false), 103 | query!(3, 97, PTR, Forward, 0, 0, 0, 263_583, false), 104 | query!(4, 98, A, Gravity, 1, 1, 0, 263_583, false), 105 | query!(5, 99, AAAA, Cache, 0, 1, 0, 263_584, false), 106 | query!(6, 100, AAAA, Wildcard, 2, 1, 0, 263_585, false), 107 | query!(7, 101, A, Blacklist, 3, 2, 0, 263_585, false), 108 | query!(8, 0, AAAA, ExternalBlock, 4, 2, 1, 263_586, false), 109 | query!(9, 0, A, Forward, 5, 3, 0, 263_587, true), 110 | ] 111 | } 112 | 113 | /// The counters necessary for the history tests. 114 | pub fn test_counters() -> FtlCounters { 115 | FtlCounters { 116 | total_queries: 9, 117 | total_upstreams: 2, 118 | total_domains: 6, 119 | total_clients: 4, 120 | ..FtlCounters::default() 121 | } 122 | } 123 | 124 | /// 6 domains. See `test_queries` for how they're used. 125 | pub fn test_domains() -> Vec { 126 | vec![ 127 | FtlDomain::new(4, 0, 1, FtlRegexMatch::NotBlocked), 128 | FtlDomain::new(1, 1, 2, FtlRegexMatch::NotBlocked), 129 | FtlDomain::new(1, 1, 3, FtlRegexMatch::Blocked), 130 | FtlDomain::new(1, 1, 4, FtlRegexMatch::NotBlocked), 131 | FtlDomain::new(1, 0, 5, FtlRegexMatch::NotBlocked), 132 | FtlDomain::new(1, 0, 13, FtlRegexMatch::NotBlocked), 133 | ] 134 | } 135 | 136 | /// 4 clients. See `test_queries` for how they're used. 137 | pub fn test_clients() -> Vec { 138 | vec![ 139 | FtlClient::new(3, 0, 6, Some(7)), 140 | FtlClient::new(3, 2, 8, None), 141 | FtlClient::new(2, 2, 9, None), 142 | FtlClient::new(1, 0, 10, None), 143 | ] 144 | } 145 | 146 | /// 1 upstream. See `test_queries` for how it's used. 147 | pub fn test_upstreams() -> Vec { 148 | vec![ 149 | FtlUpstream::new(3, 0, 11, Some(12)), 150 | FtlUpstream::new(1, 0, 14, Some(15)), 151 | ] 152 | } 153 | 154 | /// Strings used in the test data 155 | pub fn test_strings() -> HashMap { 156 | let mut strings = HashMap::new(); 157 | strings.insert(1, "domain1.com".to_owned()); 158 | strings.insert(2, "domain2.com".to_owned()); 159 | strings.insert(3, "domain3.com".to_owned()); 160 | strings.insert(4, "domain4.com".to_owned()); 161 | strings.insert(5, "domain5.com".to_owned()); 162 | strings.insert(6, "192.168.1.10".to_owned()); 163 | strings.insert(7, "client1".to_owned()); 164 | strings.insert(8, "192.168.1.11".to_owned()); 165 | strings.insert(9, "192.168.1.12".to_owned()); 166 | strings.insert(10, "0.0.0.0".to_owned()); 167 | strings.insert(11, "8.8.8.8".to_owned()); 168 | strings.insert(12, "google-public-dns-a.google.com".to_owned()); 169 | strings.insert(13, "hidden".to_owned()); 170 | strings.insert(14, "8.8.4.4".to_owned()); 171 | strings.insert(15, "google-public-dns-b.google.com".to_owned()); 172 | 173 | strings 174 | } 175 | -------------------------------------------------------------------------------- /src/routes/stats/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Statistic API Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod clients; 12 | mod common; 13 | mod history; 14 | mod over_time_clients; 15 | mod over_time_history; 16 | mod query_types; 17 | mod recent_blocked; 18 | mod summary; 19 | mod top_clients; 20 | mod top_domains; 21 | mod upstreams; 22 | 23 | pub mod database; 24 | 25 | pub use self::{ 26 | clients::*, history::*, over_time_clients::*, over_time_history::*, query_types::*, 27 | recent_blocked::*, summary::*, top_clients::*, top_domains::*, upstreams::* 28 | }; 29 | -------------------------------------------------------------------------------- /src/routes/stats/over_time_history.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query History Over Time Endpoint 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | ftl::FtlMemory, 13 | routes::stats::common::get_current_over_time_slot, 14 | util::{reply_data, Reply} 15 | }; 16 | use rocket::State; 17 | 18 | /// Get the query history over time (separated into blocked and not blocked) 19 | #[get("/stats/overTime/history")] 20 | pub fn over_time_history(ftl_memory: State) -> Reply { 21 | let lock = ftl_memory.lock()?; 22 | let over_time = ftl_memory.over_time(&lock)?; 23 | 24 | let over_time_data: Vec = over_time.iter() 25 | // Take all of the slots including the current slot 26 | .take(get_current_over_time_slot(&over_time) + 1) 27 | // Skip the overTime slots without any data 28 | .skip_while(|time| { 29 | (time.total_queries <= 0 && time.blocked_queries <= 0) 30 | }) 31 | .map(|time| { 32 | OverTimeItem { 33 | timestamp: time.timestamp as u64, 34 | total_queries: time.total_queries as usize, 35 | blocked_queries: time.blocked_queries as usize 36 | } 37 | }) 38 | .collect(); 39 | 40 | reply_data(over_time_data) 41 | } 42 | 43 | #[derive(Serialize)] 44 | #[cfg_attr(test, derive(PartialEq, Debug))] 45 | pub struct OverTimeItem { 46 | pub timestamp: u64, 47 | pub total_queries: usize, 48 | pub blocked_queries: usize 49 | } 50 | 51 | #[cfg(test)] 52 | mod test { 53 | use crate::{ 54 | ftl::{FtlCounters, FtlMemory, FtlOverTime, FtlSettings}, 55 | testing::TestBuilder 56 | }; 57 | use std::collections::HashMap; 58 | 59 | /// Data for testing over_time_history 60 | fn test_data() -> FtlMemory { 61 | FtlMemory::Test { 62 | over_time: vec![ 63 | FtlOverTime::new(1, 1, 0, 0, 1, [0; 7]), 64 | FtlOverTime::new(2, 1, 1, 1, 0, [0; 7]), 65 | FtlOverTime::new(3, 0, 1, 0, 0, [0; 7]), 66 | ], 67 | counters: FtlCounters { 68 | ..FtlCounters::default() 69 | }, 70 | clients: Vec::new(), 71 | upstreams: Vec::new(), 72 | strings: HashMap::new(), 73 | domains: Vec::new(), 74 | queries: Vec::new(), 75 | settings: FtlSettings::default() 76 | } 77 | } 78 | 79 | /// Default params will skip overTime slots until it finds the first slot 80 | /// with queries. 81 | #[test] 82 | fn default_params() { 83 | TestBuilder::new() 84 | .endpoint("/admin/api/stats/overTime/history") 85 | .ftl_memory(test_data()) 86 | .expect_json(json!([ 87 | { "timestamp": 1, "total_queries": 1, "blocked_queries": 0 }, 88 | { "timestamp": 2, "total_queries": 1, "blocked_queries": 1 }, 89 | { "timestamp": 3, "total_queries": 0, "blocked_queries": 1 } 90 | ])) 91 | .test(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/routes/stats/query_types.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Query Types Endpoint 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | ftl::{FtlMemory, FtlQueryType}, 13 | routes::auth::User, 14 | util::{reply_result, Error, Reply} 15 | }; 16 | use rocket::State; 17 | 18 | /// Get the query types 19 | #[get("/stats/query_types")] 20 | pub fn query_types(_auth: User, ftl_memory: State) -> Reply { 21 | reply_result(query_types_impl(&ftl_memory)) 22 | } 23 | 24 | /// Get the query types 25 | fn query_types_impl(ftl_memory: &FtlMemory) -> Result, Error> { 26 | let lock = ftl_memory.lock()?; 27 | let counters = ftl_memory.counters(&lock)?; 28 | 29 | Ok(FtlQueryType::variants() 30 | .iter() 31 | .map(|&variant| QueryTypeReply { 32 | name: variant.get_name(), 33 | count: counters.query_type(variant) 34 | }) 35 | .collect()) 36 | } 37 | 38 | /// Represents the reply structure for returning query type data 39 | #[derive(Serialize)] 40 | #[cfg_attr(test, derive(Debug, PartialEq))] 41 | pub struct QueryTypeReply { 42 | pub name: String, 43 | pub count: usize 44 | } 45 | 46 | #[cfg(test)] 47 | mod test { 48 | use super::query_types_impl; 49 | use crate::{ 50 | ftl::{FtlCounters, FtlMemory, FtlSettings}, 51 | routes::stats::query_types::QueryTypeReply 52 | }; 53 | use std::collections::HashMap; 54 | 55 | fn test_data() -> FtlMemory { 56 | FtlMemory::Test { 57 | counters: FtlCounters { 58 | query_type_counters: [2, 2, 1, 1, 1, 2, 1], 59 | total_queries: 10, 60 | ..FtlCounters::default() 61 | }, 62 | domains: Vec::new(), 63 | over_time: Vec::new(), 64 | strings: HashMap::new(), 65 | upstreams: Vec::new(), 66 | queries: Vec::new(), 67 | clients: Vec::new(), 68 | settings: FtlSettings::default() 69 | } 70 | } 71 | 72 | /// Simple test to validate output 73 | #[test] 74 | fn query_types() { 75 | let expected = vec![ 76 | QueryTypeReply { 77 | name: "A".to_owned(), 78 | count: 2 79 | }, 80 | QueryTypeReply { 81 | name: "AAAA".to_owned(), 82 | count: 2 83 | }, 84 | QueryTypeReply { 85 | name: "ANY".to_owned(), 86 | count: 1 87 | }, 88 | QueryTypeReply { 89 | name: "SRV".to_owned(), 90 | count: 1 91 | }, 92 | QueryTypeReply { 93 | name: "SOA".to_owned(), 94 | count: 1 95 | }, 96 | QueryTypeReply { 97 | name: "PTR".to_owned(), 98 | count: 2 99 | }, 100 | QueryTypeReply { 101 | name: "TXT".to_owned(), 102 | count: 1 103 | }, 104 | ]; 105 | 106 | let actual = query_types_impl(&test_data()).unwrap(); 107 | 108 | assert_eq!(actual, expected); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/routes/stats/recent_blocked.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Recent Blocked Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | env::Env, 13 | ftl::FtlMemory, 14 | routes::auth::User, 15 | settings::{ConfigEntry, FtlConfEntry, FtlPrivacyLevel}, 16 | util::{reply_data, Reply} 17 | }; 18 | use rocket::{request::Form, State}; 19 | 20 | /// Get the `num` most recently blocked domains 21 | #[get("/stats/recent_blocked?")] 22 | pub fn recent_blocked( 23 | _auth: User, 24 | ftl_memory: State, 25 | env: State, 26 | params: Form 27 | ) -> Reply { 28 | get_recent_blocked(&ftl_memory, &env, params.num.unwrap_or(1)) 29 | } 30 | 31 | /// Represents the possible GET parameters on `/stats/recent_blocked` 32 | #[derive(FromForm)] 33 | pub struct RecentBlockedParams { 34 | num: Option 35 | } 36 | 37 | /// Get `num`-many most recently blocked domains 38 | pub fn get_recent_blocked(ftl_memory: &FtlMemory, env: &Env, num: usize) -> Reply { 39 | // Check if client details are private 40 | if FtlConfEntry::PrivacyLevel.read_as::(&env)? >= FtlPrivacyLevel::HideDomains 41 | { 42 | return reply_data([0; 0]); 43 | } 44 | 45 | let lock = ftl_memory.lock()?; 46 | let counters = ftl_memory.counters(&lock)?; 47 | let queries = ftl_memory.queries(&lock)?; 48 | let strings = ftl_memory.strings(&lock)?; 49 | let domains = ftl_memory.domains(&lock)?; 50 | 51 | let recent_blocked: Vec<&str> = queries 52 | .iter() 53 | // Get the most recent queries first 54 | .rev() 55 | // Skip the uninitialized queries 56 | .skip(queries.len() - counters.total_queries as usize) 57 | // Only get blocked queries 58 | .filter(|query| query.is_blocked()) 59 | // Get up to num queries 60 | .take(num) 61 | // Only return the domain 62 | .map(|query| domains[query.domain_id as usize].get_domain(&strings)) 63 | .collect(); 64 | 65 | reply_data(recent_blocked) 66 | } 67 | 68 | #[cfg(test)] 69 | mod test { 70 | use crate::{ 71 | ftl::{ 72 | FtlCounters, FtlDnssecType, FtlDomain, FtlMemory, FtlQuery, FtlQueryReplyType, 73 | FtlQueryStatus, FtlQueryType, FtlRegexMatch, FtlSettings, MAGIC_BYTE 74 | }, 75 | testing::TestBuilder 76 | }; 77 | use std::collections::HashMap; 78 | 79 | /// Shorthand for making `FtlQuery` structs 80 | macro_rules! query { 81 | ($id:expr, $status:ident, $domain:expr) => { 82 | FtlQuery { 83 | magic: MAGIC_BYTE, 84 | id: $id, 85 | database_id: 0, 86 | timestamp: 1, 87 | time_index: 1, 88 | response_time: 1, 89 | domain_id: $domain, 90 | client_id: 0, 91 | upstream_id: 0, 92 | query_type: FtlQueryType::A, 93 | status: FtlQueryStatus::$status, 94 | reply_type: FtlQueryReplyType::IP, 95 | dnssec_type: FtlDnssecType::Unspecified, 96 | is_complete: true, 97 | is_private: false, 98 | ad_bit: false 99 | } 100 | }; 101 | } 102 | 103 | /// 6 queries, 4 blocked 104 | fn test_queries() -> Vec { 105 | vec![ 106 | query!(1, Forward, 0), 107 | query!(2, Gravity, 1), 108 | query!(3, Blacklist, 2), 109 | query!(4, Wildcard, 3), 110 | query!(5, ExternalBlock, 4), 111 | query!(6, Cache, 0), 112 | ] 113 | } 114 | 115 | /// 5 domain, 4 blocked, 1 regex blocked, 1 not blocked 116 | fn test_domains() -> Vec { 117 | vec![ 118 | FtlDomain::new(2, 0, 1, FtlRegexMatch::NotBlocked), 119 | FtlDomain::new(1, 1, 2, FtlRegexMatch::NotBlocked), 120 | FtlDomain::new(1, 1, 3, FtlRegexMatch::NotBlocked), 121 | FtlDomain::new(1, 1, 4, FtlRegexMatch::Blocked), 122 | FtlDomain::new(1, 1, 5, FtlRegexMatch::NotBlocked), 123 | ] 124 | } 125 | 126 | /// Strings used in the test data 127 | fn test_strings() -> HashMap { 128 | let mut strings = HashMap::new(); 129 | strings.insert(1, "domain1.com".to_owned()); 130 | strings.insert(2, "domain2.com".to_owned()); 131 | strings.insert(3, "domain3.com".to_owned()); 132 | strings.insert(4, "domain4.com".to_owned()); 133 | strings.insert(5, "domain5.com".to_owned()); 134 | 135 | strings 136 | } 137 | 138 | /// Creates an `FtlMemory` struct from the other test data functions 139 | fn test_memory() -> FtlMemory { 140 | FtlMemory::Test { 141 | queries: test_queries(), 142 | domains: test_domains(), 143 | over_time: Vec::new(), 144 | strings: test_strings(), 145 | clients: Vec::new(), 146 | upstreams: Vec::new(), 147 | counters: FtlCounters { 148 | total_queries: 6, 149 | total_domains: 5, 150 | ..FtlCounters::default() 151 | }, 152 | settings: FtlSettings::default() 153 | } 154 | } 155 | 156 | /// The default behavior shows one most recently blocked domain 157 | #[test] 158 | fn default_params() { 159 | TestBuilder::new() 160 | .endpoint("/admin/api/stats/recent_blocked") 161 | .ftl_memory(test_memory()) 162 | .expect_json(json!(["domain5.com"])) 163 | .test(); 164 | } 165 | 166 | /// The `num` parameter returns that many most recently blocked domain 167 | #[test] 168 | fn multiple() { 169 | TestBuilder::new() 170 | .endpoint("/admin/api/stats/recent_blocked?num=3") 171 | .ftl_memory(test_memory()) 172 | .expect_json(json!(["domain5.com", "domain4.com", "domain3.com"])) 173 | .test(); 174 | } 175 | 176 | /// If there are less blocked domains than requested, return as many as we 177 | /// can find 178 | #[test] 179 | fn less_than_requested() { 180 | TestBuilder::new() 181 | .endpoint("/admin/api/stats/recent_blocked?num=10") 182 | .ftl_memory(test_memory()) 183 | .expect_json(json!([ 184 | "domain5.com", 185 | "domain4.com", 186 | "domain3.com", 187 | "domain2.com" 188 | ])) 189 | .test(); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/routes/web.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Web Interface Endpoints 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use rocket::{ 12 | http::ContentType, 13 | response::{Redirect, Response} 14 | }; 15 | use std::{borrow::Cow, io::Cursor, path::PathBuf}; 16 | 17 | #[derive(RustEmbed)] 18 | #[folder = "web/"] 19 | pub struct WebAssets; 20 | 21 | /// Get a file from the embedded web assets 22 | fn get_file<'r>(filename: &str) -> Option> { 23 | let has_extension = filename.contains('.'); 24 | let content_type = if has_extension { 25 | match ContentType::from_extension(filename.rsplit('.').next().unwrap()) { 26 | Some(value) => value, 27 | None => return None 28 | } 29 | } else { 30 | ContentType::Binary 31 | }; 32 | 33 | WebAssets::get(filename).map_or_else( 34 | // If the file was not found, and there is no extension on the filename, 35 | // fall back to the web interface index.html 36 | || { 37 | if !has_extension { 38 | WebAssets::get("index.html").map(|data| build_response(data, ContentType::HTML)) 39 | } else { 40 | None 41 | } 42 | }, 43 | // The file was found, so build the response 44 | |data| Some(build_response(data, content_type)) 45 | ) 46 | } 47 | 48 | /// Build a `Response` from raw data and its content type 49 | fn build_response<'r>(data: Cow<'static, [u8]>, content_type: ContentType) -> Response<'r> { 50 | Response::build() 51 | .header(content_type) 52 | .sized_body(Cursor::new(data)) 53 | .finalize() 54 | } 55 | 56 | /// Redirect root requests to the web interface. This allows http://pi.hole to 57 | /// redirect to http://pi.hole/admin 58 | #[get("/")] 59 | pub fn web_interface_redirect() -> Redirect { 60 | Redirect::to(uri!(web_interface_index)) 61 | } 62 | 63 | /// Return the index page of the web interface 64 | #[get("/admin")] 65 | pub fn web_interface_index<'r>() -> Option> { 66 | get_file("index.html") 67 | } 68 | 69 | /// Return the requested page/file, if it exists. 70 | #[get("/admin/")] 71 | pub fn web_interface<'r>(path: PathBuf) -> Option> { 72 | get_file(&path.display().to_string()) 73 | } 74 | -------------------------------------------------------------------------------- /src/settings/mod.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Setting Specifications For SetupVars & FTL Configuration Files 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | mod dnsmasq; 12 | mod entries; 13 | mod privacy_level; 14 | mod value_type; 15 | 16 | pub use self::{ 17 | dnsmasq::generate_dnsmasq_config, 18 | entries::{ConfigEntry, FtlConfEntry, SetupVarsEntry}, 19 | privacy_level::FtlPrivacyLevel, 20 | value_type::ValueType 21 | }; 22 | -------------------------------------------------------------------------------- /src/settings/privacy_level.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // FTL Privacy Level Enum 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::util::{Error, ErrorKind}; 12 | use std::str::FromStr; 13 | 14 | /// The privacy levels used by FTL 15 | #[derive(PartialOrd, PartialEq)] 16 | pub enum FtlPrivacyLevel { 17 | ShowAll, 18 | HideDomains, 19 | HideDomainsAndClients, 20 | Maximum, 21 | NoStats 22 | } 23 | 24 | impl FromStr for FtlPrivacyLevel { 25 | type Err = Error; 26 | 27 | fn from_str(s: &str) -> Result { 28 | match s { 29 | "0" => Ok(FtlPrivacyLevel::ShowAll), 30 | "1" => Ok(FtlPrivacyLevel::HideDomains), 31 | "2" => Ok(FtlPrivacyLevel::HideDomainsAndClients), 32 | "3" => Ok(FtlPrivacyLevel::Maximum), 33 | "4" => Ok(FtlPrivacyLevel::NoStats), 34 | _ => Err(Error::from(ErrorKind::InvalidSettingValue)) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/setup.rs: -------------------------------------------------------------------------------- 1 | // Pi-hole: A black hole for Internet advertisements 2 | // (c) 2019 Pi-hole, LLC (https://pi-hole.net) 3 | // Network-wide ad blocking via your own hardware. 4 | // 5 | // API 6 | // Server Setup Functions 7 | // 8 | // This file is copyright under the latest version of the EUPL. 9 | // Please see LICENSE file for your rights under this license. 10 | 11 | use crate::{ 12 | databases::{ftl::FtlDatabase, load_databases}, 13 | env::{Config, Env}, 14 | ftl::{FtlConnectionType, FtlMemory}, 15 | routes::{ 16 | auth::{self, AuthData}, 17 | dns, settings, stats, version, web 18 | }, 19 | settings::{ConfigEntry, SetupVarsEntry}, 20 | util::{Error, ErrorKind} 21 | }; 22 | use rocket::config::{ConfigBuilder, Environment}; 23 | use rocket_cors::Cors; 24 | 25 | #[cfg(test)] 26 | use crate::{databases::load_test_databases, env::PiholeFile}; 27 | #[cfg(test)] 28 | use rocket::{config::LoggingLevel, local::Client}; 29 | #[cfg(test)] 30 | use std::collections::HashMap; 31 | #[cfg(test)] 32 | use tempfile::NamedTempFile; 33 | 34 | const CONFIG_LOCATION: &str = "/etc/pihole/API.toml"; 35 | 36 | #[catch(404)] 37 | fn not_found() -> Error { 38 | Error::from(ErrorKind::NotFound) 39 | } 40 | 41 | #[catch(401)] 42 | fn unauthorized() -> Error { 43 | Error::from(ErrorKind::Unauthorized) 44 | } 45 | 46 | /// Run the API normally (connect to FTL over the socket) 47 | pub fn start() -> Result<(), Error> { 48 | let config = Config::parse(CONFIG_LOCATION)?; 49 | let env = Env::Production(config); 50 | let key = SetupVarsEntry::WebPassword.read(&env)?; 51 | 52 | setup( 53 | rocket::custom( 54 | ConfigBuilder::new(Environment::Production) 55 | .address(env.config().address()) 56 | .port(env.config().port() as u16) 57 | .log_level(env.config().log_level()?) 58 | .extra("databases", load_databases(&env)?) 59 | .finalize() 60 | .unwrap() 61 | ), 62 | FtlConnectionType::Socket, 63 | FtlMemory::production(), 64 | env, 65 | key, 66 | true 67 | ) 68 | .launch(); 69 | 70 | Ok(()) 71 | } 72 | 73 | /// Setup the API with the testing data and return a Client to test with 74 | #[cfg(test)] 75 | pub fn test( 76 | ftl_data: HashMap>, 77 | ftl_memory: FtlMemory, 78 | env_data: HashMap, 79 | needs_database: bool 80 | ) -> Client { 81 | use toml; 82 | 83 | Client::new(setup( 84 | rocket::custom( 85 | ConfigBuilder::new(Environment::Development) 86 | .log_level(LoggingLevel::Debug) 87 | .extra("databases", load_test_databases()) 88 | .finalize() 89 | .unwrap() 90 | ), 91 | FtlConnectionType::Test(ftl_data), 92 | ftl_memory, 93 | Env::Test(toml::from_str("").unwrap(), env_data), 94 | "test_key".to_owned(), 95 | needs_database 96 | )) 97 | .unwrap() 98 | } 99 | 100 | /// General server setup 101 | fn setup( 102 | server: rocket::Rocket, 103 | ftl_socket: FtlConnectionType, 104 | ftl_memory: FtlMemory, 105 | env: Env, 106 | api_key: String, 107 | needs_database: bool 108 | ) -> rocket::Rocket { 109 | // Set up CORS 110 | let cors = Cors { 111 | allow_credentials: true, 112 | ..Cors::default() 113 | }; 114 | 115 | // Attach the databases if required 116 | let server = if needs_database { 117 | server.attach(FtlDatabase::fairing()) 118 | } else { 119 | server 120 | }; 121 | 122 | // Create a scheduler for scheduling work (ex. disable for 10 minutes) 123 | let scheduler = task_scheduler::Scheduler::new(); 124 | 125 | // Set up the server 126 | server 127 | // Attach CORS handler 128 | .attach(cors) 129 | // Add custom error handlers 130 | .register(catchers![not_found, unauthorized]) 131 | // Manage the FTL socket configuration 132 | .manage(ftl_socket) 133 | // Manage the FTL shared memory configuration 134 | .manage(ftl_memory) 135 | // Manage the environment 136 | .manage(env) 137 | // Manage the API key 138 | .manage(AuthData::new(api_key)) 139 | // Manage the scheduler 140 | .manage(scheduler) 141 | // Mount the web interface 142 | .mount("/", routes![ 143 | web::web_interface_redirect, 144 | web::web_interface_index, 145 | web::web_interface 146 | ]) 147 | // Mount the API 148 | .mount("/admin/api", routes![ 149 | version::version, 150 | auth::check, 151 | auth::logout, 152 | stats::get_summary, 153 | stats::top_domains, 154 | stats::top_clients, 155 | stats::upstreams, 156 | stats::query_types, 157 | stats::history, 158 | stats::recent_blocked, 159 | stats::clients, 160 | stats::over_time_history, 161 | stats::over_time_clients, 162 | stats::database::get_summary_db, 163 | stats::database::over_time_clients_db, 164 | stats::database::over_time_history_db, 165 | stats::database::query_types_db, 166 | stats::database::top_clients_db, 167 | stats::database::top_domains_db, 168 | stats::database::upstreams_db, 169 | dns::get_whitelist, 170 | dns::get_blacklist, 171 | dns::get_regexlist, 172 | dns::status, 173 | dns::change_status, 174 | dns::add_whitelist, 175 | dns::add_blacklist, 176 | dns::add_regexlist, 177 | dns::delete_whitelist, 178 | dns::delete_blacklist, 179 | dns::delete_regexlist, 180 | settings::get_dhcp, 181 | settings::put_dhcp, 182 | settings::get_dns, 183 | settings::put_dns, 184 | settings::get_ftldb, 185 | settings::get_ftl, 186 | settings::get_network, 187 | settings::get_web, 188 | settings::put_web 189 | ]) 190 | } 191 | -------------------------------------------------------------------------------- /test/FTL.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pi-hole/api/ce7b5736554e0d0b59eb22a1f2f24dcc9f6e155c/test/FTL.db -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | --------------------------------------------------------------------------------