commit ac958c04f0159a55c45fc99c05dfd8b7eb14d5c1 Author: irl Date: Wed Jan 7 16:52:10 2026 +0000 feat: initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cba423 --- /dev/null +++ b/.gitignore @@ -0,0 +1,210 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..66eede3 --- /dev/null +++ b/Justfile @@ -0,0 +1,3 @@ +build: + rm -rf dist + hatch build -t sdist diff --git a/README.md b/README.md new file mode 100644 index 0000000..5650b0c --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ + +

+ Guardian Project +

+ +

bootbridge

+

+A command line tool to bootstrap a Tor bridge +(not to be confused with bridgestrap). + +

+

+Language: Python + + Licence: BSD 2-Clause + + Lifecycle: Experimental +
+ + Issues + + + Open Collective backers and sponsors + +

+ +--- + +### Purpose + +You want to run many Tor bridges and would like to quickly bootstrap them from a fresh Debian installation. + +### Requirements + +* Debian stable VM fresh out of the box that won't be used for any other purpose +* Either: + * SSH access to that box + * Ability to give it cloud-init user data + +### Caveats + +* Many cloud providers now implement network firewalls outside the control of the operating system. + This is good for security in that if the virtual machine is compromised, it's not possible to change the firewall + rules, but it also means that you can't deliberately change the firewall rules from inside the virtual machine. + If your cloud has such a feature, it'll be up to you to configure that separately, perhaps with + [OpenTofu](https://opentofu.org/). + +### Concept + +![original concept](./docs/concept.jpg) + +### Copyright + +Copyright © 2022-2025 SR2 Communications Limited. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/bootbridge/__init__.py b/bootbridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bootbridge/__main__.py b/bootbridge/__main__.py new file mode 100644 index 0000000..50f9d0a --- /dev/null +++ b/bootbridge/__main__.py @@ -0,0 +1,11 @@ +import os +import sys + +if __name__ == "__main__": + if not __package__: + package_source_path = os.path.dirname(os.path.dirname(__file__)) + sys.path.insert(0, package_source_path) + + from bootbridge.app import app + + app() diff --git a/bootbridge/app.py b/bootbridge/app.py new file mode 100644 index 0000000..523b350 --- /dev/null +++ b/bootbridge/app.py @@ -0,0 +1,64 @@ +import os +import sys + +import typer +from typing_extensions import Annotated + +from bootbridge.settings import validate_settings, settings +from bootbridge.configure import ConfigureCommand +from bootbridge.console import console +from bootbridge.webtunnel import up_webtunnel + +if not __package__: + package_source_path = os.path.dirname(os.path.dirname(__file__)) + sys.path.insert(0, package_source_path) + +app = typer.Typer( + help="Bootstrap a Tor bridge", + no_args_is_help=True, +) + + +@app.command(rich_help_panel="Configure") +def configure(): + """ + Interactively define configuration for a Tor bridge. + """ + ConfigureCommand().run() + + + +@app.command(rich_help_panel="Provision") +def up( + force_tls_renewal: Annotated[ + bool, typer.Option(help="Force renewal of TLS certificate(s), if any.") + ] = False, +): + """ + Bootstrap a Tor bridge. + """ + up_functions = { + "webtunnel": up_webtunnel, + } + console.rule("[bold]bootbridge up") + validate_settings() + try: + up_functions[settings.bridge.transport]( + settings.bridge, force_tls_renewal=force_tls_renewal + ) + except KeyError: + raise NotImplementedError() + raise typer.Exit() + + +@app.command(rich_help_panel="Utilities") +def validate(): + """ + Validate configuration. + """ + validate_settings() + raise typer.Exit() + + +if __name__ == "__main__": + app() diff --git a/bootbridge/apt.py b/bootbridge/apt.py new file mode 100644 index 0000000..201d6a0 --- /dev/null +++ b/bootbridge/apt.py @@ -0,0 +1,95 @@ +import base64 +import subprocess +from functools import cache + +import typer + +from bootbridge.console import console +from bootbridge.utils import run_command, requests_session, ensure_file + + +@cache +def get_codename() -> str: + return subprocess.check_output(["lsb_release", "-cs"], text=True).strip() + + +def setup_deb_tpo() -> None: + with console.status("Configuring Tor Project Debian repository..."): + s = requests_session() + r = s.get( + "https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc" + ) + r.raise_for_status() + if r.headers["content-type"] == "application/pgp-keys": + key = r.content + else: + key = dearmor_gpg_key(r.content) + ensure_file( + "/usr/share/keyrings/tor-archive-keyring.gpg", + key, + owner=0, + group=0, + mode=0o644, + ) + ensure_file( + "/etc/apt/sources.list.d/tor.list", + f"""deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org {get_codename()} main + deb-src [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org {get_codename()} main""", + owner=0, + group=0, + mode=0o644, + ) + + +def apt_update(upgrade: bool = True) -> None: + for action in [ + ("Updating apt package lists", "update"), + ("Upgrading apt packages", "upgrade"), + ]: + with console.status(action[0] + "..."): + try: + command = ["apt", "-y", action[1]] + for output in run_command(command): + console.log("[gray30]" + output) + except subprocess.CalledProcessError: + console.log(f"[red]An error occurred while {action[0].lower()}. :boom:") + console.log( + f"[blue]To debug, you can try running '{' '.join(command)}' yourself." + ) + raise typer.Exit(1) + console.log( + f":white_check_mark: Updated the apt package lists{' and upgraded packages' if upgrade else ''}." + ) + + +def apt_install(pkgs: str | list[str]) -> None: + with console.status(f"Installing apt packages ({', '.join(pkgs)})..."): + try: + if isinstance(pkgs, str): + pkgs = [pkgs] + command = ["apt", "install", "-y"] + pkgs + for output in run_command(command): + console.log("[gray30]" + output) + except subprocess.CalledProcessError: + console.log( + "[red]An error occurred while installing packages with apt. :boom:" + ) + console.log( + f"[blue]To debug, you can try running 'apt install {' '.join(pkgs)}' yourself." + ) + raise typer.Exit(1) + console.log(f":white_check_mark: Installed apt packages ({', '.join(pkgs)}).") + + +def dearmor_gpg_key(ascii_key: bytes) -> bytes: + lines = [line.strip() for line in ascii_key.strip().splitlines()] + if ( + lines[0] != b"-----BEGIN PGP PUBLIC KEY BLOCK-----" + or lines[-1] != b"-----END PGP PUBLIC KEY BLOCK-----" + ): + raise ValueError("Invalid PGP key format") + start_line = lines.index(b"") + body = lines[start_line:-1] + key_data = b"".join(body) + binary_key = base64.b64decode(key_data) + return binary_key diff --git a/bootbridge/configure.py b/bootbridge/configure.py new file mode 100644 index 0000000..057903d --- /dev/null +++ b/bootbridge/configure.py @@ -0,0 +1,110 @@ +import os +import random +import string +from typing import Any + +import petname +import typer +import yaml +from rich.panel import Panel +from rich.prompt import Confirm, Prompt + +from bootbridge.settings import CONFIG_PATH, validate_settings +from bootbridge.console import console +from bootbridge.utils import ensure_file + + +class ConfigureCommand: + settings: dict[str, Any] + old_settings: None | dict[str, Any] = None + + def load_old_settings(self): + if os.path.exists(CONFIG_PATH): + if Confirm.ask("Would you like to load existing settings?"): + with open(CONFIG_PATH, "r") as config_file: + self.old_settings = yaml.safe_load(config_file) + + def old_setting( + self, key: str, default: Any, current: None | dict[str, Any] = None + ) -> Any: + if current is None: + if not (current := self.old_settings): + return default + key_parts = key.split(".") + if len(key_parts) == 1: + try: + return current[key_parts[0]] + except KeyError: + return default + else: + try: + return self.old_setting( + ".".join(key_parts[1:]), default, current[key_parts[0]] + ) + except KeyError: + return default + + def ask_webtunnel(self): + self.ask( + "Enter a URL path", + "bridge.webtunnel.path", + "".join( + random.choice(string.ascii_letters + string.digits) + for _ in range(random.randint(30, 50)) + ), + ) + self.ask( + "Choose a certificate method", + "bridge.webtunnel.certificate", + "zerossl-ip", + choices=["zerossl-ip"], + ) + if self.settings["bridge"]["webtunnel"]["certificate"] == "zerossl-ip": + self.ask("Enter your ZeroSSL API token", "zerossl.token", None) + + def ask( + self, prompt: str, key: str, default: str | None, choices: list[str] | None = None + ) -> None: + result = Prompt.ask( + prompt, default=self.old_setting(key, default), choices=choices + ) + key_parts = key.split(".") + current = self.settings + while len(key_parts) > 1: + head = key_parts.pop(0) + if head not in current: + current[head] = {} + current = current[head] + current[key_parts[0]] = result + + def save(self): + ensure_file( + CONFIG_PATH, + yaml.dump(self.settings, indent=2), + owner=0, + group=0, + mode=0o600, + ) + + def run(self): + console.rule("bootbridge configure") + self.load_old_settings() + self.settings = {} + self.ask( + "Enter a nickname", + "bridge.nickname", + petname.Generate(3).title().replace("-", ""), + ) + self.ask("Enter contact details", "bridge.contact", "n/a") + self.ask("Choose a transport", "bridge.transport", "webtunnel", ["webtunnel"]) + if self.settings["bridge"]["transport"] == "webtunnel": + self.ask_webtunnel() + self.save() + validate_settings() + console.print( + Panel( + ":floppy_disk: Configuration saved.\n\n" + ":toolbox: Run [bold]bootbridge up[/bold] to bootstrap this bridge." + ) + ) + raise typer.Exit() diff --git a/bootbridge/console.py b/bootbridge/console.py new file mode 100644 index 0000000..a9463af --- /dev/null +++ b/bootbridge/console.py @@ -0,0 +1,3 @@ +from rich.console import Console + +console = Console() diff --git a/bootbridge/httpserver.py b/bootbridge/httpserver.py new file mode 100644 index 0000000..3857b30 --- /dev/null +++ b/bootbridge/httpserver.py @@ -0,0 +1,39 @@ +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + + +def bootstrap_http_handler(content: str): + class BootstrapHTTPHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(content.encode("utf-8")) + + return BootstrapHTTPHandler + + +server_instance: HTTPServer | None = None + + +def _start_bootstrap_server(content: str) -> None: + global server_instance + server_address = ("", 80) + server_instance = HTTPServer(server_address, bootstrap_http_handler(content)) + server_instance.serve_forever() + + +def start_bootstrap_server(content: str) -> None: + server_thread = threading.Thread(target=_start_bootstrap_server, args=(content,)) + server_thread.daemon = True + server_thread.start() + time.sleep(1) # Give the server a second to start + + +def stop_bootstrap_server() -> None: + global server_instance + if server_instance: + server_instance.shutdown() + server_instance.server_close() + server_instance = None diff --git a/bootbridge/settings.py b/bootbridge/settings.py new file mode 100644 index 0000000..aedad8d --- /dev/null +++ b/bootbridge/settings.py @@ -0,0 +1,96 @@ +import os +import time +from typing import Literal, Type, Any + +import typer +from pydantic import BaseModel, ValidationError +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) + +from bootbridge.console import console + +CONFIG_PATH = os.getenv("BOOTBRIDGE_CONFIG", "/etc/bootbridge.yaml") + + +class WebtunnelSettings(BaseModel): + certificate: Literal["zerossl-ip"] + path: str + + +class KeysSettings(BaseModel): + ed25519_master_id_public_key: str + ed25519_master_id_secret_key: str + secret_id_key: str + + +class BridgeSettings(BaseModel): + nickname: str # TODO: string constraints + contact: str # TODO: string constraints + transport: Literal["webtunnel"] + webtunnel: WebtunnelSettings | None = None + keys: KeysSettings | None = None + + +class ZerosslSettings(BaseModel): + token: str # TODO: string constraints? + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(yaml_file=CONFIG_PATH) + + bridge: BridgeSettings + zerossl: ZerosslSettings | None = None + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return (YamlConfigSettingsSource(settings_cls),) + + +settings: Settings + + +def ensure_setting(key: str, current: dict[str, Any] | None = None) -> None: + if current is None: + current = settings + if current is None: + console.log(":boom: No settings available.") + raise typer.Exit(1) + key_parts = key.split(".") + head = key_parts.pop(0) + return ensure_setting(head, current) + + +def validate_settings(): + global settings + with console.status("Validating settings..."): + time.sleep(0.5) + try: + settings = Settings() + except ValidationError as exc: + console.log(str(exc)) + raise typer.Exit(1) + bridge = settings.bridge + if bridge.transport == "webtunnel": + if bridge.webtunnel is None: + console.log( + ":boom: [red]Missing webtunnel section in configuration file." + ) + raise typer.Exit(1) + if bridge.webtunnel.certificate == "zerossl-ip": + if settings.zerossl is None: + console.log( + ":boom: [red]Missing ZeroSSL section in configuration file." + ) + typer.Exit(1) + console.log(":white_check_mark: Settings validated") diff --git a/bootbridge/systemd.py b/bootbridge/systemd.py new file mode 100644 index 0000000..2543eee --- /dev/null +++ b/bootbridge/systemd.py @@ -0,0 +1,44 @@ +import subprocess + +import typer + +from bootbridge.console import console +from bootbridge.utils import run_command + + +def start_service(service: str, enable: bool = False, restart: bool = False) -> None: + actions = ["restart" if restart else "start"] + if enable: + actions = ["enable"] + actions + with console.status(f"Starting {service}..."): + for action in actions: + try: + command = ["systemctl", action, service] + for output in run_command(command): + console.log("[gray30]" + output) + except subprocess.CalledProcessError: + console.log(f"[red]An error occurred while starting {service}. :boom:") + console.log( + f"[blue]To debug, you can try running '{' '.join(command)}' yourself." + ) + raise typer.Exit(1) + console.log(f":white_check_mark: {'Res' if restart else 'S'}tarted {service}.") + + +def stop_service(service: str, disable: bool = False) -> None: + actions = ["stop"] + if disable: + actions = actions + ["disable"] + with console.status(f"Stopping {service}..."): + for action in actions: + try: + command = ["systemctl", action, service] + for output in run_command(command): + console.log("[gray30]" + output) + except subprocess.CalledProcessError: + console.log(f"[red]An error occurred while stopping {service}. :boom:") + console.log( + f"[blue]To debug, you can try running '{' '.join(command)}' yourself." + ) + raise typer.Exit(1) + console.log(f":white_check_mark: Stopped {service}.") diff --git a/bootbridge/tor.py b/bootbridge/tor.py new file mode 100644 index 0000000..ca57ff5 --- /dev/null +++ b/bootbridge/tor.py @@ -0,0 +1,73 @@ +import base64 +import os +import time + +from bootbridge.settings import KeysSettings +from bootbridge.console import console +from bootbridge.utils import ensure_file, gid, uid, ensure_dir + + +def get_tor_data_file(filename: str) -> str: # TODO: add timeout + with console.status(f"Waiting for {filename.replace('-', ' ')}..."): + while not os.path.exists(f"/var/lib/tor/{filename}"): + time.sleep(1) + with open(f"/var/lib/tor/{filename}") as data_file: + return data_file.read() + + +def get_fingerprint(): + return get_tor_data_file("fingerprint").split(" ")[1].strip() + + +def get_hashed_fingerprint(): + return get_tor_data_file("hashed-fingerprint").split(" ")[1].strip() + + +def render_torrc( + nickname: str, + contact: str, + extra_config=list[str], +): + return "\n".join( + [ + "# -- This file managed by bridgeboot. Don't edit it. --", + "DataDirectory /var/lib/tor", + "KeyDirectory /var/lib/tor/bootbridge_keys", + f"Nickname {nickname}", + f"ContactInfo {contact}", + "BridgeRelay 1", + "AssumeReachable 1", + "ORPort 127.0.0.1:auto", + "ExtORPort auto", + "SocksPort 0", + "Log notice file /var/log/tor/bootbridge.log" "", + "# -- Transport specific configuration below --", + ] + + extra_config + + [""] + ) + + +def write_keys(keys: KeysSettings): + os.makedirs("/var/lib/tor/bootbridge_keys", exist_ok=True) + ensure_dir( + "/var/lib/tor/bootbridge_keys", + owner=uid("debian-tor"), + group=gid("debian-tor"), + mode=0o2700, + ) + ensure_file( + "/var/lib/tor/bootbridge_keys/secret_id_key", + keys.secret_id_key, + uid("debian-tor"), + gid("debian-tor"), + 0o600, + ) + for key in ["ed25519_master_id_secret_key", "ed25519_master_id_public_key"]: + ensure_file( + f"/var/lib/tor/bootbridge_keys/{key}", + base64.b64decode(getattr(keys, key)), + uid("debian-tor"), + gid("debian-tor"), + 0o600, + ) diff --git a/bootbridge/utils.py b/bootbridge/utils.py new file mode 100644 index 0000000..aac69c6 --- /dev/null +++ b/bootbridge/utils.py @@ -0,0 +1,121 @@ +import grp +import os +import pwd +import select +import subprocess +from functools import cache +from typing import Generator + +import requests +from requests.adapters import HTTPAdapter +from rich.progress import Progress, TextColumn, BarColumn +from urllib3 import Retry + +from bootbridge.console import console + + +def requests_session() -> requests.Session: + s = requests.Session() + retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]) + s.mount("http://", HTTPAdapter(max_retries=retries)) + s.mount("https://", HTTPAdapter(max_retries=retries)) + return s + + +def download_file( + description: str, url: str, path: str, owner: int, group: int, mode: int = 0o644 +) -> None: + response = requests.get(url, stream=True) + total_size = int(response.headers.get("content-length", 0)) + with Progress( + TextColumn("[bold blue]{task.description}"), + BarColumn(), + TextColumn("[bold green]{task.completed}/{task.total}"), + transient=True, + ) as progress: + task = progress.add_task(f"Downloading {description}...", total=total_size) + with open(path, "wb") as file: + for data in response.iter_content(chunk_size=1024): + file.write(data) + progress.update(task, advance=len(data)) + console.log(f":floppy_disk: Saved {os.path.abspath(path)}.") + ensure_file(path, None, owner=owner, group=group, mode=mode) + + +@cache +def get_public_ip() -> str: + r = requests_session().get("https://ipv4.icanhazip.com/") + return r.text.strip() + + +@cache +def uid(user: str) -> int: + return pwd.getpwnam("debian-tor").pw_uid + + +@cache +def gid(group: str) -> int: + return grp.getgrnam("debian-tor").gr_gid + + +def ensure_dir(path: str, owner: int, group: int, mode: int = 0o755): + parts = path.split(os.sep) + current_path = "" + for part in parts: + if part: # Skip empty parts (e.g., leading separators) + current_path = os.path.join(current_path, part) + if not os.path.exists(current_path): + os.mkdir(current_path) + os.chown(current_path, owner, group) + os.chmod(current_path, mode) + console.log(f":floppy_disk: Created directory {current_path}.") + + +def ensure_file( + path: str, + content: None | str | bytes, + owner: int = 0, + group: int = 0, + mode: int = 0o640, +): + if content: + new_content = False + with open(path, "r" if isinstance(content, str) else "rb") as file: + new_content = file.read() != content + if new_content: + open_mode = "w" if isinstance(content, str) else "wb" + with open(path, open_mode) as file: + file.write(content) + console.log(f":floppy_disk: Saved {os.path.abspath(path)}.") + else: + console.log(f":floppy_disk: Already up to date {os.path.abspath(path)}.") + stat = os.stat(path) + if stat.st_uid != owner or stat.st_gid != group: + os.chown(path, owner, group) + console.log(f":floppy_disk: Changed ownership of {os.path.abspath(path)}.") + if stat.st_mode & 0o7777 != mode: + os.chmod(path, mode) + console.log(f":floppy_disk: Changed permissions of {os.path.abspath(path)}.") + + +def run_command(command: list[str]) -> Generator[str, None, None]: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + while True: + reads = [process.stdout, process.stderr] + ready, _, _ = select.select(reads, [], []) + for stream in ready: + line = stream.readline() + if line: + yield line.strip() + else: + break + if process.poll() is not None: + break + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command) diff --git a/bootbridge/webtunnel.py b/bootbridge/webtunnel.py new file mode 100644 index 0000000..5a3bdc0 --- /dev/null +++ b/bootbridge/webtunnel.py @@ -0,0 +1,162 @@ +import os +import subprocess + +from rich.panel import Panel + +from bootbridge.apt import apt_install, apt_update, setup_deb_tpo +from bootbridge.settings import BridgeSettings +from bootbridge.console import console +from bootbridge.systemd import start_service, stop_service +from bootbridge.tor import ( + render_torrc, + get_hashed_fingerprint, + get_fingerprint, + write_keys, +) +from bootbridge.utils import download_file, ensure_file +import bootbridge.zerossl as zerossl +from bootbridge.utils import get_public_ip + +NGINX_VIRTUALHOST_CONFIG = """ +server { + listen 80 default_server; + listen [::]:80 default_server; + + listen [::]:443 ssl http2 default_server; + listen 443 ssl http2 default_server; + server_name _; + + server_tokens off; + + ssl_certificate /etc/ssl/private/combined.pem; + ssl_certificate_key /etc/ssl/private/key.pem; + + ssl_session_timeout 15m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000" always; + + root /var/www/html; + + location / { + try_files $uri $uri/ =403; + } + + location = /!WEBTUNNEL_PATH! { + proxy_pass http://127.0.0.1:15000; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Accept-Encoding ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Front-End-Https on; + + proxy_redirect off; + access_log off; + error_log off; + } +} +""" + + +def download_webtunnel() -> None: + binary_path = "/usr/local/bin/webtunnel_server" + if not os.path.exists(binary_path): + download_file( + "Webtunnel server binary", + "https://guardianproject.dev/api/packages/irl/generic/webtunnel/202507080/webtunnel_server_amd64", + binary_path, + owner=0, + group=0, + mode=0o755, + ) + console.log(":white_check_mark: Webtunnel server binary downloaded.") + else: + console.log(":white_check_mark: Webtunnel server binary already present.") + + +def write_nginx_vhost_config(path: str): + with console.status("Writing nginx virtual host configuration file..."): + ensure_file( + "/etc/nginx/sites-available/default", + NGINX_VIRTUALHOST_CONFIG.replace("!WEBTUNNEL_PATH!", path), + owner=0, + group=0, + mode=0o644, + ) + + +def configure_tor(settings: BridgeSettings): + with console.status("Writing Tor configuration file..."): + torrc_extra_lines = [ + "ServerTransportPlugin webtunnel exec /usr/local/bin/webtunnel_server", + "ServerTransportListenAddr webtunnel 127.0.0.1:15000", + f"ServerTransportOptions webtunnel url=https://{get_public_ip()}/{settings.webtunnel.path}", + ] + ensure_file( + "/etc/tor/torrc", + render_torrc( + settings.nickname, + settings.contact, + extra_config=torrc_extra_lines, + ), + owner=0, + group=0, + mode=0o644, + ) + if settings.keys: + with console.status("Writing Tor keys..."): + write_keys(settings.keys) + + +def update_apparmor(): + with console.status("Configure apparmor profile override..."): + ensure_file( + "/etc/apparmor.d/local/system_tor", + "/usr/local/bin/webtunnel_server ix,", + owner=0, + group=0, + mode=0o644, + ) + subprocess.check_output(["apparmor_parser", "-r", "/etc/apparmor.d/system_tor"]) + console.log(":white_check_mark: Apparmor profile override updated.") + + +def up_webtunnel(settings: BridgeSettings, force_tls_renewal: bool = False): + console.rule("Install system packages") + setup_deb_tpo() + apt_update() + apt_install(["tor", "nginx"]) + download_webtunnel() + console.rule("Provision TLS certificate") + if not os.path.exists("/etc/ssl/private/combined.pem"): + stop_service("nginx") + else: + start_service("nginx") + if settings.webtunnel.certificate == "zerossl-ip": + zerossl.zerossl_ip_certificate(force_renew=force_tls_renewal) + console.rule("Configure nginx") + write_nginx_vhost_config(settings.webtunnel.path) + start_service("nginx", enable=True, restart=True) + console.rule("Configure Tor") + configure_tor(settings) + update_apparmor() + start_service("tor", enable=True, restart=True) + console.print( + Panel( + ":thumbsup: Your Tor bridge is up.\n\n" + ":globe_with_meridians: Connect to your bridge with the following bridgeline:\n\n" + f" webtunnel 10.0.0.2:443 {get_fingerprint()} url=https://{get_public_ip()}/{settings.webtunnel.path}\n\n" + ":bar_chart: Check out your bridge metrics on the Tor Metrics website (may take a while to appear):\n\n" + f" https://metrics.torproject.org/rs.html#details/{get_hashed_fingerprint()}\n\n" + ":vertical_traffic_light: Check the status of your bridge (may take a while to appear):\n\n" + f" https://bridges.torproject.org/status?id={get_hashed_fingerprint()}" + ) + ) diff --git a/bootbridge/zerossl.py b/bootbridge/zerossl.py new file mode 100644 index 0000000..1b1845b --- /dev/null +++ b/bootbridge/zerossl.py @@ -0,0 +1,234 @@ +import os +import sys +import time +from ipaddress import IPv4Address +from typing import Any + +import typer +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from bootbridge.console import console +from bootbridge.httpserver import start_bootstrap_server, stop_bootstrap_server +from bootbridge.settings import settings +from bootbridge.utils import get_public_ip, requests_session, ensure_file, ensure_dir + +ID_PATH = "/etc/ssl/private/certificate.id" +KEY_PATH = "/etc/ssl/private/key.pem" +CERTIFICATE_PATH = "/etc/ssl/private/certificate.pem" +BUNDLE_PATH = "/etc/ssl/private/bundle.pem" +COMBINED_PATH = "/etc/ssl/private/combined.pem" + +WEBROOT_PATH = "/var/www/html" +VALIDATION_PATH = "/.well-known/pki-validation" + +def stage_validation(url: str, content: str) -> None: + validation_dir = os.path.join(WEBROOT_PATH, VALIDATION_PATH) + filename = os.path.basename(url) + ensure_dir(validation_dir, owner=0, group=0, mode=0o755) + ensure_file( + os.path.join(validation_dir, filename), + "\n".join(content), + owner=0, + group=0, + mode=0o644, + ) + +def get_certificate_id() -> str | None: + if os.path.exists(ID_PATH): + with open(ID_PATH, "r") as id_file: + return id_file.read() + return None + +def generate_tls_key() -> rsa.RSAPrivateKey: + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + return private_key + +def write_tls_key(private_key: rsa.RSAPrivateKey) -> None: + ensure_file( + KEY_PATH, + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + encryption_algorithm=serialization.NoEncryption(), + format=serialization.PrivateFormat.TraditionalOpenSSL, + ), + owner=0, + group=0, + mode=0o600, + ) + +def read_tls_key() -> rsa.RSAPrivateKey: + with open(KEY_PATH, "rb") as key_file: + key = serialization.load_pem_private_key( + key_file.read(), password=None, backend=default_backend() + ) + assert isinstance(key, rsa.RSAPrivateKey) + return key + +def generate_tls_csr(ip, private_key: rsa.RSAPrivateKey) -> str: + subject = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, ip)]) + alt_names = x509.SubjectAlternativeName([x509.IPAddress(IPv4Address(ip))]) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(subject) + .add_extension(alt_names, critical=False) + .sign(private_key, hashes.SHA256(), default_backend()) + ) + return csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") + +def write_certificate(cert: str, bundle: str) -> None: + ensure_file(CERTIFICATE_PATH, cert, owner=0, group=0, mode=0o644) + ensure_file(BUNDLE_PATH, bundle, owner=0, group=0, mode=0o644) + ensure_file(COMBINED_PATH, cert + "\n" + bundle, owner=0, group=0, mode=0o644) + +def save_certificate_id(id: str): + with open(ID_PATH, "w") as id_file: + id_file.write(id) + +class ZeroSSLManager: + def __init__(self, token: str): + self.token = token + + def create_certificate(self, ip: str, csr: str) -> dict[str, Any]: + data = { + "certificate_domains": ip, + "certificate_csr": csr, + } + response = requests_session().post( + "https://api.zerossl.com/certificates", + json=data, + params={"access_key": self.token}, + ) + response.raise_for_status() + return response.json() + + def validate_certificate(self, id: str) -> dict[str, Any]: + data = { + "validation_method": "HTTP_CSR_HASH", + } + response = requests_session().post( + f"https://api.zerossl.com/certificates/{id}/challenges", + json=data, + params={"access_key": self.token}, + ) + response.raise_for_status() + return response.json() + + def get_certificate(self, id: str) -> dict[str, Any]: + response = requests_session().get( + f"https://api.zerossl.com/certificates/{id}", + params={"access_key": self.token}, + ) + response.raise_for_status() + return response.json() + + def download_certificate(self, id: str) -> dict[str, Any]: + response = requests_session().get( + f"https://api.zerossl.com/certificates/{id}/download/return", + params={"access_key": self.token}, + ) + response.raise_for_status() + return response.json() + + def revoke_certificate(self, id: str, reason: str = "Superseded"): + data = { + "reason": reason, + } + response = requests_session().post( + f"https://api.zerossl.com/certificates/{id}/revoke", + json=data, + params={"access_key": self.token}, + ) + response.raise_for_status() + return response.json() + + +def zerossl_ip_certificate(force_renew: bool = False): + bootstrap = True + ip = get_public_ip() + console.log( + f":globe_with_meridians: [blue]Your public IP is {ip}. This will be used for the TLS certificate." + ) + if settings.zerossl is None: + console.log(":boom: No ZeroSSL token in configuration.") + raise typer.Exit(1) + z = ZeroSSLManager(settings.zerossl.token) + if os.path.exists(KEY_PATH): + console.log( + ":locked_with_key: [blue]Found an existing TLS private key, so not generating a new one." + ) + key = read_tls_key() + else: + with console.status("Generating TLS private key..."): + key = generate_tls_key() + with console.status("Saving TLS private key..."): + write_tls_key(key) + console.log(":white_check_mark: TLS private key generated and saved.") + if os.path.exists(CERTIFICATE_PATH): + bootstrap = False # TODO: determine based on anything listening on port 80 + days_since_modification = (time.time() - os.stat(CERTIFICATE_PATH).st_mtime) / ( + 60 * 60 * 24 + ) + console.log( + f":locked_with_key: [blue]Found an existing TLS certificate, which was last modified {int(days_since_modification)} days ago." + ) + # TODO: Actually check the certificate expiry + if days_since_modification > 80: + console.log(":clock9: [blue]Certificate is due for renewal.") + elif force_renew: + console.log(":clock12: [orange3]Certificate renewal is forced.") + else: + console.log(":white_check_mark: Certificate is still fine.") + return + with console.status("Generating TLS certificate signing request (CSR)..."): + csr = generate_tls_csr(ip, key) + console.log(":white_check_mark: TLS CSR generated.") + with console.status("Requesting to create certificate..."): + cert = z.create_certificate(ip, csr) + console.log(f":white_check_mark: Certificate created with ID {cert['id']}.") + if bootstrap: + with console.status("Starting bootstrap server... (background)"): + start_bootstrap_server( + "\n".join( + cert["validation"]["other_methods"][ip]["file_validation_content"] + ) + ) + console.log( + f":white_check_mark: Started bootstrap server listening at http://{ip}/." + ) + else: + with console.status("Writing validation file to webroot..."): + stage_validation( + cert["validation"]["other_methods"][ip]["file_validation_url_http"], + cert["validation"]["other_methods"][ip]["file_validation_content"], + ) + console.log(":white_check_mark: Wrote validation file to webroot.") + with console.status("Starting validation process..."): + cert = z.validate_certificate(cert["id"]) + while cert["status"] == "pending_validation": + time.sleep(1) + cert = z.get_certificate(cert["id"]) + if cert["status"] != "issued": + console.log( + ":boom: Invalid certificate state, something went wrong so I give up." + ) + sys.exit(1) + console.log(":white_check_mark: Certificate has been issued.") + stop_bootstrap_server() + with console.status("Downloading certificate..."): + dl = z.download_certificate(cert["id"]) + with console.status("Saving certificate..."): + write_certificate(dl["certificate.crt"], dl["ca_bundle.crt"]) + console.log(":white_check_mark: TLS certificate downloaded and saved.") + if old_id := get_certificate_id(): + console.log(f"[blue]Found ID for the previous certificate {old_id}.") + with console.status("Revoking old certificate..."): + z.revoke_certificate(old_id) # TODO: check return status + console.log(":white_check_mark: Old certificate revoked.") + with console.status("Saving new certificate ID..."): + save_certificate_id(cert["id"]) + console.log(":white_check_mark: New certificate ID saved.") diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..2f834e2 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,7 @@ +[bridge] +nickname = "VastlyClearFrog" +transport = "webtunnel" + +[bridge.webtunnel] +path = "mA8gsdsnJKSJGea4zF8BxVnzNCb75obO2f6KmzDHYeVkD5" +certificate = "zerossl-ip" diff --git a/docs/concept.jpg b/docs/concept.jpg new file mode 100644 index 0000000..04cb142 Binary files /dev/null and b/docs/concept.jpg differ diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e0fae67 --- /dev/null +++ b/install.sh @@ -0,0 +1,14 @@ +#!/bin/bash +CODENAME=$(grep VERSION_CODENAME /etc/os-release | cut -d '=' -f 2) +apt update && apt install -y apt-transport-https git gnupg pipx wget +wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor > /usr/share/keyrings/deb.torproject.org-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/deb.torproject.org-keyring.gpg] https://deb.torproject.org/torproject.org $CODENAME main" > /etc/apt/sources.list.d/tor.list +echo "deb-src [signed-by=/usr/share/keyrings/deb.torproject.org-keyring.gpg] https://deb.torproject.org/torproject.org $CODENAME main" >> /etc/apt/sources.list.d/tor.list +apt update && apt install -y tor nginx deb.torproject.org-keyring +systemctl stop tor nginx && systemctl disable tor nginx +git clone https://guardianproject.dev/ops/bootbridge.git /opt/bootbridge +cd /opt/bootbridge +python3 -m venv venv +source venv/bin/activate +pip install . + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2598957 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling >= 1.26"] +build-backend = "hatchling.build" + +[project] +name = "bootbridge" +version = "0.0.0" +authors = [ + { name = "irl", email = "irl@sr2.group" }, +] +description = "A simple tool to bootstrap a Tor bridge" +readme = "README.md" +license = "BSD-2-Clause" +keywords = ["tor", "censorship circumvention", "obfs4", "webtunnel"] +dependencies = [ + "cryptography", + "petname", + "pydantic", + "pydantic-settings", + "pyyaml", + "requests", + "typer", + "typing-extensions", +] + +[project.optional-dependencies] +dev = [ + "black", + "mypy", + "ruff", + "types-pyyaml", + "types-requests", +] + +[project.scripts] +bootbridge = "bootbridge.app:app" + +[tool.hatch.build.targets.binary] +scripts = ["bootbridge"] + +[tool.hatch.build.targets.wheel] +packages = ["bootbridge"]