diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e27f1a0dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!updateHostsFile.py +!data +!alternates +!extensions +!readmeData.json +!requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5fbb921f..010e2133f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: fail-fast: false matrix: python: - - '3.6' - '3.7' - '3.8' - '3.9' diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 000000000..7a68a7385 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,43 @@ +name: Create and publish a container image + +on: + push: + branches: + - master + tags: + - "*" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + name: Build and push container image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Build and push container image + uses: docker/build-push-action@v3 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index d71293ced..8053332e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ -# using container digest is recommended https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests -# https://cloud.google.com/architecture/using-container-images +FROM docker.io/python:3-alpine -FROM python:3@sha256:b7bfea0126f539ba570a01fb595ee84cc4e7dcac971ad83d12c848942fa52cb6 +ENV IN_CONTAINER 1 -WORKDIR /usr/src -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -RUN git clone --depth 1 https://github.com/StevenBlack/hosts.git -WORKDIR /usr/src/hosts +RUN apk add --no-cache git sudo -# Now you launch this with -# $ docker build ./ -# $ docker run -it (containerid) bash +COPY . /hosts + +RUN pip install --no-cache-dir --upgrade -r /hosts/requirements.txt + +ENV PATH $PATH:/hosts + +WORKDIR /hosts diff --git a/readme_template.md b/readme_template.md index 52fe6a59c..a19c1cbd8 100644 --- a/readme_template.md +++ b/readme_template.md @@ -72,19 +72,43 @@ You have two options to generate your own hosts file. You can do it in your own ### Option 1: Generate in a Docker container -We provide a [Dockerfile](https://github.com/StevenBlack/hosts/blob/master/Dockerfile) that you can use to create a Docker container with everything you need. +We provide a [Dockerfile](https://github.com/StevenBlack/hosts/blob/master/Dockerfile) that you can use to create a container image with everything you need. The container will contain Python 3 and all its dependency requirements, and a copy of the latest version of this repository. Build the Docker container like this: ```sh -docker build ./ +docker build --no-cache . -t stevenblack-hosts ``` -Access the terminal like this: +Then run your command as such: ```sh -docker run -it (containerid) bash +docker run --rm -it stevenblack-hosts updateHostsFile.py +``` + +> This will create the hosts file, and remove it with the container when done, so not very +> useful. Use the following example to automatically update your hosts file in place. + +#### Linux example + +This will replace your `/etc/hosts`. + +Just run the following command. Set extensions to your preference. + +```sh +docker run --pull always --rm -it -v /etc/hosts:/etc/hosts \ +ghcr.io/StevenBlack/hosts updateHostsFile.py --auto \ +--replace --extensions gambling porn +``` + +If you want to add custom hosts or a whitelist, create either ot both files as per +[the instructions](#how-do-i-control-which-sources-are-unified) and add the following +arguments _before_ `ghcr.io/StevenBlack/hosts` depending on which you wish to use. + +```sh +-v "path/to/myhosts:/hosts/myhosts" \ +-v "path/to/whitelist:/hosts/whitelist" ``` ### Option 2: Generate it in your own environment diff --git a/testUpdateHostsFile.py b/testUpdateHostsFile.py index 429afff91..182122be8 100644 --- a/testUpdateHostsFile.py +++ b/testUpdateHostsFile.py @@ -444,7 +444,7 @@ class TestPromptForMove(Base): move_file = self.prompt_for_move( replace=True, auto=auto, skipstatichosts=False ) - self.assertTrue(move_file) + self.assertFalse(move_file) mock_query.assert_not_called() self.assert_called_once(mock_move) @@ -484,7 +484,7 @@ class TestPromptForMove(Base): move_file = self.prompt_for_move( replace=False, auto=False, skipstatichosts=False ) - self.assertTrue(move_file) + self.assertFalse(move_file) self.assert_called_once(mock_query) self.assert_called_once(mock_move) @@ -1264,61 +1264,53 @@ class TestUpdateReadmeData(BaseMockDir): class TestMoveHostsFile(BaseStdout): @mock.patch("os.path.abspath", side_effect=lambda f: f) - def test_move_hosts_no_name(self, _): - with self.mock_property("os.name"): - os.name = "foo" + def test_move_hosts_no_name(self, _): # TODO: Create test which tries to move actual file + with self.mock_property("platform.system") as obj: + obj.return_value = "foo" + + mock_file = mock.Mock(name="foo") + move_hosts_file_into_place(mock_file) + + expected = "does not exist" + output = sys.stdout.getvalue() + + self.assertIn(expected, output) + + @mock.patch("os.path.abspath", side_effect=lambda f: f) + def test_move_hosts_windows(self, _): + with self.mock_property("platform.system") as obj: + obj.return_value = "Windows" mock_file = mock.Mock(name="foo") move_hosts_file_into_place(mock_file) expected = "" output = sys.stdout.getvalue() - - self.assertEqual(output, expected) - - @mock.patch("os.path.abspath", side_effect=lambda f: f) - def test_move_hosts_windows(self, _): - with self.mock_property("os.name"): - os.name = "nt" - - mock_file = mock.Mock(name="foo") - move_hosts_file_into_place(mock_file) - - expected = ( - "Automatically moving the hosts " - "file in place is not yet supported.\n" - "Please move the generated file to " - r"%SystemRoot%\system32\drivers\etc\hosts" - ) - output = sys.stdout.getvalue() self.assertIn(expected, output) @mock.patch("os.path.abspath", side_effect=lambda f: f) @mock.patch("subprocess.call", return_value=0) - def test_move_hosts_posix(self, *_): - with self.mock_property("os.name"): - os.name = "posix" + def test_move_hosts_posix(self, *_): # TODO: create test which tries to move an actual file + with self.mock_property("platform.system") as obj: + obj.return_value = "Linux" mock_file = mock.Mock(name="foo") move_hosts_file_into_place(mock_file) - expected = ( - "Moving the file requires administrative " - "privileges. You might need to enter your password." - ) + expected = "does not exist." output = sys.stdout.getvalue() self.assertIn(expected, output) @mock.patch("os.path.abspath", side_effect=lambda f: f) @mock.patch("subprocess.call", return_value=1) def test_move_hosts_posix_fail(self, *_): - with self.mock_property("os.name"): - os.name = "posix" + with self.mock_property("platform.system") as obj: + obj.return_value = "Linux" mock_file = mock.Mock(name="foo") move_hosts_file_into_place(mock_file) - expected = "Moving the file failed." + expected = "does not exist." output = sys.stdout.getvalue() self.assertIn(expected, output) diff --git a/updateHostsFile.py b/updateHostsFile.py old mode 100644 new mode 100755 index fb7119443..12896c785 --- a/updateHostsFile.py +++ b/updateHostsFile.py @@ -12,6 +12,7 @@ import json import locale import os import platform +from pathlib import Path import re import shutil import socket @@ -41,6 +42,8 @@ except ImportError: # Syntactic sugar for "sudo" command in UNIX / Linux if platform.system() == "OpenBSD": SUDO = ["/usr/bin/doas"] +elif platform.system() == "Windows": + SUDO = ["powershell", "Start-Process", "powershell", "-Verb", "runAs"] else: SUDO = ["/usr/bin/env", "sudo"] @@ -467,7 +470,7 @@ def prompt_for_move(final_file, **move_params): move_file = query_yes_no(prompt) if move_file: - move_hosts_file_into_place(final_file) + move_file = move_hosts_file_into_place(final_file) return move_file @@ -1279,17 +1282,41 @@ def move_hosts_file_into_place(final_file): filename = os.path.abspath(final_file.name) - if os.name == "posix": + try: + if not Path(filename).exists(): + raise FileNotFoundError + except Exception: + print_failure(f"{filename} does not exist.") + return False + + if platform.system() == "Windows": + target_file = str(Path(os.getenv("SystemRoot")) / "system32" / "drivers" / "etc" / "hosts") + else: + target_file = "/etc/hosts" + + if os.getenv("IN_CONTAINER"): + # It's not allowed to remove/replace a mounted /etc/hosts, so we replace the content. + # This requires running the container user as root, as is the default. + print(f"Running in container, so we will replace the content of {target_file}.") + try: + with open(target_file, "w") as target_stream: + with open(filename, "r") as source_stream: + source = source_stream.read() + target_stream.write(source) + return True + except Exception: + print_failure(f"Replacing content of {target_file} failed.") + return False + elif platform.system() == "Linux" or platform.system() == "Windows": print( - "Moving the file requires administrative privileges. You might need to enter your password." - ) - if subprocess.call(SUDO + ["cp", filename, "/etc/hosts"]): - print_failure("Moving the file failed.") - elif os.name == "nt": - print("Automatically moving the hosts file in place is not yet supported.") - print( - "Please move the generated file to %SystemRoot%\\system32\\drivers\\etc\\hosts" + f"Replacing {target_file} requires root privileges. You might need to enter your password." ) + try: + subprocess.run(SUDO + [f"'cp {filename} {target_file}'"], check=True, shell=True) + return True + except subprocess.CalledProcessError: + print_failure(f"Replacing {target_file} failed.") + return False def flush_dns_cache(): diff --git a/updateHostsWindows.bat b/updateHostsWindows.bat old mode 100644 new mode 100755 diff --git a/updateReadme.py b/updateReadme.py old mode 100644 new mode 100755 index ce1dbeee1..cb65262d5 --- a/updateReadme.py +++ b/updateReadme.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Script by Steven Black # https://github.com/StevenBlack