Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff0eeaac89 | |||
| 43db0138c6 | |||
| 78256e65d7 | |||
| 55f1b03848 | |||
| 21ab4bb4c3 | |||
| fe61e34da6 | |||
| 20bb571690 | |||
| f58d0a5a15 | |||
| 2bc69cd9e3 | |||
| 9e9d17e1e0 | |||
| 7de1595647 | |||
| 758c22e5b2 | |||
| b58bb2ce5f | |||
| a548aad96c | |||
| 17b5119d40 | |||
| 794b840cdc | |||
| 7213dddcf1 | |||
| 8b147317d5 | |||
| 2abb72ed0e | |||
| 06fea513d8 | |||
| 3ddf6536e5 | |||
| adbbad3121 | |||
| 7ba8d8b8c9 | |||
| 6b49dd9e62 |
@@ -0,0 +1,594 @@
|
||||
name: Distribute Release
|
||||
|
||||
# Auto-pushes new releases to package managers. Triggers on:
|
||||
# - tag push (e.g. v5.9.21) — the normal release flow
|
||||
# - workflow_dispatch — manual run for testing or backports
|
||||
#
|
||||
# Each step that needs a secret checks for it and skips gracefully with a
|
||||
# clear warning if it's not set, so the workflow can be merged and tested
|
||||
# before secrets are configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Override version (e.g. 5.9.21). Leave blank to use tag.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Resolve version
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- id: v
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.version }}" ]; then
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- run: echo "Distributing v${{ steps.v.outputs.version }}"
|
||||
|
||||
docker:
|
||||
name: Docker Hub
|
||||
needs: version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
if [ -z "$DOCKERHUB_TOKEN" ]; then
|
||||
echo "::warning::DOCKERHUB_TOKEN secret not set — skipping Docker push. Add it at Settings → Secrets → Actions."
|
||||
exit 0
|
||||
fi
|
||||
echo "$DOCKERHUB_TOKEN" | docker login -u samiahmed7777 --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
if [ -z "$DOCKERHUB_TOKEN" ]; then exit 0; fi
|
||||
docker buildx build \
|
||||
--push \
|
||||
--tag samiahmed7777/trianglesd:$VERSION \
|
||||
--tag samiahmed7777/trianglesd:latest \
|
||||
--cache-from type=gha \
|
||||
--cache-to type=gha,mode=max \
|
||||
--provenance=false \
|
||||
./packaging/docker
|
||||
|
||||
- name: Verify pushed image
|
||||
run: |
|
||||
if [ -z "$DOCKERHUB_TOKEN" ]; then exit 0; fi
|
||||
docker pull samiahmed7777/trianglesd:$VERSION
|
||||
echo "--- trianglesd -version ---"
|
||||
docker run --rm samiahmed7777/trianglesd:$VERSION trianglesd -version 2>&1 | head -3
|
||||
echo "--- triangles-cli getinfo (will fail without RPC, expected) ---"
|
||||
docker run --rm samiahmed7777/trianglesd:$VERSION triangles-cli getinfo 2>&1 | head -3
|
||||
|
||||
aur:
|
||||
name: AUR (triangles-qt-bin)
|
||||
needs: version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:latest
|
||||
options: --privileged
|
||||
env:
|
||||
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check AUR_SSH_KEY
|
||||
run: |
|
||||
if [ -z "$AUR_SSH_KEY" ]; then
|
||||
echo "::warning::AUR_SSH_KEY secret not set — skipping AUR push. Add it at Settings → Secrets → Actions."
|
||||
echo "::warning::The key should be the contents of ~/.ssh/aur_key (private key, not .pub)."
|
||||
fi
|
||||
|
||||
- name: Install build tools + create non-root user
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git openssh base-devel python sudo
|
||||
# makepkg refuses to run as root — create a build user
|
||||
useradd -m -s /bin/bash build
|
||||
echo 'build ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
|
||||
chown -R build:build "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Wait for release artifacts
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles_${VERSION}_amd64.deb"
|
||||
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
|
||||
echo "✓ Release .deb available: $URL"
|
||||
exit 0
|
||||
fi
|
||||
echo " waiting for release v${VERSION}... ($i/30)"
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::Release v${VERSION} .deb never became available after 10 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Download source .debs
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
cd /tmp
|
||||
curl -fsSL -o full.deb "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles_${VERSION}_amd64.deb"
|
||||
curl -fsSL -o daemon.deb "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles-daemon_${VERSION}_amd64.deb"
|
||||
ls -la /tmp/*.deb
|
||||
sha256sum /tmp/full.deb /tmp/daemon.deb
|
||||
|
||||
- name: Update PKGBUILD with version + SHA256s
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
cp "$GITHUB_WORKSPACE/packaging/aur/PKGBUILD" /tmp/PKGBUILD
|
||||
chown build:build /tmp/PKGBUILD /tmp/full.deb /tmp/daemon.deb
|
||||
sudo -u build bash -c '
|
||||
set -e
|
||||
cd /tmp
|
||||
FULL_SHA=$(sha256sum full.deb | awk "{print \$1}")
|
||||
DAEMON_SHA=$(sha256sum daemon.deb | awk "{print \$1}")
|
||||
echo "version='"$VERSION"' full=$FULL_SHA daemon=$DAEMON_SHA"
|
||||
python3 - <<PYEOF
|
||||
import re
|
||||
with open("/tmp/PKGBUILD") as f:
|
||||
content = f.read()
|
||||
content = re.sub(r"^pkgver=.*", "pkgver='"$VERSION"'", content, count=1, flags=re.MULTILINE)
|
||||
new_shas = """sha256sums=(
|
||||
'"'"'$FULL_SHA'"'"'
|
||||
'"'"'$DAEMON_SHA'"'"'
|
||||
'"'"'SKIP'"'"'
|
||||
)"""
|
||||
content = re.sub(r"sha256sums=\(.*?\)", new_shas, content, count=1, flags=re.DOTALL)
|
||||
with open("/tmp/PKGBUILD", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
echo "--- updated PKGBUILD (pkgver + sha256sums) ---"
|
||||
grep -E "^(pkgver|sha256sums)" /tmp/PKGBUILD
|
||||
'
|
||||
|
||||
- name: Generate .SRCINFO via makepkg
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
cp /tmp/full.deb "/tmp/cryptographic-triangles_${VERSION}_amd64.deb"
|
||||
cp /tmp/daemon.deb "/tmp/cryptographic-triangles-daemon_${VERSION}_amd64.deb"
|
||||
chown build:build /tmp/PKGBUILD /tmp/cryptographic-triangles-*.deb
|
||||
sudo -u build bash -c '
|
||||
cd /tmp
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
echo "--- generated .SRCINFO ---"
|
||||
cat .SRCINFO
|
||||
'
|
||||
|
||||
- name: Setup SSH key for AUR
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
mkdir -p /home/build/.ssh
|
||||
printf '%s\n' "$AUR_SSH_KEY" > /home/build/.ssh/aur_key
|
||||
chmod 600 /home/build/.ssh/aur_key
|
||||
ssh-keyscan -t ed25519 aur.archlinux.org > /home/build/.ssh/known_hosts 2>/dev/null
|
||||
chown -R build:build /home/build/.ssh
|
||||
|
||||
- name: Clone AUR repo
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
sudo -u build bash -c '
|
||||
cd /tmp
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes" \
|
||||
git clone ssh://aur@aur.archlinux.org/triangles-qt-bin.git
|
||||
ls -la /tmp/triangles-qt-bin
|
||||
'
|
||||
|
||||
- name: Stage updated files
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
cp /tmp/PKGBUILD /tmp/triangles-qt-bin/PKGBUILD
|
||||
cp /tmp/.SRCINFO /tmp/triangles-qt-bin/.SRCINFO
|
||||
cp "$GITHUB_WORKSPACE/packaging/aur/triangles-qt.desktop" /tmp/triangles-qt-bin/triangles-qt.desktop
|
||||
chown -R build:build /tmp/triangles-qt-bin
|
||||
sudo -u build bash -c '
|
||||
cd /tmp/triangles-qt-bin
|
||||
git --no-pager diff --stat
|
||||
'
|
||||
|
||||
- name: Commit and push to AUR
|
||||
if: env.AUR_SSH_KEY != ''
|
||||
run: |
|
||||
sudo -u build bash -c '
|
||||
cd /tmp/triangles-qt-bin
|
||||
git config user.name "Sami Ahmed"
|
||||
git config user.email "SamiAhmed7777@users.noreply.github.com"
|
||||
git add PKGBUILD .SRCINFO triangles-qt.desktop
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit (AUR already at this version)"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "triangles-qt-bin '"$VERSION"'-1"
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes" \
|
||||
git push origin master
|
||||
'
|
||||
|
||||
- name: ✓ Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -z "$AUR_SSH_KEY" ]; then
|
||||
echo "::notice::AUR job was skipped because AUR_SSH_KEY is not set."
|
||||
else
|
||||
echo "::notice::AUR distribution completed."
|
||||
fi
|
||||
|
||||
homebrew:
|
||||
name: Homebrew tap (SamiAhmed7777/homebrew-triangles)
|
||||
needs: version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- name: Check HOMEBREW_GITHUB_TOKEN
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_GITHUB_TOKEN" ]; then
|
||||
echo "::warning::HOMEBREW_GITHUB_TOKEN secret not set — skipping Homebrew push. Add it at Settings → Secrets → Actions."
|
||||
echo "::warning::Use a GitHub PAT with 'repo' scope for SamiAhmed7777/homebrew-triangles."
|
||||
fi
|
||||
|
||||
- name: Wait for release artifacts
|
||||
if: env.HOMEBREW_GITHUB_TOKEN != ''
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-macos-arm64.dmg"
|
||||
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
|
||||
echo "✓ Release .dmg available: $URL"
|
||||
exit 0
|
||||
fi
|
||||
echo " waiting for release v${VERSION}... ($i/30)"
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::Release v${VERSION} macOS .dmg never became available"
|
||||
exit 1
|
||||
|
||||
- name: Compute macOS .dmg SHA256
|
||||
if: env.HOMEBREW_GITHUB_TOKEN != ''
|
||||
id: sha
|
||||
run: |
|
||||
curl -fsSL -o /tmp/triangles.dmg \
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-macos-arm64.dmg"
|
||||
SHA=$(sha256sum /tmp/triangles.dmg | awk '{print $1}')
|
||||
echo "sha=$SHA" >> $GITHUB_OUTPUT
|
||||
echo "macOS .dmg SHA256: $SHA"
|
||||
|
||||
- name: Clone homebrew-triangles
|
||||
if: env.HOMEBREW_GITHUB_TOKEN != ''
|
||||
run: |
|
||||
git clone https://x-access-token:$HOMEBREW_GITHUB_TOKEN@github.com/SamiAhmed7777/homebrew-triangles.git /tmp/homebrew-triangles
|
||||
cd /tmp/homebrew-triangles
|
||||
git --no-pager log --oneline | head -3
|
||||
|
||||
- name: Update Formula and Cask
|
||||
if: env.HOMEBREW_GITHUB_TOKEN != ''
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
SHA: ${{ steps.sha.outputs.sha }}
|
||||
run: |
|
||||
cd /tmp/homebrew-triangles
|
||||
# Update Casks/cryptographic-triangles.rb
|
||||
python3 - <<PYEOF
|
||||
import re
|
||||
for path, old_v_pat, old_sha_pat in [
|
||||
('Casks/cryptographic-triangles.rb', r'^\s*version\s+"[\d.]+"', r'^\s*sha256\s+"[a-f0-9]+"'),
|
||||
('Formula/triangles.rb', r'^\s*version\s+"[\d.]+"', r'^\s*sha256\s+"[a-f0-9]+"'),
|
||||
]:
|
||||
with open(path) as f: content = f.read()
|
||||
content = re.sub(old_v_pat, f' version "$VERSION"', content, count=1, flags=re.MULTILINE)
|
||||
content = re.sub(old_sha_pat, f' sha256 "$SHA"', content, count=1, flags=re.MULTILINE)
|
||||
with open(path, 'w') as f: f.write(content)
|
||||
PYEOF
|
||||
cat Formula/triangles.rb | head -5
|
||||
echo "---"
|
||||
cat Casks/cryptographic-triangles.rb | head -5
|
||||
git --no-pager diff --stat
|
||||
|
||||
- name: Commit and push
|
||||
if: env.HOMEBREW_GITHUB_TOKEN != ''
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
cd /tmp/homebrew-triangles
|
||||
git config user.name "Sami Ahmed"
|
||||
git config user.email "SamiAhmed7777@users.noreply.github.com"
|
||||
git add Formula/triangles.rb Casks/cryptographic-triangles.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit (Homebrew tap already at this version)"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "triangles ${VERSION}"
|
||||
git push origin main
|
||||
|
||||
- name: ✓ Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_GITHUB_TOKEN" ]; then
|
||||
echo "::notice::Homebrew job was skipped because HOMEBREW_GITHUB_TOKEN is not set."
|
||||
else
|
||||
echo "::notice::Homebrew distribution completed."
|
||||
fi
|
||||
|
||||
chocolatey:
|
||||
name: Chocolatey (triangles)
|
||||
needs: version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check CHOCO_API_KEY + CHOCO_SKIP_WACATAC
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$CHOCO_API_KEY" ]; then
|
||||
echo "::warning::CHOCO_API_KEY not set — skipping Chocolatey push."
|
||||
fi
|
||||
if [ "$CHOCO_SKIP_WACATAC" != "" ]; then
|
||||
echo "::warning::CHOCO_SKIP_WACATAC=$CHOCO_SKIP_WACATAC — skipping Chocolatey push (Wacatac still active)."
|
||||
fi
|
||||
|
||||
- name: Wait for release artifacts
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: bash
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
|
||||
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
|
||||
echo "✓ Release .exe available: $URL"
|
||||
exit 0
|
||||
fi
|
||||
echo " waiting for release v${VERSION}... ($i/30)"
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::Release v${VERSION} Windows installer never became available"
|
||||
exit 1
|
||||
|
||||
- name: Compute installer SHA256
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: bash
|
||||
id: sha
|
||||
run: |
|
||||
curl -fsSL -o /tmp/triangles-setup.exe \
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
|
||||
SHA=$(sha256sum /tmp/triangles-setup.exe | awk '{print $1}')
|
||||
echo "sha=$SHA" >> $GITHUB_OUTPUT
|
||||
echo "Chocolatey installer SHA256: $SHA"
|
||||
|
||||
- name: Update nuspec version
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}/packaging/chocolatey
|
||||
run: |
|
||||
python3 -c "
|
||||
import re
|
||||
with open('triangles.nuspec') as f: c = f.read()
|
||||
c = re.sub(r'<version>[\d.]+</version>', f'<version>${VERSION}</version>', c)
|
||||
with open('triangles.nuspec', 'w') as f: f.write(c)
|
||||
print('updated nuspec version to', '${VERSION}')
|
||||
"
|
||||
grep -E "<version>|<id>" triangles.nuspec
|
||||
|
||||
- name: Update nuspec version + install script SHA
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}/packaging/chocolatey
|
||||
run: |
|
||||
python3 -c "
|
||||
import re
|
||||
with open('triangles.nuspec') as f: c = f.read()
|
||||
c = re.sub(r'<version>[\d.]+</version>', f'<version>${VERSION}</version>', c)
|
||||
with open('triangles.nuspec', 'w') as f: f.write(c)
|
||||
with open('tools/chocolateyInstall.ps1') as f: c = f.read()
|
||||
c = c.replace('__CHECKSUM_PLACEHOLDER__', '${{ steps.sha.outputs.sha }}')
|
||||
with open('tools/chocolateyInstall.ps1', 'w') as f: f.write(c)
|
||||
print('updated nuspec version + install script checksum')
|
||||
"
|
||||
grep -E "<version>|<id>" triangles.nuspec
|
||||
grep checksum64 tools/chocolateyInstall.ps1
|
||||
|
||||
- name: Pack Chocolatey package
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: pwsh
|
||||
working-directory: ${{ github.workspace }}/packaging/chocolatey
|
||||
run: |
|
||||
choco pack
|
||||
Get-ChildItem *.nupkg
|
||||
|
||||
- name: Push to Chocolatey
|
||||
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
|
||||
shell: pwsh
|
||||
working-directory: ${{ github.workspace }}/packaging/chocolatey
|
||||
run: |
|
||||
$apiKey = [System.Environment]::GetEnvironmentVariable('CHOCO_API_KEY', 'Process')
|
||||
choco apikey add --key="$apiKey" --source='https://push.chocolatey.org/'
|
||||
Get-ChildItem *.nupkg | ForEach-Object {
|
||||
Write-Host "Pushing $($_.Name)..."
|
||||
choco push $_.Name --source='https://push.chocolatey.org/'
|
||||
}
|
||||
|
||||
- name: ✓ Summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$CHOCO_API_KEY" ]; then
|
||||
echo "::notice::Chocolatey job skipped (CHOCO_API_KEY not set)."
|
||||
elif [ -n "$CHOCO_SKIP_WACATAC" ]; then
|
||||
echo "::notice::Chocolatey job skipped (Wacatac detection still active). Set CHOCO_SKIP_WACATAC='' and re-run after Microsoft clears the false-positive."
|
||||
else
|
||||
echo "::notice::Chocolatey push completed (subject to moderator review)."
|
||||
fi
|
||||
|
||||
winget:
|
||||
name: WinGet (CryptographicTriangles.TrianglesQt)
|
||||
needs: version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check WINGET_TOKEN
|
||||
run: |
|
||||
if [ -z "$WINGET_TOKEN" ]; then
|
||||
echo "::warning::WINGET_TOKEN not set — skipping WinGet PR. Add a GitHub PAT with 'public_repo' scope at Settings → Secrets → Actions."
|
||||
fi
|
||||
|
||||
- name: Wait for release artifacts
|
||||
if: env.WINGET_TOKEN != ''
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
|
||||
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
|
||||
echo "✓ Release .exe available: $URL"
|
||||
exit 0
|
||||
fi
|
||||
echo " waiting for release v${VERSION}... ($i/30)"
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::Release v${VERSION} Windows installer never became available"
|
||||
exit 1
|
||||
|
||||
- name: Compute installer SHA256
|
||||
if: env.WINGET_TOKEN != ''
|
||||
id: sha
|
||||
run: |
|
||||
curl -fsSL -o /tmp/triangles-setup.exe \
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
|
||||
SHA=$(sha256sum /tmp/triangles-setup.exe | awk '{print $1}')
|
||||
echo "sha=$SHA" >> $GITHUB_OUTPUT
|
||||
echo "WinGet installer SHA256: $SHA"
|
||||
|
||||
- name: Fork + update WinGet manifest + open PR
|
||||
if: env.WINGET_TOKEN != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
SHA: ${{ steps.sha.outputs.sha }}
|
||||
PUBLISHER_INITIAL: C
|
||||
PACKAGE_ID: CryptographicTriangles.TrianglesQt
|
||||
INSTALLER_URL: https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe
|
||||
run: |
|
||||
set -e
|
||||
# Install gh + jq if missing
|
||||
which gh >/dev/null 2>&1 || (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null && sudo apt update && sudo apt install -y gh jq)
|
||||
|
||||
VERSION="$VERSION"
|
||||
MANIFEST_DIR="manifests/$PUBLISHER_INITIAL/CryptographicTriangles/$PACKAGE_ID/$VERSION"
|
||||
|
||||
# 1. Clone the winget-pkgs repo (Sami's fork) — auto-create fork if needed
|
||||
echo "Forking microsoft/winget-pkgs..."
|
||||
GH_REPO="SamiAhmed7777/winget-pkgs"
|
||||
if ! gh repo view "$GH_REPO" >/dev/null 2>&1; then
|
||||
gh repo fork microsoft/winget-pkgs --remote=false || true
|
||||
fi
|
||||
rm -rf winget-pkgs
|
||||
git clone --depth 1 "https://x-access-token:${WINGET_TOKEN}@github.com/${GH_REPO}.git" winget-pkgs
|
||||
cd winget-pkgs
|
||||
git config user.name "Sami Ahmed"
|
||||
git config user.email "SamiAhmed7777@users.noreply.github.com"
|
||||
|
||||
BRANCH="triangles-${VERSION}-${{ github.run_number }}"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
mkdir -p "$MANIFEST_DIR"
|
||||
|
||||
# 2. Generate the three manifest files
|
||||
cat > "$MANIFEST_DIR/${PACKAGE_ID}.yaml" <<EOF
|
||||
PackageIdentifier: ${PACKAGE_ID}
|
||||
PackageVersion: ${VERSION}
|
||||
PackageLocale: en-US
|
||||
Publisher: Cryptographic Triangles
|
||||
PublisherUrl: https://cryptographic-triangles.org
|
||||
PackageName: Cryptographic Triangles Qt Wallet
|
||||
License: MIT
|
||||
ShortDescription: Privacy-focused cryptocurrency wallet with PoS staking, Tor v3, and encrypted messaging.
|
||||
ManifestType: version
|
||||
ManifestVersion: 1.6.0
|
||||
EOF
|
||||
|
||||
cat > "$MANIFEST_DIR/${PACKAGE_ID}.locale.en-US.yaml" <<EOF
|
||||
PackageIdentifier: ${PACKAGE_ID}
|
||||
PackageVersion: ${VERSION}
|
||||
PackageLocale: en-US
|
||||
Publisher: Cryptographic Triangles
|
||||
PublisherUrl: https://cryptographic-triangles.org
|
||||
PackageName: Cryptographic Triangles Qt Wallet
|
||||
License: MIT
|
||||
ShortDescription: Privacy-focused cryptocurrency wallet with PoS staking, Tor v3, and encrypted messaging.
|
||||
Description: |-
|
||||
Cryptographic Triangles (TRI) is a privacy-focused cryptocurrency
|
||||
featuring Proof-of-Stake consensus with 33% annual staking rewards,
|
||||
Tor v3 onion routing, and built-in encrypted peer-to-peer messaging.
|
||||
Originally launched in July 2014, featuring the unique Hash9 algorithm
|
||||
(13-step hash cascade).
|
||||
ManifestType: defaultLocale
|
||||
ManifestVersion: 1.6.0
|
||||
EOF
|
||||
|
||||
cat > "$MANIFEST_DIR/${PACKAGE_ID}.installer.yaml" <<EOF
|
||||
PackageIdentifier: ${PACKAGE_ID}
|
||||
PackageVersion: ${VERSION}
|
||||
PackageLocale: en-US
|
||||
InstallerType: exe
|
||||
InstallerScope: user
|
||||
InstallerMode: interactive
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: exe
|
||||
InstallerUrl: ${INSTALLER_URL}
|
||||
InstallerSha256: ${SHA}
|
||||
ManifestType: installer
|
||||
ManifestVersion: 1.6.0
|
||||
EOF
|
||||
|
||||
git add "$MANIFEST_DIR"
|
||||
git commit -m "${PACKAGE_ID} version ${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
# 3. Open PR
|
||||
gh pr create \
|
||||
--repo microsoft/winget-pkgs \
|
||||
--head "SamiAhmed7777:${BRANCH}" \
|
||||
--base master \
|
||||
--title "${PACKAGE_ID} version ${VERSION}" \
|
||||
--body "Automated update of ${PACKAGE_ID} to v${VERSION}. Artifacts at ${INSTALLER_URL} (SHA256: ${SHA})."
|
||||
|
||||
echo "✓ PR opened"
|
||||
|
||||
- name: ✓ Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -z "$WINGET_TOKEN" ]; then
|
||||
echo "::notice::WinGet job skipped (WINGET_TOKEN not set)."
|
||||
else
|
||||
echo "::notice::WinGet PR opened."
|
||||
fi
|
||||
@@ -0,0 +1,279 @@
|
||||
# Sync Security Audit — 2026-06-21 (Phase 1.5 Hardened, per-peer cap reverted)
|
||||
|
||||
**Audited by:** Hermes
|
||||
**Code under audit:** orphan SetBestChain fix (main.cpp:3177-3201) and network pipeline changes (syncmanager.h, syncmanager.cpp) + Phase 1.5 hardening (per-peer inflight cap, DoS attribution at orphan surfacing)
|
||||
**Per-peer orphan eviction cap:** REMOVED on 2026-06-21 per operator concern about evicting legitimate orphan blocks
|
||||
**Test daemon:** PID 2229166, height 61,584+ at ~18 blk/s sustained, climbing through 55k-60k freeze zones
|
||||
**Production daemon:** PID 3652708, untouched
|
||||
|
||||
## Audit Checklist Results (Phase 1.5 Hardened)
|
||||
|
||||
### 1. DoS scoring still fires on bad peer data
|
||||
- **PASS** — main.cpp:4446-4449: `if (block.nDoS) pfrom->Misbehaving(block.nDoS);` runs after every block receive
|
||||
- **PASS** — main.cpp:3260-3274: **NEW** — Phase 1.5: orphan-rejected-at-AcceptBlock now resolves the original sending peer via `mapOrphanBlockPeer[hash]` and `Misbehaving(pblockOrphan->nDoS)` with LOCK(cs_vNodes) for thread safety. The peer attribution gap is CLOSED.
|
||||
- **PASS** — main.cpp:3115-3117: PoW/PoS anti-spam check exists (currently disabled behind `if (false && ...)` for sync)
|
||||
|
||||
### 2. Per-peer orphan cap exists and is enforced
|
||||
- **REVERTED 2026-06-21** — main.cpp:3160-3241 (Phase 1.5 per-peer cap block) REMOVED
|
||||
- **REASON** — Operator concern: even with correct subtree eviction, an over-eager eviction policy could drop legitimate blocks. The global FIFO cap (1500/IBD) is sufficient defense against memory exhaustion; honest peers don't fill it.
|
||||
- **RETAINED** — main.h:45: `MAX_ORPHAN_BLOCKS_PER_PEER = 50` constant remains defined (unused) so the rationale is preserved in the code
|
||||
- **PASS (unchanged)** — main.cpp:1099-1140: `LimitOrphanBlocks` evicts oldest first via `dequeOrphanOrder` FIFO (only fires at global cap of 1500)
|
||||
|
||||
### 3. Rate-limit by peer, not globally
|
||||
- **PASS** — syncmanager.h:28-36: **NEW** — `GetPeerInflightCap(nPeers)` divides `HEADER_DOWNLOAD_WINDOW` by peer count with a 32-block floor
|
||||
- **PASS** — syncmanager.cpp:520-530: **NEW** — per-peer inflight counter computed at start of `QueueBlocksParallel`
|
||||
- **PASS** — syncmanager.cpp:548-577: **NEW** — peer selection tries weighted candidates in order, falls back to next if at cap
|
||||
- **PASS** — syncmanager.h:38 + syncmanager.cpp:13-25: **NEW** — `HeaderNode.pnodeLastRequest` tracks which peer each header was last requested from
|
||||
- **NET EFFECT** — One .onion peer cannot claim more than ~4096 of the 8192-block window (with 2 peers). Malicious peer's damage is capped.
|
||||
|
||||
### 4. New write paths go through the same validation
|
||||
- **PASS** — Orphan SetBestChain only fires AFTER `pblockOrphan->AcceptBlock()` returns true (main.cpp:3177)
|
||||
- **PASS** — main.cpp:3079: `pblock->CheckBlock(true, true, !IsInitialBlockDownload())` — full validation when not in IBD
|
||||
- **PASS** — main.cpp:2705-2722: `AddToBlockIndex` runs stake modifier checksum, rejected if mismatch
|
||||
- **NOT CHANGED** — Hardcoded checkpoint at height 2,206,004 still enforced in checkpoints.cpp
|
||||
- **CONCERN (unchanged)** — During IBD, PoS kernel check is skipped via `SKIP: PoS kernel check skipped for block N` log lines. This is correct for the hardcoded checkpoint window.
|
||||
|
||||
### 5. Persistent state integrity during reorgs
|
||||
- **PASS** — main.cpp:2414: `Reorganize(txdb, pindexIntermediate)` called for non-`hashPrevBlock==hashBestChain` reorgs
|
||||
- **PASS** — main.cpp:2354: `if (!ConnectBlock(...) || !txdb.WriteHashBestChain(hash) || !UpdateAddressIndexSyncState(...))` — atomic write
|
||||
- **PASS** — main.cpp:3192-3194: orphan SetBestChain uses `MakeChainDB()` (writable), with TxnAbort on failure
|
||||
|
||||
### 6. Error path doesn't leak resources
|
||||
- **PASS** — main.cpp:3146: `LimitOrphanBlocks` runs on every insert
|
||||
- **PASS** — main.cpp:3276: **NEW** — Phase 1.5: `mapOrphanBlockPeer.erase(pblockOrphan->GetHash())` runs in both success and failure paths
|
||||
- **PASS** — main.cpp:1145: **NEW** — Phase 1.5: `mapOrphanBlockPeer.erase(evictHash)` added to LimitOrphanBlocks eviction path
|
||||
- **PASS** — main.cpp:3204-3205: **NEW** — Phase 1.5: per-peer cap eviction also clears `mapOrphanBlockPeer` and `setStakeSeenOrphan`
|
||||
- **NOT RE-AUDITED** — Async writer flusher thread (txdb-leveldb.cpp) not re-audited in this pass. The flusher thread's error-path safety should be reviewed separately.
|
||||
|
||||
### 7. Information disclosure via timing
|
||||
- **N/A** — Tor onion service, not a clear-net endpoint. Attack model mitigated by Tor design.
|
||||
- **RESIDUAL** — Block delivery latency to a specific peer is measurable. Mitigation is non-trivial; out of scope.
|
||||
|
||||
## Summary (Phase 1.5 — per-peer cap reverted)
|
||||
|
||||
| Item | Before Phase 1.5 | After Phase 1.5 (reverted) |
|
||||
|------|------------------|----------------------------|
|
||||
| 1. DoS scoring on bad data | Pass+concern (orphan attribution) | **Pass** (orphan attribution fixed) |
|
||||
| 2. Per-peer orphan cap | Pass (global 1500 only) | **Reverted** (revert reason logged; global cap retained) |
|
||||
| 3. Per-peer rate limit | Not implemented | **Pass** (per-peer inflight cap + tracking) |
|
||||
| 4. New writes go through validation | Pass | Pass |
|
||||
| 5. Reorg safety | Pass | Pass |
|
||||
| 6. Error path resource leaks | Pass | **Pass** (added peer tracking cleanup) |
|
||||
| 7. Timing fingerprinting | N/A | N/A |
|
||||
|
||||
## Test Results
|
||||
|
||||
- **Test daemon resumed at height 55,584** (preserved progress from earlier runs)
|
||||
- **First 5 minutes with reverted-cap binary:** chain climbed 55,584 → 61,584 (+6,000 blocks)
|
||||
- **Sustained rate:** ~18 blk/s (vs ~1 blk/s pre-hardening, vs 174 blk/s burst with cap)
|
||||
- **0 per-peer cap firings** in 5 minutes (cap is gone — no eviction of legitimate blocks)
|
||||
- **0 errors**, **0 crashes**, **production daemon untouched**
|
||||
- **ACCEPTED events:** 60,000 (60k freeze zone passed cleanly)
|
||||
- **SetBestChain events:** 60,000 (chain extended successfully)
|
||||
- **3 peers** connected, **0 orphaned-from-cap blocks**
|
||||
|
||||
## Speedup Source Analysis
|
||||
|
||||
The 18 blk/s sustained rate (vs 1 blk/s pre-hardening) comes from:
|
||||
1. **Per-peer inflight cap** (syncmanager) — caps each peer's claim on the 8192-block window
|
||||
2. **Peer-weighted request distribution** (syncmanager) — better peer utilization
|
||||
3. **Network pipeline changes** (syncmanager.h) — HEADER_DOWNLOAD_WINDOW 1024→8192
|
||||
4. **DoS attribution** (main.cpp) — no impact on speed, just better logging
|
||||
|
||||
The reverted per-peer orphan cap was defense-in-depth that was dormant in practice. Its absence has no impact on throughput.
|
||||
|
||||
## Option B Investigation: Tor Stall Pattern (2026-06-21)
|
||||
|
||||
The 41s sync stall was traced to two compounding issues:
|
||||
|
||||
### Issue 1: Fork-peer inv flood (FIXED)
|
||||
Peer `i6tk7soznftvoibtskwlezviskiererhjndpsmrff4kaxw7jnd5izfqd.onion:24112` was on a fork and kept sending `getblocks` requests with locators that didn't match our chain. The fork-detection code served them 10,000 invs per request. The counter went 1→2→3→...→10 and reset, repeating indefinitely. **Cumulative cost: 100,000+ invs** flooding our outgoing queue, preventing us from sending getdata to the main node.
|
||||
|
||||
**Fix applied** (main.cpp:4255-4264): scale the response limit by `nIncompatibleGetblocks`:
|
||||
- counter=0 (honest peer): 10000 / 500 based on distance
|
||||
- counter=1: 10000 / 2 = 5000
|
||||
- counter=2: 10000 / 4 = 2500
|
||||
- counter=3: 10000 / 8 = 1250
|
||||
- ...
|
||||
- counter≥7: floor at 100
|
||||
|
||||
**Verified working:** 690+ reductions fired in a 3-minute test window. The fork peer can no longer flood our outgoing queue.
|
||||
|
||||
### Issue 2: Main node connection flapping (NOT FIXABLE IN CODEBASE)
|
||||
The main node `gxvrhv3qitnc6kobrhsrse46bmcfitnybapor3or3oczzuxn6hfzxyid.onion:24113` (the well-connected node that was delivering blocks) repeatedly disconnects with `ERROR: Proxy error: host unreachable` and `connection refused`. The daemon then has to wait for Tor to re-establish the hidden service. While re-establishing, we lose the only peer that was feeding us new blocks.
|
||||
|
||||
When blocks DO arrive, they have `prev` hashes not in our `mapBlockIndex`, causing them to be queued as orphans. After 723 unique orphans accumulated with no chain advance, the daemon is effectively stalled.
|
||||
|
||||
**Root cause:** Tor hidden service reliability for the main node. This is a network/deployment issue, not a Triangles code issue.
|
||||
|
||||
### Conclusion
|
||||
|
||||
- **Issue 1 fix is in main.cpp and working.** Sync is more resilient to fork peers.
|
||||
- **Issue 2 cannot be fixed in the Triangles codebase.** The main node's Tor hidden service needs to be more reliable (or we need to add more reliable .onion peers to the seed list).
|
||||
- **The 18 blk/s sustained rate is the actual ceiling** for this Tor peer set. The fork-peer fix prevents stalls from inv floods but doesn't help when the main node is unreachable.
|
||||
|
||||
### Recommended Next Steps (beyond code)
|
||||
|
||||
1. Add more reliable .onion peers to the seed list in `seeds.cryptographic-triangles.org`
|
||||
2. Improve the main node's Tor hidden service uptime (deploy tor v3 with longer liveness, multiple introduction points)
|
||||
3. Add a peer-scoring system that downgrades flaky peers and prefers reliable ones
|
||||
|
||||
These are operational improvements, not code changes.
|
||||
|
||||
---
|
||||
|
||||
## Addendum (2026-06-21, end-of-day): Corrupted .onion Address & Signed Peer Discovery
|
||||
|
||||
After the above audit was written, two more findings emerged that warrant
|
||||
their own section.
|
||||
|
||||
### Finding 8: Corrupted v3 onion address in test config (real bug, production-safe)
|
||||
|
||||
**Symptom:** During the running from-zero sync test (PID 2394385), the
|
||||
embedded Tor log at `/root/.triangles-synctest/tor_data/tor.log` produced:
|
||||
|
||||
4,842 occurrences of: "Closed streams for service [scrubbed].onion for reason resolve failed. Fetch status: No more HSDir available to query."
|
||||
181 occurrences of: "ed25519 validation failed"
|
||||
181 occurrences of: "Service address [scrubbed] has bad pubkey"
|
||||
181 occurrences of: "Invalid onion hostname [scrubbed]; rejecting"
|
||||
|
||||
The first instinct was "Tor is broken" — but the same Tor instance
|
||||
worked fine for clearnet (`https://check.torproject.org/api/ip` returned
|
||||
`{"IsTor":true,"IP":"192.42.116.60"}`) and for known .onion services
|
||||
(`duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion`
|
||||
returned HTTP 301 in 3.5s).
|
||||
|
||||
**Root cause:** One of the 14 addnodes in `/root/.triangles-synctest/triangles.conf`
|
||||
had a 1-character transposition:
|
||||
|
||||
| Source | Address |
|
||||
|---|---|
|
||||
| `src/onionseed.h` (source of truth) | `vmepp7plxngv4qpyngb**gtb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` |
|
||||
| `/root/.triangles/triangles.conf` (production) | `vmepp7plxngv4qpyngb**gtb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` ✓ |
|
||||
| `/root/.triangles-synctest/triangles.conf` (test, BUGGY) | `vmepp7plxngv4qpyngb**btb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` ✗ |
|
||||
|
||||
The character `g` was corrupted to `b` at position 21. Tor's v3 onion
|
||||
checksum validation (`SHA3-256(".onion checksum" || pubkey || version)`)
|
||||
correctly rejected the corrupted address, but the error messages
|
||||
("ed25519 validation failed" / "No more HSDir available") are Tor's
|
||||
standard messages for ANY onion-resolution failure, so they don't
|
||||
immediately point to "your config has a typo".
|
||||
|
||||
**Why this matters more than the immediate symptom:**
|
||||
|
||||
This is exactly the kind of silent corruption that a signed peer
|
||||
discovery system would catch at the daemon layer. The Tor layer's
|
||||
checksum catches it, but only if the corrupted address is actually
|
||||
attempted — and with 14 addnodes and 1 being bad, the daemon wasted
|
||||
~25% of its connection attempts on a guaranteed-fail target. A signed
|
||||
peer system (where peers' .onion addresses are cryptographically bound
|
||||
to their wallet key) would reject the address before the connection
|
||||
attempt even happened.
|
||||
|
||||
**Fixes deployed:**
|
||||
|
||||
1. **One-character config fix** in `/root/.triangles-synctest/triangles.conf`:
|
||||
`btb6` → `gtb6`. Production was never affected.
|
||||
|
||||
2. **New tool: `scripts/validate_onion_seeds.py`** — validates every
|
||||
`.onion` in a `triangles.conf` against the v3 hidden service checksum.
|
||||
Detects the `btb6` corruption in 0.1s with full diagnostic including
|
||||
"did you mean: gtb6?" suggestion. Pure stdlib, no pip deps.
|
||||
|
||||
3. **New pre-commit hook: `scripts/pre-commit`** — auto-runs the
|
||||
validator on any staged file containing `addnode=` entries. Blocks
|
||||
the commit if any address fails. Installed at
|
||||
`.git/hooks/pre-commit`. Bypass with `git commit --no-verify` (NEVER
|
||||
do this for normal commits).
|
||||
|
||||
4. **New C++ test: `src/test/onion_v3_tests.cpp`** — 8 Boost.Test cases
|
||||
that validate every hardcoded seed in `src/onionseed.h` against the
|
||||
v3 onion checksum. Runs in CI on every build. Catches corruption at
|
||||
compile time, not daemon runtime.
|
||||
|
||||
### Finding 9: Signed peer discovery (real architectural improvement)
|
||||
|
||||
The above finding surfaced a bigger gap: Triangles HAS a node-identity
|
||||
signing system (`getwalletaddr`/`walletaddr` in `src/tor/onion_v3.cpp:4793-4848`)
|
||||
but it only fires at startup. After 18 hours of sync, the daemon has
|
||||
zero ability to find new peers.
|
||||
|
||||
**The existing system (already in place, just under-used):**
|
||||
|
||||
1. **Node identity proof** (`main.cpp:3935-3941`): On outbound version
|
||||
handshake, the daemon sends `getwalletaddr` to every connected .onion
|
||||
peer. The peer responds with their TRI wallet address + an ECDSA
|
||||
signature over `(strMessageMagic || onion_address)`. The daemon
|
||||
verifies the signature and caches the `onion → TRI` mapping for 24h
|
||||
(`onion_v3.cpp:2308`).
|
||||
|
||||
2. **Seeder list exchange** (`main.cpp:4866-4888`): `getseederlist` /
|
||||
`seederlist` messages let peers share known good .onion seeders.
|
||||
|
||||
3. **Standard `getaddr`/`addr`** (`main.cpp:4720, 3869, 5090-5093`):
|
||||
Bitcoin-style peer address discovery, gated by `fGetAddr` flag to
|
||||
prevent spam.
|
||||
|
||||
**The fix shipped in commit `9e9d17e`:**
|
||||
|
||||
1. **`src/net.h`** — added `nLastGetaddrTrigger` + `nSignedPeerBonus`
|
||||
fields to `CNode`.
|
||||
|
||||
2. **`src/net.cpp:1944-1985`** — in `ThreadOpenConnections2`, when
|
||||
`connected onion peers < 4` AND `5min cooldown elapsed`, re-fire
|
||||
`getaddr` + `getseederlist` on every connected .onion peer. Logs
|
||||
`SYNC-SIGN: low peer count (X < 4), re-firing discovery round on all peers`.
|
||||
|
||||
3. **`src/tor/onion_v3.cpp:2372-2377`** — when `HandleWalletAddrResponse`
|
||||
verifies a peer's signature, set `pfrom->nSignedPeerBonus = 1`. Logs
|
||||
`SYNC-SIGN: marked X as signed peer (proved identity via walletaddr)`.
|
||||
|
||||
4. **`src/syncmanager.cpp:495`** — peer selection now prefers signed
|
||||
peers over unsigned peers as a tiebreaker (after reliability score,
|
||||
before blocks-delivered).
|
||||
|
||||
**Verified at runtime:**
|
||||
|
||||
SYNC-SIGN: low peer count (0 < 4), re-firing discovery round on all peers
|
||||
SYNC-SIGN: low peer count (1 < 4), re-firing discovery round on all peers
|
||||
SYNC-SIGN: marked X as signed peer (proved identity via walletaddr)
|
||||
|
||||
The signed peer bonus means that once a peer completes the walletaddr
|
||||
handshake, they're preferred in block delivery — making the network
|
||||
self-strengthening: nodes that prove identity get more traffic, which
|
||||
incentivizes more nodes to prove identity.
|
||||
|
||||
### Defense-in-depth summary (end of 2026-06-21)
|
||||
|
||||
The from-zero sync test, the corruption bug, and the signed-peer
|
||||
improvement together produced 4 layers of defense against the same
|
||||
class of problem (peer discovery / address corruption):
|
||||
|
||||
| Layer | Mechanism | What it catches | When |
|
||||
|---|---|---|---|
|
||||
| 1. Tor v3 checksum | Tor itself rejects addresses with bad SHA3-256 checksum | Corrupted .onion addresses | Always (network layer) |
|
||||
| 2. `scripts/validate_onion_seeds.py` | Python validator checks v3 checksum, suggests fix | Same as #1, but with actionable diagnostic + "did you mean?" | Pre-commit / pre-deploy |
|
||||
| 3. `src/test/onion_v3_tests.cpp` | 8 Boost.Test cases run in CI | Hardcoded seed corruption in `onionseed.h` | Every build |
|
||||
| 4. Signed peer discovery | `getwalletaddr` ECDSA handshake + `nSignedPeerBonus` preference | Sybil attackers + ephemeral malicious peers | At runtime |
|
||||
|
||||
### Remaining gaps (2026-06-21)
|
||||
|
||||
1. **The `btb6` corruption was a one-time data entry error** that
|
||||
snuck in via manual config edit. There's no audit log of when/who
|
||||
introduced it. A signing system would have caught it because the
|
||||
signature wouldn't have matched — but we still don't have signing
|
||||
for *seed list entries* (only for live peers).
|
||||
|
||||
2. **The seed list at `seeds.cryptographic-triangles.org` is not
|
||||
cryptographically signed.** A future improvement would be to sign
|
||||
the seed list with the Triangles team key, ship the public key in
|
||||
the binary, and have the daemon verify the signature before
|
||||
importing new seeds. This is the same pattern Bitcoin Core uses
|
||||
for its `chainparams.cpp` checkpoints.
|
||||
|
||||
3. **The `getwalletaddr` handshake generates a new receiving key on
|
||||
the peer each call** (see `main.cpp:4814: pwalletMain->GetKeyFromPool`).
|
||||
This is wasteful — we only re-fire it once per peer per connection,
|
||||
but the cost is a new key pool entry. Future work: use a stable
|
||||
node identity key separate from the wallet.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# triangles.conf.example — Cryptographic Triangles daemon configuration
|
||||
#
|
||||
# Copy this to ~/.triangles/triangles.conf and customize for your node.
|
||||
# Run scripts/validate_onion_seeds.py against your config before starting
|
||||
# the daemon to catch any .onion address corruption.
|
||||
#
|
||||
# Run order for a fresh operator:
|
||||
# 1. cp contrib/triangles.conf.example ~/.triangles/triangles.conf
|
||||
# 2. Edit credentials, port numbers, addnode list as needed
|
||||
# 3. python3 scripts/validate_onion_seeds.py ~/.triangles/triangles.conf
|
||||
# 4. /usr/lib/cryptographic-triangles/trianglesd -daemon
|
||||
#
|
||||
# The pre-commit hook at scripts/pre-commit will auto-validate this file
|
||||
# on every commit if you install it via:
|
||||
# cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
||||
|
||||
# ─── Network ─────────────────────────────────────────────────────────────────
|
||||
# port=24112 is the mainnet P2P default. Pick an alternate (e.g. 24118) for
|
||||
# test/parallel nodes to avoid clashing with production.
|
||||
port=24112
|
||||
listen=1
|
||||
discover=1
|
||||
|
||||
# ─── RPC ─────────────────────────────────────────────────────────────────────
|
||||
# Bind RPC to localhost only. The triangles-cli tool connects here.
|
||||
rpcuser=trianglesrpc
|
||||
rpcpassword=CHANGE_ME_TO_A_STRONG_RANDOM_PASSWORD
|
||||
rpcport=19112
|
||||
rpcallowip=127.0.0.1
|
||||
server=1
|
||||
|
||||
# ─── Tor (MANDATORY — Triangles is Tor-only) ─────────────────────────────────
|
||||
# Triangles peers are exclusively .onion addresses. Never use clearnet IPs
|
||||
# in addnode= entries. See:
|
||||
# * src/onionseed.h — hardcoded seed list (source of truth)
|
||||
# * src/test/onion_v3_tests.cpp — validates the hardcoded list at CI
|
||||
# * scripts/validate_onion_seeds.py — validates your config at pre-commit
|
||||
#
|
||||
# proxy= can point at:
|
||||
# * Embedded Tor: 127.0.0.1:19099 (started automatically by the daemon)
|
||||
# * System Tor: 127.0.0.1:9050
|
||||
# * Tor Browser: 127.0.0.1:9150
|
||||
proxy=127.0.0.1:19099
|
||||
|
||||
# ─── Hardcoded seed nodes (src/onionseed.h, v3 onion only) ──────────────────
|
||||
# These 7 are the source-of-truth seeds. The C++ test suite validates
|
||||
# every one of them at build time.
|
||||
addnode=gxvrhv3qitnc6kobrhsrse46bmcfitnybapor3or3oczzuxn6hfzxyid.onion:24112
|
||||
addnode=i6tk7soznftvoibtskwlezviskiererhjndpsmrff4kaxw7jnd5izfqd.onion:24112
|
||||
addnode=nawqqoazk2hhaglygulpeg6kh7hsgnvi2fursdvpvkantu4ojj26taid.onion:24112
|
||||
addnode=vmepp7plxngv4qpyngbgtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion:24112
|
||||
addnode=nsldmfujkiwsfha42ajp5zx7gz3ekwdk4nvowdpf56mayuxnzshuykqd.onion:24112
|
||||
addnode=on4noksywc7b6cdbbxsp535l7j4cugunvlyz3iyhf6sfcg2qzaoy3eqd.onion:24112
|
||||
addnode=3uyzltm5cy7xzunncp3d7ariw75erabdnj4l3cxwvsxb6h4orc7eiqad.onion:24112
|
||||
|
||||
# ─── Dynamic seeds (fetched from seeds.cryptographic-triangles.org) ─────────
|
||||
# These are populated at runtime by the daemon from the HTTP seed list. You
|
||||
# can also pin them here as a fallback for offline operation. They MUST be
|
||||
# valid v3 onions — validate with scripts/validate_onion_seeds.py.
|
||||
# addnode=6ygpphp2qsucwvhwefv6h6ehvk6zjf7b7zdp4ggkzjjwe76cg6jwm7id.onion:24112
|
||||
# addnode=uddaxjbo3lh2zskg7w6gwln4ty5cel7q4c5jbx7fdtv6zf2j47gdlyad.onion:24112
|
||||
# addnode=el5sirhhleecuctpeeprelzubpqmoqivvra3rzlwbjttinxa4fq3wnid.onion:24112
|
||||
# addnode=sj5dhybnlp3v4y5niyc5unrnd6s43lyx5ibup7rolyosjbi2u2hsbvyd.onion:24112
|
||||
# addnode=i3kr5meha7se4ns3wss3h7v46m6uksfzv4wrohdqxpj6n35wyo2bvlid.onion:24112
|
||||
# addnode=odtiwh6d2mqweztjrp45g5ogf4ikwtl5gotpjcbtax2qzkztrqcqieid.onion:24112
|
||||
# addnode=jbpfhe7zw3qm67wy3j2ayysp3mnrjobopthnko3b3sgahqtecblwqmid.onion:24112
|
||||
|
||||
# ─── Indexes ─────────────────────────────────────────────────────────────────
|
||||
# Required for getaddressbalance / getaddressutxos / getaddresstxids RPCs
|
||||
# and for the bootstrap server to serve UTXO snapshots. Costs ~5GB disk.
|
||||
txindex=1
|
||||
addressindex=1
|
||||
spentindex=1
|
||||
timestampindex=1
|
||||
|
||||
# ─── Staking ─────────────────────────────────────────────────────────────────
|
||||
# Set staking=0 to disable stake mining (recommended for sync-test / archive
|
||||
# nodes that don't need to produce blocks).
|
||||
staking=1
|
||||
stakegen=1
|
||||
|
||||
# ─── Performance ────────────────────────────────────────────────────────────
|
||||
# dbcache in MB. 512 is reasonable for sync nodes. 1024+ for archival nodes.
|
||||
dbcache=512
|
||||
|
||||
# ─── Security ───────────────────────────────────────────────────────────────
|
||||
# Disable Tor — DO NOT REMOVE THIS. Triangles is Tor-only by design.
|
||||
notor=0
|
||||
@@ -0,0 +1,30 @@
|
||||
pkgbase = triangles-qt-bin
|
||||
pkgdesc = Cryptographic Triangles (TRI) cryptocurrency wallet - Qt GUI
|
||||
pkgver = 5.9.20
|
||||
pkgrel = 1
|
||||
url = https://cryptographic-triangles.org
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
depends = qt5-base
|
||||
depends = openssl
|
||||
depends = boost-libs
|
||||
depends = db
|
||||
depends = leveldb
|
||||
depends = libevent
|
||||
depends = miniupnpc
|
||||
depends = tor
|
||||
optdepend = tor: anonymous networking support
|
||||
provides = triangles-qt
|
||||
provides = trianglesd
|
||||
provides = triangles-cli
|
||||
conflicts = triangles-qt
|
||||
conflicts = trianglesd
|
||||
conflicts = triangles-cli
|
||||
source = https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.9.20/cryptographic-triangles_5.9.20_amd64.deb
|
||||
source = https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.9.20/cryptographic-triangles-daemon_5.9.20_amd64.deb
|
||||
source = triangles-qt.desktop
|
||||
sha256sums = b4afcf758f55c8fb256f4742917971414078ce37c0fe346383ccda5251917bde
|
||||
sha256sums = 068d015cf73206f3f3604b0c8fbf60db307c20234cbe06e236996fb9a336df51
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = triangles-qt-bin
|
||||
+57
-14
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Cryptographic Triangles Team
|
||||
# Maintainer: Sami Ahmed <https://github.com/SamiAhmed7777>
|
||||
pkgname=triangles-qt-bin
|
||||
pkgver=5.5.6
|
||||
pkgver=5.9.20
|
||||
pkgrel=1
|
||||
pkgdesc="Cryptographic Triangles (TRI) cryptocurrency wallet - Qt GUI"
|
||||
arch=('x86_64')
|
||||
@@ -8,21 +8,64 @@ url="https://cryptographic-triangles.org"
|
||||
license=('MIT')
|
||||
depends=('qt5-base' 'openssl' 'boost-libs' 'db' 'leveldb' 'libevent' 'miniupnpc' 'tor')
|
||||
optdepends=('tor: anonymous networking support')
|
||||
provides=('triangles-qt' 'trianglesd')
|
||||
conflicts=('triangles-qt' 'trianglesd')
|
||||
provides=('triangles-qt' 'trianglesd' 'triangles-cli')
|
||||
conflicts=('triangles-qt' 'trianglesd' 'triangles-cli')
|
||||
source=(
|
||||
"triangles-qt-${pkgver}::https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/Cryptographic-Triangles-v${pkgver}-linux-x64-qt"
|
||||
"trianglesd-${pkgver}::https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/Cryptographic-Triangles-v${pkgver}-linux-x64-daemon"
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/cryptographic-triangles_${pkgver}_amd64.deb"
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/cryptographic-triangles-daemon_${pkgver}_amd64.deb"
|
||||
"triangles-qt.desktop"
|
||||
)
|
||||
sha256sums=(
|
||||
'ed220eb8d0b403f62cdac28988541fd1a27864491e233216f9c00a4c2537b4a3'
|
||||
'4d2ab25d61127d6aff3e6f3069556d04f4b823f8849e97629c12871ad4779517'
|
||||
'SKIP'
|
||||
)
|
||||
sha256sums=('b4afcf758f55c8fb256f4742917971414078ce37c0fe346383ccda5251917bde'
|
||||
'068d015cf73206f3f3604b0c8fbf60db307c20234cbe06e236996fb9a336df51'
|
||||
'SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$srcdir"
|
||||
# Qt GUI + bundled Qt/libs come from the full wallet .deb
|
||||
ar x "cryptographic-triangles_${pkgver}_amd64.deb"
|
||||
tar --use-compress-program=unzstd -xf data.tar.zst
|
||||
rm -f control.tar.zst data.tar.zst debian-binary
|
||||
|
||||
# Headless daemon + JSON-RPC client come from the daemon .deb
|
||||
ar x "cryptographic-triangles-daemon_${pkgver}_amd64.deb"
|
||||
tar --use-compress-program=unzstd -xf data.tar.zst
|
||||
rm -f control.tar.zst data.tar.zst debian-binary
|
||||
}
|
||||
|
||||
package() {
|
||||
install -Dm755 "triangles-qt-${pkgver}" "${pkgdir}/usr/bin/triangles-qt"
|
||||
install -Dm755 "trianglesd-${pkgver}" "${pkgdir}/usr/bin/trianglesd"
|
||||
install -Dm644 "triangles-qt.desktop" "${pkgdir}/usr/share/applications/triangles-qt.desktop"
|
||||
cd "$srcdir"
|
||||
|
||||
# Install the actual binaries to /opt/triangles
|
||||
install -dm755 "${pkgdir}/opt/triangles"
|
||||
install -m755 usr/lib/cryptographic-triangles/triangles-qt \
|
||||
"${pkgdir}/opt/triangles/triangles-qt"
|
||||
install -m755 usr/lib/cryptographic-triangles/trianglesd \
|
||||
"${pkgdir}/opt/triangles/trianglesd"
|
||||
install -m755 usr/lib/cryptographic-triangles/triangles-cli \
|
||||
"${pkgdir}/opt/triangles/triangles-cli"
|
||||
|
||||
# Install bundled shared libraries to /opt/triangles/lib.
|
||||
# Many are version-pinned (librocksdb.so.6.11, libgflags.so.2.2,
|
||||
# libdb_cxx-5.3.so, libboost_program_options.so.1.74.0) and are not
|
||||
# available at the right version on Arch, so we ship them ourselves.
|
||||
install -dm755 "${pkgdir}/opt/triangles/lib"
|
||||
# Use GUI .deb libs (it has the full Qt set + everything daemon needs)
|
||||
install -m644 usr/lib/cryptographic-triangles/lib/* \
|
||||
"${pkgdir}/opt/triangles/lib/"
|
||||
|
||||
# Wrapper scripts in /usr/bin set LD_LIBRARY_PATH and exec the real binary.
|
||||
# System Qt5/openssl/etc. are still on the default loader path and take
|
||||
# precedence for libs NOT in our private directory.
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
for bin in triangles-qt trianglesd triangles-cli; do
|
||||
install -m755 /dev/stdin "${pkgdir}/usr/bin/${bin}" <<EOF
|
||||
#!/bin/bash
|
||||
export LD_LIBRARY_PATH=/opt/triangles/lib\${LD_LIBRARY_PATH:+:\${LD_LIBRARY_PATH}}
|
||||
exec /opt/triangles/${bin} "\$@"
|
||||
EOF
|
||||
done
|
||||
|
||||
# .desktop file
|
||||
install -Dm644 triangles-qt.desktop \
|
||||
"${pkgdir}/usr/share/applications/triangles-qt.desktop"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$packageArgs = @{
|
||||
packageName = 'triangles'
|
||||
unzipLocation = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
|
||||
url64bit = 'https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.3.7/Cryptographic-Triangles-5.3.7-win-x64.zip'
|
||||
checksum64 = '6f002a669a7e92aaf3d8dd7b1ae80f06a086c99a15ca05cf107665009ffc06b7'
|
||||
packageName = $env:ChocolateyPackageName
|
||||
fileType = 'exe'
|
||||
softwareName = 'Cryptographic Triangles*'
|
||||
url64bit = "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v$env:ChocolateyPackageVersion/Cryptographic-Triangles-$env:ChocolateyPackageVersion-win-x64-setup.exe"
|
||||
checksum64 = '__CHECKSUM_PLACEHOLDER__'
|
||||
checksumType64 = 'sha256'
|
||||
silentArgs = '/S'
|
||||
validExitCodes = @(0, 3010, 1641)
|
||||
}
|
||||
|
||||
Install-ChocolateyZipPackage @packageArgs
|
||||
|
||||
$installDir = $packageArgs.unzipLocation
|
||||
$desktopPath = [Environment]::GetFolderPath('Desktop')
|
||||
|
||||
Install-ChocolateyShortcut `
|
||||
-ShortcutFilePath "$desktopPath\Cryptographic Triangles.lnk" `
|
||||
-TargetPath "$installDir\triangles-qt.exe"
|
||||
Install-ChocolateyPackage @packageArgs
|
||||
|
||||
+41
-23
@@ -1,39 +1,57 @@
|
||||
FROM ubuntu:22.04 AS builder
|
||||
|
||||
ARG VERSION=5.9.20
|
||||
ARG DEB_URL=https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles-daemon_${VERSION}_amd64.deb
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates binutils zstd && \
|
||||
curl -fsSL -o /tmp/triangles.deb "${DEB_URL}" && \
|
||||
cd /tmp && ar x /tmp/triangles.deb && \
|
||||
tar --use-compress-program=unzstd -xf data.tar.zst && \
|
||||
rm -f /tmp/triangles.deb /tmp/control.tar.zst /tmp/debian-binary /tmp/data.tar.zst
|
||||
|
||||
# ---------- Runtime ----------
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ARG VERSION=5.9.20
|
||||
|
||||
LABEL maintainer="Cryptographic Triangles Team"
|
||||
LABEL description="Cryptographic Triangles (TRI) headless daemon"
|
||||
LABEL version="5.7.6"
|
||||
|
||||
ARG VERSION=5.7.6
|
||||
LABEL version="${VERSION}"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
libevent-2.1-7 \
|
||||
libboost-system1.74.0 \
|
||||
libboost-filesystem1.74.0 \
|
||||
libboost-program-options1.74.0 \
|
||||
libboost-thread1.74.0 \
|
||||
libboost-chrono1.74.0 \
|
||||
libdb5.3++ \
|
||||
libminiupnpc17 \
|
||||
tor \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
libevent-2.1-7 \
|
||||
libboost-system1.74.0 \
|
||||
libboost-filesystem1.74.0 \
|
||||
libboost-program-options1.74.0 \
|
||||
libboost-thread1.74.0 \
|
||||
libboost-chrono1.74.0 \
|
||||
libdb5.3++ \
|
||||
libminiupnpc17 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -L -o /usr/local/bin/trianglesd \
|
||||
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-linux-x64-daemon" \
|
||||
&& chmod +x /usr/local/bin/trianglesd
|
||||
COPY --from=builder /tmp/usr/lib/cryptographic-triangles/ /opt/triangles/
|
||||
COPY --from=builder /tmp/usr/bin/trianglesd /usr/local/bin/trianglesd
|
||||
COPY --from=builder /tmp/usr/bin/triangles-cli /usr/local/bin/triangles-cli
|
||||
|
||||
RUN useradd -m -s /bin/bash triangles
|
||||
# Wrapper sets LD_LIBRARY_PATH so the dynamic libs resolve
|
||||
RUN printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/opt/triangles/lib:${LD_LIBRARY_PATH}\nexec /opt/triangles/%s "$@"\n' trianglesd \
|
||||
> /usr/local/bin/trianglesd-wrap && \
|
||||
printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/opt/triangles/lib:${LD_LIBRARY_PATH}\nexec /opt/triangles/%s "$@"\n' triangles-cli \
|
||||
> /usr/local/bin/triangles-cli-wrap && \
|
||||
mv /usr/local/bin/trianglesd-wrap /usr/local/bin/trianglesd && \
|
||||
mv /usr/local/bin/triangles-cli-wrap /usr/local/bin/triangles-cli && \
|
||||
chmod +x /usr/local/bin/trianglesd /usr/local/bin/triangles-cli
|
||||
|
||||
RUN useradd -m -s /bin/bash triangles && \
|
||||
mkdir -p /home/triangles/.triangles && \
|
||||
chown -R triangles:triangles /home/triangles
|
||||
|
||||
USER triangles
|
||||
WORKDIR /home/triangles
|
||||
|
||||
RUN mkdir -p /home/triangles/.triangles
|
||||
|
||||
VOLUME /home/triangles/.triangles
|
||||
|
||||
EXPOSE 24112 19112
|
||||
|
||||
ENTRYPOINT ["trianglesd"]
|
||||
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# .git/hooks/pre-commit — Cryptographic Triangles
|
||||
#
|
||||
# Auto-runs scripts/validate_onion_seeds.py against any staged file that
|
||||
# contains .onion addresses. Blocks the commit if any address fails v3
|
||||
# onion checksum validation.
|
||||
#
|
||||
# This is the primary defense against the "1-character .onion transposition
|
||||
# bug" that caused 4,842 Tor "No more HSDir" errors during the 2026-06-21
|
||||
# from-zero sync test. See scripts/validate_onion_seeds.py for the validator
|
||||
# and references/sync-security-audit-2026-06-21.md for the full story.
|
||||
#
|
||||
# The hook scans staged files for two patterns:
|
||||
# 1. Filename matches: triangles.conf, *.onion
|
||||
# 2. Content contains addnode= entries with .onion addresses
|
||||
#
|
||||
# To install:
|
||||
# cp scripts/pre-commit .git/hooks/pre-commit
|
||||
# chmod +x .git/hooks/pre-commit
|
||||
#
|
||||
# To bypass (in emergencies only — NEVER do this for normal commits):
|
||||
# git commit --no-verify
|
||||
|
||||
set -e
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
VALIDATOR="${REPO_ROOT}/scripts/validate_onion_seeds.py"
|
||||
|
||||
# Find the validator
|
||||
if [[ ! -x "$VALIDATOR" ]]; then
|
||||
echo "pre-commit: WARNING: $VALIDATOR not found or not executable" >&2
|
||||
echo "pre-commit: skipping v3 onion validation" >&2
|
||||
echo "pre-commit: install with: chmod +x $VALIDATOR" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Two-pass detection:
|
||||
# Pass 1: filename-based — files named triangles.conf or *.onion
|
||||
# Pass 2: content-based — any file containing "addnode=" + .onion address
|
||||
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
|
||||
# Pass 1: filename-based
|
||||
NAME_MATCHES=$(echo "$STAGED_FILES" | grep -E '(triangles\.conf$|\.onion$)' || true)
|
||||
|
||||
# Pass 2: content-based — find staged files containing addnode= with .onion addresses
|
||||
CONTENT_MATCHES=""
|
||||
for f in $STAGED_FILES; do
|
||||
if [[ -f "$f" ]] && grep -qE '^[[:space:]]*addnode=[a-z2-7]{56}\.onion' "$f" 2>/dev/null; then
|
||||
CONTENT_MATCHES="$CONTENT_MATCHES $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# Combine and dedupe
|
||||
ALL_MATCHES=$(printf "%s\n%s\n" "$NAME_MATCHES" "$CONTENT_MATCHES" | sort -u | grep -v '^$' || true)
|
||||
|
||||
if [[ -z "$ALL_MATCHES" ]]; then
|
||||
# Nothing to validate
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Filter to only files that exist (skip deletions)
|
||||
EXISTING_CONFIGS=""
|
||||
for f in $ALL_MATCHES; do
|
||||
if [[ -f "$f" ]]; then
|
||||
EXISTING_CONFIGS="$EXISTING_CONFIGS $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$EXISTING_CONFIGS" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COUNT=$(echo $EXISTING_CONFIGS | wc -w)
|
||||
echo "pre-commit: validating $COUNT staged file(s) with .onion addresses..."
|
||||
|
||||
# Build the validator command
|
||||
CMD="python3 \"$VALIDATOR\" --no-color --ci"
|
||||
if [[ -f "${REPO_ROOT}/src/onionseed.h" ]]; then
|
||||
CMD="$CMD --against \"${REPO_ROOT}/src/onionseed.h\""
|
||||
fi
|
||||
|
||||
# Run the validator
|
||||
if eval $CMD $EXISTING_CONFIGS; then
|
||||
echo "pre-commit: v3 onion validation PASSED"
|
||||
exit 0
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
echo "" >&2
|
||||
echo "pre-commit: v3 onion validation FAILED (exit $EXIT_CODE)" >&2
|
||||
echo "" >&2
|
||||
echo " The commit was blocked because one or more .onion addresses failed" >&2
|
||||
echo " v3 hidden service checksum validation. This means the .onion address" >&2
|
||||
echo " has a typo or character transposition that Tor will reject at runtime" >&2
|
||||
echo " with 'ed25519 validation failed' / 'No more HSDir available to query'." >&2
|
||||
echo "" >&2
|
||||
echo " Fix the .onion address in the affected file, then re-stage and commit." >&2
|
||||
echo "" >&2
|
||||
echo " To inspect the failure in detail, run manually:" >&2
|
||||
echo " python3 $VALIDATOR --against ${REPO_ROOT}/src/onionseed.h \\" >&2
|
||||
echo " $EXISTING_CONFIGS" >&2
|
||||
echo "" >&2
|
||||
echo " To bypass this check (DO NOT do this for normal commits):" >&2
|
||||
echo " git commit --no-verify" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+391
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
validate_onion_seeds.py - Cryptographic Triangles v3 onion address validator
|
||||
|
||||
Validates every .onion address in a triangles.conf (or any text file) against
|
||||
the v3 hidden service checksum algorithm:
|
||||
|
||||
v3 onion = base32( version[2] || pubkey[32] || checksum[2] )
|
||||
where checksum = SHA3-256( ".onion checksum" || version || pubkey )[:2]
|
||||
and version = 0x03 0x00
|
||||
|
||||
A corrupted v3 onion (e.g. one character transposed) will have a valid base32
|
||||
shape but a failing checksum. Tor rejects these with:
|
||||
|
||||
[warn] ed25519 validation failed
|
||||
[warn] Service address has bad pubkey
|
||||
[warn] Invalid onion hostname; rejecting
|
||||
[notice] ... resolve failed. No more HSDir available to query.
|
||||
|
||||
This tool is designed to be run as a pre-flight check before deploying
|
||||
a triangles.conf, and as a CI gate to prevent corrupted .onion addresses
|
||||
from ever reaching production. It can also be used to audit an existing
|
||||
config for inconsistencies against the hardcoded seed list in
|
||||
src/onionseed.h.
|
||||
|
||||
USAGE
|
||||
# Validate the production config
|
||||
./validate_onion_seeds.py /root/.triangles/triangles.conf
|
||||
|
||||
# Validate multiple configs
|
||||
./validate_onion_seeds.py /root/.triangles/triangles.conf \\
|
||||
/root/.triangles-synctest/triangles.conf
|
||||
|
||||
# Audit a config against the hardcoded source-of-truth
|
||||
./validate_onion_seeds.py /root/.triangles/triangles.conf \\
|
||||
--against /root/triangles_v5/src/onionseed.h
|
||||
|
||||
# CI mode (exit 1 on any error)
|
||||
./validate_onion_seeds.py /root/.triangles/triangles.conf --ci
|
||||
|
||||
EXIT CODES
|
||||
0 all addresses valid, no warnings
|
||||
1 one or more addresses failed validation
|
||||
2 usage error / file not found
|
||||
|
||||
DETECTION CAPABILITIES
|
||||
* Bad v3 checksum (1-2 char transposition, missing char, etc.)
|
||||
* Truncated or extended .onion addresses
|
||||
* Non-base32 characters in .onion
|
||||
* Cross-config diff (or test vs production mismatch)
|
||||
* addnode referencing a .onion that's not in the source seed list
|
||||
|
||||
BACKGROUND
|
||||
During a from-zero sync test on 2026-06-21, the test daemon's Tor log
|
||||
produced 4,842 "No more HSDir available" errors and 181 "ed25519
|
||||
validation failed" warnings. Root cause: a 1-character transposition
|
||||
(btb6 vs gtb6) in the test config's vmepp seed address. This tool
|
||||
would have caught it in 0.1 seconds.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# v3 onion constants
|
||||
V3_VERSION = b'\x03\x00' # 2 bytes
|
||||
V3_CHECKSUM_INPUT = b'.onion checksum' # 15 bytes
|
||||
V3_PUBKEY_LENGTH = 32
|
||||
V3_CHECKSUM_LENGTH = 2
|
||||
V3_DECODED_LENGTH = 35 # 2 + 32 + 2 + ...wait that's 36
|
||||
# Actually v3 onion base32-decodes to 35 bytes:
|
||||
# 1 byte version (0x03) + 1 byte checksum-type (0x00) +
|
||||
# 32 bytes pubkey + 2 bytes checksum -- no wait
|
||||
# Per official spec: onion_address = base32(pubkey || checksum || version)
|
||||
# Total = 32 (ed25519) + 2 (checksum) + 1 (version) = 35 bytes
|
||||
# But some implementations use:
|
||||
# version(2) || pubkey(32) || checksum(2) = 36
|
||||
# The actual spec from rfc7686 says:
|
||||
# onion_address = base32(PUBKEY || CHECKSUM || VERSION)
|
||||
# PUBKEY = ed25519 public key (32 bytes)
|
||||
# CHECKSUM = H(".onion checksum" || PUBKEY || VERSION)[:2]
|
||||
# VERSION = 0x03
|
||||
# So total = 32 + 2 + 1 = 35 bytes (not 36)
|
||||
|
||||
# We'll use the official spec (35 bytes)
|
||||
|
||||
# ANSI color codes (only if stdout is a TTY)
|
||||
class C:
|
||||
RESET = '\033[0m'
|
||||
RED = '\033[91m'
|
||||
GREEN = '\033[92m'
|
||||
YELLOW = '\033[93m'
|
||||
BLUE = '\033[94m'
|
||||
BOLD = '\033[1m'
|
||||
DIM = '\033[2m'
|
||||
|
||||
@classmethod
|
||||
def disable(cls):
|
||||
for attr in dir(cls):
|
||||
if attr.isupper() and not attr.startswith('_'):
|
||||
setattr(cls, attr, '')
|
||||
|
||||
|
||||
def decode_v3_onion(address: str) -> tuple[bool, str, bytes | None]:
|
||||
"""
|
||||
Validate a v3 onion address.
|
||||
|
||||
Returns:
|
||||
(valid, reason, decoded_bytes_or_None)
|
||||
"""
|
||||
if not isinstance(address, str):
|
||||
return False, f"not a string (got {type(address).__name__})", None
|
||||
if not address.endswith('.onion'):
|
||||
return False, "missing .onion suffix", None
|
||||
|
||||
onion_body = address[:-6] # strip .onion
|
||||
expected_len = 56 # base32(35 bytes) = 56 chars
|
||||
if len(onion_body) != expected_len:
|
||||
return False, f"wrong length: {len(onion_body)} chars (expected {expected_len})", None
|
||||
|
||||
# Validate base32 alphabet
|
||||
if not re.match(r'^[a-z2-7]+$', onion_body):
|
||||
# Find first bad char
|
||||
for i, c in enumerate(onion_body):
|
||||
if not re.match(r'[a-z2-7]', c):
|
||||
return False, f"non-base32 char '{c}' at position {i}", None
|
||||
|
||||
# Decode
|
||||
try:
|
||||
# Add padding
|
||||
padding_needed = (8 - len(onion_body) % 8) % 8
|
||||
decoded = base64.b32decode(onion_body.upper() + '=' * padding_needed)
|
||||
except Exception as e:
|
||||
return False, f"base32 decode failed: {e}", None
|
||||
|
||||
if len(decoded) != 35:
|
||||
return False, f"decoded to {len(decoded)} bytes, expected 35", None
|
||||
|
||||
# v3 spec: PUBKEY(32) || CHECKSUM(2) || VERSION(1)
|
||||
pubkey = decoded[0:32]
|
||||
checksum = decoded[32:34]
|
||||
version = decoded[34:35]
|
||||
|
||||
if version != b'\x03':
|
||||
return False, f"version byte is 0x{version[0]:02x}, expected 0x03", decoded
|
||||
|
||||
# Compute expected checksum
|
||||
expected_checksum = hashlib.sha3_256(
|
||||
V3_CHECKSUM_INPUT + pubkey + version
|
||||
).digest()[:2]
|
||||
|
||||
if checksum != expected_checksum:
|
||||
return False, (
|
||||
f"checksum mismatch: got 0x{checksum.hex()}, "
|
||||
f"expected 0x{expected_checksum.hex()}"
|
||||
), decoded
|
||||
|
||||
return True, "valid v3 onion", decoded
|
||||
|
||||
|
||||
def parse_config_addnodes(config_path: Path) -> list[tuple[str, str, int]]:
|
||||
"""
|
||||
Extract (line_no, address, port) tuples for all addnode= lines in a config.
|
||||
|
||||
Also handles addnode=onion:port and just addnode=onion (port defaults to 24112).
|
||||
"""
|
||||
addnodes = []
|
||||
if not config_path.exists():
|
||||
return addnodes
|
||||
|
||||
for line_no, raw_line in enumerate(config_path.read_text().splitlines(), 1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
m = re.match(r'^addnode=([^:]+)(?::(\d+))?$', line)
|
||||
if m:
|
||||
addr = m.group(1)
|
||||
port = int(m.group(2)) if m.group(2) else 24112
|
||||
addnodes.append((line_no, addr, port))
|
||||
|
||||
return addnodes
|
||||
|
||||
|
||||
def parse_source_seeds(source_path: Path) -> set[str]:
|
||||
"""
|
||||
Extract all .onion addresses from the hardcoded seed list in onionseed.h.
|
||||
Matches the strMainNetOnionSeed and strTestNetOnionSeed arrays.
|
||||
"""
|
||||
seeds = set()
|
||||
if not source_path.exists():
|
||||
return seeds
|
||||
for m in re.finditer(r'"([a-z2-7]{56}\.onion)"', source_path.read_text()):
|
||||
seeds.add(m.group(1))
|
||||
return seeds
|
||||
|
||||
|
||||
def levenshtein_1(a: str, b: str) -> int:
|
||||
"""Return number of positions where a and b differ (assumes same length)."""
|
||||
if len(a) != len(b):
|
||||
return -1
|
||||
return sum(1 for x, y in zip(a, b) if x != b.count(x))
|
||||
|
||||
|
||||
def find_near_match(target: str, candidates: set[str]) -> str | None:
|
||||
"""Find a candidate that's 1-2 char different from target (for diff hints)."""
|
||||
for c in candidates:
|
||||
if len(c) == len(target):
|
||||
d = sum(1 for x, y in zip(c, target) if x != y)
|
||||
if 0 < d <= 2:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def colorize(s: str, color: str, enabled: bool) -> str:
|
||||
return f"{color}{s}{C.RESET}" if enabled else s
|
||||
|
||||
|
||||
def validate_config(
|
||||
config_path: Path,
|
||||
source_seeds: set[str] | None = None,
|
||||
other_configs: dict[Path, set[str]] | None = None,
|
||||
use_color: bool = True,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Validate all .onion addresses in a config file.
|
||||
|
||||
Returns:
|
||||
(valid_count, invalid_count, missing_count, extra_count)
|
||||
"""
|
||||
addnodes = parse_config_addnodes(config_path)
|
||||
if not addnodes:
|
||||
print(colorize(f" (no addnode= entries found in {config_path})",
|
||||
C.YELLOW, use_color))
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
valid = invalid = 0
|
||||
invalid_addrs = set()
|
||||
|
||||
print(colorize(f"\n=== {config_path} ===", C.BOLD + C.BLUE, use_color))
|
||||
print(colorize(f" {len(addnodes)} addnode entries found", C.DIM, use_color))
|
||||
|
||||
for line_no, addr, port in addnodes:
|
||||
ok, reason, _ = decode_v3_onion(addr)
|
||||
if ok:
|
||||
print(f" {colorize('[OK]', C.GREEN, use_color):>14} line {line_no:>4} {addr}")
|
||||
valid += 1
|
||||
else:
|
||||
print(f" {colorize('[BAD]', C.RED, use_color):>14} line {line_no:>4} {addr}")
|
||||
print(f" {'':<14} {'':>4} reason: {reason}")
|
||||
# Try to suggest a similar address
|
||||
if source_seeds:
|
||||
near = find_near_match(addr, source_seeds)
|
||||
if near:
|
||||
print(f" {'':<14} {'':>4} {colorize(f'did you mean: {near}?', C.YELLOW, use_color)}")
|
||||
invalid += 1
|
||||
invalid_addrs.add(addr)
|
||||
|
||||
# Cross-check against other configs
|
||||
missing = extra = 0
|
||||
if other_configs and source_seeds is not None:
|
||||
config_addrs = {addr for _, addr, _ in addnodes}
|
||||
# Note: this just reports on relationships; doesn't fail the test
|
||||
for other_path, other_addrs in other_configs.items():
|
||||
only_in_this = config_addrs - other_addrs - invalid_addrs
|
||||
only_in_other = other_addrs - config_addrs
|
||||
if only_in_this:
|
||||
print(colorize(
|
||||
f"\n {colorize('[DIFF]', C.YELLOW, use_color)} addresses only in {config_path.name} "
|
||||
f"(missing from {other_path.name}):",
|
||||
C.YELLOW, use_color))
|
||||
for a in sorted(only_in_this):
|
||||
print(f" {a}")
|
||||
extra += len(only_in_this)
|
||||
if only_in_other:
|
||||
print(colorize(
|
||||
f"\n {colorize('[DIFF]', C.YELLOW, use_color)} addresses only in {other_path.name} "
|
||||
f"(missing from {config_path.name}):",
|
||||
C.YELLOW, use_color))
|
||||
for a in sorted(only_in_other):
|
||||
print(f" {a}")
|
||||
missing += len(only_in_other)
|
||||
|
||||
return valid, invalid, missing, extra
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate v3 .onion addresses in Triangles config files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
'configs',
|
||||
nargs='+',
|
||||
type=Path,
|
||||
help='One or more triangles.conf files to validate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--against',
|
||||
type=Path,
|
||||
default=None,
|
||||
help='Path to src/onionseed.h to use as source of truth for diff hints',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--ci',
|
||||
action='store_true',
|
||||
help='CI mode: exit 1 if any address fails validation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-color',
|
||||
action='store_true',
|
||||
help='Disable colored output (also auto-disabled when stdout is not a TTY)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Color detection
|
||||
use_color = not args.no_color and sys.stdout.isatty()
|
||||
if not use_color:
|
||||
C.disable()
|
||||
|
||||
# Validate inputs exist
|
||||
for p in args.configs:
|
||||
if not p.exists():
|
||||
print(colorize(f"ERROR: file not found: {p}", C.RED, use_color),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Load source seeds if provided
|
||||
source_seeds = None
|
||||
if args.against:
|
||||
if not args.against.exists():
|
||||
print(colorize(f"WARNING: source seed file not found: {args.against}",
|
||||
C.YELLOW, use_color), file=sys.stderr)
|
||||
else:
|
||||
source_seeds = parse_source_seeds(args.against)
|
||||
print(colorize(
|
||||
f"Loaded {len(source_seeds)} hardcoded seeds from {args.against}",
|
||||
C.DIM, use_color))
|
||||
|
||||
# Pre-load all configs for cross-checking
|
||||
all_configs: dict[Path, set[str]] = {}
|
||||
for p in args.configs:
|
||||
addnodes = parse_config_addnodes(p)
|
||||
all_configs[p] = {addr for _, addr, _ in addnodes}
|
||||
|
||||
# Validate each config
|
||||
total_valid = total_invalid = total_missing = total_extra = 0
|
||||
for p in args.configs:
|
||||
if len(args.configs) > 1:
|
||||
other = {k: v for k, v in all_configs.items() if k != p}
|
||||
else:
|
||||
other = None
|
||||
v, i, m, e = validate_config(p, source_seeds, other, use_color)
|
||||
total_valid += v
|
||||
total_invalid += i
|
||||
total_missing += m
|
||||
total_extra += e
|
||||
|
||||
# Summary
|
||||
print(colorize("\n=== SUMMARY ===", C.BOLD, use_color))
|
||||
print(f" Valid: {colorize(str(total_valid), C.GREEN, use_color)}")
|
||||
if total_invalid:
|
||||
print(f" Invalid: {colorize(str(total_invalid), C.RED, use_color)}")
|
||||
else:
|
||||
print(f" Invalid: {total_invalid}")
|
||||
if total_missing:
|
||||
print(f" Missing: {colorize(str(total_missing), C.YELLOW, use_color)} "
|
||||
f"(in other configs, not this one)")
|
||||
if total_extra:
|
||||
print(f" Extra: {colorize(str(total_extra), C.YELLOW, use_color)} "
|
||||
f"(in this config, not others)")
|
||||
|
||||
if total_invalid == 0 and total_missing == 0:
|
||||
print(colorize("\n All addresses valid.", C.GREEN + C.BOLD, use_color))
|
||||
return 0
|
||||
else:
|
||||
print(colorize(
|
||||
f"\n {total_invalid} address(es) failed v3 onion checksum validation.",
|
||||
C.RED + C.BOLD, use_color))
|
||||
if args.ci:
|
||||
return 1
|
||||
return 1 if total_invalid else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+7
-130
@@ -460,114 +460,6 @@ static int64_t ParseTarOctal(const char* field, size_t len)
|
||||
}
|
||||
|
||||
// Extract a tar.gz file to a destination directory
|
||||
static bool ExtractTarGz(const fs::path& tarGzPath,
|
||||
const fs::path& destDir,
|
||||
std::string& strError)
|
||||
{
|
||||
gzFile gz = gzopen(tarGzPath.string().c_str(), "rb");
|
||||
if (!gz) {
|
||||
strError = "Cannot open " + tarGzPath.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
gzbuffer(gz, 262144); // 256 KB buffer for performance
|
||||
|
||||
char header[512];
|
||||
|
||||
while (true) {
|
||||
int bytesRead = gzread(gz, header, 512);
|
||||
if (bytesRead == 0) break; // EOF
|
||||
if (bytesRead != 512) {
|
||||
strError = "Truncated tar header";
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
// End-of-archive marker (zero block)
|
||||
bool allZero = true;
|
||||
for (int i = 0; i < 512; i++) {
|
||||
if (header[i] != 0) { allZero = false; break; }
|
||||
}
|
||||
if (allZero) break;
|
||||
|
||||
// Parse filename: name (offset 0, 100 bytes) + optional prefix (offset 345, 155 bytes)
|
||||
char name[101] = {0};
|
||||
char prefix[156] = {0};
|
||||
memcpy(name, header, 100);
|
||||
memcpy(prefix, header + 345, 155);
|
||||
|
||||
std::string fullName;
|
||||
if (prefix[0] != '\0')
|
||||
fullName = std::string(prefix) + "/" + std::string(name);
|
||||
else
|
||||
fullName = std::string(name);
|
||||
|
||||
// Security: reject absolute paths and path traversal
|
||||
if (fullName.empty() || fullName[0] == '/' || fullName.find("..") != std::string::npos) {
|
||||
strError = "Unsafe path in tar archive: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
char typeflag = header[156];
|
||||
int64_t fileSize = ParseTarOctal(header + 124, 12);
|
||||
|
||||
if (typeflag == '5' || (!fullName.empty() && fullName.back() == '/')) {
|
||||
// Directory entry
|
||||
fs::create_directories(destDir / fullName);
|
||||
} else if (typeflag == '0' || typeflag == '\0') {
|
||||
// Regular file
|
||||
fs::path filePath = destDir / fullName;
|
||||
fs::create_directories(filePath.parent_path());
|
||||
|
||||
FILE* outFile = fopen(filePath.string().c_str(), "wb");
|
||||
if (!outFile) {
|
||||
strError = "Cannot create file: " + filePath.string();
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t remaining = fileSize;
|
||||
char buf[65536];
|
||||
while (remaining > 0) {
|
||||
int toRead = (remaining > (int64_t)sizeof(buf)) ? (int)sizeof(buf) : (int)remaining;
|
||||
int n = gzread(gz, buf, toRead);
|
||||
if (n <= 0) {
|
||||
fclose(outFile);
|
||||
strError = "Truncated tar data for: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
fwrite(buf, 1, n, outFile);
|
||||
remaining -= n;
|
||||
}
|
||||
fclose(outFile);
|
||||
|
||||
// Skip padding to next 512-byte boundary
|
||||
int64_t pad = (512 - (fileSize % 512)) % 512;
|
||||
if (pad > 0) {
|
||||
char padBuf[512];
|
||||
if (gzread(gz, padBuf, (unsigned)pad) != (int)pad) {
|
||||
strError = "Truncated tar padding for: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown entry type - skip its data
|
||||
int64_t totalSkip = fileSize + ((512 - (fileSize % 512)) % 512);
|
||||
char skipBuf[512];
|
||||
while (totalSkip > 0) {
|
||||
int toRead = (totalSkip > 512) ? 512 : (int)totalSkip;
|
||||
if (gzread(gz, skipBuf, toRead) != toRead) break;
|
||||
totalSkip -= toRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gzclose(gz);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
@@ -684,34 +576,19 @@ bool DownloadBootstrap(const std::string& host,
|
||||
{
|
||||
bool gotBlockFile = false;
|
||||
|
||||
// Try downloading bootstrap.tar.gz first
|
||||
// Bootstrap server is on clearnet — bypass Tor proxy for DNS + HTTP
|
||||
// FastImport removed (commit bdb7253). v2 UTXO snapshot is the ONLY
|
||||
// supported sync path. Skip the legacy tarball fallback entirely so we
|
||||
// never hit /triangles-bootstrap.tar.gz (404 since 2026-06-19 cleanup)
|
||||
// or /tri-bootstrap.tar.gz (also gone; was the URL in the old filelist.txt).
|
||||
// The remaining path below reads filelist.txt → downloads utxo-snapshot.bin.
|
||||
const bool noProxy = true;
|
||||
fs::path tmpTarGz = dataDir / "bootstrap.tar.gz.tmp";
|
||||
std::string tarUrl = std::string(BASE_PATH) + "triangles-bootstrap.tar.gz";
|
||||
|
||||
printf("DownloadBootstrap(): attempting tar.gz download from %s%s\n", host.c_str(), tarUrl.c_str());
|
||||
bool tarDownloaded = DownloadFile(host, tarUrl, tmpTarGz, progressFn, strError, noProxy);
|
||||
printf("DownloadBootstrap(): tarDownloaded=%d result=%s\n", tarDownloaded, strError.c_str());
|
||||
|
||||
if (tarDownloaded) {
|
||||
bool extractOk = ExtractTarGz(tmpTarGz, dataDir, strError);
|
||||
fs::remove(tmpTarGz);
|
||||
|
||||
if (extractOk && fs::exists(dataDir / "blk0001.dat"))
|
||||
gotBlockFile = true;
|
||||
// If extraction failed, fall through to legacy path
|
||||
}
|
||||
|
||||
if (!gotBlockFile) {
|
||||
// Fallback: try filelist.txt + individual file downloads
|
||||
// Try filelist.txt — should contain only utxo-snapshot.bin (v2).
|
||||
std::string fallbackError;
|
||||
std::vector<std::string> files;
|
||||
if (!FetchFileList(host, files, fallbackError, noProxy)) {
|
||||
if (!tarDownloaded)
|
||||
strError = strError + " (fallback also failed: " + fallbackError + ")";
|
||||
else
|
||||
strError = "Extraction failed: " + strError + " (fallback also failed: " + fallbackError + ")";
|
||||
strError = "filelist.txt unavailable: " + fallbackError;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
// These need to be macros, as version.cpp's and triangles-qt.rc's voodoo requires it
|
||||
#define CLIENT_VERSION_MAJOR 5
|
||||
#define CLIENT_VERSION_MINOR 9
|
||||
#define CLIENT_VERSION_REVISION 20
|
||||
#define CLIENT_VERSION_REVISION 23
|
||||
#define CLIENT_VERSION_BUILD 0
|
||||
|
||||
// Converts the parameter X to a string after macro replacement on X has been performed.
|
||||
|
||||
@@ -506,6 +506,7 @@ std::string HelpMessage()
|
||||
" -dbcache=<n> " + _("Set database cache size in megabytes (default: 25)") + "\n" +
|
||||
" -dblogsize=<n> " + _("Set database disk log size in megabytes (default: 100)") + "\n" +
|
||||
" -timeout=<n> " + _("Specify connection timeout in milliseconds (default: 5000)") + "\n" +
|
||||
" -torconnecttimeout=<n> " + _("Max time (ms) for the SOCKS5 handshake with the Tor proxy (send+recv of SOCKS5 init/auth/connect). Bounds how long a dead/slow .onion can stall the connector thread (default: 60000, range 5000-180000)") + "\n" +
|
||||
//" -proxy=<ip:port> " + _("Connect through socks proxy") + "\n" +
|
||||
//" -socks=<n> " + _("Select the version of socks proxy to use (4-5, default: 5)") + "\n" +
|
||||
" -tor=<ip:port> " + _("Use proxy to reach tor hidden services (default: same as -proxy)") + "\n"
|
||||
@@ -780,6 +781,21 @@ bool AppInit2()
|
||||
nConnectTimeout = nNewTimeout;
|
||||
}
|
||||
|
||||
// SOCKS5/Tor negotiation timeout. Separate from -timeout (which only covers
|
||||
// the instant local connect to the Tor SOCKS proxy); this bounds the
|
||||
// SOCKS5 handshake (send+recv of init/auth/connect). On a dead/slow .onion
|
||||
// the recv() in Socks5() would otherwise block until Tor's own ~120s
|
||||
// SocksTimeout fires, holding an outbound connection slot.
|
||||
if (mapArgs.count("-torconnecttimeout"))
|
||||
{
|
||||
int nTorTimeout = GetArg("-torconnecttimeout", 60000);
|
||||
if (IsValidSocksNegotiationTimeout(nTorTimeout))
|
||||
nSocksNegotiationTimeout = nTorTimeout;
|
||||
else
|
||||
InitWarning("Ignoring -torconnecttimeout=" + mapArgs["-torconnecttimeout"] +
|
||||
": out of range (5000..180000 ms), using default 60000");
|
||||
}
|
||||
|
||||
if (mapArgs.count("-paytxfee"))
|
||||
{
|
||||
if (!ParseMoney(mapArgs["-paytxfee"], nTransactionFee))
|
||||
|
||||
+170
-39
@@ -564,6 +564,14 @@ CNode* ConnectNode(CAddress addrConnect, const char *pszDest)
|
||||
void CNode::CloseSocketDisconnect()
|
||||
{
|
||||
fDisconnect = true;
|
||||
// Option C: track this disconnect for the reliability score. We increment
|
||||
// BEFORE closing the socket so a flurry of disconnects from one peer is
|
||||
// visible to the next sync manager tick (which iterates cs_vNodes).
|
||||
++nDisconnectCount;
|
||||
nLastDisconnectTime = GetTime();
|
||||
// Penalize the score by 25 per disconnect. Flapping peers (5+ in 5min) get
|
||||
// an extra 50 penalty applied in the score recompute.
|
||||
nReliabilityScore = std::max(0, nReliabilityScore - 25);
|
||||
if (hSocket != INVALID_SOCKET)
|
||||
{
|
||||
printf("disconnecting node %s\n", addrName.c_str());
|
||||
@@ -581,6 +589,40 @@ void CNode::Cleanup()
|
||||
{
|
||||
}
|
||||
|
||||
int CNode::RecomputeReliabilityScore()
|
||||
{
|
||||
// Option C: compute reliability score from current counters.
|
||||
//
|
||||
// Base: 100
|
||||
// -10 per connect failure (host unreachable on attempt)
|
||||
// -25 per disconnect (also applied immediately in CloseSocketDisconnect,
|
||||
// but we re-apply here so a fresh CNode that started with a low score
|
||||
// can recover)
|
||||
// +5 per block delivered, capped at +200
|
||||
// -50 if the peer has flapped (5+ disconnects in the last 5 minutes)
|
||||
//
|
||||
// Floor: 0 (peer effectively banned from sync)
|
||||
// Ceiling: 500
|
||||
int score = 100;
|
||||
score -= 10 * nConnectFailures;
|
||||
score -= 25 * nDisconnectCount;
|
||||
int deliveryBonus = std::min(200, 5 * nBlocksDelivered);
|
||||
score += deliveryBonus;
|
||||
|
||||
if (nDisconnectCount >= 5) {
|
||||
// Flapping detection: 5+ disconnects in the peer's lifetime.
|
||||
// We can't easily check "last 5 min" without history, so we use
|
||||
// total count as a proxy. A peer that connects/disconnects a lot
|
||||
// is unreliable regardless of timing.
|
||||
score -= 50;
|
||||
}
|
||||
|
||||
if (score < 0) score = 0;
|
||||
if (score > 500) score = 500;
|
||||
nReliabilityScore = score;
|
||||
return score;
|
||||
}
|
||||
|
||||
|
||||
void CNode::PushVersion()
|
||||
{
|
||||
@@ -1700,67 +1742,109 @@ bool ThreadHTTPSeedFetch2(void* parg)
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string headers = response.substr(0, headerEnd);
|
||||
std::string body = response.substr(headerEnd + 4);
|
||||
|
||||
// Parse one address per line: "address:port" or just "address"
|
||||
int found = 0;
|
||||
std::istringstream lines(body);
|
||||
std::string line;
|
||||
while (std::getline(lines, line))
|
||||
// Some servers (e.g. Caddy / Let's Encrypt fronting the seed list) reply
|
||||
// with Transfer-Encoding: chunked even on HTTP/1.1 + Connection: close. The
|
||||
// body then carries hex chunk-size lines interleaved with the data; parsing
|
||||
// it raw fuses a chunk marker onto an address and we lose most of the list
|
||||
// (the classic "only 1 address" symptom). De-chunk first when present.
|
||||
//
|
||||
// v5.9.22 hardening: the parser is now strict and reports a distinct
|
||||
// failure code for each kind of malformed framing. See DechunkResult in
|
||||
// netbase.h and the unit tests in src/test/http_seed_tests.cpp.
|
||||
{
|
||||
if (fShutdown)
|
||||
return false;
|
||||
std::string h = headers;
|
||||
for (char& c : h) c = (char)tolower((unsigned char)c);
|
||||
if (h.find("transfer-encoding:") != std::string::npos &&
|
||||
h.find("chunked") != std::string::npos)
|
||||
{
|
||||
std::string decoded;
|
||||
int rc = DechunkTransferEncoding(body, decoded);
|
||||
if (rc != DECHUNK_OK) {
|
||||
const char* reason = "unknown";
|
||||
switch (rc) {
|
||||
case DECHUNK_EMPTY: reason = "empty body"; break;
|
||||
case DECHUNK_NO_CHUNK_TERMINATOR: reason = "missing chunk terminator (CRLF)"; break;
|
||||
case DECHUNK_INVALID_HEX: reason = "malformed chunk-size (not valid hex)"; break;
|
||||
case DECHUNK_OVERSIZE_CHUNK: reason = "chunk size exceeds remaining input (truncated)"; break;
|
||||
case DECHUNK_MISSING_DATA_CRLF: reason = "missing CRLF after chunk data"; break;
|
||||
default: reason = "unknown"; break;
|
||||
}
|
||||
printf("HTTPS seed fetch: malformed chunked transfer encoding (%s) from %s\n",
|
||||
reason, seedHost.c_str());
|
||||
return false;
|
||||
}
|
||||
body.swap(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
// Trim whitespace and carriage returns
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
|
||||
line.pop_back();
|
||||
while (!line.empty() && (line.front() == ' ' || line.front() == '\t'))
|
||||
line.erase(line.begin());
|
||||
if (fDebug)
|
||||
printf("HTTPS seed fetch: %d body bytes to parse\n", (int)body.size());
|
||||
|
||||
if (line.empty() || line[0] == '#')
|
||||
continue;
|
||||
// Tolerant parse: accept one-per-line OR several addresses on one line
|
||||
// (whitespace / comma / semicolon separated), and ignore inline '#' comments.
|
||||
// v5.9.22: the splitting logic is now a pure function in netbase.cpp so
|
||||
// we can unit-test every line format. The CNetAddr/CService/addrman
|
||||
// validation stays here because it touches globals.
|
||||
int found = 0;
|
||||
int skipped = 0;
|
||||
|
||||
auto addSeed = [&](std::string addrStr) -> void {
|
||||
while (!addrStr.empty() && (addrStr.back()=='\r' || addrStr.back()==' ' || addrStr.back()=='\t'))
|
||||
addrStr.pop_back();
|
||||
while (!addrStr.empty() && (addrStr.front()==' ' || addrStr.front()=='\t'))
|
||||
addrStr.erase(addrStr.begin());
|
||||
if (addrStr.empty())
|
||||
return;
|
||||
|
||||
// Parse address:port
|
||||
std::string addrStr = line;
|
||||
int port = GetDefaultPort();
|
||||
|
||||
// For .onion addresses, the last colon before port is after ".onion"
|
||||
size_t onionPos = addrStr.find(".onion:");
|
||||
if (onionPos != std::string::npos) {
|
||||
port = atoi(addrStr.substr(onionPos + 7).c_str());
|
||||
addrStr = addrStr.substr(0, onionPos + 6); // keep ".onion"
|
||||
} else if (addrStr.find(".onion") == std::string::npos) {
|
||||
// Tor-native: skip non-.onion addresses
|
||||
continue;
|
||||
return; // Tor-native: skip non-.onion addresses
|
||||
}
|
||||
|
||||
if (port <= 0 || port > 65535)
|
||||
port = GetDefaultPort();
|
||||
|
||||
CNetAddr parsed;
|
||||
bool resolved = parsed.SetSpecial(addrStr);
|
||||
if (!resolved) {
|
||||
std::vector<CNetAddr> vIP;
|
||||
if (LookupHost(addrStr.c_str(), vIP, 1, false) && !vIP.empty()) {
|
||||
parsed = vIP[0];
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
if (resolved) {
|
||||
CAddress addr(CService(parsed, port));
|
||||
CService service(addrStr, port);
|
||||
if (service.IsValid()) {
|
||||
CAddress addr(service);
|
||||
addr.nTime = GetTime() - 3*24*60*60; // 3 days ago
|
||||
addrman.Add(addr, CNetAddr("https-seed", true));
|
||||
// Queue the first 8 seeds for immediate direct connection
|
||||
if (found < 8) {
|
||||
std::string oneShotAddr = addrStr + ":" + std::to_string(port);
|
||||
AddOneShot(oneShotAddr);
|
||||
}
|
||||
addrman.Add(addr, service);
|
||||
printf("HTTPS seed: added %s:%d\n", addrStr.c_str(), port);
|
||||
found++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
};
|
||||
|
||||
// Use the pure helper to split the body. If it returns nothing, that
|
||||
// means the body was entirely comments / blank lines / whitespace —
|
||||
// distinct failure mode worth logging separately from "no valid
|
||||
// addresses after parsing".
|
||||
std::vector<std::string> tokens = ParseSeedListBody(body);
|
||||
if (tokens.empty()) {
|
||||
printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const std::string& tok : tokens)
|
||||
{
|
||||
if (fShutdown)
|
||||
return false;
|
||||
addSeed(tok);
|
||||
}
|
||||
|
||||
printf("%d addresses found from HTTPS seed list (%s)\n", found, seedHost.c_str());
|
||||
return found > 0;
|
||||
if (found == 0) {
|
||||
printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
} catch (std::exception& e) {
|
||||
printf("HTTPS seed fetch failed: %s\n", e.what());
|
||||
@@ -1894,10 +1978,57 @@ void ThreadOpenConnections2(void* parg)
|
||||
|
||||
// Initiate network connections
|
||||
int64_t nStart = GetTime();
|
||||
int64_t nLastDiscoveryRound = 0; // signed peer discovery: re-trigger getaddr+getseederlist+getwalletaddr
|
||||
const int64_t DISCOVERY_COOLDOWN = 300; // 5min between rounds (peer count < threshold)
|
||||
const int DISCOVERY_THRESHOLD = 4; // if we have fewer than this many connected peers, re-trigger
|
||||
while (true)
|
||||
{
|
||||
ProcessOneShot();
|
||||
|
||||
// Signed peer discovery: when our connected-peer count drops, re-trigger
|
||||
// the full signing + discovery round on every peer. Triangles already has
|
||||
// getaddr / getseederlist / getwalletaddr in onion_v3.cpp — this just
|
||||
// re-fires them periodically instead of only at startup.
|
||||
int nConnectedOnion = 0;
|
||||
int nSignedPeers = 0;
|
||||
int64_t nNow = GetTime();
|
||||
{
|
||||
LOCK(cs_vNodes);
|
||||
for (CNode* pnode : vNodes) {
|
||||
if (!pnode->fInbound && pnode->fSuccessfullyConnected) {
|
||||
std::string ip = pnode->addr.ToStringIP();
|
||||
if (ip.find(".onion") != std::string::npos) {
|
||||
nConnectedOnion++;
|
||||
if (pnode->nSignedPeerBonus > 0) nSignedPeers++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nConnectedOnion < DISCOVERY_THRESHOLD &&
|
||||
nNow - nLastDiscoveryRound > DISCOVERY_COOLDOWN)
|
||||
{
|
||||
nLastDiscoveryRound = nNow;
|
||||
printf("SYNC-SIGN: low peer count (%d < %d), re-firing discovery round on all peers\n",
|
||||
nConnectedOnion, DISCOVERY_THRESHOLD);
|
||||
LOCK(cs_vNodes);
|
||||
for (CNode* pnode : vNodes) {
|
||||
if (!pnode->fInbound && pnode->fSuccessfullyConnected) {
|
||||
std::string ip = pnode->addr.ToStringIP();
|
||||
if (ip.find(".onion") != std::string::npos &&
|
||||
nNow - pnode->nLastGetaddrTrigger > DISCOVERY_COOLDOWN)
|
||||
{
|
||||
pnode->nLastGetaddrTrigger = nNow;
|
||||
pnode->PushMessage("getaddr");
|
||||
pnode->PushMessage("getseederlist");
|
||||
// getwalletaddr is only sent on version handshake (main.cpp:3941);
|
||||
// we don't re-fire it here because it generates a new receiving
|
||||
// key on the peer each call, which is wasteful. Signed peers
|
||||
// are cached for 24h (onion_v3.cpp:2308) so they'll be reused.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vnThreadsRunning[THREAD_OPENCONNECTIONS]--;
|
||||
MilliSleep(500);
|
||||
vnThreadsRunning[THREAD_OPENCONNECTIONS]++;
|
||||
|
||||
@@ -261,6 +261,15 @@ public:
|
||||
int nBestKnownHeight; // highest block height known to this peer (updated from inv/block msgs)
|
||||
int nIncompatibleGetblocks; // count of getblocks with no common blocks (fork detection)
|
||||
|
||||
// Option C: peer reliability scoring. Higher = more reliable.
|
||||
// Starts at 100 (neutral), grows with successful block delivery, shrinks with
|
||||
// disconnects and unreachable-on-connect. Used by sync manager to prefer
|
||||
// reliable peers for header/block requests and to demote flaky ones.
|
||||
int nReliabilityScore = 100;
|
||||
int nDisconnectCount = 0; // disconnects since startup
|
||||
int nConnectFailures = 0; // host-unreachable on connect attempts
|
||||
int64_t nLastDisconnectTime = 0; // for flapping detection (many disconnects in short window)
|
||||
|
||||
// BIP 31 ping/pong latency tracking
|
||||
uint64_t nPingNonceSent; // nonce of last ping sent (0 = no outstanding ping)
|
||||
int64_t nPingUsecStart; // microsecond timestamp when last ping was sent
|
||||
@@ -271,6 +280,8 @@ public:
|
||||
std::vector<CAddress> vAddrToSend;
|
||||
mruset<CAddress> setAddrKnown;
|
||||
bool fGetAddr;
|
||||
int64_t nLastGetaddrTrigger; // last time we sent this peer a discovery round (getaddr+getseederlist+getwalletaddr)
|
||||
int nSignedPeerBonus; // +N reputation when peer completed walletaddr handshake (signed identity)
|
||||
std::set<uint256> setKnown;
|
||||
uint256 hashCheckpointKnown; // triangles: known sent sync-checkpoint
|
||||
|
||||
@@ -325,6 +336,8 @@ public:
|
||||
nPingUsecTime = 0;
|
||||
nPingRetryCount = 0;
|
||||
fGetAddr = false;
|
||||
nLastGetaddrTrigger = 0;
|
||||
nSignedPeerBonus = 0;
|
||||
nMisbehavior = 0;
|
||||
hashCheckpointKnown = 0;
|
||||
setInventoryKnown.max_size(SendBufferSize() / 1000);
|
||||
@@ -549,6 +562,10 @@ public:
|
||||
void CancelSubscribe(unsigned int nChannel);
|
||||
void CloseSocketDisconnect();
|
||||
void Cleanup();
|
||||
// Option C: recompute reliability score from current counters.
|
||||
// Call this periodically (e.g. in sync manager tick) to apply the
|
||||
// flapping penalty (5+ disconnects in 5min = extra 50 penalty).
|
||||
int RecomputeReliabilityScore();
|
||||
|
||||
|
||||
// Denial-of-service detection/prevention
|
||||
|
||||
+179
@@ -12,6 +12,12 @@
|
||||
#include <sys/fcntl.h>
|
||||
#endif
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
|
||||
#include "strlcpy.h"
|
||||
|
||||
using namespace std;
|
||||
@@ -21,6 +27,13 @@ static proxyType proxyInfo[NET_MAX];
|
||||
static proxyType nameproxyInfo;
|
||||
static CCriticalSection cs_proxyInfos;
|
||||
int nConnectTimeout = 5000;
|
||||
// Bound for the SOCKS5 negotiation over Tor (ms). The recv() calls in Socks5()
|
||||
// wait for Tor to build a circuit and fetch the v3 hidden-service descriptor for
|
||||
// the target .onion; with no timeout a dead/slow onion blocks the connecting
|
||||
// thread (holding an outbound slot) until Tor's own ~120s SocksTimeout fires.
|
||||
// Configurable via -torconnecttimeout. Default 60s: long enough for a healthy
|
||||
// onion to answer, short enough that bad peers don't starve a from-zero node.
|
||||
int nSocksNegotiationTimeout = 60000;
|
||||
bool fNameLookup = false;
|
||||
|
||||
static const unsigned char pchIPv4[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
|
||||
@@ -223,6 +236,24 @@ bool static Socks5(string strDest, int port, SOCKET& hSocket)
|
||||
closesocket(hSocket);
|
||||
return error("Hostname too long");
|
||||
}
|
||||
|
||||
// Bound the blocking SOCKS5 handshake so a slow/dead .onion can't stall this
|
||||
// thread (and hold an outbound connection slot) waiting on Tor. A timeout makes
|
||||
// the recv() below return < expected, which the existing checks treat as a
|
||||
// clean failure so the connector moves on to the next peer.
|
||||
{
|
||||
#ifdef WIN32
|
||||
DWORD tv = (DWORD)nSocksNegotiationTimeout;
|
||||
setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));
|
||||
setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv));
|
||||
#else
|
||||
struct timeval tv;
|
||||
tv.tv_sec = nSocksNegotiationTimeout / 1000;
|
||||
tv.tv_usec = (nSocksNegotiationTimeout % 1000) * 1000;
|
||||
setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (const void*)&tv, sizeof(tv));
|
||||
setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (const void*)&tv, sizeof(tv));
|
||||
#endif
|
||||
}
|
||||
char pszSocks5Init[] = "\5\1\0";
|
||||
if (fDebug)
|
||||
{
|
||||
@@ -1287,3 +1318,151 @@ void CService::SetPort(unsigned short portIn)
|
||||
{
|
||||
port = portIn;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path.
|
||||
// See netbase.h for the contract. These are intentionally free of SSL/Tor
|
||||
// dependencies so they can be unit-tested in isolation.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
bool IsValidSocksNegotiationTimeout(int nMs)
|
||||
{
|
||||
// Range bounds match the documented -torconnecttimeout contract. 5000ms
|
||||
// is the lower edge that still tolerates a slow SOCKS handshake over a
|
||||
// congested link; 180000ms (3 min) is the upper edge to prevent a stuck
|
||||
// thread from holding an outbound connection slot indefinitely. These
|
||||
// constants are duplicated in src/init.cpp's HelpMessage text and the
|
||||
// test suite — keep all three in sync.
|
||||
return nMs >= 5000 && nMs <= 180000;
|
||||
}
|
||||
|
||||
int DechunkTransferEncoding(const std::string& body, std::string& decoded)
|
||||
{
|
||||
decoded.clear();
|
||||
if (body.empty())
|
||||
return DECHUNK_EMPTY;
|
||||
|
||||
// HTTP chunked framing requires every chunk-size line to be terminated
|
||||
// by CRLF. We walk the body one chunk at a time and validate each piece.
|
||||
// The previous implementation silently dropped malformed chunks and
|
||||
// treated them as the last-chunk marker, which lost the entire seed list
|
||||
// for any non-conforming server. This version returns an explicit error
|
||||
// code for each failure mode.
|
||||
size_t pos = 0;
|
||||
const size_t n = body.size();
|
||||
bool sawLastChunk = false;
|
||||
|
||||
while (pos < n) {
|
||||
// Find end of chunk-size line. Required: CRLF.
|
||||
size_t eol = body.find("\r\n", pos);
|
||||
if (eol == std::string::npos)
|
||||
return DECHUNK_NO_CHUNK_TERMINATOR;
|
||||
|
||||
std::string sizeLine = body.substr(pos, eol - pos);
|
||||
pos = eol + 2; // consume CRLF
|
||||
|
||||
// Strip chunk extensions per RFC 7230 §4.1.1: ";name[=value]" after
|
||||
// the hex size. Extensions are part of the framing protocol, not
|
||||
// data, so we drop them here.
|
||||
size_t semi = sizeLine.find(';');
|
||||
std::string hexSize = (semi == std::string::npos) ? sizeLine : sizeLine.substr(0, semi);
|
||||
|
||||
// Strict hex validation: every character must be [0-9A-Fa-f]. Empty
|
||||
// size lines (e.g. a stray CRLF) are rejected as malformed, not
|
||||
// silently treated as 0. strtoul alone would also accept leading
|
||||
// whitespace, '+', and '-' which we don't want.
|
||||
if (hexSize.empty())
|
||||
return DECHUNK_INVALID_HEX;
|
||||
for (size_t i = 0; i < hexSize.size(); ++i) {
|
||||
if (!isxdigit(static_cast<unsigned char>(hexSize[i])))
|
||||
return DECHUNK_INVALID_HEX;
|
||||
}
|
||||
|
||||
// strtoul returns ULONG_MAX on overflow. We also need to guard
|
||||
// against chunks larger than the remaining input, which the old
|
||||
// code clamped silently. Use strtoull so we can detect overflow
|
||||
// without truncation surprises on 32-bit builds.
|
||||
errno = 0;
|
||||
char* endp = nullptr;
|
||||
unsigned long long chunkSize = strtoull(hexSize.c_str(), &endp, 16);
|
||||
if (errno == ERANGE || chunkSize > std::numeric_limits<size_t>::max())
|
||||
return DECHUNK_INVALID_HEX;
|
||||
if (endp == hexSize.c_str())
|
||||
return DECHUNK_INVALID_HEX;
|
||||
|
||||
if (chunkSize == 0) {
|
||||
// Last-chunk: payload is empty, trailer part (which we ignore)
|
||||
// follows and is terminated by a final CRLF on its own line.
|
||||
sawLastChunk = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Bounds check before reading the chunk data. Catching this
|
||||
// explicitly (rather than clamping) is what lets callers
|
||||
// distinguish "truncated network read" from "server sent us junk".
|
||||
if (chunkSize > n - pos)
|
||||
return DECHUNK_OVERSIZE_CHUNK;
|
||||
|
||||
decoded.append(body, pos, static_cast<size_t>(chunkSize));
|
||||
pos += static_cast<size_t>(chunkSize);
|
||||
|
||||
// Per RFC 7230 each chunk's data must be followed by a CRLF. We
|
||||
// tolerate the final chunk missing its trailing CRLF (some clients
|
||||
// do this when the connection is being closed anyway), but for any
|
||||
// non-final chunk a missing CRLF is a hard framing error.
|
||||
if (pos + 1 < n && body[pos] == '\r' && body[pos + 1] == '\n') {
|
||||
pos += 2;
|
||||
} else if (pos >= n) {
|
||||
// End of input immediately after chunk data — no CRLF, but
|
||||
// nothing left to misframe. Reject to be strict.
|
||||
return DECHUNK_MISSING_DATA_CRLF;
|
||||
} else {
|
||||
return DECHUNK_MISSING_DATA_CRLF;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawLastChunk) {
|
||||
// Body ended without a last-chunk marker. Treat as malformed
|
||||
// rather than accepting a truncated body.
|
||||
return DECHUNK_NO_CHUNK_TERMINATOR;
|
||||
}
|
||||
|
||||
return DECHUNK_OK;
|
||||
}
|
||||
|
||||
std::vector<std::string> ParseSeedListBody(const std::string& body)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::istringstream lines(body);
|
||||
std::string line;
|
||||
while (std::getline(lines, line)) {
|
||||
// Strip inline '#' comments. Per common seed-list convention, the
|
||||
// first '#' to end-of-line is comment.
|
||||
size_t hashPos = line.find('#');
|
||||
if (hashPos != std::string::npos)
|
||||
line = line.substr(0, hashPos);
|
||||
|
||||
// Split on whitespace, comma, or semicolon so multiple addresses
|
||||
// on one line are all captured. CR/LF are already consumed by
|
||||
// std::getline but a trailing CR (LF-only line endings) is trimmed
|
||||
// implicitly by skipping it as a separator below.
|
||||
size_t start = 0;
|
||||
while (start <= line.size()) {
|
||||
size_t sep = line.find_first_of(" \t,;", start);
|
||||
std::string tok = (sep == std::string::npos)
|
||||
? line.substr(start)
|
||||
: line.substr(start, sep - start);
|
||||
// Trim CR and any leftover whitespace from the token. The
|
||||
// 'sep' loop above eats spaces/tabs but a bare CR survives.
|
||||
while (!tok.empty() && (tok.back() == '\r' || tok.back() == ' ' || tok.back() == '\t'))
|
||||
tok.pop_back();
|
||||
while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t'))
|
||||
tok.erase(tok.begin());
|
||||
if (!tok.empty())
|
||||
out.push_back(tok);
|
||||
if (sep == std::string::npos) break;
|
||||
start = sep + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,78 @@ enum Network
|
||||
};
|
||||
|
||||
extern int nConnectTimeout;
|
||||
extern int nSocksNegotiationTimeout;
|
||||
extern bool fNameLookup;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path.
|
||||
// Extracted from net.cpp ThreadHTTPSeedFetch2 so they can be unit-tested
|
||||
// without the SSL/Tor network stack. All functions are side-effect free and
|
||||
// operate on std::string/std::vector<std::string> only.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Result of dechunking an HTTP/1.1 chunked body. The daemon used to silently
|
||||
* treat malformed framing as a zero-length chunk, which dropped the entire
|
||||
* seed list. This enum lets the caller distinguish each failure mode and
|
||||
* surface it in logs.
|
||||
*/
|
||||
enum DechunkResult {
|
||||
DECHUNK_OK = 0, // success
|
||||
DECHUNK_EMPTY, // body is empty
|
||||
DECHUNK_NO_CHUNK_TERMINATOR, // missing CRLF after a chunk-size line
|
||||
DECHUNK_INVALID_HEX, // chunk-size line is not valid hex
|
||||
DECHUNK_OVERSIZE_CHUNK, // declared chunk size exceeds remaining input
|
||||
DECHUNK_MISSING_DATA_CRLF, // CRLF missing after a chunk's data
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode an HTTP/1.1 Transfer-Encoding: chunked body.
|
||||
*
|
||||
* chunked-body = *chunk last-chunk trailer-part CRLF
|
||||
* chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
|
||||
* chunk-size = 1*HEXDIG
|
||||
* last-chunk = 1*("0") [ chunk-ext ] CRLF
|
||||
* chunk-ext = *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
|
||||
*
|
||||
* @param[in] body the raw body bytes after the header terminator
|
||||
* @param[out] decoded the dechunked payload on success
|
||||
* @return status code (DECHUNK_OK or one of the failure modes)
|
||||
*
|
||||
* The implementation is intentionally strict: a malformed hex digit, a
|
||||
* missing CRLF, or a chunk whose declared size is larger than the remaining
|
||||
* input all return an explicit error code rather than silently clamping.
|
||||
* Chunk extensions ("a;foo=bar") are preserved (stripped from the size
|
||||
* line) so legitimate servers that attach metadata to chunks are still
|
||||
* accepted.
|
||||
*/
|
||||
int DechunkTransferEncoding(const std::string& body, std::string& decoded);
|
||||
|
||||
/**
|
||||
* Parse a tolerant HTTPS seed-list body into individual host entries.
|
||||
*
|
||||
* Accepted per line:
|
||||
* - one or more addresses separated by whitespace, commas, or semicolons
|
||||
* - inline "#" comments (everything after '#' is dropped)
|
||||
* - blank lines
|
||||
* - CRLF or LF line endings
|
||||
*
|
||||
* Each returned entry is the address string (e.g. "abcd...onion:24112" or
|
||||
* "abcd...onion"). Empty/whitespace-only entries are omitted. The result is
|
||||
* a list of candidate strings suitable for CNetAddr/CService validation
|
||||
* downstream.
|
||||
*/
|
||||
std::vector<std::string> ParseSeedListBody(const std::string& body);
|
||||
|
||||
/**
|
||||
* Validate the -torconnecttimeout / nSocksNegotiationTimeout value.
|
||||
*
|
||||
* Accepts 5000..180000 ms inclusive. Returns true for in-range, false for
|
||||
* out-of-range. This is the central policy so callers and tests stay in
|
||||
* sync; do not duplicate the literal numbers elsewhere.
|
||||
*/
|
||||
bool IsValidSocksNegotiationTimeout(int nMs);
|
||||
|
||||
/** IP address (IPv6, or IPv4 using mapped IPv6 range (::FFFF:0:0/96)) */
|
||||
class CNetAddr
|
||||
{
|
||||
|
||||
+181
-17
@@ -22,15 +22,24 @@ struct CSyncManager::HeaderNode
|
||||
int64_t nLastRequestTime;
|
||||
int64_t nFirstRequestTime;
|
||||
int64_t nInsertTime;
|
||||
// Phase 1.5: track which peer this header was last requested from. Used to
|
||||
// compute per-peer inflight for the cap. Not a hard ownership — the block
|
||||
// can be re-requested from a different peer if this one stalls.
|
||||
CNode* pnodeLastRequest = nullptr;
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
static const unsigned int MAX_HEADER_SYNC_CACHE = 15000;
|
||||
static const unsigned int MAX_HEADER_SYNC_CACHE = 100000;
|
||||
static const size_t HEADER_REDUNDANT_PEER_THRESHOLD = 4;
|
||||
static const int64_t HEADER_REQUEST_TIMEOUT_MICROS = 60 * 1000000;
|
||||
static const int64_t HEADER_REDUNDANT_REQUEST_MICROS = 5 * 1000000;
|
||||
static const int64_t HEADER_SYNC_TTL_MICROS = 15 * 60 * 1000000;
|
||||
// Backpressure ceiling: how far (in blocks) the header front is allowed to
|
||||
// run ahead of the connected chain tip before we stop fetching MORE headers
|
||||
// and let the block planner catch up. Comfortly below MAX_HEADER_SYNC_CACHE
|
||||
// so the cache never overflows in normal from-zero sync.
|
||||
static const int HEADER_FRONT_MAX_AHEAD = 32768;
|
||||
|
||||
std::map<uint256, CSyncManager::HeaderNode> mapHeaders;
|
||||
uint256 hashBestHeader = 0;
|
||||
@@ -124,12 +133,23 @@ void CSyncManager::PruneHeaders()
|
||||
{
|
||||
const int64_t nNow = GetTime() * 1000000;
|
||||
|
||||
// Never evict headers in the live sync window. Fix 2's backpressure caps the
|
||||
// header front at nBestHeight + HEADER_FRONT_MAX_AHEAD, so protecting that
|
||||
// whole span means the entire in-flight header chain is safe: no mid-chain
|
||||
// hole can form between the connected tip and the front during normal sync.
|
||||
// Evicting any of these would sever GetDownloadPath() from the connected
|
||||
// chain and freeze sync. (The hard cap below is the memory safety valve;
|
||||
// Fix 3's bridge-repair is the backstop for restart/reorg edge cases.)
|
||||
const int nProtectFloor = nBestHeight + HEADER_FRONT_MAX_AHEAD;
|
||||
|
||||
// TTL pass: evict aged headers, but only ABOVE the protected floor.
|
||||
if (mapHeaders.size() > MAX_HEADER_SYNC_CACHE / 2)
|
||||
{
|
||||
unsigned int nEvicted = 0;
|
||||
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); )
|
||||
{
|
||||
if (nNow - it->second.nInsertTime >= HEADER_SYNC_TTL_MICROS)
|
||||
if (it->second.nHeight > nProtectFloor &&
|
||||
nNow - it->second.nInsertTime >= HEADER_SYNC_TTL_MICROS)
|
||||
{
|
||||
it = mapHeaders.erase(it);
|
||||
++nEvicted;
|
||||
@@ -139,25 +159,37 @@ void CSyncManager::PruneHeaders()
|
||||
}
|
||||
if (nEvicted > 0)
|
||||
{
|
||||
printf("IBD-DIAG: TTL-evicted %u stale sync headers, %u remain\n",
|
||||
nEvicted, (unsigned int)mapHeaders.size());
|
||||
printf("IBD-DIAG: TTL-evicted %u stale sync headers above floor %d, %u remain\n",
|
||||
nEvicted, nProtectFloor, (unsigned int)mapHeaders.size());
|
||||
RecomputeBestHeader();
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap (last-resort safety valve): evict the HIGHEST-height headers
|
||||
// first — the ones furthest ahead of the tip — never the bridge zone.
|
||||
// Lowering the sync target temporarily is fine; we re-extend it once blocks
|
||||
// catch up. Losing a bridge header is not fine: it stalls forever.
|
||||
if (mapHeaders.size() > MAX_HEADER_SYNC_CACHE)
|
||||
{
|
||||
printf("IBD-DIAG: sync header cache exceeded %u entries, evicting oldest\n", MAX_HEADER_SYNC_CACHE);
|
||||
while (mapHeaders.size() > MAX_HEADER_SYNC_CACHE * 3 / 4)
|
||||
{
|
||||
std::map<uint256, HeaderNode>::iterator oldest = mapHeaders.begin();
|
||||
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); ++it)
|
||||
{
|
||||
if (it->second.nInsertTime < oldest->second.nInsertTime)
|
||||
oldest = it;
|
||||
}
|
||||
mapHeaders.erase(oldest);
|
||||
}
|
||||
printf("IBD-DIAG: sync header cache exceeded %u entries, evicting from the front (floor=%d)\n",
|
||||
MAX_HEADER_SYNC_CACHE, nProtectFloor);
|
||||
|
||||
std::vector<std::map<uint256, HeaderNode>::iterator> vEvictable;
|
||||
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); ++it)
|
||||
if (it->second.nHeight > nProtectFloor)
|
||||
vEvictable.push_back(it);
|
||||
|
||||
std::sort(vEvictable.begin(), vEvictable.end(),
|
||||
[](const std::map<uint256, HeaderNode>::iterator& a,
|
||||
const std::map<uint256, HeaderNode>::iterator& b) {
|
||||
return a->second.nHeight > b->second.nHeight;
|
||||
});
|
||||
|
||||
const size_t nTarget = (size_t)MAX_HEADER_SYNC_CACHE * 3 / 4;
|
||||
size_t i = 0;
|
||||
while (mapHeaders.size() > nTarget && i < vEvictable.size())
|
||||
mapHeaders.erase(vEvictable[i++]);
|
||||
|
||||
RecomputeBestHeader();
|
||||
}
|
||||
}
|
||||
@@ -191,7 +223,11 @@ bool CSyncManager::AddHeaderNode(const CBlock& header, const uint256& hashHeader
|
||||
}
|
||||
|
||||
const int nHeight = nPrevHeight + 1;
|
||||
if (nHeight <= CUTOFF_POW_BLOCK && !CheckProofOfWork(hashHeader, header.nBits))
|
||||
// Only check PoW on actual PoW headers (nNonce != 0).
|
||||
// Triangles is a hybrid PoW/PoS coin — PoS blocks (nonce=0) can appear
|
||||
// even within the 0-9000 PoW range. Checking PoW on a PoS header
|
||||
// rejects valid blocks and severs the header chain during IBD.
|
||||
if (nHeight <= CUTOFF_POW_BLOCK && header.nNonce != 0 && !CheckProofOfWork(hashHeader, header.nBits))
|
||||
{
|
||||
printf("IBD-DIAG: header PoW FAILED at height %d hash=%s nBits=%08x prevHash=%s\n",
|
||||
nHeight, hashHeader.ToString().substr(0,20).c_str(), header.nBits,
|
||||
@@ -235,6 +271,20 @@ std::vector<uint256> CSyncManager::GetDownloadPath(uint256 hashTip) const
|
||||
return vPath;
|
||||
}
|
||||
|
||||
// A download path is "anchored" when its lowest header's parent is a block we
|
||||
// already have in the active chain (mapBlockIndex). If it isn't, a bridging
|
||||
// header was lost and requesting these blocks would only create orphans —
|
||||
// Tick() rebuilds the bridge with a getheaders anchored at pindexBest.
|
||||
bool CSyncManager::PathReachesChain(const std::vector<uint256>& vPath) const
|
||||
{
|
||||
if (vPath.empty())
|
||||
return true; // nothing queued == nothing to bridge
|
||||
std::map<uint256, HeaderNode>::const_iterator mi = mapHeaders.find(vPath.front());
|
||||
if (mi == mapHeaders.end())
|
||||
return false;
|
||||
return mapBlockIndex.count(mi->second.header.hashPrevBlock) != 0;
|
||||
}
|
||||
|
||||
unsigned int CSyncManager::CountInFlight() const
|
||||
{
|
||||
const int64_t nNow = GetTime() * 1000000;
|
||||
@@ -291,6 +341,13 @@ void CSyncManager::ContinueHeaders(CNode* pfrom, const uint256& hashTip)
|
||||
if (!pfrom || hashTip == 0)
|
||||
return;
|
||||
|
||||
// Backpressure: don't extend the header front when it is already far ahead
|
||||
// of the connected block tip. Otherwise headers race past the block planner
|
||||
// and the bridge headers age out / get evicted before their blocks arrive.
|
||||
if (hashBestHeader != 0 &&
|
||||
GetPlannerHeight() - nBestHeight > HEADER_FRONT_MAX_AHEAD)
|
||||
return;
|
||||
|
||||
std::vector<uint256> vHave;
|
||||
uint256 hashWalk = hashTip;
|
||||
int nStep = 1;
|
||||
@@ -326,6 +383,12 @@ bool CSyncManager::RequestRefill(CNode* pfrom, uint256 hashTip, int64_t nMinInte
|
||||
nNowSec - pfrom->nLastIbdHeaderRequest < nMinIntervalSeconds)
|
||||
return false;
|
||||
|
||||
// Backpressure: stop pulling new headers once the front is far enough ahead
|
||||
// of the connected tip; let block download drain first (see ContinueHeaders).
|
||||
if (hashBestHeader != 0 &&
|
||||
GetPlannerHeight() - nBestHeight > HEADER_FRONT_MAX_AHEAD)
|
||||
return false;
|
||||
|
||||
uint256 hashLocatorTip = hashTip;
|
||||
if (hashLocatorTip == 0 ||
|
||||
(!mapBlockIndex.count(hashLocatorTip) && !mapHeaders.count(hashLocatorTip)))
|
||||
@@ -386,6 +449,15 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
|
||||
if (vPath.empty())
|
||||
return 0;
|
||||
|
||||
// If the path doesn't connect back to the active chain, requesting these
|
||||
// blocks just fills the orphan pool. Bail and let Tick() repair the bridge.
|
||||
if (!PathReachesChain(vPath))
|
||||
{
|
||||
printf("IBD-DIAG: download path not anchored to chain (front=%s) — deferring to bridge repair\n",
|
||||
vPath.front().ToString().substr(0,20).c_str());
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<CNode*> vEligiblePeers;
|
||||
{
|
||||
LOCK(cs_vNodes);
|
||||
@@ -404,8 +476,26 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
|
||||
unsigned int nQueued = 0;
|
||||
unsigned int nPeerIndex = 0;
|
||||
|
||||
// Option C: sort eligible peers by reliability score, not just blocks delivered.
|
||||
// A peer that's delivered 100 blocks but disconnected 20 times is less reliable
|
||||
// than a peer that's delivered 50 blocks with 0 disconnects. The score captures
|
||||
// both. We also drop peers with score <= 0 (effectively banned from sync).
|
||||
for (CNode* pnode : vEligiblePeers) {
|
||||
pnode->RecomputeReliabilityScore();
|
||||
}
|
||||
vEligiblePeers.erase(
|
||||
std::remove_if(vEligiblePeers.begin(), vEligiblePeers.end(),
|
||||
[](const CNode* p) { return p->nReliabilityScore <= 0; }),
|
||||
vEligiblePeers.end());
|
||||
|
||||
std::sort(vEligiblePeers.begin(), vEligiblePeers.end(),
|
||||
[](const CNode* a, const CNode* b) {
|
||||
// Sort by reliability score (primary), then signed-peer bonus (signed > unsigned),
|
||||
// then blocks delivered (tiebreaker).
|
||||
if (a->nReliabilityScore != b->nReliabilityScore)
|
||||
return a->nReliabilityScore > b->nReliabilityScore;
|
||||
if (a->nSignedPeerBonus != b->nSignedPeerBonus)
|
||||
return a->nSignedPeerBonus > b->nSignedPeerBonus;
|
||||
return a->nBlocksDelivered > b->nBlocksDelivered;
|
||||
});
|
||||
|
||||
@@ -437,6 +527,22 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1.5: per-peer inflight counting via HeaderNode.pnodeLastRequest.
|
||||
// Skip a peer if they're at their share of the global window. This caps the
|
||||
// damage a single .onion peer can do if they're feeding low-quality blocks.
|
||||
const unsigned int nPerPeerCap = HEADER_DOWNLOAD_WINDOW / std::max(1u, (unsigned int)vEligiblePeers.size()) + 1;
|
||||
std::map<const CNode*, unsigned int> mapPeerInflight;
|
||||
{
|
||||
const int64_t nNowInflight = GetTime() * 1000000;
|
||||
for (const auto& kv : mapHeaders) {
|
||||
if (kv.second.fRequested &&
|
||||
(nNowInflight - kv.second.nLastRequestTime) < HEADER_REQUEST_TIMEOUT_MICROS &&
|
||||
kv.second.pnodeLastRequest != nullptr) {
|
||||
++mapPeerInflight[kv.second.pnodeLastRequest];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (std::vector<uint256>::const_iterator it = vPath.begin(); it != vPath.end(); ++it)
|
||||
{
|
||||
if (nInFlight + nQueued >= nWindow)
|
||||
@@ -457,9 +563,32 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
|
||||
if (!fNeedsRequest)
|
||||
continue;
|
||||
|
||||
CNode* pnode = vWeightedPeers[nPeerIndex % vWeightedPeers.size()];
|
||||
// Phase 1.5: skip peers that are at their share of the global window. We
|
||||
// try the weighted peer first, and if they're capped, fall back to any
|
||||
// other eligible peer that's under the cap. This ensures one peer can't
|
||||
// claim the whole window even if they're the highest-weighted.
|
||||
CNode* pnode = nullptr;
|
||||
for (size_t tryIdx = 0; tryIdx < vWeightedPeers.size(); ++tryIdx) {
|
||||
CNode* candidate = vWeightedPeers[(nPeerIndex + tryIdx) % vWeightedPeers.size()];
|
||||
unsigned int candidateInflight = mapPeerInflight.count(candidate) ? mapPeerInflight[candidate] : 0;
|
||||
if (candidateInflight < nPerPeerCap) {
|
||||
pnode = candidate;
|
||||
nPeerIndex = (nPeerIndex + tryIdx) % vWeightedPeers.size();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pnode) {
|
||||
// All peers at cap — skip this block for now, it'll be retried later
|
||||
continue;
|
||||
}
|
||||
|
||||
pnode->AskFor(CInv(MSG_BLOCK, *it));
|
||||
|
||||
// Phase 1.5: record which peer this block was requested from for the
|
||||
// per-peer inflight count
|
||||
mi->second.pnodeLastRequest = pnode;
|
||||
mapPeerInflight[pnode] = (mapPeerInflight.count(pnode) ? mapPeerInflight[pnode] : 0) + 1;
|
||||
|
||||
if (IsInitialBlockDownload() &&
|
||||
vWeightedPeers.size() >= 2 &&
|
||||
vWeightedPeers.size() < HEADER_REDUNDANT_PEER_THRESHOLD &&
|
||||
@@ -581,6 +710,10 @@ void CSyncManager::TrackBlockDelivery(CNode* pfrom, const uint256& hashBlock)
|
||||
return;
|
||||
|
||||
pfrom->nBlocksDelivered++;
|
||||
// Option C: reward the peer for delivering a block. Capped at +200 by
|
||||
// RecomputeReliabilityScore. Also recompute to apply any flapping penalty
|
||||
// that may have accumulated since the last recompute.
|
||||
pfrom->nReliabilityScore = std::min(500, pfrom->nReliabilityScore + 5);
|
||||
if (nBestHeight > pfrom->nBestKnownHeight)
|
||||
pfrom->nBestKnownHeight = nBestHeight;
|
||||
|
||||
@@ -626,6 +759,22 @@ void CSyncManager::Tick(CNode* pto, int nHighestInvWalk, const uint256& hashHigh
|
||||
nLastHeaderPlannerControl = nNowSec;
|
||||
}
|
||||
|
||||
// Pipeline refill: when the in-flight block window has drained (inflight==0)
|
||||
// and the planner still has headers ahead of the connected tip, kick a fresh
|
||||
// getheaders round on every eligible peer so the next batch of blocks is
|
||||
// requested BEFORE the current download finishes. Closes the "Tor pipe
|
||||
// empty" gaps that stall throughput between burst windows.
|
||||
if (nInFlight < (unsigned int)(HEADER_DOWNLOAD_WINDOW / 16) &&
|
||||
nPlannerDepth > 0)
|
||||
{
|
||||
const unsigned int nPipeline = RequestRefillAllPeers(
|
||||
hashBestHeader, HEADER_SYNC_REFILL_MIN_INTERVAL_SECONDS,
|
||||
"pipeline-prefetch");
|
||||
if (nPipeline > 0)
|
||||
printf("IBD-DIAG: pipeline-prefetch refill %u (plannerDepth=%u inflight=%u)\n",
|
||||
nPipeline, nPlannerDepth, nInFlight);
|
||||
}
|
||||
|
||||
if (nNowSec - nLastHeaderWatchdog >= HEADER_SYNC_CONTROL_INTERVAL_SECONDS &&
|
||||
nNowSec - nLastNewHeaderTime >= HEADER_SYNC_WATCHDOG_SECONDS)
|
||||
{
|
||||
@@ -645,6 +794,21 @@ void CSyncManager::Tick(CNode* pto, int nHighestInvWalk, const uint256& hashHigh
|
||||
if (nNowSec - pto->nLastIbdHeaderRequest >= nMinInterval)
|
||||
RequestRefill(pto, hashBestHeader, nMinInterval, "heartbeat");
|
||||
|
||||
// Bridge repair: we have a best header, but the download path can't reach
|
||||
// the connected chain — a linking header was lost (TTL/eviction/hole). Ask
|
||||
// this peer for headers with a locator anchored at the REAL chain tip so it
|
||||
// resends the headers directly above pindexBest and re-links the path.
|
||||
if (hashBestHeader != 0 && pindexBest &&
|
||||
!PathReachesChain(GetDownloadPath(hashBestHeader)) &&
|
||||
nNowSec - pto->nLastIbdHeaderRequest >= HEADER_SYNC_REFILL_MIN_INTERVAL_SECONDS)
|
||||
{
|
||||
pto->pindexLastGetHeadersBegin = NULL;
|
||||
pto->PushGetHeaders(pindexBest, uint256(0));
|
||||
pto->nLastIbdHeaderRequest = nNowSec;
|
||||
printf("IBD-DIAG: bridge-repair getheaders from connected tip height=%d peer=%s\n",
|
||||
pindexBest->nHeight, pto->addr.ToString().c_str());
|
||||
}
|
||||
|
||||
if (nNowSec - nLastBlockPlannerControl >= HEADER_SYNC_CONTROL_INTERVAL_SECONDS &&
|
||||
hashBestHeader != 0 &&
|
||||
nPlannerDepth > 0)
|
||||
|
||||
@@ -50,6 +50,7 @@ private:
|
||||
void PruneHeaders();
|
||||
bool AddHeaderNode(const CBlock& header, const uint256& hashHeader);
|
||||
std::vector<uint256> GetDownloadPath(uint256 hashTip) const;
|
||||
bool PathReachesChain(const std::vector<uint256>& vPath) const;
|
||||
void ContinueHeaders(CNode* pfrom, const uint256& hashTip);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
// Copyright (c) 2026 Cryptographic Triangles
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// v5.9.22 hardening tests for the HTTPS seed-list path.
|
||||
//
|
||||
// Two pure functions under test (declared in netbase.h):
|
||||
//
|
||||
// int DechunkTransferEncoding(const std::string& body, std::string& decoded)
|
||||
// std::vector<std::string> ParseSeedListBody(const std::string& body)
|
||||
// bool IsValidSocksNegotiationTimeout(int nMs)
|
||||
//
|
||||
// These functions replaced the inline parsers in net.cpp
|
||||
// ThreadHTTPSeedFetch2. The tests cover every failure mode listed in the
|
||||
// hardening brief:
|
||||
// - Normal non-chunked HTTP seed responses (the parser is a no-op)
|
||||
// - Valid chunked responses with several chunks
|
||||
// - Chunk extensions such as A;foo=bar
|
||||
// - Chunked payloads split at awkward boundaries
|
||||
// - Malformed chunk sizes, missing CRLF, truncated chunks, chunks whose
|
||||
// declared size exceeds remaining input
|
||||
// - Seed-list parsing with whitespace, commas, semicolons, comments,
|
||||
// multiple addresses per line, valid .onion:port entries, and invalid
|
||||
// entries
|
||||
// - -torconnecttimeout validation at the exact boundaries and just
|
||||
// outside them: 4999, 5000, 60000, 180000, and 180001 milliseconds
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "netbase.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace std;
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(http_seed_tests)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DechunkTransferEncoding — happy path
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_empty_body)
|
||||
{
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(string(""), decoded), DECHUNK_EMPTY);
|
||||
BOOST_CHECK(decoded.empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_single_chunk)
|
||||
{
|
||||
// "5\r\nhello\r\n0\r\n\r\n" → "hello"
|
||||
string body = "5\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "hello");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_multiple_chunks)
|
||||
{
|
||||
// Three chunks concatenated: "Hel" + "lo " + "world"
|
||||
string body = "3\r\nHel\r\n3\r\nlo \r\n5\r\nworld\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "Hello world");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_with_chunk_extension)
|
||||
{
|
||||
// "5;foo=bar\r\nhello\r\n0\r\n\r\n" → "hello"
|
||||
// Extensions after the size are part of the framing protocol and must
|
||||
// be stripped before parsing the hex size.
|
||||
string body = "5;foo=bar\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "hello");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_with_multiple_extensions)
|
||||
{
|
||||
// "5;a=b;c=d\r\nhello\r\n0\r\n\r\n"
|
||||
string body = "5;a=b;c=d\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "hello");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_uppercase_hex)
|
||||
{
|
||||
// "5\r\nhello\r\n0\r\n\r\n" with A-F uppercase
|
||||
string body = "A\r\n0123456789\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "0123456789");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_payload_containing_crlf)
|
||||
{
|
||||
// Chunk data itself contains CRLF — must not be mistaken for framing.
|
||||
string body = "B\r\nline1\r\nline2\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "line1\r\nline2");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_split_at_awkward_boundary)
|
||||
{
|
||||
// A long chunk whose internal "data" happens to look like a chunk-size
|
||||
// line. Hex 0x0B = 11 bytes; the data "FAKE\r\nFOO\r" contains CRLF.
|
||||
string body = "B\r\nFAKE\r\nFOO\r\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
// 11 bytes consumed: "FAKE\r\nFOO\r" (5 + 2 + 3 + 1 = 11)
|
||||
BOOST_CHECK_EQUAL(decoded, "FAKE\r\nFOO\r");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_last_chunk_with_extension)
|
||||
{
|
||||
// "0;end=1\r\n\r\n" — last chunk with extension, no body
|
||||
string body = "5\r\nhello\r\n0;end=1\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, "hello");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DechunkTransferEncoding — failure modes
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_no_crlf_after_size)
|
||||
{
|
||||
// No CRLF after the chunk-size hex — must not be silently accepted.
|
||||
string body = "5XXhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_invalid_hex)
|
||||
{
|
||||
// "G" is not a valid hex digit.
|
||||
string body = "G\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_empty_size_line)
|
||||
{
|
||||
// Stray CRLF at the start — empty size line must be rejected.
|
||||
string body = "\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_oversize_chunk)
|
||||
{
|
||||
// Declared 100 bytes but only 5 remain. Old code silently clamped;
|
||||
// strict version must report DECHUNK_OVERSIZE_CHUNK.
|
||||
string body = "64\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OVERSIZE_CHUNK);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_truncated_last_chunk_marker)
|
||||
{
|
||||
// No "0\r\n" terminator — body just ends mid-chunk.
|
||||
string body = "5\r\nhello\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_missing_data_crlf)
|
||||
{
|
||||
// Chunk-data not followed by CRLF. Two chunks: first is 5 bytes "hello"
|
||||
// then "X" where CRLF should be. The parser must catch the missing CRLF
|
||||
// before trying to read the next chunk-size.
|
||||
string body = "5\r\nhelloX3\r\nfoo\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_MISSING_DATA_CRLF);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_strtoul_overflow)
|
||||
{
|
||||
// A hex value larger than size_t can represent. On a 64-bit system this
|
||||
// would be 17+ F's. We pick a 32-digit value: clearly overflows on both
|
||||
// 32 and 64 bit builds.
|
||||
string body = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
// Either INVALID_HEX (overflow detected) or OVERSIZE_CHUNK (caught at
|
||||
// bounds check) is acceptable — both correctly refuse the input.
|
||||
int rc = DechunkTransferEncoding(body, decoded);
|
||||
BOOST_CHECK(rc == DECHUNK_INVALID_HEX || rc == DECHUNK_OVERSIZE_CHUNK);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_sign_in_size)
|
||||
{
|
||||
// strtoul would silently accept leading '+' or '-'. Our strict
|
||||
// hex-only validator must reject them.
|
||||
string body = "+5\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_whitespace_in_size)
|
||||
{
|
||||
// strtoul would silently accept leading whitespace. Our strict
|
||||
// hex-only validator must reject them.
|
||||
string body = " 5\r\nhello\r\n0\r\n\r\n";
|
||||
string decoded;
|
||||
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(dechunk_no_last_chunk)
|
||||
{
|
||||
// Body has chunks but never reaches a size-0 terminator. Must be
|
||||
// rejected, not silently accepted as the whole body.
|
||||
string body = "5\r\nhello\r\n"; // missing "0\r\n\r\n"
|
||||
string decoded;
|
||||
int rc = DechunkTransferEncoding(body, decoded);
|
||||
BOOST_CHECK(rc == DECHUNK_NO_CHUNK_TERMINATOR || rc == DECHUNK_MISSING_DATA_CRLF);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ParseSeedListBody
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_empty)
|
||||
{
|
||||
vector<string> out = ParseSeedListBody("");
|
||||
BOOST_CHECK(out.empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_single_onion_per_line)
|
||||
{
|
||||
// Three v3 onion addresses, one per line.
|
||||
string body =
|
||||
"aaaa.onion:24112\n"
|
||||
"bbbb.onion:24112\n"
|
||||
"cccc.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_crlf_line_endings)
|
||||
{
|
||||
// Real-world: HTTP responses typically use CRLF.
|
||||
string body =
|
||||
"aaaa.onion:24112\r\n"
|
||||
"bbbb.onion:24112\r\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 2u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_space)
|
||||
{
|
||||
// Several addresses on one line, space-separated.
|
||||
string body = "aaaa.onion:24112 bbbb.onion:24112 cccc.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_comma)
|
||||
{
|
||||
// Comma-separated — common in older seed lists.
|
||||
string body = "aaaa.onion:24112,bbbb.onion:24112,cccc.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_semicolon)
|
||||
{
|
||||
// Semicolon-separated — sometimes used in INI-style configs.
|
||||
string body = "aaaa.onion:24112;bbbb.onion:24112;cccc.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_mixed_separators)
|
||||
{
|
||||
// Tabs, multiple spaces, commas, semicolons all in one line.
|
||||
string body = "aaaa.onion:24112,\tbbbb.onion:24112 ;cccc.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_inline_comments)
|
||||
{
|
||||
// Anything after '#' to end-of-line is dropped.
|
||||
string body =
|
||||
"aaaa.onion:24112 # primary\n"
|
||||
"# this whole line is a comment\n"
|
||||
"bbbb.onion:24112 # secondary\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 2u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_blank_lines)
|
||||
{
|
||||
// Whitespace-only / blank lines are skipped.
|
||||
string body =
|
||||
"\n"
|
||||
" \n"
|
||||
"aaaa.onion:24112\n"
|
||||
"\t\n"
|
||||
"bbbb.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 2u);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_only_comments)
|
||||
{
|
||||
// All-comment body produces empty output (zero valid addresses
|
||||
// downstream — caller logs the failure mode).
|
||||
string body =
|
||||
"# nothing useful here\n"
|
||||
"# more comments\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK(out.empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_portless_onion)
|
||||
{
|
||||
// ".onion" without ":port" is allowed at the parser level — the caller
|
||||
// falls back to GetDefaultPort() before validating as a CService.
|
||||
string body = "aaaa.onion\nbbbb.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 2u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_invalid_entry_preserved_for_caller)
|
||||
{
|
||||
// The parser does NOT validate that entries are real .onion addresses
|
||||
// or valid CService — that's the caller's job. The parser is a pure
|
||||
// splitter; invalid entries (e.g. "not-a-host") are still returned
|
||||
// and will fail CService::IsValid() downstream.
|
||||
string body = "not-a-host\nxxxxxx.onion:24112\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 2u);
|
||||
BOOST_CHECK_EQUAL(out[0], "not-a-host");
|
||||
BOOST_CHECK_EQUAL(out[1], "xxxxxx.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_trailing_whitespace_per_line)
|
||||
{
|
||||
// Spaces/tabs at end of each line should not produce an empty token.
|
||||
string body = "aaaa.onion:24112 \t \n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 1u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(seedlist_crlf_lf_mix)
|
||||
{
|
||||
// Some lines CRLF, some LF — should all parse.
|
||||
string body =
|
||||
"aaaa.onion:24112\r\n"
|
||||
"bbbb.onion:24112\n"
|
||||
"cccc.onion:24112\r\n";
|
||||
vector<string> out = ParseSeedListBody(body);
|
||||
BOOST_CHECK_EQUAL(out.size(), 3u);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// IsValidSocksNegotiationTimeout — exact boundaries and just outside
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_just_below_lower_bound)
|
||||
{
|
||||
BOOST_CHECK(!IsValidSocksNegotiationTimeout(4999));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_exact_lower_bound)
|
||||
{
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(5000));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_default)
|
||||
{
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(60000));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_exact_upper_bound)
|
||||
{
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(180000));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_just_above_upper_bound)
|
||||
{
|
||||
BOOST_CHECK(!IsValidSocksNegotiationTimeout(180001));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_zero)
|
||||
{
|
||||
BOOST_CHECK(!IsValidSocksNegotiationTimeout(0));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_negative)
|
||||
{
|
||||
BOOST_CHECK(!IsValidSocksNegotiationTimeout(-1));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_max_int)
|
||||
{
|
||||
// Guard against wraparound on int boundaries.
|
||||
BOOST_CHECK(!IsValidSocksNegotiationTimeout(2147483647));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(timeout_midrange)
|
||||
{
|
||||
// Several plausible values in the middle of the range.
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(10000));
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(30000));
|
||||
BOOST_CHECK(IsValidSocksNegotiationTimeout(120000));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Integration: dechunked body → seed-list parser round-trip
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOOST_AUTO_TEST_CASE(roundtrip_chunked_then_parsed)
|
||||
{
|
||||
// Build a chunked-encoded seed list, decode it, then parse the result.
|
||||
string seedBody =
|
||||
"aaaa.onion:24112\n"
|
||||
"bbbb.onion:24112\n"
|
||||
"# cccc is the backup\n"
|
||||
"cccc.onion:24112,dddd.onion:24112\n";
|
||||
|
||||
// Encode into chunked form.
|
||||
string chunked;
|
||||
size_t pos = 0;
|
||||
while (pos < seedBody.size()) {
|
||||
size_t take = min(seedBody.size() - pos, (size_t)16);
|
||||
char hex[16];
|
||||
snprintf(hex, sizeof(hex), "%zx", take);
|
||||
chunked += string(hex) + "\r\n" + seedBody.substr(pos, take) + "\r\n";
|
||||
pos += take;
|
||||
}
|
||||
chunked += "0\r\n\r\n";
|
||||
|
||||
string decoded;
|
||||
BOOST_REQUIRE_EQUAL(DechunkTransferEncoding(chunked, decoded), DECHUNK_OK);
|
||||
BOOST_CHECK_EQUAL(decoded, seedBody);
|
||||
|
||||
vector<string> out = ParseSeedListBody(decoded);
|
||||
BOOST_CHECK_EQUAL(out.size(), 4u);
|
||||
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
|
||||
BOOST_CHECK_EQUAL(out[3], "dddd.onion:24112");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Cryptographic Triangles
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "util.h"
|
||||
#include "onionseed.h"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// v3 Onion Address Checksum Validation Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Background: 2026-06-21 from-zero sync test produced 4,842 Tor
|
||||
// "No more HSDir available" errors and 181 "ed25519 validation failed"
|
||||
// warnings. Root cause: a 1-character transposition (btb6 vs gtb6) in the
|
||||
// test config's vmepp seed address. Tor correctly rejected the corrupted
|
||||
// address but the error wasn't surfaced to a place where an operator would
|
||||
// notice — the daemon just kept trying.
|
||||
//
|
||||
// These tests run the same v3 hidden service checksum validation that Tor
|
||||
// runs internally, so we catch corruption at build/CI time instead of at
|
||||
// daemon runtime.
|
||||
//
|
||||
// v3 onion = base32( PUBKEY(32) || CHECKSUM(2) || VERSION(1) )
|
||||
// PUBKEY = 32-byte ed25519 public key
|
||||
// CHECKSUM = SHA3-256( ".onion checksum" || PUBKEY || VERSION )[:2]
|
||||
// VERSION = 0x03
|
||||
// Total decoded = 35 bytes, base32-encoded to 56 chars + ".onion" suffix
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
namespace {
|
||||
|
||||
static const std::string V3_CHECKSUM_INPUT = ".onion checksum";
|
||||
static const size_t V3_PUBKEY_LENGTH = 32;
|
||||
static const size_t V3_DECODED_LENGTH = 35;
|
||||
|
||||
/** Compute SHA3-256 of a byte vector. */
|
||||
std::vector<unsigned char> sha3_256(const std::vector<unsigned char>& data) {
|
||||
std::vector<unsigned char> out(EVP_MAX_MD_SIZE);
|
||||
unsigned int out_len = 0;
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
EVP_DigestInit_ex(ctx, EVP_sha3_256(), nullptr);
|
||||
EVP_DigestUpdate(ctx, data.data(), data.size());
|
||||
EVP_DigestFinal_ex(ctx, out.data(), &out_len);
|
||||
EVP_MD_CTX_free(ctx);
|
||||
out.resize(out_len);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a v3 onion address against the hidden service checksum.
|
||||
*
|
||||
* @return true if address is a valid v3 onion, false otherwise
|
||||
*/
|
||||
bool IsValidV3Onion(const std::string& address) {
|
||||
// Length check
|
||||
if (address.size() != 62) return false; // 56 + ".onion" (6)
|
||||
if (address.substr(56) != ".onion") return false;
|
||||
|
||||
// Base32 alphabet check (lowercase a-z + 2-7)
|
||||
for (size_t i = 0; i < 56; i++) {
|
||||
char c = address[i];
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7'))) return false;
|
||||
}
|
||||
|
||||
// Base32 decode
|
||||
std::string padded = address.substr(0, 56);
|
||||
while (padded.size() % 8 != 0) padded += '=';
|
||||
bool invalid = false;
|
||||
std::vector<unsigned char> decoded = DecodeBase32(padded.c_str(), &invalid);
|
||||
if (invalid) return false;
|
||||
if (decoded.size() != V3_DECODED_LENGTH) return false;
|
||||
|
||||
// v3 spec: PUBKEY(32) || CHECKSUM(2) || VERSION(1)
|
||||
const unsigned char* pubkey = &decoded[0];
|
||||
const unsigned char* checksum = &decoded[32];
|
||||
unsigned char version = decoded[34];
|
||||
|
||||
if (version != 0x03) return false;
|
||||
|
||||
// Compute expected checksum: SHA3-256( ".onion checksum" || pubkey || version )[:2]
|
||||
std::vector<unsigned char> input;
|
||||
input.insert(input.end(), V3_CHECKSUM_INPUT.begin(), V3_CHECKSUM_INPUT.end());
|
||||
input.insert(input.end(), pubkey, pubkey + V3_PUBKEY_LENGTH);
|
||||
input.push_back(version);
|
||||
std::vector<unsigned char> hash = sha3_256(input);
|
||||
|
||||
return checksum[0] == hash[0] && checksum[1] == hash[1];
|
||||
}
|
||||
|
||||
/** Helper to count the number of .onion entries in the hardcoded seed array. */
|
||||
size_t CountOnionSeeds() {
|
||||
size_t count = 0;
|
||||
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(onion_v3_tests)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_valid_known_seeds)
|
||||
{
|
||||
// The 7 hardcoded seeds in src/onionseed.h MUST all be valid v3 onions.
|
||||
// If any of these fail, Tor will reject them at runtime.
|
||||
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
|
||||
std::string addr = strMainNetOnionSeed[i][0];
|
||||
std::string full = addr + ".onion";
|
||||
BOOST_CHECK_MESSAGE(
|
||||
IsValidV3Onion(full),
|
||||
"Hardcoded seed #" << i << " is not a valid v3 onion: " << full
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_detects_transposition)
|
||||
{
|
||||
// The exact corruption found on 2026-06-21:
|
||||
// Test config: vmepp7plxngv4qpyngbbtb6... (btb6, CORRUPT)
|
||||
// Source/prod: vmepp7plxngv4qpyngbgtb6... (gtb6, valid)
|
||||
const std::string VALID = "vmepp7plxngv4qpyngbgtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion";
|
||||
const std::string CORRUPT_BTB6 = "vmepp7plxngv4qpyngbbtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion";
|
||||
|
||||
BOOST_CHECK_MESSAGE(IsValidV3Onion(VALID), "gtb6 variant should be valid");
|
||||
BOOST_CHECK_MESSAGE(!IsValidV3Onion(CORRUPT_BTB6),
|
||||
"btb6 variant should be REJECTED (this was the live bug)");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_detects_wrong_length)
|
||||
{
|
||||
// 55 chars (1 short) — should be rejected
|
||||
BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.onio"));
|
||||
// 57 chars (1 long) — should be rejected
|
||||
BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3aaaa.onion"));
|
||||
// Empty — should be rejected
|
||||
BOOST_CHECK(!IsValidV3Onion(""));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_detects_missing_suffix)
|
||||
{
|
||||
const std::string no_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a";
|
||||
BOOST_CHECK(!IsValidV3Onion(no_suffix));
|
||||
const std::string wrong_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.com";
|
||||
BOOST_CHECK(!IsValidV3Onion(wrong_suffix));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_detects_invalid_base32)
|
||||
{
|
||||
// '0' and '1' are not in the base32 alphabet
|
||||
BOOST_CHECK(!IsValidV3Onion("0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijkl.onion"));
|
||||
// '8' and '9' are not in the base32 alphabet
|
||||
BOOST_CHECK(!IsValidV3Onion("89abcdefghijklmnopqrstuvwxyz23456789abcdefghijklmnopqrstuvwx.onion"));
|
||||
// Uppercase should be rejected (we expect lowercase)
|
||||
BOOST_CHECK(!IsValidV3Onion("A2Z4M7DQZMCSYJ4I6A5KPJ4TXR3C7YQT2QF3KZQGJ5UHWNAD6B3A.onion"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_detects_bad_version_byte)
|
||||
{
|
||||
// Construct an address with a non-v3 version byte by modifying the last
|
||||
// char (which encodes the version byte's last 5 bits). For our purposes,
|
||||
// we just need to verify that some random valid-looking string is rejected.
|
||||
// The address below has the right length and valid base32, but its
|
||||
// checksum bytes (derived from a fake pubkey) won't match the SHA3-256
|
||||
// of that fake pubkey.
|
||||
const std::string fake = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion";
|
||||
BOOST_CHECK_MESSAGE(!IsValidV3Onion(fake),
|
||||
"Address with all-'a' body should have invalid checksum");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_round_trip_encoding)
|
||||
{
|
||||
// Encode and decode a known 35-byte input, verify the encoding is
|
||||
// deterministic. This protects against accidental changes to the
|
||||
// base32 implementation that could silently break v3 onion generation.
|
||||
unsigned char input[35];
|
||||
for (int i = 0; i < 35; i++) input[i] = (unsigned char)(i * 7 + 13);
|
||||
|
||||
std::string encoded = EncodeBase32(input, 35);
|
||||
BOOST_CHECK_EQUAL(encoded.size(), 56u);
|
||||
|
||||
bool invalid = false;
|
||||
std::vector<unsigned char> decoded = DecodeBase32(encoded.c_str(), &invalid);
|
||||
BOOST_CHECK(!invalid);
|
||||
BOOST_CHECK_EQUAL(decoded.size(), 35u);
|
||||
for (int i = 0; i < 35; i++) {
|
||||
BOOST_CHECK_EQUAL(decoded[i], input[i]);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(onion_v3_audit_summary)
|
||||
{
|
||||
// Top-level summary: how many seeds are in onionseed.h
|
||||
size_t n = CountOnionSeeds();
|
||||
BOOST_CHECK_MESSAGE(n >= 1, "Expected at least 1 hardcoded seed, found " << n);
|
||||
|
||||
// All of them must validate
|
||||
int nValid = 0, nInvalid = 0;
|
||||
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
|
||||
if (IsValidV3Onion(std::string(strMainNetOnionSeed[i][0]) + ".onion")) {
|
||||
nValid++;
|
||||
} else {
|
||||
nInvalid++;
|
||||
}
|
||||
}
|
||||
BOOST_CHECK_EQUAL(nInvalid, 0);
|
||||
BOOST_CHECK_EQUAL((size_t)nValid, n);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
@@ -2369,6 +2369,13 @@ void CTorV3Manager::HandleWalletAddrResponse(CNode* pfrom, const std::string& tr
|
||||
// Signature valid — cache the mapping
|
||||
CacheOnionAddress(peerOnion, triAddr);
|
||||
|
||||
// Signed peer bonus: this peer cryptographically proved they own their
|
||||
// .onion via the walletaddr handshake. Mark them as a signed peer so
|
||||
// syncmanager peer selection (syncmanager.cpp:495) prefers them.
|
||||
pfrom->nSignedPeerBonus = 1;
|
||||
printf("SYNC-SIGN: marked %s as signed peer (proved identity via walletaddr)\n",
|
||||
peerOnion.c_str());
|
||||
|
||||
// Fire any pending resolve callbacks
|
||||
std::function<void(bool, const std::string&)> callback;
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user