diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b354484 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.idea +.vs +.env +.env.* +!.env.example +**/bin +**/obj +**/TestResults +**/*.user +**/*.suo +coverage +artifacts +data +Test.TruthGate +TruthGate-Web/TruthGate-Web.Tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2cdb4ca --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# Tested multi-platform release. Override this to pin a version or use a local image. +TRUTHGATE_IMAGE=magiccodingman/truthgate-ipfs:stable + +# Build inputs. Both official base images are multi-platform. +DOTNET_VERSION=10.0 +KUBO_VERSION=v0.42.0 +TRUTHGATE_UID=1000 +TRUTHGATE_GID=1000 + +# Host ports. +TRUTHGATE_HTTP_PORT=80 +TRUTHGATE_HTTPS_PORT=443 +TRUTHGATE_DEV_HTTP_PORT=8080 +IPFS_SWARM_PORT=4001 + +# Persistent host paths. The blockstore is always separate so it can later be +# moved to different storage without changing the container's Kubo layout. +TRUTHGATE_DATA_HOST_PATH=./data/truthgate +IPFS_REPO_HOST_PATH=./data/ipfs/repo +IPFS_BLOCKS_HOST_PATH=./data/ipfs/blocks + +# Certificate behavior. +TRUTHGATE_ACME_STAGING=false +# Optional comma-separated IP addresses for the self-signed fallback cert. +TRUTHGATE_CERT_IPS= + +# TruthGate creates data/truthgate/config/kubo-settings.json on first boot. +# That persistent file contains the normal server defaults and is the future +# control-plane contract for the TruthGate UI. Uncomment any value below to +# override the corresponding persistent setting from Docker/Compose. +# +# TRUTHGATE_KUBO_APPLY_SERVER_PROFILE=true +# TRUTHGATE_KUBO_ENSURE_SWARM_LISTENERS=true +# TRUTHGATE_KUBO_ROUTING_TYPE=dhtserver +# TRUTHGATE_KUBO_HOLE_PUNCHING=true +# TRUTHGATE_KUBO_RELAY_CLIENT=true +# TRUTHGATE_KUBO_PROVIDE_ENABLED=true +# TRUTHGATE_KUBO_PROVIDE_STRATEGY=all +# TRUTHGATE_KUBO_PROVIDE_INTERVAL=22h +# TRUTHGATE_KUBO_PROVIDE_SWEEP=true +# TRUTHGATE_KUBO_PROVIDE_RESUME=true +# +# Storage can be "auto" or any Kubo size such as 750GB, 2TB, or 5TiB. +# Auto uses the configured percentage of the filesystem containing the blockstore. +# TRUTHGATE_KUBO_STORAGE_MAX=auto +# TRUTHGATE_KUBO_STORAGE_PERCENT=90 +# TRUTHGATE_KUBO_STORAGE_FALLBACK=200GB +# TRUTHGATE_KUBO_STORAGE_GC_WATERMARK=90 +# TRUTHGATE_KUBO_ENABLE_GC=true +# +# Public addresses can be "auto", "off", or a literal address. The announce +# port defaults to IPFS_SWARM_PORT so non-default Docker host mappings work. +# TRUTHGATE_KUBO_PUBLIC_IPV4=auto +# TRUTHGATE_KUBO_PUBLIC_IPV6=auto +# TRUTHGATE_KUBO_ANNOUNCE_PORT=4001 diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 0000000..fe07ba8 --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -0,0 +1,235 @@ +name: Reusable Docker appliance CI + +on: + workflow_call: + +permissions: + contents: read + +jobs: + validate-compose: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Validate production Compose + run: docker compose -f compose.yaml config + + - name: Validate development override + run: docker compose -f compose.yaml -f compose.dev.yaml config + + - name: Validate container scripts + run: | + bash -n docker/entrypoint.sh + bash -n docker/healthcheck.sh + bash -n docker/kubo-configure.sh + bash -n docker/kubo-status.sh + + smoke-test: + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-24.04 + - architecture: arm64 + runner: ubuntu-24.04-arm + + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Build production image (${{ matrix.architecture }}) + shell: bash + run: | + set -o pipefail + docker build \ + --progress=plain \ + --platform linux/${{ matrix.architecture }} \ + --target production \ + --tag truthgate-ipfs:test-${{ matrix.architecture }} \ + . 2>&1 | tee production-image-build-${{ matrix.architecture }}.log + + - name: Upload production build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-image-build-${{ matrix.architecture }} + path: production-image-build-${{ matrix.architecture }}.log + if-no-files-found: error + + - name: Build development image (${{ matrix.architecture }}) + shell: bash + run: | + set -o pipefail + docker build \ + --progress=plain \ + --platform linux/${{ matrix.architecture }} \ + --target development \ + --tag truthgate-ipfs:dev-test-${{ matrix.architecture }} \ + . 2>&1 | tee development-image-build-${{ matrix.architecture }}.log + + - name: Upload development build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: development-image-build-${{ matrix.architecture }} + path: development-image-build-${{ matrix.architecture }}.log + if-no-files-found: ignore + + - name: Start, replace, and revalidate the appliance + shell: bash + run: | + exec > >(tee runtime-smoke-${{ matrix.architecture }}.log) 2>&1 + set -Eeuo pipefail + + export TRUTHGATE_IMAGE=truthgate-ipfs:test-${{ matrix.architecture }} + export TRUTHGATE_HTTP_PORT=18080 + export TRUTHGATE_HTTPS_PORT=18443 + export IPFS_SWARM_PORT=14001 + export TRUTHGATE_KUBO_PUBLIC_IPV4=off + export TRUTHGATE_KUBO_PUBLIC_IPV6=off + export TRUTHGATE_DATA_HOST_PATH=./.ci-data/truthgate + export IPFS_REPO_HOST_PATH=./.ci-data/ipfs/repo + export IPFS_BLOCKS_HOST_PATH=./.ci-data/ipfs/blocks + + cleanup() { + docker compose logs --no-color 2>/dev/null || true + docker compose down --remove-orphans 2>/dev/null || true + } + trap cleanup EXIT + + wait_for_health() { + for attempt in $(seq 1 60); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' truthgate)" + if [[ "${status}" == "healthy" ]]; then + return 0 + fi + + if [[ "${status}" == "unhealthy" ]]; then + echo "TruthGate became unhealthy." + return 1 + fi + + sleep 5 + done + + echo "TruthGate did not become healthy in time." + return 1 + } + + read_peer_id() { + docker exec truthgate \ + ipfs --api=/ip4/127.0.0.1/tcp/5001 id \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["ID"])' + } + + read_bootstrap_hash() { + docker exec truthgate \ + sha256sum /data/truthgate/state/bootstrap-admin-password \ + | cut -d' ' -f1 + } + + read_settings_hash() { + docker exec truthgate \ + sha256sum /data/truthgate/config/kubo-settings.json \ + | cut -d' ' -f1 + } + + validate_truthgate_portal() { + local asset=/tmp/truthgate-blazor.web.js + + curl \ + --silent \ + --show-error \ + --fail \ + --insecure \ + --header 'Accept-Encoding: identity' \ + --output "${asset}" \ + https://127.0.0.1:18443/_framework/blazor.web.js + + local size + size="$(wc -c <"${asset}")" + echo "Blazor bootstrap size: ${size} bytes" + test "${size}" -gt 10000 + } + + validate_kubo_server_defaults() { + test "$(docker exec truthgate ipfs config Routing.Type)" = "dhtserver" + test "$(docker exec truthgate ipfs config Swarm.EnableHolePunching)" = "true" + test "$(docker exec truthgate ipfs config Swarm.RelayClient.Enabled)" = "true" + test "$(docker exec truthgate ipfs config Provide.Enabled)" = "true" + test "$(docker exec truthgate ipfs config Provide.Strategy)" = "all" + test "$(docker exec truthgate ipfs config Provide.DHT.Interval)" = "22h" + test "$(docker exec truthgate ipfs config Provide.DHT.SweepEnabled)" = "true" + test "$(docker exec truthgate ipfs config Provide.DHT.ResumeEnabled)" = "true" + test "$(docker exec truthgate ipfs config Datastore.StorageGCWatermark)" = "90" + + storage_max="$(docker exec truthgate ipfs config Datastore.StorageMax)" + test "${storage_max}" != "10GB" + [[ "${storage_max}" =~ ^[0-9]+B$ ]] + + docker exec truthgate test -s /data/truthgate/state/kubo-server-profile-v1 + docker exec truthgate test -s /data/truthgate/config/kubo-settings.json + docker exec truthgate test -s /data/truthgate/config/kubo-overrides.json + + docker exec truthgate \ + ipfs config --json Addresses.Swarm \ + | jq -e ' + index("/ip4/0.0.0.0/tcp/4001") and + index("/ip6/::/tcp/4001") and + index("/ip4/0.0.0.0/udp/4001/quic-v1") and + index("/ip4/0.0.0.0/udp/4001/quic-v1/webtransport") and + index("/ip6/::/udp/4001/quic-v1") and + index("/ip6/::/udp/4001/quic-v1/webtransport") + ' + + test "$(docker exec truthgate ipfs config Addresses.API)" = "/ip4/127.0.0.1/tcp/5001" + test "$(docker exec truthgate ipfs config Addresses.Gateway)" = "/ip4/127.0.0.1/tcp/9010" + docker exec truthgate truthgate-kubo-status + } + + mkdir -p .ci-data/truthgate .ci-data/ipfs/repo .ci-data/ipfs/blocks + + docker compose up --detach --no-build + wait_for_health + + # Read protected state from inside the container. The host runner is + # intentionally unable to inspect the bootstrap secret directly. + docker exec truthgate test -s /data/ipfs/repo/config + docker exec truthgate test -s /data/truthgate/state/bootstrap-admin-password + validate_truthgate_portal + validate_kubo_server_defaults + peer_id_before="$(read_peer_id)" + bootstrap_hash_before="$(read_bootstrap_hash)" + settings_hash_before="$(read_settings_hash)" + + # Replace the container while retaining only the documented bind-mounted + # state. Kubo identity, managed settings, and first-run credentials must + # remain stable. + docker compose down --remove-orphans + docker compose up --detach --no-build + wait_for_health + + docker exec truthgate test -s /data/ipfs/repo/config + docker exec truthgate test -s /data/truthgate/state/bootstrap-admin-password + validate_truthgate_portal + validate_kubo_server_defaults + peer_id_after="$(read_peer_id)" + bootstrap_hash_after="$(read_bootstrap_hash)" + settings_hash_after="$(read_settings_hash)" + + test "${peer_id_before}" = "${peer_id_after}" + test "${bootstrap_hash_before}" = "${bootstrap_hash_after}" + test "${settings_hash_before}" = "${settings_hash_after}" + + docker compose logs --no-color + + - name: Upload runtime diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: runtime-smoke-${{ matrix.architecture }} + path: runtime-smoke-${{ matrix.architecture }}.log + if-no-files-found: ignore diff --git a/.github/workflows/docker-repo-migration.yml b/.github/workflows/docker-repo-migration.yml new file mode 100644 index 0000000..28e9c40 --- /dev/null +++ b/.github/workflows/docker-repo-migration.yml @@ -0,0 +1,121 @@ +name: Docker repository migration + +on: + pull_request: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: docker-repo-migration-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + migrate-kubo-repository: + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-24.04 + - architecture: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Build production image (${{ matrix.architecture }}) + run: | + docker build \ + --platform linux/${{ matrix.architecture }} \ + --target production \ + --tag truthgate-ipfs:migration-test-${{ matrix.architecture }} \ + . + + - name: Create a Kubo 0.36 repository and verify automatic migration + shell: bash + run: | + exec > >(tee repo-migration-${{ matrix.architecture }}.log) 2>&1 + set -Eeuo pipefail + + export TRUTHGATE_IMAGE=truthgate-ipfs:migration-test-${{ matrix.architecture }} + export TRUTHGATE_HTTP_PORT=18080 + export TRUTHGATE_HTTPS_PORT=18443 + export IPFS_SWARM_PORT=14001 + export TRUTHGATE_KUBO_PUBLIC_IPV4=off + export TRUTHGATE_KUBO_PUBLIC_IPV6=off + export TRUTHGATE_DATA_HOST_PATH=./.ci-migration/truthgate + export IPFS_REPO_HOST_PATH=./.ci-migration/ipfs/repo + export IPFS_BLOCKS_HOST_PATH=./.ci-migration/ipfs/blocks + + cleanup() { + docker compose logs --no-color 2>/dev/null || true + docker compose down --remove-orphans 2>/dev/null || true + } + trap cleanup EXIT + + rm -rf .ci-migration + mkdir -p \ + .ci-migration/truthgate \ + .ci-migration/ipfs/repo \ + .ci-migration/ipfs/blocks + + docker run --rm \ + --user 0:0 \ + --entrypoint /usr/local/bin/ipfs \ + --env IPFS_PATH=/data/ipfs/repo \ + --volume "$PWD/.ci-migration/ipfs/repo:/data/ipfs/repo" \ + --volume "$PWD/.ci-migration/ipfs/blocks:/data/ipfs/repo/blocks" \ + ipfs/kubo:v0.36.0 \ + init --profile server + + test "$(sudo tr -d '[:space:]' < .ci-migration/ipfs/repo/version)" = "16" + peer_id_before="$(sudo jq -r '.Identity.PeerID' .ci-migration/ipfs/repo/config)" + test -n "${peer_id_before}" + + sudo chown -R 1000:1000 .ci-migration + + docker compose up --detach --no-build + + for attempt in $(seq 1 60); do + state="$(docker inspect --format '{{.State.Status}}' truthgate)" + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' truthgate)" + echo "Attempt ${attempt}: state=${state} health=${health}" + + if [[ "${health}" == "healthy" ]]; then + break + fi + + if [[ "${state}" == "exited" || "${health}" == "unhealthy" ]]; then + docker compose logs --no-color + exit 1 + fi + + sleep 5 + done + + test "$(docker inspect --format '{{.State.Health.Status}}' truthgate)" = "healthy" + test "$(docker exec truthgate cat /data/ipfs/repo/version | tr -d '[:space:]')" = "18" + + peer_id_after="$(docker exec truthgate ipfs config Identity.PeerID)" + test "${peer_id_before}" = "${peer_id_after}" + + docker compose logs --no-color \ + | grep -F "Migrating Kubo repository from version 16 to 18 before applying managed configuration." + docker compose logs --no-color \ + | grep -F "Kubo repository migration completed at version 18." + + test "$(docker exec truthgate ipfs config Routing.Type)" = "dhtserver" + test "$(docker exec truthgate ipfs config Provide.Strategy)" = "all" + docker exec truthgate truthgate-kubo-status + + - name: Upload migration diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: repo-migration-${{ matrix.architecture }} + path: repo-migration-${{ matrix.architecture }}.log + if-no-files-found: ignore diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..94e0940 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,73 @@ +name: Docker appliance + +on: + pull_request: + push: + branches: + - master + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: docker-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ghcr.io/magiccodingman/truthgate-ipfs + +jobs: + appliance-ci: + name: Validate and smoke test appliance + uses: ./.github/workflows/docker-ci.yml + + build-and-publish: + name: Publish rolling GHCR image + if: github.event_name != 'pull_request' + needs: + - appliance-ci + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=master,enable=${{ github.ref == 'refs/heads/master' }} + type=sha,prefix=sha- + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Publish AMD64 + ARM64 under one image tag + uses: docker/build-push-action@v6 + with: + context: . + target: production + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true diff --git a/.github/workflows/dockerhub-stable.yml b/.github/workflows/dockerhub-stable.yml new file mode 100644 index 0000000..fe37b0c --- /dev/null +++ b/.github/workflows/dockerhub-stable.yml @@ -0,0 +1,205 @@ +name: Docker Hub stable release + +on: + push: + branches: + - stable + +permissions: + contents: write + +concurrency: + group: dockerhub-stable-release + cancel-in-progress: false + +jobs: + appliance-ci: + name: Validate and smoke test appliance + uses: ./.github/workflows/docker-ci.yml + + repository-migration: + name: Validate legacy Kubo migration + uses: ./.github/workflows/docker-repo-migration.yml + + tls-lifecycle: + name: Validate TLS lifecycle + uses: ./.github/workflows/tls-lifecycle.yml + + prepare-release: + name: Allocate stable version + needs: + - appliance-ci + - repository-migration + - tls-lifecycle + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + series: ${{ steps.version.outputs.series }} + tag: ${{ steps.version.outputs.tag }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Calculate idempotent patch version + id: version + shell: bash + run: | + set -Eeuo pipefail + + series="$(tr -d '[:space:]' < VERSION)" + if [[ ! "${series}" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "VERSION must contain only a major.minor series such as 0.1." >&2 + exit 1 + fi + + git fetch --force --tags origin + + existing_tag="" + while IFS= read -r candidate; do + [[ -n "${candidate}" ]] || continue + if [[ "${candidate}" =~ ^v${series//./\.}\.([0-9]+)$ ]]; then + existing_tag="${candidate}" + fi + done < <(git tag --points-at "${GITHUB_SHA}" --list "v${series}.*" | sort -V) + + if [[ -n "${existing_tag}" ]]; then + tag="${existing_tag}" + version="${tag#v}" + echo "Reusing ${tag}; this commit was already assigned a stable version." + else + max_patch=-1 + + while IFS= read -r candidate; do + [[ -n "${candidate}" ]] || continue + + if [[ "${candidate}" =~ ^v${series//./\.}\.([0-9]+)$ ]]; then + patch="${BASH_REMATCH[1]}" + if (( patch > max_patch )); then + max_patch="${patch}" + fi + fi + done < <(git tag --list "v${series}.*") + + version="${series}.$((max_patch + 1))" + tag="v${version}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --annotate "${tag}" --message "TruthGate ${tag}" "${GITHUB_SHA}" + git push origin "${tag}" + + echo "Created stable release tag ${tag}." + fi + + echo "series=${series}" >>"${GITHUB_OUTPUT}" + echo "version=${version}" >>"${GITHUB_OUTPUT}" + echo "tag=${tag}" >>"${GITHUB_OUTPUT}" + + publish-dockerhub: + name: Publish Docker Hub image + needs: + - prepare-release + runs-on: ubuntu-24.04 + env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/truthgate-ipfs + VERSION: ${{ needs.prepare-release.outputs.version }} + SERIES: ${{ needs.prepare-release.outputs.series }} + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate stable image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ env.VERSION }},priority=900 + type=raw,value=${{ env.SERIES }},priority=800 + type=raw,value=stable,priority=700 + type=raw,value=latest,priority=600 + type=sha,prefix=sha-,priority=100 + labels: | + org.opencontainers.image.title=TruthGate + org.opencontainers.image.description=Secure, self-hosted IPFS edge gateway with HTTPS, authentication, GUI, and Kubo. + org.opencontainers.image.version=${{ env.VERSION }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + + - name: Publish AMD64 + ARM64 to Docker Hub + uses: docker/build-push-action@v6 + with: + context: . + target: production + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + + - name: Verify published multi-platform manifest + shell: bash + run: | + set -Eeuo pipefail + + docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" \ + | tee dockerhub-manifest.txt + + grep -F 'linux/amd64' dockerhub-manifest.txt + grep -F 'linux/arm64' dockerhub-manifest.txt + + - name: Upload Docker Hub manifest diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: dockerhub-manifest-${{ env.VERSION }} + path: dockerhub-manifest.txt + if-no-files-found: ignore + + update-dockerhub-description: + name: Sync Docker Hub README + needs: + - prepare-release + - publish-dockerhub + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Update Docker Hub overview from README + uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: ${{ secrets.DOCKERHUB_USERNAME }}/truthgate-ipfs + short-description: Secure, self-hosted IPFS edge gateway with HTTPS, authentication, GUI, and Kubo. + readme-filepath: ./README.md + enable-url-completion: true + + - name: Stable release summary + shell: bash + run: | + { + echo "## TruthGate stable release" + echo + echo "- Git tag: \`${{ needs.prepare-release.outputs.tag }}\`" + echo "- Docker image: \`${{ secrets.DOCKERHUB_USERNAME }}/truthgate-ipfs:${{ needs.prepare-release.outputs.version }}\`" + echo "- Moving tags: \`stable\`, \`latest\`, and \`${{ needs.prepare-release.outputs.series }}\`" + echo "- Architectures: \`linux/amd64\`, \`linux/arm64\`" + echo "- Docker Hub overview synchronized from \`README.md\`" + } >>"${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/tls-lifecycle.yml b/.github/workflows/tls-lifecycle.yml index 714162a..839e3e5 100644 --- a/.github/workflows/tls-lifecycle.yml +++ b/.github/workflows/tls-lifecycle.yml @@ -3,18 +3,18 @@ name: TLS lifecycle tests on: pull_request: paths: - - "TruthGate-Web/TruthGate-Web/Configuration/**" - - "TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs" - - "TruthGate-Web/TruthGate-Web.Tests/**" + - "TruthGate-Web/**" - ".github/workflows/tls-lifecycle.yml" push: branches: - master paths: - - "TruthGate-Web/TruthGate-Web/Configuration/**" - - "TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs" - - "TruthGate-Web/TruthGate-Web.Tests/**" + - "TruthGate-Web/**" - ".github/workflows/tls-lifecycle.yml" + workflow_call: + +permissions: + contents: read jobs: test: @@ -25,37 +25,22 @@ jobs: - uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" + + - name: Restore solution + run: dotnet restore TruthGate-IPFS.sln - - name: Restore + # This test project is intentionally outside TruthGate-IPFS.sln, so restore it explicitly. + - name: Restore TLS tests run: dotnet restore TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj - - name: Test - id: test - shell: bash - run: | - set +e - dotnet test \ - TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj \ - --no-restore \ - --configuration Release \ - --logger "console;verbosity=normal" \ - > tls-test-output.log 2>&1 - status=$? - echo "status=$status" >> "$GITHUB_OUTPUT" - exit 0 - - - name: Upload test diagnostics - if: always() - uses: actions/upload-artifact@v4 - with: - name: tls-test-output - path: tls-test-output.log - if-no-files-found: error - - - name: Report failure - if: steps.test.outputs.status != '0' - shell: bash - run: | - echo "TLS lifecycle tests failed. Download the tls-test-output artifact for the complete compiler/test diagnostics." - exit 1 + - name: Build solution + run: dotnet build TruthGate-IPFS.sln --no-restore --configuration Release + + - name: Test TLS lifecycle + run: >- + dotnet test + TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj + --no-restore + --configuration Release + --logger "console;verbosity=normal" diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..8c3c118 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,353 @@ +# TruthGate Docker appliance + +TruthGate ships as one container containing the TruthGate ASP.NET application, +Kubo, and the matching `ipfs` CLI. Docker owns the software lifecycle; mounted +state survives image replacement. + +The appliance is intentionally configured as a contributing IPFS server. It is +not a quiet desktop-node preset: DHT server mode, inbound TCP/UDP swarm +transports, content providing, and automatic repository GC are enabled unless +an operator explicitly overrides them. + +## Requirements + +- Docker Engine with the Compose v2 plugin +- Docker Compose 2.24.4 or newer for the development override's `!override` + merge directive +- TCP ports 80, 443, and 4001 plus UDP port 4001 available by default +- Public TCP and UDP forwarding for the selected swarm port when deployed + behind NAT + +## Production quick start + +```bash +git clone https://github.com/magiccodingman/TruthGate-IPFS.git +cd TruthGate-IPFS +cp .env.example .env +docker compose pull +docker compose up -d +``` + +The default Compose image is `magiccodingman/truthgate-ipfs:stable`. Open +`https://localhost` after the container becomes healthy. The first connection +uses TruthGate's self-signed fallback certificate unless a configured domain +has an issued certificate. + +On the first boot, retrieve the generated administrator password with: + +```bash +docker compose logs truthgate +``` + +The username is `admin`. Change its password in the TruthGate UI. Until the +configuration is persisted, the bootstrap password is also retained at +`data/truthgate/state/bootstrap-admin-password` with restrictive permissions. + +To build the production image locally instead of pulling the stable release: + +```bash +docker compose up --build -d +``` + +## Published image tags + +A successful promotion to the protected `stable` branch runs the complete +AMD64/ARM64 appliance, legacy-repository migration, and TLS lifecycle gates +before Docker Hub publishing starts. + +The release workflow publishes: + +```text +magiccodingman/truthgate-ipfs:stable +magiccodingman/truthgate-ipfs:latest +magiccodingman/truthgate-ipfs:0.1 +magiccodingman/truthgate-ipfs:0.1.0 +magiccodingman/truthgate-ipfs:sha- +``` + +`stable` and `latest` move to the newest successful stable release. The +major/minor series tag, such as `0.1`, also moves forward. Full semantic +versions are immutable release identifiers. `VERSION` contains the intentional +major/minor series; every successful stable promotion automatically allocates +the next patch version. + +The same multi-platform tag resolves to the correct `linux/amd64` or +`linux/arm64` image automatically. Replacing the container does not replace +mounted state. + +To update an existing deployment: + +```bash +docker compose pull +docker compose up -d +``` + +Pin `TRUTHGATE_IMAGE` in `.env` to a full version when an installation should +not automatically follow the moving `stable` tag. + +## Persistent storage contract + +The default deployment keeps three host paths separate: + +```text +data/ +├── truthgate/ # config, database, certificates, secrets, app state +└── ipfs/ + ├── repo/ # Kubo identity, config, datastore, keystore, repo version + └── blocks/ # Kubo block files +``` + +They are mounted as: + +```text +/data/truthgate +/data/ipfs/repo +/data/ipfs/repo/blocks +``` + +Kubo still sees a conventional `$IPFS_PATH/blocks` directory, while the host +can later relocate only the blockstore by changing `IPFS_BLOCKS_HOST_PATH`. +No network filesystem, FUSE, JuiceFS, mount management, or mount health logic +is included in this implementation. + +The software inside the image is disposable. Backups should target the mounted +state, not the .NET runtime, Kubo binary, application binaries, or build output. + +## Opinionated Kubo server defaults + +A new repository is initialized with Kubo's `server` profile. An existing +repository receives that profile once, tracked by +`data/truthgate/state/kubo-server-profile-v1`. The profile disables mDNS and +automatic NAT port mapping and filters non-public address ranges, matching a +public server deployment. Set `TRUTHGATE_KUBO_APPLY_SERVER_PROFILE=false` +before the first managed boot when that behavior is not appropriate. + +TruthGate then manages these defaults: + +```text +Routing.Type dhtserver +Addresses.Swarm current Kubo defaults, with missing + TCP/QUIC/WebTransport listeners restored +Swarm.EnableHolePunching true +Swarm.RelayClient.Enabled true +Provide.Enabled true +Provide.Strategy all +Provide.DHT.Interval 22h +Provide.DHT.SweepEnabled true +Provide.DHT.ResumeEnabled true +Datastore.StorageMax auto: 90% of the blockstore filesystem +Datastore.StorageGCWatermark 90 +Automatic repository GC enabled +Addresses.API /ip4/127.0.0.1/tcp/5001 +Addresses.Gateway /ip4/127.0.0.1/tcp/9010 +``` + +The entrypoint preserves Kubo's current listener array and only appends missing +standard listeners. That avoids deleting newer transports such as +WebTransport when Kubo changes its defaults. + +The RPC API and HTTP gateway are always loopback-only. TruthGate already +authenticates and proxies public `/api/v0`, `/ipfs`, `/ipns`, and WebUI access. +Those two Kubo listener settings cannot be overridden. + +The public swarm mapping on port 4001 is the current implementation stage. +TruthGate is expected to own and proxy swarm transports in a future +architecture; until that transport layer exists, Docker publishes Kubo's TCP +and UDP swarm traffic directly. + +## Persistent Kubo settings + +On first boot TruthGate creates: + +```text +data/truthgate/config/kubo-settings.json +``` + +This file is persistent and contains the normal managed settings. It is also +the contract intended for future live management from the TruthGate UI. +Editing the file and restarting the container changes the effective Kubo +configuration without rebuilding the image. + +Environment variables take precedence over the persistent settings file. The +available variables are documented in `.env.example`, including: + +```text +TRUTHGATE_KUBO_ROUTING_TYPE +TRUTHGATE_KUBO_PROVIDE_ENABLED +TRUTHGATE_KUBO_PROVIDE_STRATEGY +TRUTHGATE_KUBO_STORAGE_MAX +TRUTHGATE_KUBO_STORAGE_PERCENT +TRUTHGATE_KUBO_ENABLE_GC +TRUTHGATE_KUBO_PUBLIC_IPV4 +TRUTHGATE_KUBO_PUBLIC_IPV6 +TRUTHGATE_KUBO_ANNOUNCE_PORT +``` + +For advanced settings, edit: + +```text +data/truthgate/config/kubo-overrides.json +``` + +It is a JSON object whose keys are Kubo config paths and whose values are raw +JSON values: + +```json +{ + "Swarm.ConnMgr.LowWater": 100, + "Swarm.ConnMgr.HighWater": 300, + "Routing.AcceleratedDHTClient": false +} +``` + +Advanced overrides run after TruthGate's normal defaults. `Addresses.API` and +`Addresses.Gateway` are ignored in this file because they are protected +loopback interfaces. + +## Storage policy + +The default storage policy is: + +```json +{ + "storage": { + "max": "auto", + "percent": 90, + "fallback": "200GB", + "gcWatermark": 90, + "enableGc": true + } +} +``` + +`auto` reads the total capacity of the filesystem containing +`/data/ipfs/repo/blocks` and sets Kubo's `Datastore.StorageMax` to 90 percent of +that total. It uses total capacity rather than currently free space so the +limit does not shrink as content is added. If capacity detection fails, the +fallback is `200GB`. + +A fixed value is also supported: + +```env +TRUTHGATE_KUBO_STORAGE_MAX=2TB +``` + +Auto mode recalculates on every container start, so expanding a VPS disk only +requires a container restart. Fixed mode remains exactly the configured value. +`StorageMax` is a soft Kubo blockstore/GC threshold rather than a hard quota, +so filesystem and metadata headroom still matter. + +## Public announce addresses + +By default TruthGate attempts to detect public IPv4 and IPv6 addresses and +manages matching TCP, QUIC-v1, and WebTransport entries in +`Addresses.AppendAnnounce`. Previously managed entries are removed before new +ones are added, so public-IP changes do not accumulate stale addresses. + +Each address can be automatic, disabled, or literal: + +```env +TRUTHGATE_KUBO_PUBLIC_IPV4=auto +TRUTHGATE_KUBO_PUBLIC_IPV6=off +TRUTHGATE_KUBO_ANNOUNCE_PORT=4001 +``` + +The announce port defaults to `IPFS_SWARM_PORT`. If Docker publishes +`14001:4001`, set `IPFS_SWARM_PORT=14001`; TruthGate will announce `14001` while +Kubo continues listening on container port 4001. + +## Kubo startup and migrations + +Every start uses automatic repository migration. Automatic GC is included when +enabled: + +```bash +ipfs daemon --migrate=true --enable-gc +``` + +The entrypoint applies managed settings before starting the daemon, then starts +TruthGate only after Kubo's loopback RPC API is ready. + +## Diagnostics + +Inspect the effective configuration and live node state with: + +```bash +docker exec truthgate truthgate-kubo-status +``` + +The report includes: + +- peer ID and connected peer count +- effective routing, provide, swarm, and storage configuration +- configured and active listen addresses +- public append-announcement addresses +- repository statistics +- AutoNAT observations +- sweep-provider statistics + +Useful direct checks include: + +```bash +docker exec truthgate ipfs config Routing.Type +docker exec truthgate ipfs config --json Addresses.Swarm +docker exec truthgate ipfs config --json Addresses.AppendAnnounce +docker exec truthgate ipfs provide stat +docker exec truthgate ipfs stats dht +``` + +A real external validation should use another IPFS node: + +```bash +ipfs routing findpeer +``` + +Then add a unique file through TruthGate, obtain its CID, and retrieve it from +the separate node. That verifies routing and content providing, not merely +local daemon health. + +## Development + +Development uses the same Dockerfile and base Compose service: + +```bash +docker compose -f compose.yaml -f compose.dev.yaml up --build +``` + +The override changes the image target to the .NET SDK development stage, +bind-mounts the repository at `/workspace`, adds persistent NuGet caches, and +runs the web project with `dotnet watch`. TruthGate is available at +`http://localhost:8080` by default. + +Kubo initialization, server defaults, repository migrations, persistent paths, +startup order, and process supervision are shared with production. This +prevents development from silently using a different node layout. + +Rider can use `compose.yaml` followed by `compose.dev.yaml` directly in a Docker +Compose run configuration. The source directory is mounted directly, so normal +edits trigger `dotnet watch`. + +## General configuration + +Ordinary host-facing settings live in `.env`. Important values include: + +- `TRUTHGATE_IMAGE` +- `KUBO_VERSION` +- `TRUTHGATE_HTTP_PORT` +- `TRUTHGATE_HTTPS_PORT` +- `IPFS_SWARM_PORT` +- `TRUTHGATE_DATA_HOST_PATH` +- `IPFS_REPO_HOST_PATH` +- `IPFS_BLOCKS_HOST_PATH` + +Use absolute host paths for advanced deployments. Relative defaults are rooted +at the directory containing `compose.yaml`. + +## Process model + +`tini` is PID 1 in normal Docker execution. Rider's debugger may become PID 1, +in which case Tini registers as a child subreaper. The entrypoint starts Kubo +first, waits for its RPC API, then starts TruthGate. If either process exits, +the entrypoint terminates the other and exits so Docker's restart policy can +recover the appliance. SIGTERM and SIGINT are forwarded to both processes for +graceful shutdown. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11fae41 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,148 @@ +# syntax=docker/dockerfile:1.7 + +ARG DOTNET_VERSION=10.0 +ARG KUBO_VERSION=v0.42.0 +ARG TRUTHGATE_UID=1000 +ARG TRUTHGATE_GID=1000 + +FROM ipfs/kubo:${KUBO_VERSION} AS kubo + +# Compile on the builder's native CPU while targeting the requested image +# architecture. This avoids running the full .NET build under QEMU for ARM64. +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-resolute AS build +ARG TARGETARCH +WORKDIR /src + +COPY TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj TruthGate-Web/TruthGate-Web/ +COPY TruthGate-Web/TruthGate-Web.Client/TruthGate-Web.Client.csproj TruthGate-Web/TruthGate-Web.Client/ +RUN dotnet restore TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj --arch "${TARGETARCH}" + +COPY . . +RUN dotnet publish TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj \ + --configuration Release \ + --arch "${TARGETARCH}" \ + --no-restore \ + --no-self-contained \ + --output /out \ + /p:UseAppHost=false + +# .NET 10 publishes the Blazor bootstrap as a static web asset instead of an +# embedded framework resource. Fail the image build if restore/publish ever +# omits it, because the portal would otherwise render static HTML while every +# interactive server component silently remains inactive. +RUN set -eu; \ + manifest=/out/TruthGate-Web.staticwebassets.endpoints.json; \ + test -s "${manifest}"; \ + grep -q '"Route":"_framework/blazor\.web\.js"' "${manifest}"; \ + asset="$(find /out/wwwroot/_framework -maxdepth 1 -type f \ + \( -name 'blazor.web.js' -o -name 'blazor.web.*.js' \) \ + -size +10000c -print -quit)"; \ + test -n "${asset}"; \ + printf 'Verified Blazor bootstrap asset: %s (%s bytes)\n' \ + "${asset}" "$(wc -c <"${asset}")" + +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-resolute AS runtime-base +ARG TRUTHGATE_UID +ARG TRUTHGATE_GID + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gosu \ + jq \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && if ! getent group "${TRUTHGATE_GID}" >/dev/null; then groupadd --gid "${TRUTHGATE_GID}" truthgate; fi \ + && if existing_user="$(getent passwd "${TRUTHGATE_UID}" | cut -d: -f1)" && [ -n "${existing_user}" ]; then \ + usermod --login truthgate --home /home/truthgate --move-home --shell /usr/sbin/nologin "${existing_user}"; \ + else \ + useradd --uid "${TRUTHGATE_UID}" --gid "${TRUTHGATE_GID}" --create-home --shell /usr/sbin/nologin truthgate; \ + fi \ + && mkdir -p /home/truthgate/.aspnet \ + && ln -s /data/truthgate/secrets/data-protection-keys /home/truthgate/.aspnet/DataProtection-Keys + +COPY --from=kubo /usr/local/bin/ipfs /usr/local/bin/ipfs +COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/truthgate-entrypoint +COPY --chmod=0755 docker/healthcheck.sh /usr/local/bin/truthgate-healthcheck +COPY --chmod=0755 docker/kubo-configure.sh /usr/local/bin/truthgate-configure-kubo +COPY --chmod=0755 docker/kubo-status.sh /usr/local/bin/truthgate-kubo-status + +ENV ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production \ + DOTNET_NOLOGO=true \ + DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true \ + HOME=/home/truthgate \ + IPFS_PATH=/data/ipfs/repo \ + TMPDIR=/run/truthgate \ + TRUTHGATE_CERT_PATH=/data/truthgate/certificates \ + TRUTHGATE_CONFIG_PATH=/data/truthgate/config/config.json \ + TRUTHGATE_DATABASE_PATH=/data/truthgate/database \ + TRUTHGATE_KUBO_OVERRIDES_PATH=/data/truthgate/config/kubo-overrides.json \ + TRUTHGATE_KUBO_SETTINGS_PATH=/data/truthgate/config/kubo-settings.json \ + TRUTHGATE_STATE_PATH=/data/truthgate/state \ + TRUTHGATE_HEALTH_URL=https://127.0.0.1:443/ + +EXPOSE 80 443 4001/tcp 4001/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/truthgate-entrypoint"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ + CMD ["/usr/local/bin/truthgate-healthcheck"] + +FROM runtime-base AS production +WORKDIR /app +COPY --from=build /out . +CMD ["production"] + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-resolute AS development +ARG TRUTHGATE_UID +ARG TRUTHGATE_GID + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gosu \ + jq \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && if ! getent group "${TRUTHGATE_GID}" >/dev/null; then groupadd --gid "${TRUTHGATE_GID}" truthgate; fi \ + && if existing_user="$(getent passwd "${TRUTHGATE_UID}" | cut -d: -f1)" && [ -n "${existing_user}" ]; then \ + usermod --login truthgate --home /home/truthgate --move-home --shell /bin/bash "${existing_user}"; \ + else \ + useradd --uid "${TRUTHGATE_UID}" --gid "${TRUTHGATE_GID}" --create-home --shell /bin/bash truthgate; \ + fi \ + && mkdir -p /workspace /home/truthgate/.aspnet \ + && chown "${TRUTHGATE_UID}:${TRUTHGATE_GID}" /workspace \ + && ln -s /data/truthgate/secrets/data-protection-keys /home/truthgate/.aspnet/DataProtection-Keys + +COPY --from=kubo /usr/local/bin/ipfs /usr/local/bin/ipfs +COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/truthgate-entrypoint +COPY --chmod=0755 docker/healthcheck.sh /usr/local/bin/truthgate-healthcheck +COPY --chmod=0755 docker/kubo-configure.sh /usr/local/bin/truthgate-configure-kubo +COPY --chmod=0755 docker/kubo-status.sh /usr/local/bin/truthgate-kubo-status + +ENV ASPNETCORE_ENVIRONMENT=Development \ + DOTNET_ENVIRONMENT=Development \ + DOTNET_CLI_HOME=/tmp/dotnet \ + DOTNET_NOLOGO=true \ + DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true \ + HOME=/home/truthgate \ + IPFS_PATH=/data/ipfs/repo \ + TMPDIR=/run/truthgate \ + TRUTHGATE_CERT_PATH=/data/truthgate/certificates \ + TRUTHGATE_CONFIG_PATH=/data/truthgate/config/config.json \ + TRUTHGATE_DATABASE_PATH=/data/truthgate/database \ + TRUTHGATE_DEV_PROJECT=/workspace/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj \ + TRUTHGATE_KUBO_OVERRIDES_PATH=/data/truthgate/config/kubo-overrides.json \ + TRUTHGATE_KUBO_SETTINGS_PATH=/data/truthgate/config/kubo-settings.json \ + TRUTHGATE_STATE_PATH=/data/truthgate/state \ + TRUTHGATE_HEALTH_URL=http://127.0.0.1:80/ + +WORKDIR /workspace +EXPOSE 80 4001/tcp 4001/udp +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/truthgate-entrypoint"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ + CMD ["/usr/local/bin/truthgate-healthcheck"] +CMD ["development"] diff --git a/Publish.CLI/Publish.CLI.csproj b/Publish.CLI/Publish.CLI.csproj index fd4bd08..ed9781c 100644 --- a/Publish.CLI/Publish.CLI.csproj +++ b/Publish.CLI/Publish.CLI.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/README.md b/README.md index bb26024..ece6c62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # TruthGate - > The Secure, Self-Hosted Edge Gateway IPFS Always Needed, with Logins, API Keys, GUI Control, and Web3 Site Publishing. > **[truthgate.io](https://truthgate.io)** for full docs, guides, and live demos. @@ -19,17 +18,73 @@ --- +## Docker quick start + +TruthGate, Kubo, and the matching `ipfs` CLI run together as one appliance +container. Application state, Kubo repository metadata, and Kubo blocks are +persisted separately by default. + +```bash +git clone https://github.com/magiccodingman/TruthGate-IPFS.git +cd TruthGate-IPFS +cp .env.example .env +docker compose pull +docker compose up -d +docker compose logs truthgate +``` + +The default Compose image is the tested multi-platform stable release: + +```text +magiccodingman/truthgate-ipfs:stable +``` + +The first-start logs contain a generated password for the `admin` account. +Open `https://localhost`, accept the temporary self-signed fallback certificate, +and change the password. + +The appliance configures Kubo as a contributing server by default: DHT server +mode, TCP/QUIC/WebTransport swarm listeners, content providing, automatic +storage sizing, and repository GC are enabled with persistent per-setting +overrides. Inspect the live node with: + +```bash +docker exec truthgate truthgate-kubo-status +``` + +For the full persistence contract, Kubo settings, release tags, image update +flow, ARM64/AMD64 publishing, and Docker-based development setup, see +**[DOCKER.md](DOCKER.md)**. + +To build the production image locally instead of pulling the stable release: + +```bash +docker compose up --build -d +``` + +Development with hot reload uses the production definition plus a small +override: + +```bash +docker compose -f compose.yaml -f compose.dev.yaml up --build +``` + +Then open `http://localhost:8080`. + +--- + ## What Is It? TruthGate is a **secure edge layer for IPFS nodes**. -Think **Netlify, but for IPFS**, self-hosted, login-protected, and actually yours. +Think **Netlify, but for IPFS**, self-hosted, login-protected, and actually yours. + +It solves the problems developers hit with IPFS: -It solves the problems developers hit with IPFS: -- Node exposure risks -- SSL/domain linking headaches -- CLI-only publishing -- Zero protection for `/api` or `/webui` routes -- Reliance on public gateways +- Node exposure risks +- SSL/domain linking headaches +- CLI-only publishing +- Zero protection for `/api` or `/webui` routes +- Reliance on public gateways [See how it works](https://truthgate.io) @@ -37,15 +92,15 @@ It solves the problems developers hit with IPFS: ## Get Started -Head to **[truthgate.io](https://truthgate.io)** for installation, configuration, and publishing guides. +Head to **[truthgate.io](https://truthgate.io)** for installation, configuration, and publishing guides. --- ## A Note from the Creator -I built this out of frustration. +I built this out of frustration. -I wanted a way to serve Web3-native apps that *actually worked*, securely, reliably, and without selling my soul to centralized hosts. Now it’s real. +I wanted a way to serve Web3-native apps that *actually worked*, securely, reliably, and without selling my soul to centralized hosts. Now it’s real. If you’ve ever wrestled with IPFS routing, SSL certs, or gateway hacks just to get your site online, **TruthGate is for you.** @@ -55,11 +110,11 @@ If you’ve ever wrestled with IPFS routing, SSL certs, or gateway hacks just to Pull requests welcome. Stars encourage me. -Issues are sacred. +Issues are sacred. -Let’s fix decentralized hosting together. +Let’s fix decentralized hosting together. -[truthgate.io](https://truthgate.io) +[truthgate.io](https://truthgate.io) --- diff --git a/Test.TruthGate/Test.TruthGate.csproj b/Test.TruthGate/Test.TruthGate.csproj index 63bf931..a396ab3 100644 --- a/Test.TruthGate/Test.TruthGate.csproj +++ b/Test.TruthGate/Test.TruthGate.csproj @@ -1,23 +1,29 @@  - net9.0 + net10.0 enable enable false - - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/TruthGate-Web/TruthGate-Web.Client/Cache.cs b/TruthGate-Web/TruthGate-Web.Client/Cache.cs index 0cedb0f..9ab4425 100644 --- a/TruthGate-Web/TruthGate-Web.Client/Cache.cs +++ b/TruthGate-Web/TruthGate-Web.Client/Cache.cs @@ -6,10 +6,10 @@ public static class Cache { public static MudTheme CustomTheme = new() { - PaletteLight = new() + PaletteLight = new PaletteLight { - Primary = "#2D2A5A", // Deep Indigo - Secondary = "#00E3C0", // Electric Mint + Primary = "#2D2A5A", + Secondary = "#00E3C0", Black = "#110e2d", AppbarText = "#424242", AppbarBackground = "rgba(255,255,255,0.8)", @@ -17,22 +17,24 @@ public static class Cache GrayLight = "#e8e8e8", GrayLighter = "#f9f9f9", }, - PaletteDark = new() + + PaletteDark = new PaletteDark { - Primary = "#00E3C0", // Electric Mint now the star + Primary = "#00E3C0", PrimaryContrastText = "#000000", - Secondary = "#3C3970", // Softer indigo accent (less oppressive than deep indigo) - Background = "#1E1F22", // Discord-ish near-black with a whisper of warmth - Surface = "#2B2D31", // Elevated panels (matches Discord card tone) - DrawerBackground = "#232428", // Sidebar tone - AppbarBackground = "#1B1C1F", // Slightly darker than background for separation - AppbarText = "#E0E0E0", // Soft but high-contrast - TextPrimary = "#FFFFFF", // True white for legibility - TextSecondary = "#A3A6AA", // Muted gray - ActionDefault = "#00E3C0", // Same as primary for cohesion - Divider = "#383A40" // Subtle panel separators + Secondary = "#3C3970", + Background = "#1E1F22", + Surface = "#2B2D31", + DrawerBackground = "#232428", + AppbarBackground = "#1B1C1F", + AppbarText = "#E0E0E0", + TextPrimary = "#FFFFFF", + TextSecondary = "#A3A6AA", + ActionDefault = "#00E3C0", + Divider = "#383A40" }, + LayoutProperties = new LayoutProperties() }; } -} +} \ No newline at end of file diff --git a/TruthGate-Web/TruthGate-Web.Client/TruthGate-Web.Client.csproj b/TruthGate-Web/TruthGate-Web.Client/TruthGate-Web.Client.csproj index 32d92b4..01abcaf 100644 --- a/TruthGate-Web/TruthGate-Web.Client/TruthGate-Web.Client.csproj +++ b/TruthGate-Web/TruthGate-Web.Client/TruthGate-Web.Client.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable true @@ -12,8 +12,8 @@ - - + + diff --git a/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj b/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj index 69e4f2d..4b520df 100644 --- a/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj +++ b/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false @@ -9,9 +9,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Pinned.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Pinned.razor index 2e8862b..c57db2a 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Pinned.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Pinned.razor @@ -282,7 +282,7 @@ MaxWidth = MaxWidth.Medium }; var parameters = new DialogParameters(); - var dialog = Dialogs.Show("Add Pinned Item", parameters, options); + var dialog = await Dialogs.ShowAsync("Add Pinned Item", parameters, options); var result = await dialog.Result; if (result.Canceled || result.Data is not AddPinnedItemDialog.AddPinnedResult r) return; @@ -331,7 +331,7 @@ { { x => x.Message, $"Unpin and delete '{(row.Name ?? row.Cid)}'?" } }; - var dialog = Dialogs.Show("Confirm Delete", parameters); + var dialog = await Dialogs.ShowAsync("Confirm Delete", parameters); var result = await dialog.Result; if (result.Canceled || result.Data is not bool ok || !ok) return; diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/PinnedIpns.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/PinnedIpns.razor index 1e594cd..f7f1034 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/PinnedIpns.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/PinnedIpns.razor @@ -283,7 +283,7 @@ { x => x.ManagedRoot, ManagedRoot }, { x => x.IsEdit, false } }; - var dlg = Dialogs.Show("Add IPNS", parameters, + var dlg = await Dialogs.ShowAsync("Add IPNS", parameters, new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.Medium, CloseButton = true }); var res = await dlg.Result; if (res.Canceled || res.Data is not AddOrEditIpnsDialog.IpnsResult r) return; @@ -317,7 +317,7 @@ { x => x.InitialKeepOldCidPinned, row.KeepOld } }; - var dlg = Dialogs.Show($"Edit IPNS - {row.Name}", parameters, + var dlg = await Dialogs.ShowAsync($"Edit IPNS - {row.Name}", parameters, new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.Medium, CloseButton = true }); var res = await dlg.Result; if (res.Canceled || res.Data is not AddOrEditIpnsDialog.IpnsResult r) return; @@ -348,7 +348,7 @@ { { x => x.Message, $"Remove IPNS '{row.Name}' from tracking? (Pinned versions stay.)" } }; - var dlg = Dialogs.Show("Confirm Remove", confirmParams); + var dlg = await Dialogs.ShowAsync("Confirm Remove", confirmParams); var res = await dlg.Result; if (res.Canceled || res.Data is not bool ok || !ok) return; diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/ApiKeys.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/ApiKeys.razor index 2aa622a..5d7aaf9 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/ApiKeys.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/ApiKeys.razor @@ -1,4 +1,4 @@ -@page "/keys" +@page "/keys" @using TruthGate_Web.Components.Pages.Settings.Shared @using TruthGate_Web.Models @using TruthGate_Web.Utils @@ -71,10 +71,10 @@ { x => x.ExistingNames, _keys.Select(k => k.Name).ToList() } }; - var dialog = Dialogs.Show("Add API Key", p, new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + var dialog = await Dialogs.ShowAsync("Add API Key", p, new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }); var result = await dialog.Result; - if (result.Canceled || result.Data is not AddApiKeyDialog.AddApiKeyResult r) return; + if (result is null || result.Canceled || result.Data is not AddApiKeyDialog.AddApiKeyResult r) return; // Final uniqueness check (server-side) if (_keys.Any(k => string.Equals(k.Name, r.Name, StringComparison.OrdinalIgnoreCase))) @@ -103,10 +103,10 @@ { x => x.Message, $"Delete API key '{key.Name}'?" } }; - var dialog = Dialogs.Show("Confirm Delete", p); + var dialog = await Dialogs.ShowAsync("Confirm Delete", p); var result = await dialog.Result; - if (result.Canceled || result.Data is not bool yes || !yes) return; + if (result is null || result.Canceled || result.Data is not bool yes || !yes) return; await ConfigSvc.UpdateAsync(cfg => { diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Domains.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Domains.razor index 8e0e32a..ffe2c93 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Domains.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Domains.razor @@ -1,4 +1,4 @@ -@page "/domains" +@page "/domains" @using System.Globalization @using System.Text.RegularExpressions @using MudBlazor @@ -47,7 +47,7 @@ @context.Domain @if (!string.IsNullOrWhiteSpace(context.RedirectUrl)) { - + @{ var u = context.RedirectUrl ?? string.Empty; } @@ -60,7 +60,7 @@ - + @($"{GetIpnsHost(context, useShort: true)}") @@ -88,7 +88,7 @@ @context.IpnsKeyName *@ - + IPNS: @Short(context.IpnsPeerId) @@ -168,7 +168,7 @@ private async Task PublishDomain(EdgeDomain d) { var parms = new DialogParameters { { x => x.Domain, d.Domain } }; - var dlg = Dialogs.Show($"Publish: {d.Domain}", parms, + var dlg = await Dialogs.ShowAsync($"Publish: {d.Domain}", parms, new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.Large, CloseButton = true }); await dlg.Result; @@ -225,7 +225,7 @@ private async Task ImportDomain() { var parms = new DialogParameters(); - var dlg = Dialogs.Show("Import Domain Backup", parms, + var dlg = await Dialogs.ShowAsync("Import Domain Backup", parms, new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.Medium, CloseButton = true }); await dlg.Result; } @@ -233,7 +233,7 @@ private async Task PromptAsync(string title, string message) { var p = new DialogParameters { { x => x.Message, message } }; - var dlg = Dialogs.Show(title, p); + var dlg = await Dialogs.ShowAsync(title, p); var res = await dlg.Result; return res.Canceled ? null : (res.Data as string); } @@ -271,7 +271,7 @@ => string.IsNullOrWhiteSpace(s) || s.Length <= head + tail + 1 ? s : $"{s[..head]}…{s[^tail..]}"; private async Task AddDomain() { - var dialog = Dialogs.Show("Add Domain"); + var dialog = await Dialogs.ShowAsync("Add Domain"); var result = await dialog.Result; if (result.Canceled || result.Data is not AddOrEditDomainDialog.DomainResult r) return; @@ -316,7 +316,7 @@ { x => x.IsEdit, true } }; - var dialog = Dialogs.Show($"Edit Domain: {domain.Domain}", parameters); + var dialog = await Dialogs.ShowAsync($"Edit Domain: {domain.Domain}", parameters); var result = await dialog.Result; if (result.Canceled || result.Data is not AddOrEditDomainDialog.DomainResult r) return; @@ -359,7 +359,7 @@ { x => x.Message, $"Delete domain '{domain.Domain}'?\n\nThis will remove its config entry, IPNS key, pins, and MFS folders." } }; - var dialog = Dialogs.Show("Confirm Delete", parameters); + var dialog = await Dialogs.ShowAsync("Confirm Delete", parameters); var result = await dialog.Result; if (result.Canceled || result.Data is not bool confirmed || !confirmed) return; diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/IpBansPage.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/IpBansPage.razor index 8e1235a..acb3adc 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/IpBansPage.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/IpBansPage.razor @@ -1,4 +1,4 @@ -@page "/ip-bans" +@page "/ip-bans" @using Microsoft.EntityFrameworkCore @using System.Net @using TruthGate_Web.Components.Pages.Settings.Shared @@ -52,7 +52,7 @@ Hover="true" Dense="true" Bordered="true" - Sortable="false" + SortMode="SortMode.None" Filterable="false" RowsPerPage="20"> @@ -118,7 +118,7 @@ Hover="true" Dense="true" Bordered="true" - Sortable="false" + SortMode="SortMode.None" Filterable="false" RowsPerPage="20"> @@ -164,7 +164,7 @@ Hover="true" Dense="true" Bordered="true" - Sortable="false" + SortMode="SortMode.None" Filterable="false" RowsPerPage="20"> @@ -212,7 +212,7 @@ } private BanFilters _banFilters = new(); - private async Task> LoadBansGridAsync(GridState state) + private async Task> LoadBansGridAsync(GridState state, CancellationToken cancellationToken) { var page = state.Page + 1; // Mud uses 0-based; your API is 1-based var pageSize = state.PageSize; @@ -237,7 +237,7 @@ private async Task ConfirmUnbanById(Guid id) { - var res = await Dialogs.ShowMessageBox("Confirm", "Unban this entry?", yesText: "Unban", cancelText: "Cancel"); + var res = await Dialogs.ShowMessageBoxAsync("Confirm", "Unban this entry?", yesText: "Unban", cancelText: "Cancel"); if (res == true) { var ok = await RateSvc.UnbanByIdAsync(id); @@ -248,7 +248,7 @@ private async Task ConfirmUnbanIp(string ip) { - var res = await Dialogs.ShowMessageBox("Confirm", $"Unban IP {ip} ?", yesText: "Unban", cancelText: "Cancel"); + var res = await Dialogs.ShowMessageBoxAsync("Confirm", $"Unban IP {ip} ?", yesText: "Unban", cancelText: "Cancel"); if (res == true) { var ok = await RateSvc.UnbanIpAsync(ip); @@ -259,7 +259,7 @@ private async Task ConfirmUnbanPrefix(string pfx) { - var res = await Dialogs.ShowMessageBox("Confirm", $"Unban prefix {pfx} ?", yesText: "Unban", cancelText: "Cancel"); + var res = await Dialogs.ShowMessageBoxAsync("Confirm", $"Unban prefix {pfx} ?", yesText: "Unban", cancelText: "Cancel"); if (res == true) { var ok = await RateSvc.UnbanIpv6PrefixAsync(pfx); @@ -272,13 +272,15 @@ private async Task OpenSearchDialog() { var parameters = new DialogParameters { { x => x.InitialIp, _banFilters.IpFilter } }; - await Dialogs.Show("Search IP", parameters, new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }).Result; + var dialog = await Dialogs.ShowAsync("Search IP", parameters, new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }); + await dialog.Result; } // -------------------- ADD BAN DIALOG -------------------- private async Task OpenAddBanDialog() { - await Dialogs.Show("Add Ban", new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }).Result; + var dialog = await Dialogs.ShowAsync("Add Ban", new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }); + await dialog.Result; await ReloadBans(); } @@ -289,9 +291,9 @@ private string? _newWhitelistReason; private int? _newWhitelistExpireHours; - private async Task> LoadWhitelistIpGridAsync(GridState state) + private async Task> LoadWhitelistIpGridAsync(GridState state, CancellationToken cancellationToken) { - await using var db = await DbFactory.CreateDbContextAsync(); + await using var db = await DbFactory.CreateDbContextAsync(cancellationToken); var q = db.Whitelists.AsNoTracking().Where(x => x.Ip != null); if (!string.IsNullOrWhiteSpace(_wlIpSearch)) @@ -300,12 +302,12 @@ q = q.Where(x => x.Ip!.Contains(s)); } - var total = await q.CountAsync(); + var total = await q.CountAsync(cancellationToken); var items = await q .OrderByDescending(x => x.CreatedUtc) .Skip(state.Page * state.PageSize) .Take(state.PageSize) - .ToListAsync(); + .ToListAsync(cancellationToken); return new GridData { Items = items, TotalItems = total }; } @@ -331,7 +333,7 @@ private async Task RemoveWhitelistIp(string ip) { - var res = await Dialogs.ShowMessageBox("Confirm", $"Remove {ip} from whitelist?", yesText: "Remove", cancelText: "Cancel"); + var res = await Dialogs.ShowMessageBoxAsync("Confirm", $"Remove {ip} from whitelist?", yesText: "Remove", cancelText: "Cancel"); if (res == true) { var ok = await RateSvc.RemoveWhitelistIpAsync(ip); @@ -347,9 +349,9 @@ private string? _newWhitelistReasonPfx; private int? _newWhitelistExpireHoursPfx; - private async Task> LoadWhitelistPrefixGridAsync(GridState state) + private async Task> LoadWhitelistPrefixGridAsync(GridState state, CancellationToken cancellationToken) { - await using var db = await DbFactory.CreateDbContextAsync(); + await using var db = await DbFactory.CreateDbContextAsync(cancellationToken); var q = db.Whitelists.AsNoTracking().Where(x => x.Ipv6Prefix != null); if (!string.IsNullOrWhiteSpace(_wlPfxSearch)) @@ -358,12 +360,12 @@ q = q.Where(x => x.Ipv6Prefix!.Contains(s)); } - var total = await q.CountAsync(); + var total = await q.CountAsync(cancellationToken); var items = await q .OrderByDescending(x => x.CreatedUtc) .Skip(state.Page * state.PageSize) .Take(state.PageSize) - .ToListAsync(); + .ToListAsync(cancellationToken); return new GridData { Items = items, TotalItems = total }; } @@ -389,7 +391,7 @@ private async Task RemoveWhitelistPrefix(string pfx) { - var res = await Dialogs.ShowMessageBox("Confirm", $"Remove {pfx} from whitelist?", yesText: "Remove", cancelText: "Cancel"); + var res = await Dialogs.ShowMessageBoxAsync("Confirm", $"Remove {pfx} from whitelist?", yesText: "Remove", cancelText: "Cancel"); if (res == true) { var ok = await RateSvc.RemoveWhitelistIpv6PrefixAsync(pfx); diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddBanDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddBanDialog.razor index 45e4ca4..55d30b2 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddBanDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddBanDialog.razor @@ -10,7 +10,7 @@ @Message } - + @@ -19,6 +19,7 @@ Placeholder="@(_usePrefix ? "2001:db8::/64" : "203.0.113.42 or ::ffff:203.0.113.42")" Immediate="true" Required="true" + Validation="@(new Func(ValidateTarget))" @onkeydown="OnKeyDown" /> @@ -30,7 +31,13 @@ - + Validate() + private string? ValidateTarget(string? value) { _error = null; - if (string.IsNullOrWhiteSpace(_target)) - { - _error = "Please provide a target."; - yield return _error!; - yield break; - } - - var t = _target.Trim(); + if (string.IsNullOrWhiteSpace(value)) + return _error = "Please provide a target."; + var target = value.Trim(); if (_usePrefix) { - // Accept "2001:db8::" or "2001:db8::/64" — the service canonicalizes either - var bare = t.Contains('/') ? t[..t.IndexOf('/')] : t; - if (!IPAddress.TryParse(bare, out var parsed) || parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6) + // Accept "2001:db8::" or "2001:db8::/64" — the service canonicalizes either. + var slash = target.IndexOf('/'); + var bare = slash >= 0 ? target[..slash] : target; + if (!IPAddress.TryParse(bare, out var parsed) || + parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6) { - _error = "Invalid IPv6 prefix format."; - yield return _error!; + return _error = "Invalid IPv6 prefix format."; } } - else + else if (!IPAddress.TryParse(target, out _)) { - if (!IPAddress.TryParse(t, out _)) - { - _error = "Invalid IP address."; - yield return _error!; - } + return _error = "Invalid IP address."; } - if (_durationMinutes <= 0) - { - _error = "Duration must be at least 1 minute."; - yield return _error!; - } + return null; + } + + private string? ValidateDuration(int value) + { + if (value <= 0) + return _error = "Duration must be at least 1 minute."; + + return null; } private async Task Submit() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; try { - var t = _target!.Trim(); - var dur = TimeSpan.FromMinutes(_durationMinutes); - var reason = string.IsNullOrWhiteSpace(_reason) ? (_usePrefix ? "MANUAL_PREFIX_BAN" : "MANUAL_BAN") : _reason; - - bool ok = _usePrefix - ? await RateSvc.BanIpv6PrefixAsync(t, dur, _scope, _trueBan, reason) - : await RateSvc.BanIpAsync(t, dur, _scope, _trueBan, reason); + var target = _target!.Trim(); + var duration = TimeSpan.FromMinutes(_durationMinutes); + var reason = string.IsNullOrWhiteSpace(_reason) + ? (_usePrefix ? "MANUAL_PREFIX_BAN" : "MANUAL_BAN") + : _reason; + + var ok = _usePrefix + ? await RateSvc.BanIpv6PrefixAsync(target, duration, _scope, _trueBan, reason) + : await RateSvc.BanIpAsync(target, duration, _scope, _trueBan, reason); if (ok) { diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddOrEditDomainDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddOrEditDomainDialog.razor index ceeb101..d57b477 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddOrEditDomainDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddOrEditDomainDialog.razor @@ -3,20 +3,20 @@ - + + @bind-Value="_model.Domain" + Immediate="true" + Required="true" + RequiredError="Domain is required." + For="@(() => _model.Domain)" />
+ @bind-Value="_model.UseSSL" + Color="Color.Primary" + Label="@(_model.UseSSL ? "Enabled" : "Disabled")" />
When enabled, TruthGate provisions a valid Let’s Encrypt certificate for this domain. @@ -25,16 +25,15 @@
+ @bind-Value="_model.RedirectUrl" + Immediate="true" + Required="false" + For="@(() => _model.RedirectUrl)" /> Leave empty to serve this domain normally. If a Redirect URL is set, all visitors will be instantly redirected there instead. - @if (!string.IsNullOrEmpty(_error)) { @_error @@ -85,14 +84,6 @@ public string? RedirectUrl { get; set; } } - private IEnumerable Validate() - { - _error = null; - - if (string.IsNullOrWhiteSpace(_model.Domain)) - yield return "Domain is required."; - } - private void Cancel() => MudDialog.Cancel(); [Inject] IHttpClientFactory HttpFactory { get; set; } = default!; @@ -100,38 +91,46 @@ private static string ToSafeFolderLeaf(string input) { - var s = (input ?? "").Trim().ToLowerInvariant(); - // kill pathy stuff — you only want a single folder leaf here - s = s.Replace('\\', '/').Trim('/'); - if (s.Contains("..")) throw new InvalidOperationException("Invalid domain."); - if (string.IsNullOrWhiteSpace(s)) throw new InvalidOperationException("Empty domain."); - return s; + var value = (input ?? "").Trim().ToLowerInvariant(); + // Kill path-like input — this must remain a single folder leaf. + value = value.Replace('\\', '/').Trim('/'); + if (value.Contains("..")) throw new InvalidOperationException("Invalid domain."); + if (string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException("Empty domain."); + return value; } private async Task Submit() { - await _form.Validate(); + _error = null; + await _form.ValidateAsync(); if (!_form.IsValid) return; - var domainLeaf = ToSafeFolderLeaf(_model.Domain); - var basePath = "/production/sites"; - var fullPath = $"{basePath}/{domainLeaf}"; + try + { + var domainLeaf = ToSafeFolderLeaf(_model.Domain); + const string basePath = "/production/sites"; + var fullPath = $"{basePath}/{domainLeaf}"; - // Ensure the folder path exists (creates parents as needed), returns final CID - var cid = await IpfsGateway.EnsureMfsFolderExistsAsync(fullPath, HttpFactory); + // Ensure the folder path exists (creates parents as needed), returning the final CID. + _ = await IpfsGateway.EnsureMfsFolderExistsAsync(fullPath, HttpFactory); - // (Optional) warm/refresh your cached CID for this MFS path, so the rest of your app sees it: - var ttl = TimeSpan.FromHours(2); - _ = await IpfsGateway.GetCidForMfsPathAsync( + // Warm/refresh the cached CID so the rest of the app sees the newly created path. + var ttl = TimeSpan.FromHours(2); + _ = await IpfsGateway.GetCidForMfsPathAsync( fullPath, HttpFactory, Cache, ttl, IpfsGateway.CacheMode.Refresh); - var result = new DomainResult + var result = new DomainResult + { + Domain = _model.Domain, // Normalization happens on the page. + UseSSL = _model.UseSSL, + RedirectUrl = _model.RedirectUrl + }; + + MudDialog.Close(DialogResult.Ok(result)); + } + catch (Exception ex) { - Domain = _model.Domain, // normalization happens on the page - UseSSL = _model.UseSSL, - RedirectUrl = _model.RedirectUrl - }; - - MudDialog.Close(DialogResult.Ok(result)); + _error = ex.Message; + } } } diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddUserDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddUserDialog.razor index 53e1c30..8e2691c 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddUserDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/AddUserDialog.razor @@ -2,10 +2,24 @@ - - - - + + + + @if (!string.IsNullOrEmpty(_error)) { @_error @@ -29,7 +43,7 @@ } private MudForm _form = default!; - private Model _model = new(); + private readonly Model _model = new(); private string? _error; private class Model @@ -39,34 +53,33 @@ public string Confirm { get; set; } = ""; } - private IEnumerable Validate() + private string? ValidateConfirmation(string? value) { _error = null; - if (string.IsNullOrWhiteSpace(_model.UserName)) - yield return "Username is required."; + if (string.IsNullOrEmpty(value)) + return _error = "Please confirm the password."; - if (string.IsNullOrEmpty(_model.Password)) - yield return "Password is required."; + if (_model.Password != value) + return _error = "Passwords must match."; - if (_model.Password != _model.Confirm) - yield return "Passwords must match."; + return null; } private void Cancel() => MudDialog.Cancel(); private async Task Submit() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; - // normalize username to lowercase (your Config also normalizes) + // Normalize username to lowercase (Config also normalizes it). var result = new AddUserResult - { - UserName = _model.UserName.Trim().ToLowerInvariant(), - Password = _model.Password - }; + { + UserName = _model.UserName.Trim().ToLowerInvariant(), + Password = _model.Password + }; MudDialog.Close(DialogResult.Ok(result)); } -} \ No newline at end of file +} diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ChangePasswordDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ChangePasswordDialog.razor index 07ba96d..06383f3 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ChangePasswordDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ChangePasswordDialog.razor @@ -2,10 +2,20 @@ - + User: @TargetUserName - - + + @if (!string.IsNullOrEmpty(_error)) { @_error @@ -29,7 +39,7 @@ } private MudForm _form = default!; - private Model _model = new(); + private readonly Model _model = new(); private string? _error; private class Model @@ -38,25 +48,27 @@ public string Confirm { get; set; } = ""; } - private IEnumerable Validate() + private string? ValidateConfirmation(string? value) { _error = null; - if (string.IsNullOrEmpty(_model.Password)) - yield return "Password is required."; + if (string.IsNullOrEmpty(value)) + return _error = "Please confirm the password."; - if (_model.Password != _model.Confirm) - yield return "Passwords must match."; + if (_model.Password != value) + return _error = "Passwords must match."; + + return null; } private void Cancel() => MudDialog.Cancel(); private async Task Submit() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; var result = new ChangePasswordResult { NewPassword = _model.Password }; MudDialog.Close(DialogResult.Ok(result)); } -} \ No newline at end of file +} diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ImportDomainDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ImportDomainDialog.razor index dfd94d7..3a6e7da 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ImportDomainDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/ImportDomainDialog.razor @@ -14,15 +14,17 @@ Immediate="true" Required="true" /> -
- - +
+ + + StartIcon="@Icons.Material.Filled.VpnKey" + OnClick="@context.OpenFilePickerAsync"> Select Backup (.tgbackup) -
+ +
@@ -43,8 +45,8 @@ private IBrowserFile? _file; private string? _pass; - private void OnFileChanged(IReadOnlyList picked) - => _file = picked.FirstOrDefault(); + private void OnFileChanged(IBrowserFile picked) + => _file = picked; private async Task Submit() { diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/SearchIpDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/SearchIpDialog.razor index 997c8b8..142514b 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/SearchIpDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/Shared/SearchIpDialog.razor @@ -1,4 +1,4 @@ -@using System.Net +@using System.Net @using Microsoft.EntityFrameworkCore @using MudBlazor @using TruthGate_Web.Security @@ -11,7 +11,7 @@ @Message } - + Recent ban records (DB, latest 10)
- + @context.Item.CreatedUtc.ToString("u") @@ -106,25 +106,21 @@ _ip = InitialIp?.Trim() ?? ""; } - private IEnumerable Validate() + private string? ValidateIp(string? value) { _error = null; - if (string.IsNullOrWhiteSpace(_ip)) - { - _error = "Please enter an IP address."; - yield return _error; - yield break; - } - if (!IPAddress.TryParse(_ip, out _)) - { - _error = "Invalid IP format (v4 or v6 required)."; - yield return _error; - } + if (string.IsNullOrWhiteSpace(value)) + return _error = "Please enter an IP address."; + + if (!IPAddress.TryParse(value, out _)) + return _error = "Invalid IP format (v4 or v6 required)."; + + return null; } private async Task RunSearch() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; try diff --git a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/UserSettings.razor b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/UserSettings.razor index 053b3a4..7dd9d7e 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/UserSettings.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Pages/Settings/UserSettings.razor @@ -83,7 +83,7 @@ private async Task AddUser() { - var dialog = Dialogs.Show("Add User"); + var dialog = await Dialogs.ShowAsync("Add User"); var result = await dialog.Result; if (result.Canceled || result.Data is not AddUserDialog.AddUserResult r) return; @@ -115,7 +115,7 @@ { x => x.TargetUserName, user.UserName } }; - var dialog = Dialogs.Show($"Change Password: {user.UserName}", parameters); + var dialog = await Dialogs.ShowAsync($"Change Password: {user.UserName}", parameters); var result = await dialog.Result; if (result.Canceled || result.Data is not ChangePasswordDialog.ChangePasswordResult r) return; @@ -147,7 +147,7 @@ { x => x.Message, $"Delete user '{user.UserName}'?" } }; - var dialog = Dialogs.Show("Confirm Delete", parameters); + var dialog = await Dialogs.ShowAsync("Confirm Delete", parameters); var result = await dialog.Result; if (result.Canceled || result.Data is not bool confirmed || !confirmed) return; diff --git a/TruthGate-Web/TruthGate-Web/Components/Shared/AddOrEditIpnsDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Shared/AddOrEditIpnsDialog.razor index 11bd822..6f20b04 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Shared/AddOrEditIpnsDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Shared/AddOrEditIpnsDialog.razor @@ -1,4 +1,4 @@ -@using MudBlazor +@using MudBlazor @using System.Text.Json @using TruthGate_Web.Utils @using TruthGate_Web.Endpoints @@ -6,7 +6,7 @@ - + - +
@@ -60,7 +60,7 @@ - +
@@ -193,20 +193,23 @@ _model.KeepOldCidPinned = InitialKeepOldCidPinned; } - private IEnumerable Validate() + private string? ValidateName(string? value) { - _error = null; + if (string.IsNullOrWhiteSpace(value)) + return "Name is required."; - if (string.IsNullOrWhiteSpace(_model.Name)) - yield return "Name is required."; - if (string.IsNullOrWhiteSpace(_model.Key)) - yield return "IPNS key is required."; + return IpfsGateway.ToSafeLeaf(value) is null ? "Invalid name." : null; + } - var leaf = IpfsGateway.ToSafeLeaf(_model.Name); - if (leaf is null) - yield return "Invalid name."; - if (!_model.Key.StartsWith("k51") && !_model.Key.StartsWith("/ipns/")) - yield return "IPNS Key should look like k51… or /ipns/k51…"; + private static string? ValidateKey(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "IPNS key is required."; + + return value.StartsWith("k51", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("/ipns/k51", StringComparison.OrdinalIgnoreCase) + ? null + : "IPNS Key should look like k51… or /ipns/k51…"; } private void Cancel() => MudDialog.Cancel(); @@ -293,9 +296,9 @@ return null; } - private async void Submit() + private async Task Submit() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; // Name leaf (immutable on edit) diff --git a/TruthGate-Web/TruthGate-Web/Components/Shared/AddPinnedItemDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Shared/AddPinnedItemDialog.razor index b473c89..76207eb 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Shared/AddPinnedItemDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Shared/AddPinnedItemDialog.razor @@ -1,9 +1,9 @@ -@using MudBlazor +@using MudBlazor @using TruthGate_Web.Utils - + Add Pinned Item CPU % (System vs Process) @@ -91,10 +90,9 @@ System Memory Used (GB) @@ -105,10 +103,9 @@ Process Memory (MB) · Working Set & GC Heap @@ -119,11 +116,10 @@ ThreadPool · Threads & Queue + ChartOptions="@_chartOptions" /> @@ -156,38 +152,34 @@ private int _detailedWindowSeconds = 30; // Detailed: last 30s private bool _rotateXLabels = true; - // ===== Axis cosmetics ===== - private readonly AxisChartOptions _axisOptions = new() - { - MatchBoundsToSize = true, - XAxisLabelRotation = 45 - }; - private readonly ChartOptions _chartOptions = new() - { - YAxisTicks = 8 - }; + // ===== Chart cosmetics ===== + private readonly LineChartOptions _chartOptions = new() + { + XAxisLabelRotation = 45, + YAxisTicks = 8 + }; // ===== Series & labels ===== private string[] _xLabels = Array.Empty(); - private readonly List _cpuSeries = new() + private readonly List> _cpuSeries = new() { - new ChartSeries { Name = "System CPU %", Data = Array.Empty() }, - new ChartSeries { Name = "Process CPU %", Data = Array.Empty() } + new ChartSeries { Name = "System CPU %", Data = Array.Empty() }, + new ChartSeries { Name = "Process CPU %", Data = Array.Empty() } }; - private readonly List _sysMemSeries = new() + private readonly List> _sysMemSeries = new() { - new ChartSeries { Name = "System Used (GB)", Data = Array.Empty() } + new ChartSeries { Name = "System Used (GB)", Data = Array.Empty() } }; - private readonly List _procMemSeries = new() + private readonly List> _procMemSeries = new() { - new ChartSeries { Name = "Working Set (MB)", Data = Array.Empty() }, - new ChartSeries { Name = "GC Heap (MB)", Data = Array.Empty() } + new ChartSeries { Name = "Working Set (MB)", Data = Array.Empty() }, + new ChartSeries { Name = "GC Heap (MB)", Data = Array.Empty() } }; - private readonly List _tpSeries = new() + private readonly List> _tpSeries = new() { - new ChartSeries { Name = "Threads", Data = Array.Empty() }, - new ChartSeries { Name = "Queue Length", Data = Array.Empty() } + new ChartSeries { Name = "Threads", Data = Array.Empty() }, + new ChartSeries { Name = "Queue Length", Data = Array.Empty() } }; // ===== Now-cards ===== @@ -214,7 +206,7 @@ { while (await _timer!.WaitForNextTickAsync(_cts.Token)) { - _axisOptions.XAxisLabelRotation = _rotateXLabels ? 45 : 0; + _chartOptions.XAxisLabelRotation = _rotateXLabels ? 45 : 0; RebuildSeries(); await InvokeAsync(StateHasChanged); } diff --git a/TruthGate-Web/TruthGate-Web/Components/Shared/SimplePromptDialog.razor b/TruthGate-Web/TruthGate-Web/Components/Shared/SimplePromptDialog.razor index 8416526..5052f11 100644 --- a/TruthGate-Web/TruthGate-Web/Components/Shared/SimplePromptDialog.razor +++ b/TruthGate-Web/TruthGate-Web/Components/Shared/SimplePromptDialog.razor @@ -1,4 +1,4 @@ -@using MudBlazor +@using MudBlazor @@ -7,7 +7,7 @@ @Message } - + Validate() + private string? ValidateValue(string? value) { _error = null; - if (Required && string.IsNullOrWhiteSpace(_value)) - { - _error = "Please enter a value."; - yield return _error; - } + if (Required && string.IsNullOrWhiteSpace(value)) + return _error = "Please enter a value."; + + return null; } private async Task Submit() { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) return; MudDialog.Close(DialogResult.Ok(_value)); } diff --git a/TruthGate-Web/TruthGate-Web/Models/Config.cs b/TruthGate-Web/TruthGate-Web/Models/Config.cs index 4a88011..f5e6608 100644 --- a/TruthGate-Web/TruthGate-Web/Models/Config.cs +++ b/TruthGate-Web/TruthGate-Web/Models/Config.cs @@ -1,8 +1,12 @@ -namespace TruthGate_Web.Models +using TruthGate_Web.Utils; + +namespace TruthGate_Web.Models { public class Config { public const string DefaultAdminHash = "/EUxvODjrpkTKnal6nVEAh2m+52H4OgXGEBLcE3xilcgZ8gbeE5ay/CfzYr9PCJ0"; + public const string BootstrapAdminPasswordEnvironmentVariable = "TRUTHGATE_BOOTSTRAP_ADMIN_PASSWORD"; + private List? _users; public IpnsWildCardSubDomain IpnsWildCardSubDomain { get; set; } public List Domains { get; set; } = new List(); @@ -29,7 +33,7 @@ public List Users _users.Add(new UserAccount { UserName = "admin", - PasswordHashed = DefaultAdminHash + PasswordHashed = CreateInitialAdminHash() }); } @@ -41,6 +45,14 @@ public List Users _users = value is null ? new List() : new List(value); } } + + private static string CreateInitialAdminHash() + { + var bootstrapPassword = Environment.GetEnvironmentVariable(BootstrapAdminPasswordEnvironmentVariable); + return string.IsNullOrWhiteSpace(bootstrapPassword) + ? DefaultAdminHash + : StringHasher.HashString(bootstrapPassword); + } } public class IpnsWildCardSubDomain @@ -49,7 +61,7 @@ public class IpnsWildCardSubDomain public string UseSSL { get; set; } = "true"; } - public class EdgeDomain + public class EdgeDomain { public string Domain { get; set; } = ""; public string UseSSL { get; set; } = "false"; @@ -75,6 +87,7 @@ public class ApiKey public string Name { get; set; } public string KeyHashed { get; set; } } + public class UserAccount { public string UserName { get; set; } diff --git a/TruthGate-Web/TruthGate-Web/Security/RateLimiterRegistration.cs b/TruthGate-Web/TruthGate-Web/Security/RateLimiterRegistration.cs index 7da9060..d9141a8 100644 --- a/TruthGate-Web/TruthGate-Web/Security/RateLimiterRegistration.cs +++ b/TruthGate-Web/TruthGate-Web/Security/RateLimiterRegistration.cs @@ -1,4 +1,4 @@ -using TruthGate_Web.Middleware; +using TruthGate_Web.Middleware; using TruthGate_Web.Services; using Microsoft.EntityFrameworkCore; @@ -12,20 +12,38 @@ public static IServiceCollection AddTruthGateRateLimiter( { services.AddDbContextFactory((sp, b) => { - // Safe to resolve dependencies here var cfg = sp.GetRequiredService(); - var tet = cfg.Get(); - // Figure out SQLite location from cfg.ConfigPath (directory portion) - var dir = Path.GetDirectoryName(cfg.ConfigPath) ?? AppContext.BaseDirectory; - Directory.CreateDirectory(dir); - var dbPath = Path.Combine(dir, "ratelimiter.db"); + var configuredDatabasePath = Environment.GetEnvironmentVariable("TRUTHGATE_DATABASE_PATH"); + + string dbPath; + if (string.IsNullOrWhiteSpace(configuredDatabasePath)) + { + // Preserve the historical behavior for non-container installations. + var configDirectory = Path.GetDirectoryName(cfg.ConfigPath) ?? AppContext.BaseDirectory; + Directory.CreateDirectory(configDirectory); + dbPath = Path.Combine(configDirectory, "ratelimiter.db"); + } + else + { + var resolved = Path.GetFullPath(configuredDatabasePath); + if (string.Equals(Path.GetExtension(resolved), ".db", StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(resolved)!); + dbPath = resolved; + } + else + { + Directory.CreateDirectory(resolved); + dbPath = Path.Combine(resolved, "ratelimiter.db"); + } + } + var cs = connectionString ?? $"Data Source={dbPath};Cache=Shared"; b.UseSqlite(cs); b.EnableSensitiveDataLogging(false); }); - services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); @@ -37,5 +55,4 @@ public static IServiceCollection AddTruthGateRateLimiter( public static IApplicationBuilder UseTruthGateRateLimiter(this IApplicationBuilder app) => app.UseMiddleware(); } - } diff --git a/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj b/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj index 624c429..0a9f586 100644 --- a/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj +++ b/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj @@ -1,28 +1,30 @@  - net9.0 + net10.0 enable enable TruthGate_Web $(AssemblyName.Replace(' ', '_')) + true - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - + + + + + + + + @@ -31,4 +33,4 @@ - + \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..49d5957 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1 diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..c540ebb --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,42 @@ +services: + truthgate: + image: truthgate-ipfs:dev + build: + target: development + command: ["development"] + restart: "no" + read_only: false + working_dir: /workspace + environment: + ASPNETCORE_ENVIRONMENT: Development + DOTNET_ENVIRONMENT: Development + ASPNETCORE_URLS: http://0.0.0.0:80 + DOTNET_USE_POLLING_FILE_WATCHER: "1" + DOTNET_CLI_HOME: /home/truthgate + HOME: /home/truthgate + NUGET_PACKAGES: /home/truthgate/.nuget/packages + NUGET_HTTP_CACHE_PATH: /home/truthgate/.nuget/http-cache + TINI_SUBREAPER: "1" + TRUTHGATE_DEV_PROJECT: /workspace/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj + TRUTHGATE_HEALTH_URL: http://127.0.0.1:80/ + ports: !override + - "${TRUTHGATE_DEV_HTTP_PORT:-8080}:80/tcp" + - "${IPFS_SWARM_PORT:-4001}:4001/tcp" + - "${IPFS_SWARM_PORT:-4001}:4001/udp" + volumes: + - type: bind + source: . + target: /workspace + - type: volume + source: truthgate_nuget + target: /home/truthgate/.nuget/packages + - type: volume + source: truthgate_nuget_http + target: /home/truthgate/.nuget/http-cache + tmpfs: !override + - /tmp:rw,noexec,nosuid,size=1g + - /run/truthgate:rw,noexec,nosuid,size=64m + +volumes: + truthgate_nuget: + truthgate_nuget_http: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..114ce8f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,79 @@ +name: truthgate + +services: + truthgate: + image: ${TRUTHGATE_IMAGE:-magiccodingman/truthgate-ipfs:stable} + build: + context: . + dockerfile: Dockerfile + target: production + args: + DOTNET_VERSION: ${DOTNET_VERSION:-10.0} + KUBO_VERSION: ${KUBO_VERSION:-v0.42.0} + TRUTHGATE_UID: ${TRUTHGATE_UID:-1000} + TRUTHGATE_GID: ${TRUTHGATE_GID:-1000} + container_name: ${TRUTHGATE_CONTAINER_NAME:-truthgate} + restart: unless-stopped + read_only: true + security_opt: + - no-new-privileges:true + sysctls: + net.ipv4.ip_unprivileged_port_start: "0" + environment: + ASPNETCORE_ENVIRONMENT: Production + DOTNET_ENVIRONMENT: Production + IPFS_PATH: /data/ipfs/repo + IPFS_SWARM_PORT: ${IPFS_SWARM_PORT:-4001} + TMPDIR: /run/truthgate + TRUTHGATE_ACME_STAGING: ${TRUTHGATE_ACME_STAGING:-false} + TRUTHGATE_CERT_IPS: ${TRUTHGATE_CERT_IPS:-} + TRUTHGATE_CERT_PATH: /data/truthgate/certificates + TRUTHGATE_CONFIG_PATH: /data/truthgate/config/config.json + TRUTHGATE_DATABASE_PATH: /data/truthgate/database + TRUTHGATE_STATE_PATH: /data/truthgate/state + TRUTHGATE_HEALTH_URL: https://127.0.0.1:443/ + TRUTHGATE_KUBO_SETTINGS_PATH: /data/truthgate/config/kubo-settings.json + TRUTHGATE_KUBO_OVERRIDES_PATH: /data/truthgate/config/kubo-overrides.json + TRUTHGATE_KUBO_APPLY_SERVER_PROFILE: "${TRUTHGATE_KUBO_APPLY_SERVER_PROFILE:-}" + TRUTHGATE_KUBO_ENSURE_SWARM_LISTENERS: "${TRUTHGATE_KUBO_ENSURE_SWARM_LISTENERS:-}" + TRUTHGATE_KUBO_ROUTING_TYPE: "${TRUTHGATE_KUBO_ROUTING_TYPE:-}" + TRUTHGATE_KUBO_HOLE_PUNCHING: "${TRUTHGATE_KUBO_HOLE_PUNCHING:-}" + TRUTHGATE_KUBO_RELAY_CLIENT: "${TRUTHGATE_KUBO_RELAY_CLIENT:-}" + TRUTHGATE_KUBO_PROVIDE_ENABLED: "${TRUTHGATE_KUBO_PROVIDE_ENABLED:-}" + TRUTHGATE_KUBO_PROVIDE_STRATEGY: "${TRUTHGATE_KUBO_PROVIDE_STRATEGY:-}" + TRUTHGATE_KUBO_PROVIDE_INTERVAL: "${TRUTHGATE_KUBO_PROVIDE_INTERVAL:-}" + TRUTHGATE_KUBO_PROVIDE_SWEEP: "${TRUTHGATE_KUBO_PROVIDE_SWEEP:-}" + TRUTHGATE_KUBO_PROVIDE_RESUME: "${TRUTHGATE_KUBO_PROVIDE_RESUME:-}" + TRUTHGATE_KUBO_STORAGE_MAX: "${TRUTHGATE_KUBO_STORAGE_MAX:-}" + TRUTHGATE_KUBO_STORAGE_PERCENT: "${TRUTHGATE_KUBO_STORAGE_PERCENT:-}" + TRUTHGATE_KUBO_STORAGE_FALLBACK: "${TRUTHGATE_KUBO_STORAGE_FALLBACK:-}" + TRUTHGATE_KUBO_STORAGE_GC_WATERMARK: "${TRUTHGATE_KUBO_STORAGE_GC_WATERMARK:-}" + TRUTHGATE_KUBO_ENABLE_GC: "${TRUTHGATE_KUBO_ENABLE_GC:-}" + TRUTHGATE_KUBO_PUBLIC_IPV4: "${TRUTHGATE_KUBO_PUBLIC_IPV4:-}" + TRUTHGATE_KUBO_PUBLIC_IPV6: "${TRUTHGATE_KUBO_PUBLIC_IPV6:-}" + TRUTHGATE_KUBO_ANNOUNCE_PORT: "${TRUTHGATE_KUBO_ANNOUNCE_PORT:-}" + ports: + - "${TRUTHGATE_HTTP_PORT:-80}:80/tcp" + - "${TRUTHGATE_HTTPS_PORT:-443}:443/tcp" + - "${IPFS_SWARM_PORT:-4001}:4001/tcp" + - "${IPFS_SWARM_PORT:-4001}:4001/udp" + volumes: + - type: bind + source: ${TRUTHGATE_DATA_HOST_PATH:-./data/truthgate} + target: /data/truthgate + - type: bind + source: ${IPFS_REPO_HOST_PATH:-./data/ipfs/repo} + target: /data/ipfs/repo + - type: bind + source: ${IPFS_BLOCKS_HOST_PATH:-./data/ipfs/blocks} + target: /data/ipfs/repo/blocks + tmpfs: + - /tmp:rw,noexec,nosuid,size=256m + - /run/truthgate:rw,noexec,nosuid,size=64m + healthcheck: + test: ["CMD", "/usr/local/bin/truthgate-healthcheck"] + interval: 30s + timeout: 10s + start_period: 90s + retries: 3 + stop_grace_period: 45s diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..a9de22d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +mode="${1:-production}" +truthgate_user="truthgate" +truthgate_group="$(id -g "${truthgate_user}")" + +log() { + printf '[truthgate] %s\n' "$*" +} + +fail() { + printf '[truthgate] ERROR: %s\n' "$*" >&2 + exit 1 +} + +as_truthgate() { + gosu "${truthgate_user}:${truthgate_group}" "$@" +} + +require_absolute_path() { + local name="$1" + local value="$2" + [[ "${value}" = /* ]] || fail "${name} must be an absolute path; received '${value}'." +} + +: "${IPFS_PATH:=/data/ipfs/repo}" +: "${TRUTHGATE_CONFIG_PATH:=/data/truthgate/config/config.json}" +: "${TRUTHGATE_DATABASE_PATH:=/data/truthgate/database}" +: "${TRUTHGATE_CERT_PATH:=/data/truthgate/certificates}" +: "${TRUTHGATE_STATE_PATH:=/data/truthgate/state}" +: "${TMPDIR:=/run/truthgate}" +: "${TRUTHGATE_KUBO_REPO_VERSION:=18}" + +export IPFS_PATH TRUTHGATE_CONFIG_PATH TRUTHGATE_DATABASE_PATH +export TRUTHGATE_CERT_PATH TRUTHGATE_STATE_PATH TMPDIR + +require_absolute_path IPFS_PATH "${IPFS_PATH}" +require_absolute_path TRUTHGATE_CONFIG_PATH "${TRUTHGATE_CONFIG_PATH}" +require_absolute_path TRUTHGATE_DATABASE_PATH "${TRUTHGATE_DATABASE_PATH}" +require_absolute_path TRUTHGATE_CERT_PATH "${TRUTHGATE_CERT_PATH}" +require_absolute_path TRUTHGATE_STATE_PATH "${TRUTHGATE_STATE_PATH}" +require_absolute_path TMPDIR "${TMPDIR}" +[[ "${TRUTHGATE_KUBO_REPO_VERSION}" =~ ^[0-9]+$ ]] \ + || fail "TRUTHGATE_KUBO_REPO_VERSION must be a positive integer." + +config_directory="$(dirname "${TRUTHGATE_CONFIG_PATH}")" +blocks_directory="${IPFS_PATH}/blocks" +data_protection_directory="/data/truthgate/secrets/data-protection-keys" + +: "${TRUTHGATE_KUBO_SETTINGS_PATH:=${config_directory}/kubo-settings.json}" +: "${TRUTHGATE_KUBO_OVERRIDES_PATH:=${config_directory}/kubo-overrides.json}" +require_absolute_path TRUTHGATE_KUBO_SETTINGS_PATH "${TRUTHGATE_KUBO_SETTINGS_PATH}" +require_absolute_path TRUTHGATE_KUBO_OVERRIDES_PATH "${TRUTHGATE_KUBO_OVERRIDES_PATH}" +export TRUTHGATE_KUBO_SETTINGS_PATH TRUTHGATE_KUBO_OVERRIDES_PATH + +install -d -m 0750 -o "${truthgate_user}" -g "${truthgate_group}" \ + "${config_directory}" \ + "${TRUTHGATE_DATABASE_PATH}" \ + "${TRUTHGATE_CERT_PATH}" \ + "${TRUTHGATE_STATE_PATH}" \ + "${data_protection_directory}" \ + "${IPFS_PATH}" \ + "${blocks_directory}" \ + "${TMPDIR}" + +# Named volumes are created as root-owned directories. Development tools such as +# NuGet run as the non-root truthgate user, so initialize the complete NuGet home +# (including its configuration directory) before dropping privileges. +if [[ "${mode}" == "development" ]]; then + : "${HOME:=/home/truthgate}" + : "${DOTNET_CLI_HOME:=${HOME}}" + : "${NUGET_PACKAGES:=${HOME}/.nuget/packages}" + : "${NUGET_HTTP_CACHE_PATH:=${HOME}/.nuget/http-cache}" + + export HOME DOTNET_CLI_HOME NUGET_PACKAGES NUGET_HTTP_CACHE_PATH + + nuget_root="${HOME}/.nuget" + nuget_config_directory="${nuget_root}/NuGet" + + install -d -m 0750 -o "${truthgate_user}" -g "${truthgate_group}" \ + "${HOME}" \ + "${nuget_root}" \ + "${nuget_config_directory}" \ + "${NUGET_PACKAGES}" \ + "${NUGET_HTTP_CACHE_PATH}" + + chown "${truthgate_user}:${truthgate_group}" \ + "${HOME}" \ + "${nuget_root}" \ + "${nuget_config_directory}" \ + "${NUGET_PACKAGES}" \ + "${NUGET_HTTP_CACHE_PATH}" +fi + +# The default Compose layout mounts the repo and blockstore separately. Changing +# ownership on the mount roots is cheap and avoids recursively walking a large +# existing blockstore on every container start. +chown "${truthgate_user}:${truthgate_group}" \ + "${IPFS_PATH}" \ + "${blocks_directory}" \ + "${config_directory}" \ + "${TRUTHGATE_DATABASE_PATH}" \ + "${TRUTHGATE_CERT_PATH}" \ + "${TRUTHGATE_STATE_PATH}" \ + "${data_protection_directory}" \ + "${TMPDIR}" + +migrate_existing_kubo_repository() { + local repo_config="${IPFS_PATH}/config" + local repo_version_file="${IPFS_PATH}/version" + local current_version migrated_version + + # Fresh repositories are initialized later by truthgate-configure-kubo and + # already use the format expected by the bundled Kubo binary. + [[ -s "${repo_config}" ]] || return 0 + + [[ -s "${repo_version_file}" ]] \ + || fail "Existing Kubo repository is missing its version file: ${repo_version_file}" + + current_version="$(tr -d '[:space:]' <"${repo_version_file}")" + [[ "${current_version}" =~ ^[0-9]+$ ]] \ + || fail "Kubo repository version is invalid: '${current_version}'." + + if (( current_version > TRUTHGATE_KUBO_REPO_VERSION )); then + fail "Kubo repository version ${current_version} is newer than the bundled Kubo supports (${TRUTHGATE_KUBO_REPO_VERSION}). Upgrade the TruthGate image before starting this repository." + fi + + if (( current_version == TRUTHGATE_KUBO_REPO_VERSION )); then + log "Kubo repository is already at version ${current_version}." + return 0 + fi + + log "Migrating Kubo repository from version ${current_version} to ${TRUTHGATE_KUBO_REPO_VERSION} before applying managed configuration." + as_truthgate ipfs repo migrate --to="${TRUTHGATE_KUBO_REPO_VERSION}" + + migrated_version="$(tr -d '[:space:]' <"${repo_version_file}")" + [[ "${migrated_version}" == "${TRUTHGATE_KUBO_REPO_VERSION}" ]] \ + || fail "Kubo repository migration completed without producing expected version ${TRUTHGATE_KUBO_REPO_VERSION}; found '${migrated_version}'." + + log "Kubo repository migration completed at version ${migrated_version}." +} + +migrate_existing_kubo_repository + +bootstrap_password_file="${TRUTHGATE_STATE_PATH}/bootstrap-admin-password" +if [[ ! -s "${TRUTHGATE_CONFIG_PATH}" ]]; then + if [[ -n "${TRUTHGATE_BOOTSTRAP_ADMIN_PASSWORD:-}" ]]; then + bootstrap_password="${TRUTHGATE_BOOTSTRAP_ADMIN_PASSWORD}" + elif [[ -s "${bootstrap_password_file}" ]]; then + bootstrap_password="$(<"${bootstrap_password_file}")" + else + bootstrap_password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" + umask 077 + printf '%s' "${bootstrap_password}" >"${bootstrap_password_file}" + chown "${truthgate_user}:${truthgate_group}" "${bootstrap_password_file}" + fi + + export TRUTHGATE_BOOTSTRAP_ADMIN_PASSWORD="${bootstrap_password}" + log "First-run administrator account: admin" + log "First-run administrator password: ${bootstrap_password}" + log "The password is retained at ${bootstrap_password_file} until the TruthGate config is persisted." +else + rm -f "${bootstrap_password_file}" + unset TRUTHGATE_BOOTSTRAP_ADMIN_PASSWORD || true +fi + +/usr/local/bin/truthgate-configure-kubo + +daemon_args=(daemon --migrate=true) +if [[ "$(<"${TMPDIR}/kubo-enable-gc")" == "true" ]]; then + daemon_args+=(--enable-gc) +fi + +log "Starting Kubo with automatic repository migrations enabled." +as_truthgate ipfs "${daemon_args[@]}" & +ipfs_pid=$! + +stop_children() { + trap - TERM INT + + if [[ -n "${app_pid:-}" ]] && kill -0 "${app_pid}" 2>/dev/null; then + kill -TERM "${app_pid}" 2>/dev/null || true + fi + + if kill -0 "${ipfs_pid}" 2>/dev/null; then + kill -TERM "${ipfs_pid}" 2>/dev/null || true + fi + + [[ -z "${app_pid:-}" ]] || wait "${app_pid}" 2>/dev/null || true + wait "${ipfs_pid}" 2>/dev/null || true +} + +trap 'stop_children; exit 143' TERM +trap 'stop_children; exit 130' INT + +kubo_ready=false +for _ in $(seq 1 120); do + if as_truthgate ipfs --api=/ip4/127.0.0.1/tcp/5001 id >/dev/null 2>&1; then + kubo_ready=true + break + fi + + if ! kill -0 "${ipfs_pid}" 2>/dev/null; then + wait "${ipfs_pid}" || true + fail "Kubo exited before its RPC API became ready." + fi + + sleep 1 +done + +[[ "${kubo_ready}" == true ]] || fail "Kubo did not become ready within 120 seconds." +log "Kubo is ready." + +case "${mode}" in + production) + [[ -f /app/TruthGate-Web.dll ]] || fail "Production application was not found at /app/TruthGate-Web.dll." + log "Starting TruthGate in production mode." + as_truthgate dotnet /app/TruthGate-Web.dll & + ;; + development) + project="${TRUTHGATE_DEV_PROJECT:-/workspace/TruthGate-Web/TruthGate-Web/TruthGate-Web.csproj}" + [[ -f "${project}" ]] || fail "Development project was not found at ${project}. Is the repository mounted at /workspace?" + export ASPNETCORE_URLS="${ASPNETCORE_URLS:-http://0.0.0.0:80}" + export DOTNET_USE_POLLING_FILE_WATCHER="${DOTNET_USE_POLLING_FILE_WATCHER:-1}" + log "Starting TruthGate with dotnet watch (${project})." + as_truthgate dotnet watch --project "${project}" run --no-launch-profile --urls "${ASPNETCORE_URLS}" & + ;; + *) + fail "Unknown run mode '${mode}'. Expected 'production' or 'development'." + ;; +esac + +app_pid=$! + +set +e +wait -n "${ipfs_pid}" "${app_pid}" +exit_code=$? +set -e + +log "A managed process exited with status ${exit_code}; stopping the remaining process." +stop_children +exit "${exit_code}" diff --git a/docker/healthcheck.sh b/docker/healthcheck.sh new file mode 100644 index 0000000..4172a47 --- /dev/null +++ b/docker/healthcheck.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ipfs --api=/ip4/127.0.0.1/tcp/5001 diag healthy >/dev/null + +health_url="${TRUTHGATE_HEALTH_URL:-https://127.0.0.1:443/}" +http_status="$(curl \ + --silent \ + --show-error \ + --insecure \ + --max-time 5 \ + --output /dev/null \ + --write-out '%{http_code}' \ + "${health_url}")" + +[[ "${http_status}" =~ ^[0-9]{3}$ ]] +(( 10#${http_status} >= 200 && 10#${http_status} < 500 )) diff --git a/docker/kubo-configure.sh b/docker/kubo-configure.sh new file mode 100644 index 0000000..074a137 --- /dev/null +++ b/docker/kubo-configure.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +truthgate_user="truthgate" +truthgate_group="$(id -g "${truthgate_user}")" + +log() { printf '[truthgate] %s\n' "$*"; } +warn() { printf '[truthgate] WARNING: %s\n' "$*" >&2; } +fail() { printf '[truthgate] ERROR: %s\n' "$*" >&2; exit 1; } +as_truthgate() { gosu "${truthgate_user}:${truthgate_group}" "$@"; } + +normalize_bool() { + case "${1,,}" in + 1|true|yes|on) printf 'true' ;; + 0|false|no|off) printf 'false' ;; + *) return 1 ;; + esac +} + +json_setting() { + local filter="$1" fallback="$2" value + value="$(jq -er "${filter} // empty" "${TRUTHGATE_KUBO_SETTINGS_PATH}" 2>/dev/null || true)" + [[ -n "${value}" ]] && printf '%s' "${value}" || printf '%s' "${fallback}" +} + +setting() { + local environment_name="$1" filter="$2" fallback="$3" + local environment_value="${!environment_name:-}" + [[ -n "${environment_value}" ]] \ + && printf '%s' "${environment_value}" \ + || json_setting "${filter}" "${fallback}" +} + +set_string() { + as_truthgate ipfs config --json "$1" "$(jq -cn --arg value "$2" '$value')" +} + +set_json() { + as_truthgate ipfs config --json "$1" "$2" +} + +ensure_array_value() { + local key="$1" item="$2" current compact updated + current="$(as_truthgate ipfs config --json "${key}" 2>/dev/null || printf '[]')" + compact="$(jq -c . <<<"${current}")" + updated="$(jq -c --arg item "${item}" \ + 'if type != "array" then [$item] elif index($item) then . else . + [$item] end' \ + <<<"${current}")" + [[ "${updated}" == "${compact}" ]] || set_json "${key}" "${updated}" +} + +valid_ipv4() { + local a b c d extra octet + IFS=. read -r a b c d extra <<<"$1" + [[ -z "${extra:-}" && -n "${a:-}" && -n "${b:-}" && -n "${c:-}" && -n "${d:-}" ]] || return 1 + for octet in "${a}" "${b}" "${c}" "${d}"; do + [[ "${octet}" =~ ^[0-9]{1,3}$ ]] || return 1 + (( 10#${octet} <= 255 )) || return 1 + done +} + +valid_ipv6() { + [[ "$1" == *:* && "$1" =~ ^[0-9A-Fa-f:.]+$ ]] +} + +resolve_public_address() { + local family="$1" requested="$2" address="" + case "${requested,,}" in + ""|off|none|disabled|false) return 0 ;; + auto) + if [[ "${family}" == "4" ]]; then + address="$(curl -4fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" + else + address="$(curl -6fsS --max-time 5 https://api6.ipify.org 2>/dev/null || true)" + fi + address="${address//$'\r'/}" + address="${address//$'\n'/}" + ;; + *) address="${requested}" ;; + esac + + if [[ -z "${address}" ]]; then + warn "Could not detect a public IPv${family} address; no managed IPv${family} announce address will be added." + return 0 + fi + + if [[ "${family}" == "4" ]]; then + valid_ipv4 "${address}" || fail "Invalid public IPv4 address: '${address}'." + else + valid_ipv6 "${address}" || fail "Invalid public IPv6 address: '${address}'." + fi + printf '%s' "${address}" +} + +human_bytes() { + command -v numfmt >/dev/null 2>&1 \ + && numfmt --to=iec-i --suffix=B "$1" \ + || printf '%sB' "$1" +} + +: "${IPFS_PATH:=/data/ipfs/repo}" +: "${TRUTHGATE_CONFIG_PATH:=/data/truthgate/config/config.json}" +: "${TRUTHGATE_STATE_PATH:=/data/truthgate/state}" +: "${TMPDIR:=/run/truthgate}" + +config_directory="$(dirname "${TRUTHGATE_CONFIG_PATH}")" +blocks_directory="${IPFS_PATH}/blocks" +: "${TRUTHGATE_KUBO_SETTINGS_PATH:=${config_directory}/kubo-settings.json}" +: "${TRUTHGATE_KUBO_OVERRIDES_PATH:=${config_directory}/kubo-overrides.json}" +export IPFS_PATH TRUTHGATE_KUBO_SETTINGS_PATH TRUTHGATE_KUBO_OVERRIDES_PATH + +if [[ ! -e "${TRUTHGATE_KUBO_SETTINGS_PATH}" ]]; then + cat >"${TRUTHGATE_KUBO_SETTINGS_PATH}" <<'JSON' +{ + "serverProfile": true, + "routingType": "dhtserver", + "ensureSwarmListeners": true, + "holePunching": true, + "relayClient": true, + "provide": { + "enabled": true, + "strategy": "all", + "interval": "22h", + "sweepEnabled": true, + "resumeEnabled": true + }, + "storage": { + "max": "auto", + "percent": 90, + "fallback": "200GB", + "gcWatermark": 90, + "enableGc": true + }, + "publicAnnounce": { + "ipv4": "auto", + "ipv6": "auto", + "port": null + } +} +JSON + chown "${truthgate_user}:${truthgate_group}" "${TRUTHGATE_KUBO_SETTINGS_PATH}" + chmod 0640 "${TRUTHGATE_KUBO_SETTINGS_PATH}" +fi +jq -e 'type == "object"' "${TRUTHGATE_KUBO_SETTINGS_PATH}" >/dev/null \ + || fail "Kubo settings file is not a valid JSON object: ${TRUTHGATE_KUBO_SETTINGS_PATH}" + +if [[ ! -e "${TRUTHGATE_KUBO_OVERRIDES_PATH}" ]]; then + printf '{}\n' >"${TRUTHGATE_KUBO_OVERRIDES_PATH}" + chown "${truthgate_user}:${truthgate_group}" "${TRUTHGATE_KUBO_OVERRIDES_PATH}" + chmod 0640 "${TRUTHGATE_KUBO_OVERRIDES_PATH}" +fi +jq -e 'type == "object"' "${TRUTHGATE_KUBO_OVERRIDES_PATH}" >/dev/null \ + || fail "Kubo overrides file is not a valid JSON object: ${TRUTHGATE_KUBO_OVERRIDES_PATH}" + +server_profile_raw="$(setting TRUTHGATE_KUBO_APPLY_SERVER_PROFILE '.serverProfile' true)" +server_profile="$(normalize_bool "${server_profile_raw}")" \ + || fail "TRUTHGATE_KUBO_APPLY_SERVER_PROFILE must be true or false." +server_profile_marker="${TRUTHGATE_STATE_PATH}/kubo-server-profile-v1" + +if [[ ! -s "${IPFS_PATH}/config" ]]; then + if [[ "${server_profile}" == "true" ]]; then + log "Initializing a new Kubo repository with the public-server profile." + as_truthgate ipfs init --profile server + printf 'applied during ipfs init\n' >"${server_profile_marker}" + chown "${truthgate_user}:${truthgate_group}" "${server_profile_marker}" + else + log "Initializing a new Kubo repository without the server profile." + as_truthgate ipfs init + fi +else + log "Using the existing Kubo repository at ${IPFS_PATH}." + if [[ "${server_profile}" == "true" && ! -e "${server_profile_marker}" ]]; then + log "Applying Kubo's public-server profile to the existing repository." + as_truthgate ipfs config profile apply server + printf 'applied to existing repository\n' >"${server_profile_marker}" + chown "${truthgate_user}:${truthgate_group}" "${server_profile_marker}" + fi +fi + +set_string Routing.Type "$(setting TRUTHGATE_KUBO_ROUTING_TYPE '.routingType' dhtserver)" + +ensure_swarm_raw="$(setting TRUTHGATE_KUBO_ENSURE_SWARM_LISTENERS '.ensureSwarmListeners' true)" +ensure_swarm="$(normalize_bool "${ensure_swarm_raw}")" \ + || fail "TRUTHGATE_KUBO_ENSURE_SWARM_LISTENERS must be true or false." +if [[ "${ensure_swarm}" == "true" ]]; then + for address in \ + /ip4/0.0.0.0/tcp/4001 \ + /ip6/::/tcp/4001 \ + /ip4/0.0.0.0/udp/4001/quic-v1 \ + /ip4/0.0.0.0/udp/4001/quic-v1/webtransport \ + /ip6/::/udp/4001/quic-v1 \ + /ip6/::/udp/4001/quic-v1/webtransport + do + ensure_array_value Addresses.Swarm "${address}" + done +fi + +for spec in \ + "TRUTHGATE_KUBO_HOLE_PUNCHING|.holePunching|true|Swarm.EnableHolePunching" \ + "TRUTHGATE_KUBO_RELAY_CLIENT|.relayClient|true|Swarm.RelayClient.Enabled" \ + "TRUTHGATE_KUBO_PROVIDE_ENABLED|.provide.enabled|true|Provide.Enabled" \ + "TRUTHGATE_KUBO_PROVIDE_SWEEP|.provide.sweepEnabled|true|Provide.DHT.SweepEnabled" \ + "TRUTHGATE_KUBO_PROVIDE_RESUME|.provide.resumeEnabled|true|Provide.DHT.ResumeEnabled" +do + IFS='|' read -r env_name filter fallback key <<<"${spec}" + raw="$(setting "${env_name}" "${filter}" "${fallback}")" + value="$(normalize_bool "${raw}")" || fail "${env_name} must be true or false." + set_json "${key}" "${value}" +done + +set_string Provide.Strategy "$(setting TRUTHGATE_KUBO_PROVIDE_STRATEGY '.provide.strategy' all)" +set_string Provide.DHT.Interval "$(setting TRUTHGATE_KUBO_PROVIDE_INTERVAL '.provide.interval' 22h)" + +storage_max_requested="$(setting TRUTHGATE_KUBO_STORAGE_MAX '.storage.max' auto)" +storage_percent="$(setting TRUTHGATE_KUBO_STORAGE_PERCENT '.storage.percent' 90)" +storage_fallback="$(setting TRUTHGATE_KUBO_STORAGE_FALLBACK '.storage.fallback' 200GB)" +storage_gc_watermark="$(setting TRUTHGATE_KUBO_STORAGE_GC_WATERMARK '.storage.gcWatermark' 90)" + +[[ "${storage_percent}" =~ ^[0-9]+$ ]] && (( storage_percent >= 1 && storage_percent <= 100 )) \ + || fail "TRUTHGATE_KUBO_STORAGE_PERCENT must be an integer from 1 through 100." +[[ "${storage_gc_watermark}" =~ ^[0-9]+$ ]] && (( storage_gc_watermark <= 100 )) \ + || fail "TRUTHGATE_KUBO_STORAGE_GC_WATERMARK must be an integer from 0 through 100." + +storage_source="fixed" +if [[ "${storage_max_requested,,}" == "auto" ]]; then + storage_total_bytes="$(df -PB1 -- "${blocks_directory}" 2>/dev/null | awk 'NR == 2 { print $2 }' || true)" + if [[ "${storage_total_bytes}" =~ ^[0-9]+$ && "${storage_total_bytes}" -gt 0 ]]; then + storage_max_effective="$(( storage_total_bytes * storage_percent / 100 ))B" + storage_source="auto (${storage_percent}% of $(human_bytes "${storage_total_bytes}"))" + else + storage_max_effective="${storage_fallback}" + storage_source="fallback because filesystem capacity detection failed" + fi +else + storage_max_effective="${storage_max_requested}" +fi +set_string Datastore.StorageMax "${storage_max_effective}" +set_json Datastore.StorageGCWatermark "${storage_gc_watermark}" + +enable_gc_raw="$(setting TRUTHGATE_KUBO_ENABLE_GC '.storage.enableGc' true)" +enable_gc="$(normalize_bool "${enable_gc_raw}")" \ + || fail "TRUTHGATE_KUBO_ENABLE_GC must be true or false." +printf '%s\n' "${enable_gc}" >"${TMPDIR}/kubo-enable-gc" + +announce_port="${TRUTHGATE_KUBO_ANNOUNCE_PORT:-}" +[[ -n "${announce_port}" ]] || announce_port="$(json_setting '.publicAnnounce.port' '')" +[[ -n "${announce_port}" && "${announce_port}" != "null" ]] || announce_port="${IPFS_SWARM_PORT:-4001}" +[[ "${announce_port}" =~ ^[0-9]+$ ]] && (( announce_port >= 1 && announce_port <= 65535 )) \ + || fail "TRUTHGATE_KUBO_ANNOUNCE_PORT must be an integer from 1 through 65535." + +public_ipv4="$(resolve_public_address 4 "$(setting TRUTHGATE_KUBO_PUBLIC_IPV4 '.publicAnnounce.ipv4' auto)")" +public_ipv6="$(resolve_public_address 6 "$(setting TRUTHGATE_KUBO_PUBLIC_IPV6 '.publicAnnounce.ipv6' auto)")" + +managed_addresses=() +if [[ -n "${public_ipv4}" ]]; then + managed_addresses+=( + "/ip4/${public_ipv4}/tcp/${announce_port}" + "/ip4/${public_ipv4}/udp/${announce_port}/quic-v1" + "/ip4/${public_ipv4}/udp/${announce_port}/quic-v1/webtransport" + ) +fi +if [[ -n "${public_ipv6}" ]]; then + managed_addresses+=( + "/ip6/${public_ipv6}/tcp/${announce_port}" + "/ip6/${public_ipv6}/udp/${announce_port}/quic-v1" + "/ip6/${public_ipv6}/udp/${announce_port}/quic-v1/webtransport" + ) +fi + +managed_state="${TRUTHGATE_STATE_PATH}/kubo-managed-append-announce.json" +current="$(as_truthgate ipfs config --json Addresses.AppendAnnounce 2>/dev/null || printf '[]')" +previous='[]' +if [[ -s "${managed_state}" ]]; then + previous="$(cat "${managed_state}")" + jq -e 'type == "array"' <<<"${previous}" >/dev/null || previous='[]' +fi +without_previous="$(jq -c --argjson previous "${previous}" \ + '. as $current | [$current[] | select(. as $item | ($previous | index($item)) == null)]' \ + <<<"${current}")" +if (( ${#managed_addresses[@]} > 0 )); then + desired="$(printf '%s\n' "${managed_addresses[@]}" | jq -R . | jq -sc .)" +else + desired='[]' +fi +updated="$(jq -c --argjson desired "${desired}" \ + 'reduce $desired[] as $item (. ; if index($item) then . else . + [$item] end)' \ + <<<"${without_previous}")" +set_json Addresses.AppendAnnounce "${updated}" +printf '%s\n' "${desired}" >"${managed_state}" +chown "${truthgate_user}:${truthgate_group}" "${managed_state}" +chmod 0640 "${managed_state}" + +while IFS= read -r entry; do + key="$(jq -r '.key' <<<"${entry}")" + value="$(jq -c '.value' <<<"${entry}")" + case "${key}" in + Addresses.API|Addresses.Gateway) + warn "Ignoring protected Kubo override '${key}'; TruthGate requires loopback-only listeners." + ;; + *) + log "Applying advanced Kubo override: ${key}" + set_json "${key}" "${value}" + ;; + esac +done < <(jq -c 'to_entries[]' "${TRUTHGATE_KUBO_OVERRIDES_PATH}") + +set_string Addresses.API /ip4/127.0.0.1/tcp/5001 +set_string Addresses.Gateway /ip4/127.0.0.1/tcp/9010 + +log "Kubo server configuration:" +log " Routing.Type: $(as_truthgate ipfs config Routing.Type)" +log " Swarm listeners: $(as_truthgate ipfs config --json Addresses.Swarm | jq -c .)" +log " Managed public announce IPv4: ${public_ipv4:-disabled or unavailable}" +log " Managed public announce IPv6: ${public_ipv6:-disabled or unavailable}" +log " Public announce port: ${announce_port}" +log " Provide.Enabled: $(as_truthgate ipfs config Provide.Enabled)" +log " Provide.Strategy: $(as_truthgate ipfs config Provide.Strategy)" +log " Provide.DHT.Interval: $(as_truthgate ipfs config Provide.DHT.Interval)" +log " Provide.DHT.SweepEnabled: $(as_truthgate ipfs config Provide.DHT.SweepEnabled)" +log " Provide.DHT.ResumeEnabled: $(as_truthgate ipfs config Provide.DHT.ResumeEnabled)" +log " Datastore.StorageMax: $(as_truthgate ipfs config Datastore.StorageMax) (${storage_source})" +log " Datastore.StorageGCWatermark: $(as_truthgate ipfs config Datastore.StorageGCWatermark)%" +log " Automatic repository GC: ${enable_gc}" diff --git a/docker/kubo-status.sh b/docker/kubo-status.sh new file mode 100644 index 0000000..cd91767 --- /dev/null +++ b/docker/kubo-status.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${IPFS_PATH:=/data/ipfs/repo}" +api_address="/ip4/127.0.0.1/tcp/5001" + +config_value() { + ipfs config "$1" 2>/dev/null || printf '' +} + +config_json() { + ipfs config --json "$1" 2>/dev/null | jq -c . || printf '' +} + +printf 'TruthGate Kubo status\n' +printf '=====================\n' +printf 'Repository: %s\n' "${IPFS_PATH}" +printf 'Kubo version: %s\n' "$(ipfs version --number 2>/dev/null || printf '')" +printf 'Routing.Type: %s\n' "$(config_value Routing.Type)" +printf 'Addresses.API: %s\n' "$(config_value Addresses.API)" +printf 'Addresses.Gateway: %s\n' "$(config_value Addresses.Gateway)" +printf 'Addresses.Swarm: %s\n' "$(config_json Addresses.Swarm)" +printf 'Addresses.AppendAnnounce: %s\n' "$(config_json Addresses.AppendAnnounce)" +printf 'Swarm.EnableHolePunching: %s\n' "$(config_value Swarm.EnableHolePunching)" +printf 'Swarm.RelayClient.Enabled: %s\n' "$(config_value Swarm.RelayClient.Enabled)" +printf 'Provide.Enabled: %s\n' "$(config_value Provide.Enabled)" +printf 'Provide.Strategy: %s\n' "$(config_value Provide.Strategy)" +printf 'Provide.DHT.Interval: %s\n' "$(config_value Provide.DHT.Interval)" +printf 'Provide.DHT.SweepEnabled: %s\n' "$(config_value Provide.DHT.SweepEnabled)" +printf 'Provide.DHT.ResumeEnabled: %s\n' "$(config_value Provide.DHT.ResumeEnabled)" +printf 'Datastore.StorageMax: %s\n' "$(config_value Datastore.StorageMax)" +printf 'Datastore.StorageGCWatermark: %s%%\n' "$(config_value Datastore.StorageGCWatermark)" + +if ! ipfs --api="${api_address}" id >/dev/null 2>&1; then + printf '\nDaemon: unavailable at %s\n' "${api_address}" + exit 1 +fi + +peer_id="$(ipfs --api="${api_address}" id -f='')" +peer_count="$(ipfs --api="${api_address}" swarm peers 2>/dev/null | awk 'NF { count++ } END { print count + 0 }')" + +printf '\nDaemon: ready\n' +printf 'Peer ID: %s\n' "${peer_id}" +printf 'Connected swarm peers: %s\n' "${peer_count}" +printf 'Active listen addresses:\n' +ipfs --api="${api_address}" swarm addrs listen 2>/dev/null | sed 's/^/ /' || true + +printf '\nRepository statistics:\n' +ipfs --api="${api_address}" repo stat --human 2>/dev/null | sed 's/^/ /' || true + +printf '\nAutoNAT observations:\n' +ipfs --api="${api_address}" swarm addrs autonat 2>/dev/null | sed 's/^/ /' || \ + printf ' unavailable on this Kubo build\n' + +printf '\nProvider statistics:\n' +ipfs --api="${api_address}" provide stat 2>/dev/null | sed 's/^/ /' || \ + printf ' unavailable or provider is still initializing\n'