feat: initial import

This commit is contained in:
Iain Learmonth 2026-01-07 16:52:10 +00:00
commit ac958c04f0
20 changed files with 1398 additions and 0 deletions

210
.gitignore vendored Normal file
View file

@ -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

3
Justfile Normal file
View file

@ -0,0 +1,3 @@
build:
rm -rf dist
hatch build -t sdist

70
README.md Normal file
View file

@ -0,0 +1,70 @@
<h1 align="center">
<img src="https://gitlab.com/guardianproject/public-content/guardianprojectpublic/-/raw/master/Graphics/GuardianProject/pngs/logo-color-w512.png?ref_type=heads" alt="Guardian Project">
</h1>
<h2 align="center">bootbridge</h2>
<p align="center">
A command line tool to bootstrap a Tor bridge
<i>(not to be confused with <a href="https://gitlab.torproject.org/tpo/anti-censorship/bridgestrap">bridgestrap</a>).
</i>
</p>
<p align="center">
<img
alt="Language: Python"
src="https://img.shields.io/badge/lanuage-python-3178c6?style=flat-square">
<a href="https://opensource.org/licenses/BSD-2-Clause">
<img alt="Licence: BSD 2-Clause" src="https://img.shields.io/badge/license-bsd%202--clause-orange?style=flat-square">
</a>
<img alt="Lifecycle: Experimental" src="https://img.shields.io/badge/lifecycle-experimental-339999?style=flat-square">
<br>
<a href="https://guardianproject.dev/ops/bootbridge/issues">
<img alt="Issues" src="https://img.shields.io/gitea/issues/open/ops/bootbridge?gitea_url=https%3A%2F%2Fguardianproject.dev&style=flat-square">
</a>
<a href="https://opencollective.com/guardianproject">
<img alt="Open Collective backers and sponsors" src="https://img.shields.io/opencollective/all/guardianproject?style=flat-square">
</a>
</p>
---
### 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.

0
bootbridge/__init__.py Normal file
View file

11
bootbridge/__main__.py Normal file
View file

@ -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()

64
bootbridge/app.py Normal file
View file

@ -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()

95
bootbridge/apt.py Normal file
View file

@ -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

110
bootbridge/configure.py Normal file
View file

@ -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()

3
bootbridge/console.py Normal file
View file

@ -0,0 +1,3 @@
from rich.console import Console
console = Console()

39
bootbridge/httpserver.py Normal file
View file

@ -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

96
bootbridge/settings.py Normal file
View file

@ -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")

44
bootbridge/systemd.py Normal file
View file

@ -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}.")

73
bootbridge/tor.py Normal file
View file

@ -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,
)

121
bootbridge/utils.py Normal file
View file

@ -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)

162
bootbridge/webtunnel.py Normal file
View file

@ -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()}"
)
)

234
bootbridge/zerossl.py Normal file
View file

@ -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.")

7
config.toml.example Normal file
View file

@ -0,0 +1,7 @@
[bridge]
nickname = "VastlyClearFrog"
transport = "webtunnel"
[bridge.webtunnel]
path = "mA8gsdsnJKSJGea4zF8BxVnzNCb75obO2f6KmzDHYeVkD5"
certificate = "zerossl-ip"

BIN
docs/concept.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

14
install.sh Normal file
View file

@ -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 .

42
pyproject.toml Normal file
View file

@ -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"]