mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-02 18:25:46 +01:00
Add an opt-in WireGuard egress path alongside the existing Tor mode --------- Co-authored-by: CaliBrain <calibrain@l4n.xyz>
376 lines
12 KiB
Python
376 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import fcntl
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "entrypoint.sh"
|
|
ENTRYPOINT_LOCK_PATH = Path("/tmp/shelfmark_entrypoint_test.lock")
|
|
BASH_PATH = shutil.which("bash") or "/bin/bash"
|
|
ID_PATH = shutil.which("id") or "/usr/bin/id"
|
|
MKDIR_PATH = shutil.which("mkdir") or "/bin/mkdir"
|
|
STAT_PATH = shutil.which("stat") or "/usr/bin/stat"
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _entrypoint_lock():
|
|
ENTRYPOINT_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with ENTRYPOINT_LOCK_PATH.open("w") as handle:
|
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
|
|
|
|
def _write_executable(path: Path, content: str) -> None:
|
|
path.write_text(content)
|
|
path.chmod(0o755)
|
|
|
|
|
|
def _build_stub_bin(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
|
|
runtime_home_file = tmp_path / "gunicorn-home.txt"
|
|
runtime_args_file = tmp_path / "gunicorn-args.txt"
|
|
|
|
_write_executable(
|
|
bin_dir / "getent",
|
|
"""#!/bin/sh
|
|
if [ "$1" = "passwd" ] && [ "$2" = "$ENTRYPOINT_STUB_UID" ]; then
|
|
printf 'shelfmark:x:%s:%s:Shelfmark:%s:/bin/sh\\n' "$ENTRYPOINT_STUB_UID" "$ENTRYPOINT_STUB_GID" "$ENTRYPOINT_STUB_HOME"
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "group" ] && [ "$2" = "$ENTRYPOINT_STUB_GID" ]; then
|
|
printf 'shelfmark:x:%s:\\n' "$ENTRYPOINT_STUB_GID"
|
|
exit 0
|
|
fi
|
|
exit 2
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "gunicorn",
|
|
"""#!/bin/sh
|
|
printf '%s' "$HOME" > "$ENTRYPOINT_GUNICORN_HOME_FILE"
|
|
printf '%s' "$*" > "$ENTRYPOINT_GUNICORN_ARGS_FILE"
|
|
exit 0
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "id",
|
|
"""#!/bin/sh
|
|
if [ -n "${ENTRYPOINT_STUB_CURRENT_UID:-}" ]; then
|
|
if [ "$1" = "-u" ]; then
|
|
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_UID"
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "-g" ]; then
|
|
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_GID"
|
|
exit 0
|
|
fi
|
|
fi
|
|
exec "$ENTRYPOINT_REAL_ID" "$@"
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "gosu",
|
|
"""#!/bin/sh
|
|
shift
|
|
if [ "${ENTRYPOINT_STUB_GOSU_FAIL_WRITES:-false}" = "true" ] && [ "$1" = "sh" ] && [ "$2" = "-c" ]; then
|
|
exit 1
|
|
fi
|
|
exec "$@"
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "mkdir",
|
|
"""#!/bin/sh
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "/var/log/shelfmark" ]; then
|
|
exit 0
|
|
fi
|
|
done
|
|
exec "$ENTRYPOINT_REAL_MKDIR" "$@"
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "stat",
|
|
"""#!/bin/sh
|
|
if [ "$1" = "-c" ]; then
|
|
if [ "$2" = "%u:%g" ]; then
|
|
printf '%s\\n' "${ENTRYPOINT_STUB_STAT_OWNER:-0:0}"
|
|
exit 0
|
|
fi
|
|
fi
|
|
exec "$ENTRYPOINT_REAL_STAT" "$@"
|
|
""",
|
|
)
|
|
_write_executable(
|
|
bin_dir / "chown",
|
|
"""#!/bin/sh
|
|
exit 0
|
|
""",
|
|
)
|
|
|
|
return bin_dir, runtime_home_file, runtime_args_file
|
|
|
|
|
|
def _run_entrypoint(
|
|
tmp_path: Path,
|
|
*,
|
|
extra_env: dict[str, str] | None = None,
|
|
simulate_root_startup: bool = False,
|
|
fail_gosu_writes: bool = False,
|
|
stub_home: Path | str | None = None,
|
|
) -> tuple[subprocess.CompletedProcess[str], Path, Path, Path]:
|
|
runtime_home = tmp_path / "runtime-home"
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir(exist_ok=True)
|
|
tmp_dir = tmp_path / "tmp"
|
|
|
|
if stub_home is None:
|
|
stub_home = runtime_home
|
|
|
|
bin_dir, runtime_home_file, runtime_args_file = _build_stub_bin(tmp_path)
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"BUILD_VERSION": "test-build",
|
|
"CONFIG_DIR": str(config_dir),
|
|
"DEBUG": "false",
|
|
"ENABLE_LOGGING": "false",
|
|
"ENTRYPOINT_GUNICORN_ARGS_FILE": str(runtime_args_file),
|
|
"ENTRYPOINT_GUNICORN_HOME_FILE": str(runtime_home_file),
|
|
"ENTRYPOINT_REAL_ID": ID_PATH,
|
|
"ENTRYPOINT_REAL_MKDIR": MKDIR_PATH,
|
|
"ENTRYPOINT_REAL_STAT": STAT_PATH,
|
|
"ENTRYPOINT_STUB_GID": str(os.getgid()),
|
|
"ENTRYPOINT_STUB_HOME": str(stub_home),
|
|
"ENTRYPOINT_STUB_UID": str(os.getuid()),
|
|
"FLASK_PORT": "8084",
|
|
"LOG_LEVEL": "info",
|
|
"LOG_ROOT": str(tmp_path / "logs"),
|
|
"PATH": f"{bin_dir}:{env.get('PATH', '')}",
|
|
"RELEASE_VERSION": "test-release",
|
|
"TMP_DIR": str(tmp_dir),
|
|
"TZ": "",
|
|
"USING_EXTERNAL_BYPASSER": "true",
|
|
}
|
|
)
|
|
if simulate_root_startup:
|
|
env.update(
|
|
{
|
|
"ENTRYPOINT_STUB_CURRENT_GID": "0",
|
|
"ENTRYPOINT_STUB_CURRENT_UID": "0",
|
|
"ENTRYPOINT_STUB_STAT_OWNER": "0:0",
|
|
"PGID": str(os.getgid()),
|
|
"PUID": str(os.getuid()),
|
|
}
|
|
)
|
|
if fail_gosu_writes:
|
|
env["ENTRYPOINT_STUB_GOSU_FAIL_WRITES"] = "true"
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
|
|
with _entrypoint_lock():
|
|
result = subprocess.run(
|
|
[BASH_PATH, str(ENTRYPOINT_PATH)],
|
|
capture_output=True,
|
|
cwd=ENTRYPOINT_PATH.parent,
|
|
env=env,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
return result, runtime_home_file, runtime_args_file, runtime_home
|
|
|
|
|
|
def test_entrypoint_rejects_tor_in_non_root_mode(tmp_path):
|
|
result, _, _, _ = _run_entrypoint(tmp_path, extra_env={"USING_TOR": "true"})
|
|
|
|
assert result.returncode == 1
|
|
assert "USING_TOR=true requires the container to start as root." in result.stderr
|
|
assert (
|
|
"Non-root mode skips the privileged filesystem and network setup Tor depends on."
|
|
in result.stderr
|
|
)
|
|
|
|
|
|
def test_entrypoint_non_root_mode_runs_with_stub_gunicorn(tmp_path):
|
|
result, runtime_home_file, runtime_args_file, runtime_home = _run_entrypoint(tmp_path)
|
|
|
|
assert result.returncode == 0
|
|
assert "Startup mode: non-root" in result.stdout
|
|
assert f"Runtime identity: shelfmark ({os.getuid()}:{os.getgid()})" in result.stdout
|
|
assert runtime_home.exists()
|
|
assert runtime_home_file.read_text() == str(runtime_home)
|
|
assert "shelfmark.main:app" in runtime_args_file.read_text()
|
|
|
|
|
|
def test_entrypoint_avoids_app_as_home(tmp_path):
|
|
result, runtime_home_file, runtime_args_file, _ = _run_entrypoint(
|
|
tmp_path,
|
|
stub_home="/app",
|
|
)
|
|
|
|
fallback_home = tmp_path / "tmp" / "home"
|
|
assert result.returncode == 0
|
|
assert fallback_home.exists()
|
|
assert runtime_home_file.read_text() == str(fallback_home)
|
|
assert "shelfmark.main:app" in runtime_args_file.read_text()
|
|
|
|
|
|
def test_entrypoint_non_root_mode_requires_writable_config_dir(tmp_path):
|
|
readonly_config_dir = tmp_path / "readonly-config"
|
|
readonly_config_dir.mkdir()
|
|
readonly_config_dir.chmod(0o555)
|
|
|
|
try:
|
|
result, _, _, _ = _run_entrypoint(
|
|
tmp_path,
|
|
extra_env={"CONFIG_DIR": str(readonly_config_dir)},
|
|
)
|
|
finally:
|
|
readonly_config_dir.chmod(0o755)
|
|
|
|
assert result.returncode == 1
|
|
assert (
|
|
f"Config directory is not writable in non-root mode: {readonly_config_dir}" in result.stdout
|
|
)
|
|
assert "Prepare ownership outside the container" in result.stdout
|
|
|
|
|
|
def test_entrypoint_root_bootstrap_fails_closed_when_config_repair_fails(tmp_path):
|
|
result, _, _, _ = _run_entrypoint(
|
|
tmp_path,
|
|
simulate_root_startup=True,
|
|
fail_gosu_writes=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "ERROR: Config directory is not writable!" in result.stdout
|
|
assert f"Configured runtime identity: {os.getuid()}:{os.getgid()}" in result.stdout
|
|
assert f"chown -R {os.getuid()}:{os.getgid()} /path/to/config" in result.stdout
|
|
assert "Startup mode: root" not in result.stdout
|
|
|
|
|
|
def test_entrypoint_rejects_wireguard_in_non_root_mode(tmp_path):
|
|
result, _, _, _ = _run_entrypoint(tmp_path, extra_env={"USING_WIREGUARD": "true"})
|
|
|
|
assert result.returncode == 1
|
|
assert "USING_WIREGUARD=true requires the container to start as root." in result.stderr
|
|
assert "Non-root mode skips the privileged network setup WireGuard depends on." in result.stderr
|
|
|
|
|
|
def test_entrypoint_rejects_tor_and_wireguard_together(tmp_path):
|
|
result, _, _, _ = _run_entrypoint(
|
|
tmp_path,
|
|
extra_env={"USING_TOR": "true", "USING_WIREGUARD": "true"},
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert (
|
|
"USING_TOR and USING_WIREGUARD are mutually exclusive; enable only one egress mode."
|
|
in result.stderr
|
|
)
|
|
# The mutual-exclusion check must fire before either egress script runs, so
|
|
# neither the Tor nor the WireGuard privileged-setup errors should appear.
|
|
assert "requires the container to start as root" not in result.stderr
|
|
|
|
|
|
def test_entrypoint_mutual_exclusion_precedes_tor_startup(tmp_path):
|
|
# Even in root mode, enabling both must fail fast on mutual exclusion rather
|
|
# than starting tor.sh and then aborting.
|
|
result, _, _, _ = _run_entrypoint(
|
|
tmp_path,
|
|
simulate_root_startup=True,
|
|
extra_env={"USING_TOR": "true", "USING_WIREGUARD": "true"},
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert (
|
|
"USING_TOR and USING_WIREGUARD are mutually exclusive; enable only one egress mode."
|
|
in result.stderr
|
|
)
|
|
|
|
|
|
def test_entrypoint_aborts_before_gunicorn_when_wireguard_fails(tmp_path):
|
|
"""Security invariant: if wireguard.sh exits non-zero (any fail-closed path),
|
|
entrypoint.sh must abort under `set -e` so gunicorn NEVER starts. A booting
|
|
app after a failed egress setup would be a kill-switch bypass / IP leak.
|
|
"""
|
|
# Run the REAL entrypoint from a temp cwd that provides a stub `./wireguard.sh`
|
|
# which exits 1, plus a stub `./tor.sh` (unused here) for completeness.
|
|
work = tmp_path / "work"
|
|
work.mkdir()
|
|
real_entrypoint = ENTRYPOINT_PATH.read_text()
|
|
(work / "entrypoint.sh").write_text(real_entrypoint)
|
|
(work / "entrypoint.sh").chmod(0o755)
|
|
_write_executable(
|
|
work / "wireguard.sh",
|
|
"#!/bin/sh\necho 'stub wireguard.sh failing closed' >&2\nexit 1\n",
|
|
)
|
|
_write_executable(work / "tor.sh", "#!/bin/sh\nexit 0\n")
|
|
|
|
bin_dir, runtime_home_file, runtime_args_file = _build_stub_bin(tmp_path)
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir(exist_ok=True)
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"BUILD_VERSION": "test-build",
|
|
"CONFIG_DIR": str(config_dir),
|
|
"DEBUG": "false",
|
|
"ENABLE_LOGGING": "false",
|
|
"ENTRYPOINT_GUNICORN_ARGS_FILE": str(runtime_args_file),
|
|
"ENTRYPOINT_GUNICORN_HOME_FILE": str(runtime_home_file),
|
|
"ENTRYPOINT_REAL_ID": ID_PATH,
|
|
"ENTRYPOINT_REAL_MKDIR": MKDIR_PATH,
|
|
"ENTRYPOINT_REAL_STAT": STAT_PATH,
|
|
"ENTRYPOINT_STUB_GID": str(os.getgid()),
|
|
"ENTRYPOINT_STUB_HOME": str(tmp_path / "runtime-home"),
|
|
"ENTRYPOINT_STUB_UID": str(os.getuid()),
|
|
# Root startup so the WireGuard branch runs ./wireguard.sh (our stub).
|
|
"ENTRYPOINT_STUB_CURRENT_GID": "0",
|
|
"ENTRYPOINT_STUB_CURRENT_UID": "0",
|
|
"ENTRYPOINT_STUB_STAT_OWNER": "0:0",
|
|
"FLASK_PORT": "8084",
|
|
"LOG_LEVEL": "info",
|
|
"LOG_ROOT": str(tmp_path / "logs"),
|
|
"PATH": f"{bin_dir}:{env.get('PATH', '')}",
|
|
"PGID": str(os.getgid()),
|
|
"PUID": str(os.getuid()),
|
|
"RELEASE_VERSION": "test-release",
|
|
"TMP_DIR": str(tmp_path / "tmp"),
|
|
"TZ": "",
|
|
"USING_EXTERNAL_BYPASSER": "true",
|
|
"USING_WIREGUARD": "true",
|
|
}
|
|
)
|
|
|
|
with _entrypoint_lock():
|
|
result = subprocess.run(
|
|
[BASH_PATH, str(work / "entrypoint.sh")],
|
|
capture_output=True,
|
|
cwd=work,
|
|
env=env,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
# Entrypoint must have aborted with the stub's non-zero status...
|
|
assert result.returncode != 0
|
|
# ...the failure must actually come from wireguard.sh (not some unrelated
|
|
# earlier abort), proven by the stub's marker on stderr...
|
|
assert "stub wireguard.sh failing closed" in result.stderr
|
|
# ...and gunicorn must NEVER have been invoked (args file never written).
|
|
assert not runtime_args_file.exists(), (
|
|
"gunicorn was started despite wireguard.sh failing — kill-switch bypass!"
|
|
)
|