diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 77587bf..5552327 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -142,6 +142,7 @@ jobs: export PATH="$tools_dir:$PATH" ruff check . ruff format --check . + python3 -m unittest discover -s tests -v lint-yaml: runs-on: [self-hosted, linux, arch, homelab] diff --git a/netbird/entrypoint.sh b/netbird/entrypoint.sh new file mode 100755 index 0000000..fe5420b --- /dev/null +++ b/netbird/entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +umask 077 + +TEMPLATE_PATH=/opt/netbird/config.template.yaml +RENDERED_PATH=/run/netbird/config.yaml +RELAY_SECRET_PATH=/run/secrets/relay_auth_secret +ENCRYPTION_KEY_PATH=/run/secrets/datastore_encryption_key + +is_valid_proxy_subnet() { + candidate="$1" + case "$candidate" in + 0.0.0.0/0) + return 1 + ;; + */*) + address="${candidate%%/*}" + prefix="${candidate#*/}" + ;; + *) + return 1 + ;; + esac + + case "$prefix" in + 0|[1-9]|[1-2][0-9]|3[0-2]) ;; + *) + return 1 + ;; + esac + + old_ifs="$IFS" + IFS=. + # shellcheck disable=SC2086 + set -- $address + IFS="$old_ifs" + [ "$#" -eq 4 ] || return 1 + + for octet do + case "$octet" in + 0|[1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]) ;; + *) + return 1 + ;; + esac + done +} + +read_secret() { + secret_path="$1" + + if [ ! -r "$secret_path" ]; then + echo "Required secret is not readable: $secret_path" >&2 + exit 1 + fi + + secret_value="$(cat "$secret_path")" + if [ -z "$secret_value" ]; then + echo "Required secret is empty: $secret_path" >&2 + exit 1 + fi + + printf '%s' "$secret_value" +} + +if [ -z "${NETBIRD_DOMAIN:-}" ]; then + echo "NETBIRD_DOMAIN must be set" >&2 + exit 1 +fi + +case "$NETBIRD_DOMAIN" in + *[!A-Za-z0-9.-]*) + echo "NETBIRD_DOMAIN contains unsupported characters" >&2 + exit 1 + ;; +esac + +if [ -z "${NETBIRD_PROXY_SUBNET:-}" ] || [ "$NETBIRD_PROXY_SUBNET" = "auto" ]; then + echo "NETBIRD_PROXY_SUBNET must be an explicit IPv4 CIDR; run netbird/setup.sh first" >&2 + exit 1 +fi +if ! is_valid_proxy_subnet "$NETBIRD_PROXY_SUBNET"; then + echo "NETBIRD_PROXY_SUBNET must be a non-default IPv4 CIDR, for example 172.20.0.0/16" >&2 + exit 1 +fi + +if [ "$#" -ne 2 ] || [ "$1" != "--config" ] || [ "$2" != "$RENDERED_PATH" ]; then + echo "Expected: --config $RENDERED_PATH" >&2 + exit 1 +fi + +relay_secret="$(read_secret "$RELAY_SECRET_PATH")" +encryption_key="$(read_secret "$ENCRYPTION_KEY_PATH")" + +mkdir -p "$(dirname "$RENDERED_PATH")" +sed \ + -e "s|__NETBIRD_DOMAIN__|${NETBIRD_DOMAIN}|g" \ + -e "s|__NETBIRD_AUTH_SECRET__|${relay_secret}|g" \ + -e "s|__NETBIRD_ENCRYPTION_KEY__|${encryption_key}|g" \ + -e "s|__NETBIRD_PROXY_SUBNET__|${NETBIRD_PROXY_SUBNET}|g" \ + "$TEMPLATE_PATH" >"$RENDERED_PATH" + +if grep -q '__NETBIRD_' "$RENDERED_PATH"; then + echo "Rendered NetBird configuration still contains unresolved placeholders" >&2 + exit 1 +fi + +exec /go/bin/netbird-server "$@" diff --git a/netbird/setup.sh b/netbird/setup.sh new file mode 100755 index 0000000..3b4829b --- /dev/null +++ b/netbird/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Prepare local Compose configuration without replacing existing credentials. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" +umask 077 +if [ ! -f .env ]; then + cp .env.example .env +fi + +if grep -q '^NETBIRD_PROXY_SUBNET=auto$' .env; then + subnet="$(docker network inspect proxy --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' | awk '/^[0-9]+\./ { print; exit }')" + if [ -z "$subnet" ]; then + echo "No IPv4 subnet found on the Docker proxy network. Set NETBIRD_PROXY_SUBNET in .env." >&2 + exit 1 + fi + # The detected value must be safe to substitute into the env file. + if [[ ! "$subnet" =~ ^[0-9.]+/[0-9]+$ ]]; then + echo "Unexpected Docker network subnet: $subnet" >&2 + exit 1 + fi + sed -i "s|^NETBIRD_PROXY_SUBNET=auto$|NETBIRD_PROXY_SUBNET=$subnet|" .env +fi + +mkdir -p secrets +chmod 700 secrets +for name in relay-auth-secret datastore-encryption-key; do + path="secrets/$name" + if [ -e "$path" ]; then + if [ ! -s "$path" ]; then + echo "Existing secret is empty: $path. Restore it before continuing." >&2 + exit 1 + fi + else + openssl rand -base64 32 >"$path" + fi + chmod 600 "$path" +done +printf '%s\n' 'Local files are ready. Review .env, then run docker compose config --quiet.' diff --git a/tests/test_netbird_runtime.py b/tests/test_netbird_runtime.py new file mode 100644 index 0000000..373980e --- /dev/null +++ b/tests/test_netbird_runtime.py @@ -0,0 +1,87 @@ +"""Exercise local setup and config rendering without a Docker daemon.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class NetbirdRuntimeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.stack = self.root / 'netbird' + self.stack.mkdir() + for name in ('setup.sh', '.env.example', 'config.template.yaml'): + shutil.copy(ROOT / 'netbird' / name, self.stack / name) + binary = self.root / 'bin' + binary.mkdir() + docker = binary / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" 172.20.0.0/16\n') + docker.chmod(0o755) + self.env = dict(os.environ, PATH=f'{binary}:{os.environ["PATH"]}') + + def setup(self): + return subprocess.run( # noqa: S603 - executes the repository script copied into this test's temp dir + ['/bin/bash', str(self.stack / 'setup.sh')], env=self.env, capture_output=True, check=False + ) + + def test_setup_preserves_existing_secrets_and_env(self): + self.assertEqual(self.setup().returncode, 0) + paths = [self.stack / '.env', *sorted((self.stack / 'secrets').iterdir())] + before = [p.read_bytes() for p in paths] + self.assertIn(b'NETBIRD_PROXY_SUBNET=172.20.0.0/16', before[0]) + self.assertEqual(self.setup().returncode, 0) + self.assertEqual(before, [p.read_bytes() for p in paths]) + for p in paths[1:]: + self.assertEqual(p.stat().st_mode & 0o777, 0o600) + + def test_setup_rejects_empty_existing_secret(self): + (self.stack / 'secrets').mkdir() + secret = self.stack / 'secrets/datastore-encryption-key' + secret.touch() + self.assertNotEqual(self.setup().returncode, 0) + self.assertEqual(secret.read_bytes(), b'') + + def render(self, subnet): + self.assertEqual(self.setup().returncode, 0) + rendered = self.root / 'run/config.yaml' + script = (ROOT / 'netbird/entrypoint.sh').read_text() + replacements = { + '/opt/netbird/config.template.yaml': str(self.stack / 'config.template.yaml'), + '/run/netbird/config.yaml': str(rendered), + '/run/secrets/relay_auth_secret': str(self.stack / 'secrets/relay-auth-secret'), + '/run/secrets/datastore_encryption_key': str(self.stack / 'secrets/datastore-encryption-key'), + '/go/bin/netbird-server': '/bin/true', + } + for original, local in replacements.items(): + script = script.replace(original, local) + result = subprocess.run( # noqa: S603 - repository renderer, with test-local paths + ['/bin/sh', '-c', script, 'entrypoint', '--config', str(rendered)], + env=dict(self.env, NETBIRD_DOMAIN='nb.example.com', NETBIRD_PROXY_SUBNET=subnet), + capture_output=True, + check=False, + ) + return result, rendered + + def test_renderer_replaces_placeholders_and_restricts_file_permissions(self): + result, rendered = self.render('172.20.0.0/16') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn('__NETBIRD_', rendered.read_text()) + self.assertIn('nb.example.com', rendered.read_text()) + self.assertEqual(rendered.stat().st_mode & 0o777, 0o600) + + def test_renderer_rejects_auto_and_default_route(self): + for subnet in ('auto', '0.0.0.0/0', '999.1.1.1/24'): + with self.subTest(subnet=subnet): + result, _ = self.render(subnet) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main()